diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 0b4ac2c..6040e23 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -116,7 +116,7 @@ jobs: done cd "$RELEASE_OUTPUT/anonymous" sha256sum --check SHA256SUMS - - name: Open an immutable catalog promotion draft + - name: Open the next reviewed promotion draft run: | # Promotion data starts from main; tooling stays at the reviewed workflow commit. git switch --detach origin/main diff --git a/RELEASING.md b/RELEASING.md index 409bebb..a3c7c7b 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,7 +1,7 @@ # Release operations -Version 0.3.5 pairs the supervisor and analyzer through `PIG_*` settings and -targets schema revision 5. Follow the [0.3.5 preparation](releases/0.3.5.md) +Version 0.3.6 pairs the supervisor and analyzer through `PIG_*` settings and +targets schema revision 5. Follow the [0.3.6 preparation](releases/0.3.6.md) for candidate selection and installation prerequisites. The [stable catalog](catalog/stable.json) selects published releases. Local tests are not installation acceptance. Versions 0.3.0 @@ -113,7 +113,7 @@ check can pass. No customer secret belongs in this repository or its evidence. ### Analyzer schema requirements -The 0.3.5 requirements accept starting schema revisions 0–4 and target revision 5. +The 0.3.6 requirements accept starting schema revisions 0–4 and target revision 5. The worker also accepts the target revision for retries and configuration rotation. The image must advertise `alembic-migrations-v1` and `storage-readiness-v1`. Worker CI runs installation and recovery tests on PostgreSQL 15–18 before customer GHCR image @@ -159,23 +159,26 @@ administrator fixes package visibility. Both OCI charts must pull anonymously and match their package checksums. The workflow then creates the immutable source tag and GitHub release, anonymously downloads every asset, and compares exact bytes and `SHA256SUMS`. It opens a -**draft catalog promotion PR**; it never merges that PR. A partial existing GitHub +**draft manifest-retention PR**; it never merges a PR. A partial existing GitHub release with missing or mismatched assets fails closed and needs operator repair or a new version, without overwriting already published bytes. -The promotion uses two commits: immutable manifest first, then a stable-index -entry pointing at that commit. Review the immutable URLs and evidence before -merging. If PR creation is interrupted after the branch push, rerun publication: -it verifies the existing manifest bytes, digest, and reachable immutable commit, -then creates or locates the PR without rewriting the branch. A mismatched branch -or closed, unmerged PR requires operator review. -If organization policy blocks Actions from creating PRs, the operator can open a -draft PR from the published `release/catalog-VERSION` branch to `main`, then rerun -publication. The retry verifies the branch and reuses that PR; organization-wide -Actions permissions do not need to change. -Merging makes the release eligible for automatic updates, including -major versions. Do not squash away or delete the manifest commit referenced by -its URL. Keep releases, tags, and those commits reachable and protected. +Promotion uses two reviewed PRs. The first merges the immutable manifest into +`main` without selecting the release. Rerun publication after that merge to open +the stable catalog PR, whose manifest URL references the retained commit on +`main`. Either PR can use the repository's normal squash or rebase merge method. +Review the immutable URLs and evidence before merging the stable catalog PR; +that merge makes the release eligible for automatic updates, including major +versions. Keep releases, tags, and referenced commits reachable and protected. + +If PR creation is interrupted after a branch push, rerun publication. It verifies +the existing manifest bytes, digest, and reachable immutable commit, then creates +or locates the matching PR without rewriting the branch. A mismatched branch or +closed, unmerged PR requires operator review. If organization policy blocks +Actions from creating PRs, the operator can open a draft PR from the published +`release/manifest-VERSION` or `release/catalog-VERSION` branch to `main`, then +rerun publication. The retry verifies the branch and reuses that PR; +organization-wide Actions permissions do not need to change. ## Scope of acceptance diff --git a/charts/pig-supervisor/Chart.yaml b/charts/pig-supervisor/Chart.yaml index 2130890..57f88e5 100644 --- a/charts/pig-supervisor/Chart.yaml +++ b/charts/pig-supervisor/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: pig-supervisor description: PIG release supervision for one customer deployment in one existing namespace type: application -version: 0.3.5 -appVersion: 0.3.5 +version: 0.3.6 +appVersion: 0.3.6 diff --git a/charts/pig-trace-analyzer/Chart.yaml b/charts/pig-trace-analyzer/Chart.yaml index 25babe9..d5e4169 100644 --- a/charts/pig-trace-analyzer/Chart.yaml +++ b/charts/pig-trace-analyzer/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: pig-trace-analyzer description: Manual PIG analyzer deployment with native cloud object storage type: application -version: 0.3.5 -appVersion: 0.3.5 +version: 0.3.6 +appVersion: 0.3.6 diff --git a/pyproject.toml b/pyproject.toml index 900f91a..0c8f075 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pig-supervisor" -version = "0.3.5" +version = "0.3.6" requires-python = ">=3.11" dependencies = ["httpx>=0.28,<1", "pydantic>=2.11,<3", "packaging>=24", "PyYAML>=6,<7"] [project.scripts] diff --git a/releases/0.3.6.md b/releases/0.3.6.md new file mode 100644 index 0000000..0c74a03 --- /dev/null +++ b/releases/0.3.6.md @@ -0,0 +1,56 @@ +# Prepare PIG 0.3.6 + +PIG 0.3.6 pairs the supervisor and analyzer through the `PIG_*` environment +contract. The analyzer targets schema revision 5 for cloud host enrollment and +trace provenance, with PostgreSQL 15–18 and controller protocol 1. + +## Select candidate artifacts + +1. Build the supervisor candidate from the merged 0.3.6 source using the + [candidate workflow](../.github/workflows/candidate.yml). Record its source + commit and image digest. Verify that `--version` reports `0.3.6` and its OCI + revision matches that commit. +2. Select an analyzer image published from merged application source. Execute + `deployment-capabilities` against the [requirements](requirements/0.3.6.json). + The image must read `PIG_*` settings and report schema revision 5. Its deployment + verification must permit hosted policy to disable analysis; canonical analysis + acceptance still requires analysis permission and repository access. +3. Verify anonymous image pulls and the candidate's capabilities following + [release operations](../RELEASING.md#candidate-and-acceptance). + +## Upgrade an installation + +Supervisor 0.3.4 supplies `INSTRUCTION_HUB_*` environment variables, which cannot +configure the 0.3.6 analyzer. The supervisor updates its own image after worker +verification, so this transition requires a coordinated bootstrap: + +1. Pause the installation with `spec.release.paused: true` before changing its + selected release. Confirm there is no migration in progress. +2. Scale the supervisor Deployment to zero and wait for its Pod to terminate. + A paused supervisor still reconciles the current worker configuration; stop it + before switching the environment contract. +3. Select the verified stable release and resume the installation while the + supervisor is stopped. Remove `spec.release.pinnedVersion` to follow stable + automatically, or set an explicit version when the installation requires a pin. +4. Upgrade the Helm-managed supervisor to the verified 0.3.6 chart and immutable + image digest. Preserve the installation's namespace, ingress, and other Helm + values. Confirm the new supervisor is ready and has acquired leadership. +5. Confirm the supervisor completes preflight, migration to schema 5, worker + rollout, hosted verification, and controller convergence. Check the observed + release and both running image digests against the manifest. + +Keep the installation's analysis policy unchanged. Successful deployment +verification proves hosted connectivity and required infrastructure checks; +canonical trace analysis and Dashboard acknowledgement are separate acceptance +results. Schema-5 recovery requires a compatible image or forward repair. + +## Record acceptance and publish + +Collect the required reports or obtain a version-specific owner decision using +[release operations](../RELEASING.md#candidate-and-acceptance). Bind the acceptance +record to the exact source, images, and requirements. Record omitted checks and +avoid advertising untested rollback paths. Azure and GCP remain experimental. + +Publication merges the immutable manifest before opening the stable catalog PR. +Follow [publication and promotion](../RELEASING.md#publication-and-promotion), +then verify public catalog consumption before updating production. diff --git a/releases/requirements/0.3.6.json b/releases/requirements/0.3.6.json new file mode 100644 index 0000000..ee2f6ff --- /dev/null +++ b/releases/requirements/0.3.6.json @@ -0,0 +1,14 @@ +{ + "kubernetesMin": "1.30.0", + "postgresMin": 15, + "postgresMax": 18, + "operatorCapacityRequirements": [], + "capacityConfirmationMaxAgeHours": 24, + "storageBackends": ["s3", "azureBlob", "gcs"], + "capabilities": ["native-storage-v1", "migration-ledger-v1", "installation-identity-v1", "alembic-migrations-v1", "storage-readiness-v1", "controller-self-update-v1", "crd-update-v1"], + "recoveryMaxAgeHours": null, + "destructiveMigration": false, + "schemaFrom": [0, 1, 2, 3, 4], + "schemaTo": 5, + "controllerProtocol": 1 +} diff --git a/supervisor/pig_supervisor/promotion.py b/supervisor/pig_supervisor/promotion.py index aca7d07..05af05e 100644 --- a/supervisor/pig_supervisor/promotion.py +++ b/supervisor/pig_supervisor/promotion.py @@ -1,4 +1,4 @@ -"""Resume catalog promotion without rewriting an already published manifest commit.""" +"""Promote release manifests through reviewed commits retained on main.""" import json import re @@ -11,7 +11,7 @@ def promote_draft(root: Path, manifest: Path) -> str: - """Verify/reuse a pushed promotion, then create or locate its draft PR.""" + """Open the manifest PR, then its stable-selection PR after the manifest merges.""" from .publication import promote_catalog payload = manifest.read_bytes() @@ -19,13 +19,19 @@ def promote_draft(root: Path, manifest: Path) -> str: if release.artifacts is None: raise ValueError("published release is missing its artifact inventory") version = release.version - branch = f"release/catalog-{version}" manifest_path = f"catalog/releases/{version}.json" def run(*args: str) -> str: return subprocess.check_output(args, cwd=root, text=True).strip() - def verify(ref: str) -> None: + def read_manifest(ref: str) -> bytes: + return subprocess.check_output(["git", "show", f"{ref}:{manifest_path}"], cwd=root) + + def verify_manifest(ref: str) -> None: + if read_manifest(ref) != payload: + raise ValueError("existing promotion manifest bytes differ from accepted artifacts") + + def verify_catalog(ref: str) -> None: catalog = json.loads(run("git", "show", f"{ref}:catalog/stable.json")) entries = [entry for entry in catalog["releases"] if entry["version"] == version] if len(entries) != 1 or entries[0]["sha256"] != sha256(payload).hexdigest(): @@ -36,47 +42,57 @@ def verify(ref: str) -> None: ) if url is None: raise ValueError("existing promotion must reference an immutable manifest commit") - commit = url[1] - run("git", "merge-base", "--is-ancestor", commit, ref) - for revision in (commit, ref): - existing = subprocess.check_output(["git", "show", f"{revision}:{manifest_path}"], cwd=root) - if existing != payload: - raise ValueError("existing promotion manifest bytes differ from accepted artifacts") + run("git", "merge-base", "--is-ancestor", url[1], "origin/main") + verify_manifest(url[1]) + verify_manifest(ref) run("git", "fetch", "origin", "main") main_catalog = json.loads(run("git", "show", "origin/main:catalog/stable.json")) already_promoted = any(entry["version"] == version for entry in main_catalog["releases"]) + manifest_merged = bool(run("git", "ls-tree", "--name-only", "origin/main", "--", manifest_path)) + if manifest_merged: + verify_manifest("origin/main") + catalog_phase = manifest_merged + branch = f"release/{'catalog' if catalog_phase else 'manifest'}-{version}" + changed_path = "catalog/stable.json" if catalog_phase else manifest_path promotion_commit = None if already_promoted: - verify("origin/main") + verify_catalog("origin/main") else: remote = run("git", "ls-remote", "--heads", "origin", f"refs/heads/{branch}") if remote: run("git", "fetch", "origin", f"refs/heads/{branch}") - verify("FETCH_HEAD") promotion_commit = run("git", "rev-parse", "FETCH_HEAD") base = run("git", "merge-base", "origin/main", "FETCH_HEAD") changed = set(run("git", "diff", "--name-only", base, "FETCH_HEAD").splitlines()) - catalog = json.loads(run("git", "show", "FETCH_HEAD:catalog/stable.json")) - catalog["releases"] = [entry for entry in catalog["releases"] if entry["version"] != version] - if changed != {manifest_path, "catalog/stable.json"} or catalog != json.loads( - run("git", "show", f"{base}:catalog/stable.json") - ): + if changed != {changed_path}: raise ValueError("existing promotion contains unrelated changes") + verify_manifest("FETCH_HEAD") + if catalog_phase: + verify_catalog("FETCH_HEAD") + catalog = json.loads(run("git", "show", "FETCH_HEAD:catalog/stable.json")) + catalog["releases"] = [entry for entry in catalog["releases"] if entry["version"] != version] + if catalog != json.loads(run("git", "show", f"{base}:catalog/stable.json")): + raise ValueError("existing promotion contains unrelated catalog changes") else: run("git", "switch", "--detach", "origin/main") - path = root / manifest_path - path.parent.mkdir(parents=True, exist_ok=True) - path.write_bytes(payload) - run("git", "add", manifest_path) - run("git", "commit", "-m", f"Add immutable manifest for PIG {version}") - commit = run("git", "rev-parse", "HEAD") - promote_catalog(root, path, commit) - run("git", "add", "catalog/stable.json") - run("git", "commit", "-m", f"Select PIG {version} in the stable catalog") + if catalog_phase: + # This commit is already on main, including after a squash merge. + commit = run("git", "log", "-1", "--format=%H", "origin/main", "--", manifest_path) + promote_catalog(root, root / manifest_path, commit) + else: + path = root / manifest_path + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(payload) + run("git", "add", changed_path) + message = ( + f"Select PIG {version} in the stable catalog" + if catalog_phase + else f"Add immutable manifest for PIG {version}" + ) + run("git", "commit", "-m", message) promotion_commit = run("git", "rev-parse", "HEAD") - # An absent-ref lease also rejects a concurrently created branch, even - # if our commits happen to descend from it. Never rewrite published refs. + # An absent-ref lease rejects concurrent creation without rewriting it. run("git", "push", f"--force-with-lease=refs/heads/{branch}:", "origin", f"HEAD:refs/heads/{branch}") pulls = json.loads( @@ -101,8 +117,6 @@ def verify(ref: str) -> None: ] for pull in pulls: if pull["state"] == "MERGED" and already_promoted: - run("git", "merge-base", "--is-ancestor", pull["headRefOid"], "origin/main") - verify(pull["headRefOid"]) return pull["url"] if pull["state"] == "OPEN" and not already_promoted and pull["headRefOid"] == promotion_commit: return pull["url"] @@ -110,11 +124,14 @@ def verify(ref: str) -> None: raise ValueError("existing promotion has no matching open or merged PR; inspect its review state") with tempfile.TemporaryDirectory(prefix="pig-promotion-") as directory: body = Path(directory) / "body.md" + if catalog_phase: + title = f"Promote PIG {version} to stable" + description = f"Select PIG {version} using the immutable manifest already merged into main. Merging this catalog entry makes the release eligible for automatic updates, including major upgrades." + else: + title = f"Retain the immutable manifest for PIG {version}" + description = f"Retain the accepted PIG {version} manifest on main. Both runtime images, chart packages, and source archive passed anonymous access checks. After this PR merges, rerun publication to create the stable catalog selection PR." body.write_text( - f"Promote the accepted PIG {version} release. Both runtime images, chart packages, and source archive " - "passed anonymous access checks. Release-specific acceptance or owner sign-off is in " - f"releases/acceptance/{version}.json. Merging this catalog entry makes the release eligible for " - "automatic updates, including major upgrades.\n" + f"{description}\n\nRelease-specific acceptance or owner sign-off is in releases/acceptance/{version}.json.\n" ) return run( "gh", @@ -128,7 +145,7 @@ def verify(ref: str) -> None: "--head", branch, "--title", - f"Promote PIG {version} to stable", + title, "--body-file", str(body), ) diff --git a/tests/test_promotion.py b/tests/test_promotion.py index 3b95b67..9f6de38 100644 --- a/tests/test_promotion.py +++ b/tests/test_promotion.py @@ -7,7 +7,8 @@ import pytest from pig_supervisor.promotion import promote_draft -BRANCH = "release/catalog-0.3.0" +MANIFEST_BRANCH = "release/manifest-0.3.0" +CATALOG_BRANCH = "release/catalog-0.3.0" URL = "https://github.com/Promptless/pig-deploy/pull/123" @@ -65,105 +66,166 @@ def run(args, **kwargs): if args[0] != "gh": return check_output(args, **kwargs) if args[1:3] == ("pr", "list"): - return json.dumps(github.pulls) + branch = args[args.index("--head") + 1] + return json.dumps([pull for pull in github.pulls if pull["headRefName"] == branch]) assert args[1:3] == ("pr", "create") assert "--draft" in args github.creates += 1 if github.fail_create: raise subprocess.CalledProcessError(1, args, "PR creation interrupted") - github.pulls = [ + branch = args[args.index("--head") + 1] + github.pulls.append( { "url": URL, "state": "OPEN", "headRepository": {"nameWithOwner": "Promptless/pig-deploy"}, - "headRefOid": git(remote, "rev-parse", "refs/heads/" + BRANCH), + "headRefOid": git(remote, "rev-parse", "refs/heads/" + branch), + "headRefName": branch, } - ] + ) return URL + "\n" monkeypatch.setattr(subprocess, "check_output", run) return root, remote, manifest, github -def test_resume_after_push_reuses_manifest_commit_and_existing_pr(promotion, tmp_path): +def squash_merge(root, github, branch): + git(root, "fetch", "origin", "main") + git(root, "fetch", "origin", branch) + tip = git(root, "rev-parse", "FETCH_HEAD") + git(root, "switch", "--detach", "origin/main") + git(root, "merge", "--squash", tip) + git(root, "commit", "-m", "Merge reviewed " + branch) + commit = git(root, "rev-parse", "HEAD") + git(root, "push", "origin", "HEAD:refs/heads/main") + git(root, "push", "origin", "--delete", branch) + for pull in github.pulls: + if pull["headRefName"] == branch: + pull["state"] = "MERGED" + return commit + + +def merge_manifest(promotion): + root, _remote, manifest, github = promotion + github.fail_create = False + assert promote_draft(root, manifest) == URL + return squash_merge(root, github, MANIFEST_BRANCH) + + +@pytest.mark.parametrize("catalog_phase", [False, True]) +def test_resume_after_push_reuses_existing_branch_and_pr(promotion, tmp_path, catalog_phase): root, remote, manifest, github = promotion + if catalog_phase: + merge_manifest(promotion) + branch = CATALOG_BRANCH if catalog_phase else MANIFEST_BRANCH + github.fail_create = True with pytest.raises(subprocess.CalledProcessError): promote_draft(root, manifest) - tip = git(remote, "rev-parse", "refs/heads/" + BRANCH) - index = git(remote, "show", f"{tip}:catalog/stable.json") + tip = git(remote, "rev-parse", "refs/heads/" + branch) + creates = github.creates clone = tmp_path / "retry" git(root, "clone", "--branch", "main", str(remote), str(clone)) github.fail_create = False assert promote_draft(clone, manifest) == URL assert promote_draft(clone, manifest) == URL - assert github.creates == 2 # One interrupted request, then one successful request. - assert git(remote, "rev-parse", "refs/heads/" + BRANCH) == tip - assert git(remote, "show", f"{tip}:catalog/stable.json") == index - manifest_commit = json.loads(index)["releases"][0]["url"].split("/")[5] - assert git(remote, "merge-base", "--is-ancestor", manifest_commit, tip) == "" + assert github.creates == creates + 1 + assert git(remote, "rev-parse", "refs/heads/" + branch) == tip -@pytest.mark.parametrize("mismatch", ["bytes", "digest", "commit", "unrelated"]) -def test_resume_rejects_modified_promotion_without_rewriting_it(promotion, mismatch): +@pytest.mark.parametrize("catalog_phase", [False, True]) +@pytest.mark.parametrize("mismatch", ["bytes", "unrelated"]) +def test_resume_rejects_modified_promotion_without_rewriting_it(promotion, catalog_phase, mismatch): root, remote, manifest, github = promotion + if catalog_phase: + merge_manifest(promotion) + branch = CATALOG_BRANCH if catalog_phase else MANIFEST_BRANCH + github.fail_create = True with pytest.raises(subprocess.CalledProcessError): promote_draft(root, manifest) if mismatch == "bytes": (root / "catalog/releases/0.3.0.json").write_text(manifest.read_text() + "\n") - elif mismatch == "unrelated": - (root / "unrelated.txt").write_text("unexpected change\n") else: - path = root / "catalog/stable.json" - catalog = json.loads(path.read_text()) - if mismatch == "digest": - catalog["releases"][0]["sha256"] = "f" * 64 - else: - catalog["releases"][0]["url"] = catalog["releases"][0]["url"].replace( - catalog["releases"][0]["url"].split("/")[5], "main" - ) - path.write_text(json.dumps(catalog)) - git(root, "add", "catalog", "unrelated.txt") if mismatch == "unrelated" else git(root, "add", "catalog") + (root / "unrelated.txt").write_text("unexpected change\n") + git(root, "add", ".") git(root, "commit", "-m", "Change existing promotion") - git(root, "push", "origin", f"HEAD:refs/heads/{BRANCH}") - tip = git(remote, "rev-parse", "refs/heads/" + BRANCH) + git(root, "push", "origin", f"HEAD:refs/heads/{branch}") + tip = git(remote, "rev-parse", "refs/heads/" + branch) + creates = github.creates github.fail_create = False with pytest.raises(ValueError): promote_draft(root, manifest) - assert github.creates == 1 - assert git(remote, "rev-parse", "refs/heads/" + BRANCH) == tip + assert github.creates == creates + assert git(remote, "rev-parse", "refs/heads/" + branch) == tip -def test_retry_after_merge_and_branch_deletion_returns_merged_pr(promotion): +@pytest.mark.parametrize("mismatch", ["digest", "commit", "other-release"]) +def test_catalog_retry_rejects_invalid_selection(promotion, mismatch): root, _remote, manifest, github = promotion - github.fail_create = False + merge_manifest(promotion) + github.fail_create = True + with pytest.raises(subprocess.CalledProcessError): + promote_draft(root, manifest) + path = root / "catalog/stable.json" + catalog = json.loads(path.read_text()) + entry = catalog["releases"][0] + if mismatch == "digest": + entry["sha256"] = "f" * 64 + elif mismatch == "commit": + entry["url"] = entry["url"].replace(entry["url"].split("/")[5], "main") + else: + catalog["releases"].append({**entry, "version": "0.3.1"}) + path.write_text(json.dumps(catalog)) + git(root, "add", "catalog") + git(root, "commit", "-m", "Change existing selection") + git(root, "push", "origin", f"HEAD:refs/heads/{CATALOG_BRANCH}") + with pytest.raises(ValueError): + promote_draft(root, manifest) + + +def test_squash_merges_retain_manifest_before_selecting_stable(promotion): + root, remote, manifest, github = promotion + manifest_commit = merge_manifest(promotion) + assert json.loads(git(remote, "show", "main:catalog/stable.json"))["releases"] == [] assert promote_draft(root, manifest) == URL - git(root, "push", "origin", "HEAD:refs/heads/main") - git(root, "push", "origin", "--delete", BRANCH) - github.pulls[0]["state"] = "MERGED" + catalog = json.loads(git(remote, "show", f"{CATALOG_BRANCH}:catalog/stable.json")) + assert catalog["releases"][0]["url"].split("/")[5] == manifest_commit + squash_merge(root, github, CATALOG_BRANCH) assert promote_draft(root, manifest) == URL - assert github.creates == 1 + assert git(remote, "merge-base", "--is-ancestor", manifest_commit, "main") == "" + assert git(remote, "show", f"{manifest_commit}:catalog/releases/0.3.0.json") == manifest.read_text().strip() + assert github.creates == 2 -def test_closed_unmerged_promotion_requires_review(promotion): +def test_merged_manifest_cannot_be_replaced(promotion): + root, _remote, manifest, _github = promotion + merge_manifest(promotion) + manifest.write_text(manifest.read_text() + "\n") + with pytest.raises(ValueError, match="manifest bytes"): + promote_draft(root, manifest) + + +@pytest.mark.parametrize("catalog_phase", [False, True]) +def test_closed_unmerged_promotion_requires_review(promotion, catalog_phase): root, _remote, manifest, github = promotion + if catalog_phase: + merge_manifest(promotion) github.fail_create = False promote_draft(root, manifest) - github.pulls[0]["state"] = "CLOSED" + github.pulls[-1]["state"] = "CLOSED" with pytest.raises(ValueError, match="review state"): promote_draft(root, manifest) - assert github.creates == 1 -@pytest.mark.parametrize("foreign_state", ["OPEN", "CLOSED"]) -def test_same_named_fork_pr_does_not_replace_canonical_promotion(promotion, foreign_state): +def test_same_named_fork_pr_does_not_replace_canonical_promotion(promotion): root, _remote, manifest, github = promotion github.fail_create = False github.pulls = [ { "url": "https://github.com/Promptless/pig-deploy/pull/122", - "state": foreign_state, + "state": "CLOSED", "headRepository": {"nameWithOwner": "someone/pig-deploy"}, "headRefOid": "f" * 40, + "headRefName": MANIFEST_BRANCH, } ] assert promote_draft(root, manifest) == URL diff --git a/uv.lock b/uv.lock index 04865f9..ff309ca 100644 --- a/uv.lock +++ b/uv.lock @@ -153,7 +153,7 @@ wheels = [ [[package]] name = "pig-supervisor" -version = "0.3.5" +version = "0.3.6" source = { editable = "." } dependencies = [ { name = "httpx" },