From 90f9e527b7ea33cb2790f9f05e77eb10e5f5d67a Mon Sep 17 00:00:00 2001 From: CometAPI Date: Tue, 28 Jul 2026 20:20:00 +0800 Subject: [PATCH] fix: preserve selected release execution --- .github/workflows/publish.yml | 27 +++++++++++++++++++++---- AGENTS.md | 10 ++++++++- ARCHITECTURE.md | 9 +++++++++ CHANGELOG.md | 3 +++ RELEASING.md | 20 +++++++++++++++++- ROADMAP.md | 27 +++++++++++++++++++------ SECURITY.md | 4 ++++ scripts/check_workflows.py | 37 ++++++++++++++++++++++++++++++---- tests/test_release_workflow.py | 33 +++++++++++++++++++++++++++--- 9 files changed, 151 insertions(+), 19 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index e4f1fb1..0c18e79 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -153,6 +153,7 @@ jobs: - verify-recovery if: >- always() && + !cancelled() && github.run_attempt == 1 && ( ( @@ -205,7 +206,11 @@ jobs: build: name: Verify and build the immutable default-branch release - if: github.run_attempt == 1 + if: >- + always() && + !cancelled() && + github.run_attempt == 1 && + needs.select-release.result == 'success' needs: - select-release runs-on: ubuntu-latest @@ -275,7 +280,11 @@ jobs: release-live-smoke: name: Verify the exact release commit against CometAPI - if: github.run_attempt == 1 + if: >- + always() && + !cancelled() && + github.run_attempt == 1 && + needs.build.result == 'success' needs: - build concurrency: @@ -325,7 +334,12 @@ jobs: publish: name: Publish verified artifacts with PyPI OIDC - if: github.run_attempt == 1 + if: >- + always() && + !cancelled() && + github.run_attempt == 1 && + needs.build.result == 'success' && + needs.release-live-smoke.result == 'success' needs: - build - release-live-smoke @@ -355,7 +369,12 @@ jobs: verify-registry: name: Verify the public registry artifact - if: github.run_attempt == 1 + if: >- + always() && + !cancelled() && + github.run_attempt == 1 && + needs.build.result == 'success' && + needs.publish.result == 'success' needs: - build - publish diff --git a/AGENTS.md b/AGENTS.md index 6a42357..48b6b5f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -118,6 +118,12 @@ Post-alpha invariants: attestation's Build Config URI to match the workflow identity used for the Trusted Publisher exchange. The workflow inventory and semantic tests must fail if this single-publisher boundary changes. +8. Every release job downstream of the mutually exclusive release selector + must use `always() && !cancelled()` so GitHub evaluates it after the unused + release path is skipped, must reject workflow reruns, and must require every + direct dependency's `result` to equal `success`. A skipped, cancelled, + failed, or missing dependency must never make build, live smoke, + publication, or registry verification eligible. ## Repository independence @@ -291,7 +297,9 @@ committed. `COMETAPI_KEY` scoped only to the protected live credential preflight and test. The semantic checker must reject reusable publication, split workflow identities, additional OIDC consumers, and raw dispatch inputs downstream of - the verified release selector. + the verified release selector. Every selector descendant must explicitly + evaluate skipped ancestry, reject cancellation and reruns, and require each + direct dependency to succeed. - 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 75d8384..efc0e31 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -153,6 +153,15 @@ checks its credential before checkout or any request. `scripts/check_workflows.p rejects split or reusable publisher identities, unverified selector inputs, additional OIDC consumers, and missing first-attempt guards. +The Release Please and recovery paths are mutually exclusive, so one selector +dependency is intentionally skipped on every run. GitHub propagates that +skipped ancestry to later jobs even after the selector succeeds unless each +selector descendant explicitly asks to be evaluated. Build, live smoke, +publication, and registry verification therefore use `always() && !cancelled()`, +reject every rerun, and require every direct dependency's result to equal +`success`. This crosses only the unused branch's skipped ancestry; cancellation, +failure, a skipped direct dependency, or a missing result remains fail-closed. + 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 45a409b..f89ef07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ automation. - Execute PyPI Trusted Publishing directly in the single top-level `publish.yml` identity, and add regression gates that reject reusable publication, split attestation identities, and unverified recovery inputs. +- Prevent GitHub's skipped-ancestry propagation from silently skipping the + release chain after a successful selector, while continuing to reject + cancellation, reruns, and every non-successful direct dependency. ## [0.1.0] - 2026-07-28 diff --git a/RELEASING.md b/RELEASING.md index 51dd205..3d35302 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -210,6 +210,11 @@ violations in one run and still returns non-zero when any violation exists. against the exact pre-publication digests and Trusted Publisher provenance before a clean install explicitly from `https://pypi.org/simple/`. An unset or empty live-model repository variable resolves to `gpt-5.4`. + Because the unused Release Please or recovery path is intentionally skipped, + every job after the selector must use `always() && !cancelled()`, reject + reruns, and require each direct dependency's result to equal `success`. This + makes GitHub evaluate the successful path without accepting cancellation, + failure, or a skipped direct dependency. Third-party Actions are pinned to full commit SHAs. Workflow permissions are read-only by default, and only the protected publishing job declares @@ -368,7 +373,9 @@ 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` for verification, selection, build, -live smoke, publication, and registry verification. +live smoke, publication, and registry verification. Every selector descendant +also evaluates skipped ancestry with `always() && !cancelled()` and requires +each direct dependency's result to equal `success`. [Recovery run 30353657522](https://github.com/cometapi-dev/cometapi-python/actions/runs/30353657522) passed immutable identity verification, the exact artifact rebuild, credential @@ -378,3 +385,14 @@ caller produced an attestation Build Config URI for `release-recovery.yml` while the Trusted Publisher expected `publish.yml`. The permanent correction keeps attestations enabled and moves the PyPI action into the single top-level `publish.yml`; it does not weaken or reconfigure the Trusted Publisher. + +[Recovery run 30357111315](https://github.com/cometapi-dev/cometapi-python/actions/runs/30357111315) +then passed immutable recovery verification and the shared release selector, +but GitHub propagated the intentionally skipped Release Please ancestry to the +plain downstream job conditions. Build, live smoke, publication, and registry +verification were all skipped while the overall workflow incorrectly reported +success. No live request or PyPI upload occurred, and `cometapi==0.1.0` remained +absent. The permanent correction explicitly evaluates every selector descendant +and requires all of its direct dependencies to succeed. Do not dispatch another +recovery until that fix reaches `main` and a new recovery is explicitly +authorized. diff --git a/ROADMAP.md b/ROADMAP.md index ab6b9c7..a86f21e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -3,8 +3,8 @@ Status: `0.1.0a1` released; `0.1.0` recovery in progress Last updated: 2026-07-28 Repository contract: this roadmap is self-contained. -Current gate: land and remotely verify the single-workflow stable publisher, -then complete the explicitly authorized `0.1.0` recovery. +Current gate: land and remotely verify the selector-descendant control-flow +fix, then complete a newly authorized `0.1.0` recovery. ## Product target @@ -463,6 +463,18 @@ existing Trusted Publisher intact. Stable remains unreleased until this change passes pull-request CI, reaches `main`, and a newly authorized recovery passes OIDC, provenance, and registry gates. +[Recovery run 30357111315](https://github.com/cometapi-dev/cometapi-python/actions/runs/30357111315) +verified the exact immutable release and passed the shared selector from the +correct top-level workflow identity. GitHub nevertheless propagated the +intentionally skipped Release Please ancestry to the selector descendants, so +build, live smoke, publication, and registry verification were all skipped and +the overall run incorrectly reported success. No live request or registry side +effect occurred, and PyPI still returned 404 for `cometapi==0.1.0`. The +permanent control-flow fix makes every selector descendant explicitly evaluate +skipped ancestry while rejecting cancellation and reruns and requiring every +direct dependency to succeed. Another recovery remains blocked until that fix +passes review and reaches `main`, followed by fresh explicit authorization. + ## `0.2.0`: Provider-native text adapters Planned scope: @@ -515,10 +527,13 @@ 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. The PyPI action must execute directly in -top-level `publish.yml`; workflow inventory, semantic checks, and mutation tests -must reject reusable publishing, split publisher identities, additional OIDC -consumers, or downstream use of raw dispatch inputs. +publication jobs reject rerun attempts. Every job downstream of the mutually +exclusive selector must explicitly evaluate skipped ancestry, reject +cancellation, and require every direct dependency's result to equal `success`. +The PyPI action must execute directly in top-level `publish.yml`; workflow +inventory, semantic checks, and mutation tests must reject reusable publishing, +split publisher identities, additional OIDC consumers, downstream use of raw +dispatch inputs, or weakened selector-descendant conditions. ## Maintenance cadence diff --git a/SECURITY.md b/SECURITY.md index 4a95544..749fdcd 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -54,6 +54,10 @@ the protected publishing job may declare `id-token: write`. The protected live job may reference `COMETAPI_KEY` only in its credential preflight and live-test steps. Recovery verification, release selection, and downstream publication jobs must reject workflow reruns so an old authorization cannot be replayed. +Every job downstream of the mutually exclusive selector must explicitly +evaluate skipped ancestry, reject cancellation, and require every direct +dependency to succeed; skipped or failed release work must never be represented +as an eligible publication path. ## Scope diff --git a/scripts/check_workflows.py b/scripts/check_workflows.py index 284ca7f..de54407 100644 --- a/scripts/check_workflows.py +++ b/scripts/check_workflows.py @@ -76,13 +76,29 @@ "vars.RELEASE_RECOVERY_SHA == inputs.release-sha" ) SELECT_RELEASE_CONDITION = ( - "always() && github.run_attempt == 1 && " + "always() && !cancelled() && github.run_attempt == 1 && " "( ( github.event_name == 'push' && needs.release-please.result == 'success' && " "needs.release-please.outputs.release-created == 'true' && " "needs.release-please.outputs.release-verified == 'true' ) || " "( github.event_name == 'workflow_dispatch' && " "needs.verify-recovery.result == 'success' ) )" ) +BUILD_JOB_CONDITION = ( + "always() && !cancelled() && github.run_attempt == 1 && " + "needs.select-release.result == 'success'" +) +RELEASE_LIVE_JOB_CONDITION = ( + "always() && !cancelled() && github.run_attempt == 1 && needs.build.result == 'success'" +) +PUBLISH_JOB_CONDITION = ( + "always() && !cancelled() && github.run_attempt == 1 && " + "needs.build.result == 'success' && " + "needs.release-live-smoke.result == 'success'" +) +REGISTRY_JOB_CONDITION = ( + "always() && !cancelled() && github.run_attempt == 1 && " + "needs.build.result == 'success' && needs.publish.result == 'success'" +) SELECT_RELEASE_COMMAND = """\ case "$EVENT_NAME" in push) @@ -1066,7 +1082,10 @@ def check_publish_workflow(text: str, live_smoke_text: str) -> None: "steps.release.outputs.release_created == 'true'", RECOVERY_JOB_CONDITION, SELECT_RELEASE_CONDITION, - *(["github.run_attempt == 1"] * 4), + BUILD_JOB_CONDITION, + RELEASE_LIVE_JOB_CONDITION, + PUBLISH_JOB_CONDITION, + REGISTRY_JOB_CONDITION, ] if sorted(conditions) != sorted(expected_conditions): raise CheckError( @@ -1224,6 +1243,12 @@ def check_publish_workflow(text: str, live_smoke_text: str) -> None: "steps", }, } + expected_release_conditions = { + "build": BUILD_JOB_CONDITION, + "release-live-smoke": RELEASE_LIVE_JOB_CONDITION, + "publish": PUBLISH_JOB_CONDITION, + "verify-registry": REGISTRY_JOB_CONDITION, + } for name, job in ( ("build", build), ("release-live-smoke", release_live), @@ -1231,8 +1256,12 @@ 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") + condition = " ".join(_scalar(job["if"], f"release {name} condition").split()) + if condition != expected_release_conditions[name]: + raise CheckError( + f"release {name} job must evaluate skipped ancestry, reject cancellation " + "and reruns, and require every direct dependency to succeed" + ) _require_step_working_directories( job, ({"Recheck immutable artifact digests": "release-bundle"} if name == "publish" else {}), diff --git a/tests/test_release_workflow.py b/tests/test_release_workflow.py index 5dc4300..2b80d0c 100644 --- a/tests/test_release_workflow.py +++ b/tests/test_release_workflow.py @@ -310,7 +310,30 @@ def _remove_live_credential_preflight(text: str) -> str: def _allow_publication_rerun(text: str) -> str: - return text.replace(" if: github.run_attempt == 1\n", " if: always()\n", 1) + build_start = text.index(" build:") + return text[:build_start] + text[build_start:].replace( + " github.run_attempt == 1 &&\n", + " github.run_attempt >= 1 &&\n", + 1, + ) + + +def _allow_cancelled_release_job(text: str) -> str: + build_start = text.index(" build:") + return text[:build_start] + text[build_start:].replace( + " !cancelled() &&\n", + "", + 1, + ) + + +def _accept_skipped_release_dependency(text: str) -> str: + build_start = text.index(" build:") + return text[:build_start] + text[build_start:].replace( + "needs.select-release.result == 'success'", + "needs.select-release.result != 'failure'", + 1, + ) def _remove_release_selector_dependency(text: str) -> str: @@ -362,6 +385,8 @@ def _disable_publish_attestations(text: str) -> str: _remove_live_model_fallback, _remove_live_credential_preflight, _allow_publication_rerun, + _allow_cancelled_release_job, + _accept_skipped_release_dependency, _remove_release_selector_dependency, _use_unverified_release_input, _disable_publish_attestations, @@ -476,6 +501,8 @@ def test_workflow_contract_rejects_unpinned_docker_action() -> None: "empty-live-model-bypass", "missing-live-credential-preflight", "publication-rerun-bypass", + "cancelled-release-job-bypass", + "skipped-release-dependency-bypass", "release-selector-dependency-bypass", "unverified-release-input-bypass", "disabled-publish-attestations-bypass", @@ -876,8 +903,8 @@ def test_publisher_rejects_reusable_workflow_call_identity() -> None: ("needle", "replacement", "message"), [ ( - "always() &&\n github.run_attempt == 1", - "github.run_attempt == 1", + "always() &&\n !cancelled() &&\n github.run_attempt == 1", + "!cancelled() &&\n github.run_attempt == 1", "successfully verified path", ), (