diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index f302504..845a496 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -26,6 +26,7 @@ env: jobs: build: name: Verify and build the immutable default-branch release + if: github.run_attempt == 1 runs-on: ubuntu-latest timeout-minutes: 25 outputs: @@ -93,6 +94,7 @@ jobs: release-live-smoke: name: Verify the exact release commit against CometAPI + if: github.run_attempt == 1 needs: - build concurrency: @@ -114,6 +116,10 @@ jobs: COMETAPI_LIVE_RUN: "1" COMETAPI_LIVE_STOP_ON_FAILURE: "1" steps: + - name: Require the protected live credential + env: + COMETAPI_KEY: ${{ secrets.COMETAPI_KEY }} + run: test -n "$COMETAPI_KEY" - name: Check out the verified release commit uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -138,6 +144,7 @@ jobs: publish: name: Publish verified artifacts with PyPI OIDC + if: github.run_attempt == 1 needs: - build - release-live-smoke @@ -167,6 +174,7 @@ jobs: verify-registry: name: Verify the public registry artifact + if: github.run_attempt == 1 needs: - build - publish diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index ea8bb65..facb634 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -88,3 +88,4 @@ jobs: release-tag: ${{ needs.release-please.outputs.release-tag }} release-sha: ${{ needs.release-please.outputs.release-sha }} default-branch: ${{ github.event.repository.default_branch }} + secrets: inherit diff --git a/.github/workflows/release-recovery.yml b/.github/workflows/release-recovery.yml new file mode 100644 index 0000000..a080490 --- /dev/null +++ b/.github/workflows/release-recovery.yml @@ -0,0 +1,90 @@ +name: Recover immutable release publication + +on: + workflow_dispatch: + inputs: + release-tag: + description: Exact immutable GitHub release tag + required: true + type: string + release-sha: + description: Exact commit resolved by the release tag + required: true + type: string + +permissions: + contents: read + +concurrency: + group: release-recovery + cancel-in-progress: false + +jobs: + verify-recovery: + name: Verify the authorized immutable release recovery + if: >- + github.run_attempt == 1 && + github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && + vars.RELEASE_RECOVERY_TAG == inputs.release-tag && + vars.RELEASE_RECOVERY_SHA == inputs.release-sha + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + release-sha: ${{ steps.verify-release.outputs.release-sha }} + release-tag: ${{ steps.verify-release.outputs.release-tag }} + permissions: + contents: read + steps: + - name: Verify the immutable release selected for recovery + id: verify-release + env: + EXPECTED_SHA: ${{ inputs.release-sha }} + EXPECTED_TAG: ${{ inputs.release-tag }} + GH_TOKEN: ${{ github.token }} + run: | + test -n "$EXPECTED_TAG" + test -n "$EXPECTED_SHA" + release="" + for attempt in $(seq 1 12); do + release=$(gh api "repos/${{ github.repository }}/releases/tags/$EXPECTED_TAG") || true + if test -n "$release" && test "$(jq -r .immutable <<<"$release")" = "true"; then + break + fi + if test "$attempt" -ge 12; then + echo "release did not become immutable" >&2 + exit 1 + fi + sleep 5 + done + test "$(jq -r .tag_name <<<"$release")" = "$EXPECTED_TAG" + test "$(jq -r .draft <<<"$release")" = "false" + test "$(jq -r .prerelease <<<"$release")" = "false" + test "$(jq -r .immutable <<<"$release")" = "true" + ref=$(gh api "repos/${{ github.repository }}/git/ref/tags/$EXPECTED_TAG") + tag_type=$(jq -r .object.type <<<"$ref") + tag_sha=$(jq -r .object.sha <<<"$ref") + if test "$tag_type" = "tag"; then + tag_sha=$(gh api "repos/${{ github.repository }}/git/tags/$tag_sha" --jq .object.sha) + else + test "$tag_type" = "commit" + fi + test "$tag_sha" = "$EXPECTED_SHA" + { + echo "release-tag=$EXPECTED_TAG" + echo "release-sha=$EXPECTED_SHA" + echo "release-verified=true" + } >> "$GITHUB_OUTPUT" + + publish-release: + name: Run the protected publication recovery + needs: verify-recovery + if: github.run_attempt == 1 + permissions: + contents: read + id-token: write + uses: ./.github/workflows/publish.yml + with: + release-tag: ${{ needs.verify-recovery.outputs.release-tag }} + release-sha: ${{ needs.verify-recovery.outputs.release-sha }} + default-branch: ${{ github.event.repository.default_branch }} + secrets: inherit diff --git a/AGENTS.md b/AGENTS.md index c8b2ab4..6d487c6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -99,7 +99,9 @@ Post-alpha invariants: `LIVE_SMOKE_ENABLED=true`, and keep `RELEASE_PLEASE_ENABLED` disabled outside an explicitly authorized release sequence. The reviewed `last-release-sha` bridge was used once to generate the stable release PR and must remain absent - after its human finalization. + after its human finalization. Keep `RELEASE_RECOVERY_TAG` and + `RELEASE_RECOVERY_SHA` absent outside an explicitly authorized recovery of + that exact existing immutable release identity. 4. Treat the recorded public rules, security reporting, immutable releases, and protected environments as readiness invariants. Any drift invalidates the readiness claim until it is explicitly authorized, restored, and verified. @@ -253,7 +255,13 @@ committed. - Missing identity, credentials, environments, reviewers, protection, publisher configuration, or approval blocks publication; no conditional skip or mock may bypass it. -- Manual or arbitrary-branch publication is forbidden. +- Arbitrary-branch publication is forbidden. Manual publication is permitted + only through the reviewed `release-recovery.yml` workflow from the protected + default branch, with `RELEASE_RECOVERY_TAG` and `RELEASE_RECOVERY_SHA` equal + to its exact inputs, after separately verifying the existing immutable tag + and commit. The recovery and reusable publication jobs must reject every + workflow rerun. Delete both variables immediately after the recovery succeeds + or stops. - A successful build or upload is not a release. Registry installation, import, mocked-call smoke, and provenance must be verified separately. - Every distribution `Project-URL` must use HTTPS. The canonical Support URL @@ -269,10 +277,13 @@ committed. `last-release-sha` bridge because the recovery tag's build metadata could not be inferred from the manifest. The human-finalized stable release PR removed that bridge and its prerelease-versioning controls; keep them absent. -- Keep third-party Actions pinned to full commit SHAs. Grant `id-token: write` - only to the reusable publication caller and the protected publishing job; - the caller passes this maximum permission and only the publishing job uses - the OIDC token. +- Keep third-party Actions pinned to full commit SHAs. Every local caller of + the reusable publication workflow must use `secrets: inherit`; GitHub-hosted + runners otherwise can resolve its job-level environment secret as empty. + Keep the semantic workflow checker's inheritance regression coverage. Grant + `id-token: write` only to a reviewed reusable publication caller and the + protected publishing job; callers pass this maximum permission and only the + publishing job uses the OIDC token. - Keep README, roadmap, compatibility matrix, examples, and changelog aligned with shipped behavior. Use currently supported model IDs. - All repository documentation is written in English. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 9f835fe..ceff43d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -134,6 +134,19 @@ protected exact-release live job. OIDC permission is exposed only to the protected publish job. Missing credentials, environments, approvals, or remote configuration block publication. +The protected publication chain is reusable, and every repository-local caller +must declare `secrets: inherit`. GitHub-hosted runners can otherwise bind the +called job to the `live-smoke` environment while silently resolving its +environment secret as empty. `scripts/check_workflows.py` rejects a caller that +omits inheritance and requires a credential preflight before any live request. +`release-recovery.yml` is the sole manual recovery path for an already-created +immutable release: it runs only from the protected default branch behind a +temporary tag-and-commit identity opt-in, independently verifies that exact +release identity, and then calls the same protected build, live, OIDC, +provenance, and registry chain. Both the recovery caller and reusable +publication jobs reject rerun attempts so an old authorization cannot be +replayed through GitHub's rerun controls. + The initial alpha has one release-identity exception. GitHub's immutable release tombstone permanently reserves `v0.1.0-alpha.1`, so the reviewed recovery release uses SemVer build metadata in diff --git a/CHANGELOG.md b/CHANGELOG.md index 7baa76a..63e6c03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ automation. ## [Unreleased] +### Fixed + +- Require environment-secret inheritance for every reusable publication caller + and add a fail-closed immutable-release recovery path. + ## [0.1.0] - 2026-07-28 ### Features diff --git a/RELEASING.md b/RELEASING.md index 4f5b183..b42166a 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -50,7 +50,10 @@ configuration later used an explicit `last-release-sha` bridge to establish the recovery alpha as the previous-release boundary. Maintainers enabled the repository variable only to start the stable release sequence, and human finalization removed the bridge. An unset or non-true variable prevents the -corresponding gated job from executing. +corresponding gated job from executing. `RELEASE_RECOVERY_TAG` and +`RELEASE_RECOVERY_SHA` are absent by default and may exist only during an +explicitly authorized recovery of that exact existing immutable release +identity. The release live-model configuration resolves an unset or empty `COMETAPI_LIVE_MODEL` to `gpt-5.4`. @@ -191,6 +194,12 @@ violations in one run and still returns non-zero when any violation exists. the GitHub API until that exact tag and commit are independently reported as immutable, then invokes the protected publication chain directly; workflow-token release events do not trigger a second workflow run. +- `release-recovery.yml` is the only manual publication path. It requires an + exact immutable tag and commit, the protected default branch, and the + temporary `RELEASE_RECOVERY_TAG` and `RELEASE_RECOVERY_SHA` identity opt-in + before it calls the same protected publication chain. Delete both variables + immediately after success or failure. The workflow and reusable publication + jobs reject every rerun attempt. - `publish.yml` is called only with the independently verified immutable tag, commit, and default branch. It resolves the tag to the checked-out commit, fetches the protected default branch, and rejects a commit that is not @@ -205,9 +214,14 @@ violations in one run and still returns non-zero when any violation exists. Third-party Actions are pinned to full commit SHAs. Workflow permissions are read-only by default. The reusable publication caller and protected publishing job declare `id-token: write`; the caller passes the maximum permission and -only the publishing job requests the OIDC token. +only the publishing job requests the OIDC token. Every repository-local caller +of `publish.yml` declares `secrets: inherit`; without it, GitHub-hosted runners +can silently resolve the called job's environment secret as empty. The semantic +workflow checker enforces inheritance and the live job checks the credential +before making a request. Publishing uses a protected `pypi` environment and concurrency control. -Arbitrary-branch and manual publication are forbidden. +Arbitrary-branch publication is forbidden. Manual publication is limited to the +reviewed immutable-release recovery described below. ## Alpha release checklist (completed) @@ -316,3 +330,41 @@ project metadata, lock file, and changelog must remain at the exact generated `0.1.0` version. If GitHub requires approval before checks run on the automated pull request, approve only that reviewed workflow execution and wait for every blocking check. + +## Immutable release publication recovery + +Use recovery only when an immutable GitHub release exists, its protected +publication chain stopped before PyPI accepted the version, and a reviewed fix +has already reached `main`. Do not create another tag or release, change the +existing release, bypass live smoke, or publish an artifact retained from the +failed run. + +Before dispatch, verify that the exact PyPI version is absent, the release is +immutable and non-draft, its tag resolves to the supplied commit, that commit is +reachable from protected `main`, and the repository-local caller uses +`secrets: inherit`. Then enable only the one-time recovery gate and dispatch the +workflow from `main` with the exact immutable identity: + +```bash +gh variable set RELEASE_RECOVERY_TAG --body '' +gh variable set RELEASE_RECOVERY_SHA --body '' +gh workflow run release-recovery.yml --ref main \ + -f release-tag='' \ + -f release-sha='' +``` + +The run must rebuild and verify the exact tag, pass the credential preflight and +bounded four-request live suite, wait for protected `pypi` approval, publish by +OIDC, verify provenance and public digests, and pass the registry clean-install +smoke. Delete the gate immediately after the run succeeds or stops: + +```bash +gh variable delete RELEASE_RECOVERY_TAG +gh variable delete RELEASE_RECOVERY_SHA +``` + +A recovery failure stops the sequence. Diagnose and land a separate reviewed +fix before requesting another explicit recovery authorization; do not rerun a +failed job merely to obtain a different result. The workflow enforces this by +allowing only `github.run_attempt == 1` at both the recovery and publication +boundaries. diff --git a/ROADMAP.md b/ROADMAP.md index ac25d23..e285d2d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -437,6 +437,18 @@ Stable 0.1 retains the alpha surface. Its additional exit criteria are: independent post-publication install/import/mocked-call check. - No complete credential appears in source, fixtures, artifacts, or logs. +The first stable publication attempt created immutable release `v0.1.0` at +`6f42981edcc6c252f8db997606671c3da84d1dd8` and passed default-branch CI plus +exact artifact construction, but [stopped before any live request](https://github.com/cometapi-dev/cometapi-python/actions/runs/30348177128) +because the reusable workflow caller omitted `secrets: inherit` and GitHub +resolved the `live-smoke` environment secret as empty. PyPI publication and +registry verification were skipped. The permanent correction requires +inheritance on every publish caller, checks the credential before any request, +and provides a default-branch-only, explicitly enabled recovery of that exact +immutable identity through the unchanged protected publication chain. Stable +remains unreleased until the recovery live, OIDC, provenance, and registry gates +pass. + ## `0.2.0`: Provider-native text adapters Planned scope: @@ -458,7 +470,7 @@ separated under `resources/` and `types/` when this milestone begins. ## CI/CD contract -The repository maintains four independently auditable workflows: +The repository maintains five independently auditable workflows: - `ci.yml`: offline lint, type, unit, contract, build, artifact, and clean install checks for pull requests and default-branch pushes. @@ -468,6 +480,8 @@ The repository maintains four independently auditable workflows: - `release-please.yml`: a human-reviewed version and changelog pull request, followed by bounded API verification of the exact immutable release and a direct call into the protected publication chain. +- `release-recovery.yml`: an explicitly enabled, protected-default-branch-only + recovery of an independently verified existing immutable release. - `publish.yml`: reusable immutable-tag, commit, and default-branch ancestry verification, exact-release protected live smoke, artifact rebuild and verification, protected PyPI OIDC publication, provenance, and registry @@ -483,9 +497,13 @@ Release Please requires `RELEASE_PLEASE_ENABLED=true` and remains disabled outside an explicitly authorized release sequence. Its reviewed one-time `last-release-sha` bridge established the recovery alpha boundary, generated the stable release PR, and was removed during human finalization. Release jobs -must resolve an unset or empty -`COMETAPI_LIVE_MODEL` to `gpt-5.4` rather than attempt a request with an empty -model. +must resolve an unset or empty `COMETAPI_LIVE_MODEL` to `gpt-5.4` rather than +attempt a request with an empty model. Immutable-release recovery additionally +requires `RELEASE_RECOVERY_TAG` and `RELEASE_RECOVERY_SHA` to equal the exact +dispatch inputs; keep both variables absent except for one explicitly authorized +identity and delete them immediately after success or failure. Recovery and +publication jobs reject rerun attempts. Every reusable publish caller must use +`secrets: inherit`. ## Maintenance cadence diff --git a/SECURITY.md b/SECURITY.md index 9fa9c7f..86d1bed 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -47,8 +47,13 @@ upload is incomplete until provenance and a clean public-registry installation have been verified. Third-party GitHub Actions must be pinned to full commit SHAs. The reusable -workflow caller and protected publishing job may declare `id-token: write`, but -only the publishing job may request the OIDC token. +workflow callers and protected publishing job may declare `id-token: write`, +but only the publishing job may request the OIDC token. Repository-local +publication callers must use `secrets: inherit` so the called `live-smoke` +environment can resolve its scoped credential; the reusable workflow may +reference that credential only in its protected preflight and live-test steps. +Recovery and reusable publication jobs must reject workflow reruns so an old +authorization cannot be replayed. ## Scope diff --git a/scripts/check_secrets.py b/scripts/check_secrets.py index b6af5df..1c09f8b 100644 --- a/scripts/check_secrets.py +++ b/scripts/check_secrets.py @@ -118,6 +118,7 @@ def scan_workflow_scope(root: Path) -> list[str]: allowed_id_token_counts = { "publish.yml": 1, "release-please.yml": 1, + "release-recovery.yml": 1, } for path in sorted( candidate diff --git a/scripts/check_workflows.py b/scripts/check_workflows.py index e84fd29..029ade2 100644 --- a/scripts/check_workflows.py +++ b/scripts/check_workflows.py @@ -746,7 +746,7 @@ def check_release_please_workflow(text: str) -> None: publish_job = _workflow_job(workflow, "publish-release", "Release Please workflow") _require_exact_keys( publish_job, - {"name", "needs", "if", "permissions", "uses", "with"}, + {"name", "needs", "if", "permissions", "uses", "with", "secrets"}, "Release Please publish caller", ) expected_publish_condition = ( @@ -766,6 +766,11 @@ def check_release_please_workflow(text: str) -> None: ) if publish_job["uses"] != "./.github/workflows/publish.yml": raise CheckError("Release Please must call the reviewed protected publish workflow") + if publish_job["secrets"] != "inherit": + raise CheckError( + "Release Please publish caller must inherit environment secrets for the " + "reusable workflow" + ) if _mapping(publish_job["with"], "Release Please publish inputs") != { "release-tag": "${{ needs.release-please.outputs.release-tag }}", "release-sha": "${{ needs.release-please.outputs.release-sha }}", @@ -776,6 +781,142 @@ def check_release_please_workflow(text: str) -> None: raise CheckError("Release Please must not depend on explicit repository credentials") +def check_release_recovery_workflow(text: str) -> None: + """Require a default-branch-only, explicitly enabled immutable release recovery.""" + workflow = _load_workflow(text, "release recovery workflow") + _require_exact_keys( + workflow, + {"name", "on", "permissions", "concurrency", "jobs"}, + "release recovery workflow", + ) + _require_permissions(workflow, {"contents": "read"}, "release recovery workflow") + if "env" in workflow or "defaults" in workflow: + raise CheckError("release recovery workflow must not override execution context") + + triggers = _mapping(workflow.get("on"), "release recovery triggers") + if set(triggers) != {"workflow_dispatch"}: + raise CheckError("release recovery must run only by explicit manual dispatch") + dispatch = _mapping(triggers["workflow_dispatch"], "release recovery dispatch") + _require_exact_keys(dispatch, {"inputs"}, "release recovery dispatch") + inputs = _mapping(dispatch["inputs"], "release recovery inputs") + if set(inputs) != {"release-tag", "release-sha"}: + raise CheckError("release recovery must accept only the exact release identity") + expected_descriptions = { + "release-tag": "Exact immutable GitHub release tag", + "release-sha": "Exact commit resolved by the release tag", + } + for name, description in expected_descriptions.items(): + if _mapping(inputs[name], f"release recovery input {name}") != { + "description": description, + "required": "true", + "type": "string", + }: + raise CheckError(f"release recovery input {name} must be an exact required string") + + concurrency = _mapping(workflow.get("concurrency"), "release recovery concurrency") + if concurrency != { + "group": "release-recovery", + "cancel-in-progress": "false", + }: + raise CheckError("release recovery must serialize all attempts without cancellation") + + jobs = _mapping(workflow.get("jobs"), "release recovery jobs") + if set(jobs) != {"verify-recovery", "publish-release"}: + raise CheckError("release recovery must contain only verification and publication") + verify = _workflow_job(workflow, "verify-recovery", "release recovery workflow") + _require_exact_keys( + verify, + { + "name", + "if", + "runs-on", + "timeout-minutes", + "outputs", + "permissions", + "steps", + }, + "release recovery verification job", + ) + expected_condition = ( + "github.run_attempt == 1 && " + "github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && " + "vars.RELEASE_RECOVERY_TAG == inputs.release-tag && " + "vars.RELEASE_RECOVERY_SHA == inputs.release-sha" + ) + if " ".join(_scalar(verify["if"], "release recovery condition").split()) != ( + expected_condition + ): + raise CheckError( + "release recovery must require the first workflow attempt, protected default " + "branch, and exact authorized release tag and commit" + ) + if verify["runs-on"] != "ubuntu-latest" or verify["timeout-minutes"] != "5": + raise CheckError("release recovery verification must use the reviewed bounded runner") + _require_permissions(verify, {"contents": "read"}, "release recovery verification job") + outputs = _mapping(verify["outputs"], "release recovery outputs") + if outputs != { + "release-sha": "${{ steps.verify-release.outputs.release-sha }}", + "release-tag": "${{ steps.verify-release.outputs.release-tag }}", + }: + raise CheckError("release recovery must expose only the verified release identity") + _require_step_names( + verify, + ["Verify the immutable release selected for recovery"], + "release recovery verification job", + ) + _require_step_environments( + verify, + { + "Verify the immutable release selected for recovery": { + "EXPECTED_SHA": "${{ inputs.release-sha }}", + "EXPECTED_TAG": "${{ inputs.release-tag }}", + "GH_TOKEN": "${{ github.token }}", + } + }, + "release recovery verification job", + ) + _require_step_working_directories(verify, {}, "release recovery verification job") + _, verify_step = _named_run_step( + verify, + "Verify the immutable release selected for recovery", + RELEASE_PLEASE_VERIFY_COMMAND, + "release recovery verification job", + ) + if verify_step.get("id") != "verify-release": + raise CheckError("release recovery verification must expose its exact outputs") + + publish = _workflow_job(workflow, "publish-release", "release recovery workflow") + _require_exact_keys( + publish, + {"name", "needs", "if", "permissions", "uses", "with", "secrets"}, + "release recovery publish caller", + ) + if publish["needs"] != "verify-recovery": + raise CheckError("release recovery publication must depend on immutable verification") + if publish["if"] != "github.run_attempt == 1": + raise CheckError("release recovery publication must run only on the first workflow attempt") + _require_permissions( + publish, + {"contents": "read", "id-token": "write"}, + "release recovery publish caller", + ) + if publish["uses"] != "./.github/workflows/publish.yml": + raise CheckError("release recovery must call the reviewed protected publish workflow") + if publish["secrets"] != "inherit": + raise CheckError( + "release recovery publish caller must inherit environment secrets for the " + "reusable workflow" + ) + if _mapping(publish["with"], "release recovery publish inputs") != { + "release-tag": "${{ needs.verify-recovery.outputs.release-tag }}", + "release-sha": "${{ needs.verify-recovery.outputs.release-sha }}", + "default-branch": "${{ github.event.repository.default_branch }}", + }: + raise CheckError("release recovery publication must use only verified outputs") + if _secret_references(workflow): + raise CheckError("release recovery must not reference explicit repository credentials") + + def check_release_please_config(text: str, manifest_text: str) -> None: """Require either the reviewed bridge or its exact stable cleanup state.""" try: @@ -917,13 +1058,20 @@ def check_publish_workflow(text: str, live_smoke_text: str) -> None: }: raise CheckError("monitoring live smoke must retain its bounded execution budget") + first_attempt_guards = 0 for mapping in _walk_mappings(workflow, "publish workflow"): if "if" in mapping: - raise CheckError("release gates must not be conditional") + if mapping["if"] != "github.run_attempt == 1": + raise CheckError( + "release gates may be conditional only on the first workflow attempt" + ) + first_attempt_guards += 1 if "continue-on-error" in mapping: raise CheckError("release gates must not be allowed to continue on error") if "defaults" in mapping or "shell" in mapping: raise CheckError("release gates must not override command execution") + if first_attempt_guards != 4: + raise CheckError("every release job must reject workflow reruns") monitoring_job = _workflow_job(live_workflow, "smoke", "live-smoke workflow") _require_exact_keys( @@ -1034,9 +1182,18 @@ def check_publish_workflow(text: str, live_smoke_text: str) -> None: publish = _workflow_job(workflow, "publish", "publish workflow") registry = _workflow_job(workflow, "verify-registry", "publish workflow") expected_release_job_keys = { - "build": {"name", "runs-on", "timeout-minutes", "outputs", "permissions", "steps"}, + "build": { + "name", + "if", + "runs-on", + "timeout-minutes", + "outputs", + "permissions", + "steps", + }, "release-live-smoke": { "name", + "if", "needs", "concurrency", "runs-on", @@ -1048,6 +1205,7 @@ def check_publish_workflow(text: str, live_smoke_text: str) -> None: }, "publish": { "name", + "if", "needs", "runs-on", "timeout-minutes", @@ -1057,6 +1215,7 @@ def check_publish_workflow(text: str, live_smoke_text: str) -> None: }, "verify-registry": { "name", + "if", "needs", "runs-on", "timeout-minutes", @@ -1071,6 +1230,8 @@ def check_publish_workflow(text: str, live_smoke_text: str) -> None: ("verify-registry", registry), ): _require_exact_keys(job, expected_release_job_keys[name], f"release {name} job") + if job["if"] != "github.run_attempt == 1": + raise CheckError(f"release {name} job must run only on the first workflow attempt") _require_step_working_directories( job, ({"Recheck immutable artifact digests": "release-bundle"} if name == "publish" else {}), @@ -1255,6 +1416,7 @@ def check_publish_workflow(text: str, live_smoke_text: str) -> None: _require_step_names( release_live, [ + "Require the protected live credential", "Check out the verified release commit", "Require the exact verified release commit", "Set up Python", @@ -1267,6 +1429,9 @@ def check_publish_workflow(text: str, live_smoke_text: str) -> None: _require_step_environments( release_live, { + "Require the protected live credential": { + "COMETAPI_KEY": "${{ secrets.COMETAPI_KEY }}" + }, "Require the exact verified release commit": { "RELEASE_COMMIT": "${{ needs.build.outputs.release-commit }}" }, @@ -1276,6 +1441,16 @@ def check_publish_workflow(text: str, live_smoke_text: str) -> None: }, "exact-release live-smoke job", ) + _, credential_check = _named_run_step( + release_live, + "Require the protected live credential", + 'test -n "$COMETAPI_KEY"', + "exact-release live-smoke job", + ) + if _mapping(credential_check.get("env"), "exact-release credential-check environment") != { + "COMETAPI_KEY": "${{ secrets.COMETAPI_KEY }}" + }: + raise CheckError("exact-release live credential preflight must stay step-scoped") _, release_checkout = _named_action_step( release_live, "Check out the verified release commit", @@ -1520,8 +1695,13 @@ def check_publish_workflow(text: str, live_smoke_text: str) -> None: }: raise CheckError("registry clean install must use the verified build version") - if _secret_references(workflow) != ["${{ secrets.COMETAPI_KEY }}"]: - raise CheckError("COMETAPI_KEY must appear only in the exact-release live-smoke job") + if _secret_references(workflow) != [ + "${{ secrets.COMETAPI_KEY }}", + "${{ secrets.COMETAPI_KEY }}", + ]: + raise CheckError( + "COMETAPI_KEY must appear only in the exact-release credential preflight and test" + ) def workflow_paths(directory: Path) -> list[Path]: @@ -1534,7 +1714,13 @@ def check_workflow_inventory(directory: Path) -> list[Path]: "workflow directory and .github parent must be real repository directories" ) paths = workflow_paths(directory) - expected = {"ci.yml", "live-smoke.yml", "publish.yml", "release-please.yml"} + expected = { + "ci.yml", + "live-smoke.yml", + "publish.yml", + "release-please.yml", + "release-recovery.yml", + } actual = {path.name for path in paths} if actual != expected: missing = ", ".join(sorted(expected - actual)) or "none" @@ -1566,6 +1752,11 @@ def main() -> int: type=Path, default=PROJECT_ROOT / ".github" / "workflows" / "release-please.yml", ) + parser.add_argument( + "--release-recovery-workflow", + type=Path, + default=PROJECT_ROOT / ".github" / "workflows" / "release-recovery.yml", + ) parser.add_argument( "--ci-workflow", type=Path, @@ -1588,6 +1779,7 @@ def main() -> int: args.live_smoke_workflow.read_text(encoding="utf-8"), ) check_release_please_workflow(args.release_please_workflow.read_text(encoding="utf-8")) + check_release_recovery_workflow(args.release_recovery_workflow.read_text(encoding="utf-8")) check_release_please_config( args.release_please_config.read_text(encoding="utf-8"), args.release_please_manifest.read_text(encoding="utf-8"), diff --git a/tests/test_release_workflow.py b/tests/test_release_workflow.py index 53a6e07..b33eeab 100644 --- a/tests/test_release_workflow.py +++ b/tests/test_release_workflow.py @@ -15,6 +15,7 @@ check_publish_workflow, check_release_please_config, check_release_please_workflow, + check_release_recovery_workflow, check_workflow_inventory, workflow_paths, ) @@ -25,6 +26,7 @@ PUBLISH_WORKFLOW = PROJECT_ROOT / ".github" / "workflows" / "publish.yml" LIVE_SMOKE_WORKFLOW = PROJECT_ROOT / ".github" / "workflows" / "live-smoke.yml" RELEASE_PLEASE_WORKFLOW = PROJECT_ROOT / ".github" / "workflows" / "release-please.yml" +RELEASE_RECOVERY_WORKFLOW = PROJECT_ROOT / ".github" / "workflows" / "release-recovery.yml" RELEASE_PLEASE_CONFIG = PROJECT_ROOT / "release-please-config.json" RELEASE_PLEASE_MANIFEST = PROJECT_ROOT / ".release-please-manifest.json" TRUST_SCRIPT = PROJECT_ROOT / "scripts" / "verify_release_trust.sh" @@ -302,6 +304,15 @@ def _remove_live_model_fallback(text: str) -> str: ) +def _remove_live_credential_preflight(text: str) -> str: + start, end = _step(text, "Require the protected live credential") + return text[:start] + text[end:] + + +def _allow_publication_rerun(text: str) -> str: + return text.replace(" if: github.run_attempt == 1\n", " if: always()\n", 1) + + PUBLICATION_BYPASSES: list[Callable[[str], str]] = [ _bypass_immutable_event, _remove_live_dependency, @@ -327,6 +338,8 @@ def _remove_live_model_fallback(text: str) -> str: _quote_publish_if_key, _quote_build_write_permission, _remove_live_model_fallback, + _remove_live_credential_preflight, + _allow_publication_rerun, ] @@ -436,6 +449,8 @@ def test_workflow_contract_rejects_unpinned_docker_action() -> None: "quoted-publish-if-bypass", "quoted-build-write-bypass", "empty-live-model-bypass", + "missing-live-credential-preflight", + "publication-rerun-bypass", ], ) def test_semantic_contract_rejects_publication_bypasses( @@ -759,6 +774,84 @@ def test_current_release_please_workflow_is_disabled_by_default() -> None: check_release_please_workflow(RELEASE_PLEASE_WORKFLOW.read_text(encoding="utf-8")) +def test_current_release_recovery_workflow_is_disabled_by_default() -> None: + check_release_recovery_workflow(RELEASE_RECOVERY_WORKFLOW.read_text(encoding="utf-8")) + + +@pytest.mark.parametrize( + ("needle", "replacement", "message"), + [ + ( + "github.run_attempt == 1 &&", + "github.run_attempt >= 1 &&", + "first workflow attempt", + ), + ( + "github.ref == format('refs/heads/{0}', github.event.repository.default_branch)", + "github.event_name == 'workflow_dispatch'", + "protected default branch", + ), + ( + "vars.RELEASE_RECOVERY_TAG == inputs.release-tag", + "inputs.release-tag != ''", + "exact authorized release tag", + ), + ( + "vars.RELEASE_RECOVERY_SHA == inputs.release-sha", + "inputs.release-sha != ''", + "exact authorized release tag and commit", + ), + ( + " if: github.run_attempt == 1\n permissions:", + " if: always()\n permissions:", + "first workflow attempt", + ), + ( + "release-tag: ${{ needs.verify-recovery.outputs.release-tag }}", + "release-tag: ${{ inputs.release-tag }}", + "verified outputs", + ), + ( + "secrets: inherit", + "secrets:\n COMETAPI_KEY: ${{ secrets.COMETAPI_KEY }}", + "inherit environment secrets", + ), + ( + 'test "$(jq -r .immutable <<<"$release")" = "true"', + 'test -n "$release"', + "run exactly", + ), + ], + ids=[ + "rerun-verification", + "arbitrary-branch", + "unbound-tag", + "unbound-sha", + "rerun-publication", + "unverified-input", + "explicit-secret", + "immutable-release-bypass", + ], +) +def test_release_recovery_rejects_trust_bypasses( + needle: str, replacement: str, message: str +) -> None: + text = RELEASE_RECOVERY_WORKFLOW.read_text(encoding="utf-8") + assert needle in text + with pytest.raises(RuntimeError, match=message): + check_release_recovery_workflow(text.replace(needle, replacement, 1)) + + +def test_release_please_publish_caller_requires_secret_inheritance() -> None: + text = RELEASE_PLEASE_WORKFLOW.read_text(encoding="utf-8").replace( + " secrets: inherit\n", + " secrets:\n COMETAPI_KEY: ${{ secrets.COMETAPI_KEY }}\n", + 1, + ) + with pytest.raises(RuntimeError, match="inherit environment secrets"): + check_release_please_workflow(text) + + def test_current_release_please_config_has_reviewed_stable_cleanup() -> None: check_release_please_config( RELEASE_PLEASE_CONFIG.read_text(encoding="utf-8"), @@ -1257,13 +1350,20 @@ def test_workflow_path_discovery_includes_yaml_and_yml(tmp_path: Path) -> None: def test_workflow_inventory_rejects_unreviewed_workflow(tmp_path: Path) -> None: - for name in ("ci.yml", "live-smoke.yml", "publish.yml", "release-please.yml"): + for name in ( + "ci.yml", + "live-smoke.yml", + "publish.yml", + "release-please.yml", + "release-recovery.yml", + ): (tmp_path / name).write_text("name: reviewed\n", encoding="utf-8") assert {path.name for path in check_workflow_inventory(tmp_path)} == { "ci.yml", "live-smoke.yml", "publish.yml", "release-please.yml", + "release-recovery.yml", } (tmp_path / "rogue.yaml").write_text( @@ -1277,7 +1377,12 @@ def test_workflow_inventory_rejects_unreviewed_workflow(tmp_path: Path) -> None: def test_workflow_inventory_rejects_expected_name_symlink(tmp_path: Path) -> None: workflow_root = tmp_path / "workflows" workflow_root.mkdir() - for name in ("live-smoke.yml", "publish.yml", "release-please.yml"): + for name in ( + "live-smoke.yml", + "publish.yml", + "release-please.yml", + "release-recovery.yml", + ): (workflow_root / name).write_text("name: reviewed\n", encoding="utf-8") outside = tmp_path / "outside-ci.yml" outside.write_text("name: outside\n", encoding="utf-8") @@ -1292,7 +1397,13 @@ def test_workflow_inventory_rejects_linked_directory(tmp_path: Path, linked_comp outside = tmp_path / "outside" outside_workflows = outside / "workflows" outside_workflows.mkdir(parents=True) - for name in ("ci.yml", "live-smoke.yml", "publish.yml", "release-please.yml"): + for name in ( + "ci.yml", + "live-smoke.yml", + "publish.yml", + "release-please.yml", + "release-recovery.yml", + ): (outside_workflows / name).write_text("name: outside\n", encoding="utf-8") repository = tmp_path / "repository"