From 82f3b115716aa79c3179aae7444cd083f56684c7 Mon Sep 17 00:00:00 2001 From: CometAPI Date: Thu, 30 Jul 2026 11:18:37 +0800 Subject: [PATCH] fix: isolate release PR transport retries --- .github/workflows/publish.yml | 22 ++++++- AGENTS.md | 12 ++++ ARCHITECTURE.md | 18 ++++++ CHANGELOG.md | 2 + RELEASING.md | 32 ++++++++-- ROADMAP.md | 33 ++++++++-- scripts/check_workflows.py | 113 ++++++++++++++++++++++----------- tests/test_release_workflow.py | 48 +++++++++++++- 8 files changed, 232 insertions(+), 48 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index e59bb25..f77c17c 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -43,12 +43,32 @@ jobs: contents: write pull-requests: write steps: - - name: Open or update the release PR, or create its approved release + - name: Create the approved immutable release id: release uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0, node24 with: config-file: release-please-config.json manifest-file: .release-please-manifest.json + skip-github-pull-request: "true" + - name: Open or update the release PR + id: release-pr + if: steps.release.outputs.release_created != 'true' + continue-on-error: true + uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0, node24 + with: + config-file: release-please-config.json + manifest-file: .release-please-manifest.json + skip-github-release: "true" + - name: Retry release PR maintenance once + id: retry-release-pr + if: >- + steps.release.outputs.release_created != 'true' && + steps.release-pr.outcome == 'failure' + uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0, node24 + with: + config-file: release-please-config.json + manifest-file: .release-please-manifest.json + skip-github-release: "true" - name: Verify the immutable release created by Release Please id: verify-release if: steps.release.outputs.release_created == 'true' diff --git a/AGENTS.md b/AGENTS.md index c0433c4..a18c021 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -155,6 +155,18 @@ Post-stable invariants: 9. Keep Release Please pinned to the reviewed `v5.0.0` commit `45996ed1f6d02564a971a2fa1b5860e934307cf7`, whose immutable action metadata uses `node24`. The workflow semantic checker must reject any different pin. + Invoke that action release-only first with `skip-github-pull-request: true`; + that immutable tag-and-GitHub-Release path must never continue on error or be + retried. Only after it succeeds without creating a release may PR-only + maintenance run with `skip-github-release: true`. Its first attempt is the + sole Release Please step allowed to continue on error, and one identical + second attempt may run only when that first PR attempt fails. Mutable branch + and pull-request maintenance is idempotent and may use this bounded retry; + immutable release creation may not. If the release-only invocation fails, + immediately disable `RELEASE_PLEASE_ENABLED`, inspect tag and GitHub Release + state read-only, and stop. Do not use another main push or recovery path + until the exact external state is known and recovery is separately + authorized. 10. `README.md` is the distribution long description and must remain accurate before and after publication. Use `python -m pip install cometapi`, unversioned project links, and publication-neutral maintenance language. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 74636d2..0d0f602 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -140,6 +140,24 @@ uses `node24`. The workflow semantic contract fixes that SHA and runtime disposition so GitHub does not need to force a deprecated Node 20 action onto a newer runtime. +Release Please execution is split at the mutability boundary. The first pinned +action invocation is release-only (`skip-github-pull-request: true`) and is +neither continued on error nor retried. Only when that invocation succeeds +without creating a release does PR-only maintenance run +(`skip-github-release: true`). The first PR-only attempt is allowed to continue +on error solely so one identical conditional retry can follow; the second +failure ends the job. Updating a branch or pull request is mutable and +idempotent, while retrying immutable tag or GitHub Release creation could leave +ambiguous external state and is forbidden. + +[Release Please run 30509764960](https://github.com/cometapi-dev/cometapi-python/actions/runs/30509764960) +isolated the motivating failure to the action's Undici/global `fetch`: the PR +workflow reached its write boundary and then failed with `other side closed` +before any branch, pull-request, tag, GitHub Release, live, or registry write. +The existing release branch and repository pull-request permission were not the +cause. This is negative transport evidence, not evidence of a stale branch or +authorization drift. + This complete trust chain executed successfully in [release workflow run 30261746138](https://github.com/cometapi-dev/cometapi-python/actions/runs/30261746138) for release commit `31b68904141489ca04932edbf305ccf88af09372`, recovery tag diff --git a/CHANGELOG.md b/CHANGELOG.md index 718caa0..7c33bf9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ automation. - Pin Release Please to its reviewed Node 24 action and reject regressions to the deprecated Node 20 runtime. +- Separate non-retryable immutable release creation from PR-only maintenance, + with exactly one bounded retry for an isolated pull-request transport failure. - Keep the README and built distribution long description publication-neutral, with an unpinned stable installation command and no transient approval state. diff --git a/RELEASING.md b/RELEASING.md index ecf5201..04a1e0b 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -173,6 +173,24 @@ The Release Please step is pinned to checker rejects any other pin so the workflow cannot silently regress to the deprecated Node 20 runtime. +The job invokes the pinned action release-only first with +`skip-github-pull-request: true`. That step cannot continue on error and is +never retried. If it succeeds without creating a release, PR-only maintenance +uses `skip-github-release: true`; its first attempt is the single Release Please +step allowed to continue on error, and a second identical attempt runs only +after that first PR attempt fails. A second failure ends the job. This permits +one bounded retry for mutable, idempotent branch and pull-request maintenance +without ever automatically retrying immutable tag or GitHub Release creation. + +[Run 30509764960](https://github.com/cometapi-dev/cometapi-python/actions/runs/30509764960) +failed in the PR workflow with Undici/global `fetch` reporting +`other side closed`. It had built the candidate and reached the write boundary, +but created or updated no branch, pull request, tag, GitHub Release, live +request, or registry artifact. Read-only inspection ruled out a stale release +branch and missing pull-request permission. Treat that run as isolated +transport-failure evidence; do not rerun it or reinterpret it as an +authorization failure. + Release mode (`check_version.py --require-releasable-docs`) also fails closed until project authorship, the canonical GitHub repository URL, the copyright holder, security and support contacts, a publication-neutral README, and a dated @@ -220,10 +238,16 @@ descriptions cannot drift. `last-release-sha` bridge established the recovery release boundary and was removed during human finalization of the stable release PR. Keep the variable disabled except while executing an explicitly authorized release sequence. - When it creates an approved release with the GitHub workflow token, it polls - the GitHub API until that exact tag and commit are independently reported as - immutable, then selects it for the downstream chain in the same workflow; - workflow-token release events do not trigger a second workflow. + The job checks for and creates an approved release through its non-retryable + release-only invocation before it performs retryable PR-only maintenance. If + a release is created with the GitHub workflow token, it polls the GitHub API + until that exact tag and commit are independently reported as immutable, then + selects it for the downstream chain in the same workflow; workflow-token + release events do not trigger a second workflow. If the release-only + invocation fails, immediately set `RELEASE_PLEASE_ENABLED=false`, inspect tag + and GitHub Release state read-only, and stop. Do not make another main push or + use recovery until the exact external state is known and recovery is + separately authorized. - The `verify-recovery` path in `publish.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` diff --git a/ROADMAP.md b/ROADMAP.md index a983cc0..bc3ea51 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,7 +1,7 @@ # CometAPI Python SDK Roadmap -Status: `0.1.1` stable maintenance released -Last updated: 2026-07-29 +Status: `0.1.2` stable maintenance in progress +Last updated: 2026-07-30 Repository contract: this roadmap is self-contained. Current gate: maintain the verified stable 0.1 surface. Begin 0.2 only after a separate maintainer request authorizes its provider schemas and live contracts. @@ -17,6 +17,8 @@ Alpha, stable `0.1.0`, and maintenance release `0.1.1` are complete for the sanitized public repository. Protected repository configuration, public default-branch CI, exact-release live smoke, PyPI OIDC publication, provenance, digest comparison, and public-registry smoke provide separate evidence layers. +Maintenance `0.1.2` is active only to harden release automation and immutable +package metadata; it does not activate 0.2 scope. Support and release claims remain limited to the evidence defined in this roadmap and `COMPATIBILITY.md`. @@ -30,6 +32,7 @@ roadmap and `COMPATIBILITY.md`. | `0.1.0a1` Registry Alpha | Complete | Early adopters can install the functional prerelease from PyPI; every release and registry gate passed. | | `0.1.0` stable | Complete | Complete runtime, release-PR, example, provenance, and registry gates passed. | | `0.1.1` maintenance | Complete | Configuration validation and every stable release, live, provenance, and registry gate passed. | +| `0.1.2` maintenance | In progress | Publication-neutral metadata and release-transport boundaries must pass every normal release gate before publication. | | `0.2.0` provider-native text | Planned | Optional official Anthropic and Gemini adapters. | | `0.3.0` CometAPI resources | Planned | First schema-backed typed CometAPI-specific resource. | | Media and task APIs | Planned | Coherent task lifecycle precedes individual media helpers. | @@ -567,6 +570,26 @@ imports, and passed every supported mocked call and README example. `RELEASE_PLEASE_ENABLED=false` and `LIVE_SMOKE_ENABLED=false`. Recovery variables are absent. No recovery tag or recovery workflow was used. +## `0.1.2`: Release metadata and transport maintenance + +[Release Please run 30509764960](https://github.com/cometapi-dev/cometapi-python/actions/runs/30509764960) +failed while maintaining the `0.1.2` release PR. The pinned v5 action had built +the candidate and reached its PR write boundary when Undici/global `fetch` +reported `other side closed`. It created or updated no branch, pull request, +tag, GitHub Release, live request, PyPI file, or other registry state. Read-only +inspection showed that neither the existing release branch nor repository +pull-request permission caused the failure. + +Release Please now executes according to external-state reversibility. A +release-only invocation (`skip-github-pull-request: true`) runs first without +`continue-on-error` and without retry. Only if no release was created does +PR-only maintenance (`skip-github-release: true`) run; its first attempt may +continue on error solely to permit one identical conditional retry. Mutable, +idempotent branch and pull-request maintenance can therefore recover from one +isolated transport close, while immutable tag and GitHub Release creation can +never be automatically replayed. This remains 0.1.x maintenance and does not +authorize provider, resource, CLI, or 0.2 work. + ## `0.2.0`: Provider-native text adapters Planned scope: @@ -596,8 +619,10 @@ The repository maintains three independently auditable workflows: four requests, 16 output tokens per generation, a 30-second request timeout, concurrency one, a ten-minute workflow timeout, and stop on first failure. - `publish.yml`: the single top-level release and PyPI Trusted Publisher - identity. Its gated push path maintains the human-reviewed Release Please PR - and independently verifies a created release. Its explicitly enabled manual + identity. Its gated push path first attempts non-retryable release-only + creation, then, only when no release exists, permits one bounded retry for + PR-only maintenance. It independently verifies a created release. Its + explicitly enabled manual path recovers an independently verified existing immutable release only from the protected default branch. An exact selector feeds both paths into tag, commit, and default-branch ancestry verification, artifact rebuild, protected diff --git a/scripts/check_workflows.py b/scripts/check_workflows.py index 67e3a49..b84a42b 100644 --- a/scripts/check_workflows.py +++ b/scripts/check_workflows.py @@ -22,8 +22,26 @@ RELEASE_PLEASE_ACTION_SHA = "45996ed1f6d02564a971a2fa1b5860e934307cf7" RELEASE_PLEASE_ACTION_VERSION = "5.0.0" RELEASE_PLEASE_ACTION_RUNTIME = "node24" +RELEASE_PLEASE_ACTION = f"googleapis/release-please-action@{RELEASE_PLEASE_ACTION_SHA}" RELEASE_PLEASE_BRIDGE_VERSION = "0.1.0-alpha.1" RELEASE_PLEASE_STABLE_VERSION_PATTERN = re.compile(r"0\.1\.(?:0|[1-9][0-9]*)") +RELEASE_PR_CONDITION = "steps.release.outputs.release_created != 'true'" +RELEASE_PR_RETRY_CONDITION = ( + "steps.release.outputs.release_created != 'true' && steps.release-pr.outcome == 'failure'" +) +RELEASE_VERIFY_CONDITION = "steps.release.outputs.release_created == 'true'" +REVIEWED_RELEASE_PR_FIRST_ATTEMPT: dict[str, object] = { + "name": "Open or update the release PR", + "id": "release-pr", + "if": RELEASE_PR_CONDITION, + "continue-on-error": "true", + "uses": RELEASE_PLEASE_ACTION, + "with": { + "config-file": "release-please-config.json", + "manifest-file": ".release-please-manifest.json", + "skip-github-release": "true", + }, +} RELEASE_PLEASE_VERIFY_COMMAND = """\ test -n "$EXPECTED_TAG" test -n "$EXPECTED_SHA" @@ -772,7 +790,9 @@ def check_release_please_workflow(text: str) -> None: _require_step_names( release_job, [ - "Open or update the release PR, or create its approved release", + "Create the approved immutable release", + "Open or update the release PR", + "Retry release PR maintenance once", "Verify the immutable release created by Release Please", ], "Release Please job", @@ -797,45 +817,64 @@ def check_release_please_workflow(text: str) -> None: "Release Please job", ) _require_step_working_directories(release_job, {}, "Release Please job") - _, release_step = _named_action_step( - release_job, - "Open or update the release PR, or create its approved release", - "googleapis/release-please-action", - "Release Please job", + release_step, release_pr_step, retry_release_pr_step, verify_step = _workflow_steps( + release_job, "Release Please job" ) - _require_options( - release_step, - { - "config-file": "release-please-config.json", - "manifest-file": ".release-please-manifest.json", - }, - "Release Please action", - ) - if release_step.get("id") != "release": - raise CheckError("Release Please action must expose its reviewed release outputs") - if release_step.get("uses") != ( - f"googleapis/release-please-action@{RELEASE_PLEASE_ACTION_SHA}" - ): - raise CheckError( - "Release Please must retain the release-please " - f"{RELEASE_PLEASE_ACTION_VERSION} ({RELEASE_PLEASE_ACTION_RUNTIME}) action pin" - ) - verify_matches = [ - (index, step) - for index, step in enumerate(_workflow_steps(release_job, "Release Please job")) - if step.get("name") == "Verify the immutable release created by Release Please" + common_options = { + "config-file": "release-please-config.json", + "manifest-file": ".release-please-manifest.json", + } + reviewed_steps = [ + ( + release_step, + "release", + common_options | {"skip-github-pull-request": "true"}, + {"name", "id", "uses", "with"}, + ), + ( + release_pr_step, + "release-pr", + common_options | {"skip-github-release": "true"}, + {"name", "id", "if", "continue-on-error", "uses", "with"}, + ), + ( + retry_release_pr_step, + "retry-release-pr", + common_options | {"skip-github-release": "true"}, + {"name", "id", "if", "uses", "with"}, + ), ] - if len(verify_matches) != 1: - raise CheckError("Release Please job must contain its immutable-release verification") - _, verify_step = verify_matches[0] + for step, expected_id, options, keys in reviewed_steps: + _require_exact_keys(step, keys, f"Release Please {expected_id} step") + if step.get("id") != expected_id: + raise CheckError(f"Release Please {expected_id} step must retain its reviewed id") + if step.get("uses") != RELEASE_PLEASE_ACTION: + raise CheckError( + "Release Please must retain the release-please " + f"{RELEASE_PLEASE_ACTION_VERSION} ({RELEASE_PLEASE_ACTION_RUNTIME}) action pin" + ) + _require_options(step, options, f"Release Please {expected_id} action") + if release_pr_step.get("if") != RELEASE_PR_CONDITION: + raise CheckError("Release Please must run PR maintenance only when no release was created") + if release_pr_step.get("continue-on-error") != "true": + raise CheckError("Release Please must permit exactly one bounded PR-maintenance retry") + retry_condition = " ".join( + _scalar(retry_release_pr_step.get("if"), "Release Please PR retry condition").split() + ) + if retry_condition != RELEASE_PR_RETRY_CONDITION: + raise CheckError("Release Please PR retry must require the first PR-only attempt to fail") + _require_exact_keys( + verify_step, + {"name", "id", "if", "env", "run"}, + "Release Please immutable-release verification", + ) if verify_step.get("run") != RELEASE_PLEASE_VERIFY_COMMAND or "uses" in verify_step: raise CheckError("Release Please immutable-release verification is not exact") - if verify_step.get("id") != "verify-release" or verify_step.get("if") != ( - "steps.release.outputs.release_created == 'true'" + if ( + verify_step.get("id") != "verify-release" + or verify_step.get("if") != RELEASE_VERIFY_CONDITION ): raise CheckError("Release Please must verify only the release it just created") - if any(key in verify_step for key in ("continue-on-error", "shell", "working-directory")): - raise CheckError("Release Please immutable-release verification must fail closed") if _secret_references(release_job): raise CheckError("Release Please must not depend on repository credentials") @@ -1084,13 +1123,15 @@ def check_publish_workflow(text: str, live_smoke_text: str) -> None: for mapping in _walk_mappings(workflow, "publish workflow"): if "if" in mapping: conditions.append(" ".join(_scalar(mapping["if"], "release condition").split())) - if "continue-on-error" in mapping: + if "continue-on-error" in mapping and mapping != REVIEWED_RELEASE_PR_FIRST_ATTEMPT: 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") expected_conditions = [ RELEASE_PLEASE_JOB_CONDITION, - "steps.release.outputs.release_created == 'true'", + RELEASE_PR_CONDITION, + RELEASE_PR_RETRY_CONDITION, + RELEASE_VERIFY_CONDITION, RECOVERY_JOB_CONDITION, SELECT_RELEASE_CONDITION, BUILD_JOB_CONDITION, diff --git a/tests/test_release_workflow.py b/tests/test_release_workflow.py index f34a35e..1a5cdc0 100644 --- a/tests/test_release_workflow.py +++ b/tests/test_release_workflow.py @@ -400,11 +400,12 @@ def test_current_publish_workflow_satisfies_semantic_contract() -> None: ) -def test_release_please_uses_node24_v5_pin() -> None: +def test_release_please_uses_split_node24_v5_execution() -> None: text = RELEASE_PLEASE_WORKFLOW.read_text(encoding="utf-8") - assert ( + action = ( "googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0, node24" - ) in text + ) + assert text.count(action) == 3 check_release_please_workflow(text) @@ -418,6 +419,47 @@ def test_release_please_rejects_legacy_node20_pin() -> None: check_release_please_workflow(text) +@pytest.mark.parametrize( + ("needle", "replacement"), + [ + ( + ' skip-github-pull-request: "true"', + ' skip-github-pull-request: "false"', + ), + ( + ' skip-github-release: "true"', + ' skip-github-release: "false"', + ), + (" continue-on-error: true", " continue-on-error: false"), + ( + " steps.release-pr.outcome == 'failure'", + " steps.release-pr.conclusion == 'failure'", + ), + ( + " id: release\n uses:", + " id: release\n continue-on-error: true\n uses:", + ), + ( + " id: retry-release-pr\n if:", + " id: retry-release-pr\n continue-on-error: true\n if:", + ), + ], + ids=[ + "release-step-can-maintain-pr", + "pr-step-can-create-release", + "first-pr-attempt-cannot-fail", + "retry-does-not-use-outcome", + "release-step-retryable", + "second-pr-attempt-retryable", + ], +) +def test_release_please_rejects_unsafe_retry_boundaries(needle: str, replacement: str) -> None: + text = RELEASE_PLEASE_WORKFLOW.read_text(encoding="utf-8") + assert needle in text + with pytest.raises(RuntimeError): + check_release_please_workflow(text.replace(needle, replacement, 1)) + + @pytest.mark.parametrize("configured", [None, ""]) def test_live_model_defaults_when_unset_or_empty(configured: str | None) -> None: assert resolve_live_model(configured) == "gpt-5.4"