Skip to content

Commit d0f4ca1

Browse files
author
CometAPI
committed
fix: harden stable release recovery
1 parent 6f42981 commit d0f4ca1

12 files changed

Lines changed: 531 additions & 24 deletions

.github/workflows/publish.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ env:
2626
jobs:
2727
build:
2828
name: Verify and build the immutable default-branch release
29+
if: github.run_attempt == 1
2930
runs-on: ubuntu-latest
3031
timeout-minutes: 25
3132
outputs:
@@ -93,6 +94,7 @@ jobs:
9394

9495
release-live-smoke:
9596
name: Verify the exact release commit against CometAPI
97+
if: github.run_attempt == 1
9698
needs:
9799
- build
98100
concurrency:
@@ -114,6 +116,10 @@ jobs:
114116
COMETAPI_LIVE_RUN: "1"
115117
COMETAPI_LIVE_STOP_ON_FAILURE: "1"
116118
steps:
119+
- name: Require the protected live credential
120+
env:
121+
COMETAPI_KEY: ${{ secrets.COMETAPI_KEY }}
122+
run: test -n "$COMETAPI_KEY"
117123
- name: Check out the verified release commit
118124
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
119125
with:
@@ -138,6 +144,7 @@ jobs:
138144

139145
publish:
140146
name: Publish verified artifacts with PyPI OIDC
147+
if: github.run_attempt == 1
141148
needs:
142149
- build
143150
- release-live-smoke
@@ -167,6 +174,7 @@ jobs:
167174

168175
verify-registry:
169176
name: Verify the public registry artifact
177+
if: github.run_attempt == 1
170178
needs:
171179
- build
172180
- publish

.github/workflows/release-please.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,3 +88,4 @@ jobs:
8888
release-tag: ${{ needs.release-please.outputs.release-tag }}
8989
release-sha: ${{ needs.release-please.outputs.release-sha }}
9090
default-branch: ${{ github.event.repository.default_branch }}
91+
secrets: inherit
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
name: Recover immutable release publication
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
release-tag:
7+
description: Exact immutable GitHub release tag
8+
required: true
9+
type: string
10+
release-sha:
11+
description: Exact commit resolved by the release tag
12+
required: true
13+
type: string
14+
15+
permissions:
16+
contents: read
17+
18+
concurrency:
19+
group: release-recovery
20+
cancel-in-progress: false
21+
22+
jobs:
23+
verify-recovery:
24+
name: Verify the authorized immutable release recovery
25+
if: >-
26+
github.run_attempt == 1 &&
27+
github.ref == format('refs/heads/{0}', github.event.repository.default_branch) &&
28+
vars.RELEASE_RECOVERY_TAG == inputs.release-tag &&
29+
vars.RELEASE_RECOVERY_SHA == inputs.release-sha
30+
runs-on: ubuntu-latest
31+
timeout-minutes: 5
32+
outputs:
33+
release-sha: ${{ steps.verify-release.outputs.release-sha }}
34+
release-tag: ${{ steps.verify-release.outputs.release-tag }}
35+
permissions:
36+
contents: read
37+
steps:
38+
- name: Verify the immutable release selected for recovery
39+
id: verify-release
40+
env:
41+
EXPECTED_SHA: ${{ inputs.release-sha }}
42+
EXPECTED_TAG: ${{ inputs.release-tag }}
43+
GH_TOKEN: ${{ github.token }}
44+
run: |
45+
test -n "$EXPECTED_TAG"
46+
test -n "$EXPECTED_SHA"
47+
release=""
48+
for attempt in $(seq 1 12); do
49+
release=$(gh api "repos/${{ github.repository }}/releases/tags/$EXPECTED_TAG") || true
50+
if test -n "$release" && test "$(jq -r .immutable <<<"$release")" = "true"; then
51+
break
52+
fi
53+
if test "$attempt" -ge 12; then
54+
echo "release did not become immutable" >&2
55+
exit 1
56+
fi
57+
sleep 5
58+
done
59+
test "$(jq -r .tag_name <<<"$release")" = "$EXPECTED_TAG"
60+
test "$(jq -r .draft <<<"$release")" = "false"
61+
test "$(jq -r .prerelease <<<"$release")" = "false"
62+
test "$(jq -r .immutable <<<"$release")" = "true"
63+
ref=$(gh api "repos/${{ github.repository }}/git/ref/tags/$EXPECTED_TAG")
64+
tag_type=$(jq -r .object.type <<<"$ref")
65+
tag_sha=$(jq -r .object.sha <<<"$ref")
66+
if test "$tag_type" = "tag"; then
67+
tag_sha=$(gh api "repos/${{ github.repository }}/git/tags/$tag_sha" --jq .object.sha)
68+
else
69+
test "$tag_type" = "commit"
70+
fi
71+
test "$tag_sha" = "$EXPECTED_SHA"
72+
{
73+
echo "release-tag=$EXPECTED_TAG"
74+
echo "release-sha=$EXPECTED_SHA"
75+
echo "release-verified=true"
76+
} >> "$GITHUB_OUTPUT"
77+
78+
publish-release:
79+
name: Run the protected publication recovery
80+
needs: verify-recovery
81+
if: github.run_attempt == 1
82+
permissions:
83+
contents: read
84+
id-token: write
85+
uses: ./.github/workflows/publish.yml
86+
with:
87+
release-tag: ${{ needs.verify-recovery.outputs.release-tag }}
88+
release-sha: ${{ needs.verify-recovery.outputs.release-sha }}
89+
default-branch: ${{ github.event.repository.default_branch }}
90+
secrets: inherit

AGENTS.md

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,9 @@ Post-alpha invariants:
9999
`LIVE_SMOKE_ENABLED=true`, and keep `RELEASE_PLEASE_ENABLED` disabled outside
100100
an explicitly authorized release sequence. The reviewed `last-release-sha`
101101
bridge was used once to generate the stable release PR and must remain absent
102-
after its human finalization.
102+
after its human finalization. Keep `RELEASE_RECOVERY_TAG` and
103+
`RELEASE_RECOVERY_SHA` absent outside an explicitly authorized recovery of
104+
that exact existing immutable release identity.
103105
4. Treat the recorded public rules, security reporting, immutable releases, and
104106
protected environments as readiness invariants. Any drift invalidates the
105107
readiness claim until it is explicitly authorized, restored, and verified.
@@ -253,7 +255,13 @@ committed.
253255
- Missing identity, credentials, environments, reviewers, protection,
254256
publisher configuration, or approval blocks publication; no conditional
255257
skip or mock may bypass it.
256-
- Manual or arbitrary-branch publication is forbidden.
258+
- Arbitrary-branch publication is forbidden. Manual publication is permitted
259+
only through the reviewed `release-recovery.yml` workflow from the protected
260+
default branch, with `RELEASE_RECOVERY_TAG` and `RELEASE_RECOVERY_SHA` equal
261+
to its exact inputs, after separately verifying the existing immutable tag
262+
and commit. The recovery and reusable publication jobs must reject every
263+
workflow rerun. Delete both variables immediately after the recovery succeeds
264+
or stops.
257265
- A successful build or upload is not a release. Registry installation,
258266
import, mocked-call smoke, and provenance must be verified separately.
259267
- Every distribution `Project-URL` must use HTTPS. The canonical Support URL
@@ -269,10 +277,13 @@ committed.
269277
`last-release-sha` bridge because the recovery tag's build metadata could not
270278
be inferred from the manifest. The human-finalized stable release PR removed
271279
that bridge and its prerelease-versioning controls; keep them absent.
272-
- Keep third-party Actions pinned to full commit SHAs. Grant `id-token: write`
273-
only to the reusable publication caller and the protected publishing job;
274-
the caller passes this maximum permission and only the publishing job uses
275-
the OIDC token.
280+
- Keep third-party Actions pinned to full commit SHAs. Every local caller of
281+
the reusable publication workflow must use `secrets: inherit`; GitHub-hosted
282+
runners otherwise can resolve its job-level environment secret as empty.
283+
Keep the semantic workflow checker's inheritance regression coverage. Grant
284+
`id-token: write` only to a reviewed reusable publication caller and the
285+
protected publishing job; callers pass this maximum permission and only the
286+
publishing job uses the OIDC token.
276287
- Keep README, roadmap, compatibility matrix, examples, and changelog aligned
277288
with shipped behavior. Use currently supported model IDs.
278289
- All repository documentation is written in English.

ARCHITECTURE.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,19 @@ protected exact-release live job. OIDC permission is exposed only to the
134134
protected publish job. Missing credentials, environments, approvals, or
135135
remote configuration block publication.
136136

137+
The protected publication chain is reusable, and every repository-local caller
138+
must declare `secrets: inherit`. GitHub-hosted runners can otherwise bind the
139+
called job to the `live-smoke` environment while silently resolving its
140+
environment secret as empty. `scripts/check_workflows.py` rejects a caller that
141+
omits inheritance and requires a credential preflight before any live request.
142+
`release-recovery.yml` is the sole manual recovery path for an already-created
143+
immutable release: it runs only from the protected default branch behind a
144+
temporary tag-and-commit identity opt-in, independently verifies that exact
145+
release identity, and then calls the same protected build, live, OIDC,
146+
provenance, and registry chain. Both the recovery caller and reusable
147+
publication jobs reject rerun attempts so an old authorization cannot be
148+
replayed through GitHub's rerun controls.
149+
137150
The initial alpha has one release-identity exception. GitHub's immutable
138151
release tombstone permanently reserves `v0.1.0-alpha.1`, so the reviewed
139152
recovery release uses SemVer build metadata in

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ automation.
66

77
## [Unreleased]
88

9+
### Fixed
10+
11+
- Require environment-secret inheritance for every reusable publication caller
12+
and add a fail-closed immutable-release recovery path.
13+
914
## [0.1.0] - 2026-07-28
1015

1116
### Features

RELEASING.md

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,10 @@ configuration later used an explicit `last-release-sha` bridge to establish the
5050
recovery alpha as the previous-release boundary. Maintainers enabled the
5151
repository variable only to start the stable release sequence, and human
5252
finalization removed the bridge. An unset or non-true variable prevents the
53-
corresponding gated job from executing.
53+
corresponding gated job from executing. `RELEASE_RECOVERY_TAG` and
54+
`RELEASE_RECOVERY_SHA` are absent by default and may exist only during an
55+
explicitly authorized recovery of that exact existing immutable release
56+
identity.
5457
The release live-model configuration resolves an unset or empty
5558
`COMETAPI_LIVE_MODEL` to `gpt-5.4`.
5659

@@ -191,6 +194,12 @@ violations in one run and still returns non-zero when any violation exists.
191194
the GitHub API until that exact tag and commit are independently reported as
192195
immutable, then invokes the protected publication chain directly;
193196
workflow-token release events do not trigger a second workflow run.
197+
- `release-recovery.yml` is the only manual publication path. It requires an
198+
exact immutable tag and commit, the protected default branch, and the
199+
temporary `RELEASE_RECOVERY_TAG` and `RELEASE_RECOVERY_SHA` identity opt-in
200+
before it calls the same protected publication chain. Delete both variables
201+
immediately after success or failure. The workflow and reusable publication
202+
jobs reject every rerun attempt.
194203
- `publish.yml` is called only with the independently verified immutable tag,
195204
commit, and default branch. It resolves the tag to the checked-out commit,
196205
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.
205214
Third-party Actions are pinned to full commit SHAs. Workflow permissions are
206215
read-only by default. The reusable publication caller and protected publishing
207216
job declare `id-token: write`; the caller passes the maximum permission and
208-
only the publishing job requests the OIDC token.
217+
only the publishing job requests the OIDC token. Every repository-local caller
218+
of `publish.yml` declares `secrets: inherit`; without it, GitHub-hosted runners
219+
can silently resolve the called job's environment secret as empty. The semantic
220+
workflow checker enforces inheritance and the live job checks the credential
221+
before making a request.
209222
Publishing uses a protected `pypi` environment and concurrency control.
210-
Arbitrary-branch and manual publication are forbidden.
223+
Arbitrary-branch publication is forbidden. Manual publication is limited to the
224+
reviewed immutable-release recovery described below.
211225

212226
## Alpha release checklist (completed)
213227

@@ -316,3 +330,41 @@ project metadata, lock file, and changelog must remain at the exact generated
316330
`0.1.0` version. If GitHub requires approval before checks run on the automated
317331
pull request, approve only that reviewed workflow execution and wait for every
318332
blocking check.
333+
334+
## Immutable release publication recovery
335+
336+
Use recovery only when an immutable GitHub release exists, its protected
337+
publication chain stopped before PyPI accepted the version, and a reviewed fix
338+
has already reached `main`. Do not create another tag or release, change the
339+
existing release, bypass live smoke, or publish an artifact retained from the
340+
failed run.
341+
342+
Before dispatch, verify that the exact PyPI version is absent, the release is
343+
immutable and non-draft, its tag resolves to the supplied commit, that commit is
344+
reachable from protected `main`, and the repository-local caller uses
345+
`secrets: inherit`. Then enable only the one-time recovery gate and dispatch the
346+
workflow from `main` with the exact immutable identity:
347+
348+
```bash
349+
gh variable set RELEASE_RECOVERY_TAG --body '<exact-tag>'
350+
gh variable set RELEASE_RECOVERY_SHA --body '<exact-commit>'
351+
gh workflow run release-recovery.yml --ref main \
352+
-f release-tag='<exact-tag>' \
353+
-f release-sha='<exact-commit>'
354+
```
355+
356+
The run must rebuild and verify the exact tag, pass the credential preflight and
357+
bounded four-request live suite, wait for protected `pypi` approval, publish by
358+
OIDC, verify provenance and public digests, and pass the registry clean-install
359+
smoke. Delete the gate immediately after the run succeeds or stops:
360+
361+
```bash
362+
gh variable delete RELEASE_RECOVERY_TAG
363+
gh variable delete RELEASE_RECOVERY_SHA
364+
```
365+
366+
A recovery failure stops the sequence. Diagnose and land a separate reviewed
367+
fix before requesting another explicit recovery authorization; do not rerun a
368+
failed job merely to obtain a different result. The workflow enforces this by
369+
allowing only `github.run_attempt == 1` at both the recovery and publication
370+
boundaries.

ROADMAP.md

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -437,6 +437,18 @@ Stable 0.1 retains the alpha surface. Its additional exit criteria are:
437437
independent post-publication install/import/mocked-call check.
438438
- No complete credential appears in source, fixtures, artifacts, or logs.
439439

440+
The first stable publication attempt created immutable release `v0.1.0` at
441+
`6f42981edcc6c252f8db997606671c3da84d1dd8` and passed default-branch CI plus
442+
exact artifact construction, but [stopped before any live request](https://github.com/cometapi-dev/cometapi-python/actions/runs/30348177128)
443+
because the reusable workflow caller omitted `secrets: inherit` and GitHub
444+
resolved the `live-smoke` environment secret as empty. PyPI publication and
445+
registry verification were skipped. The permanent correction requires
446+
inheritance on every publish caller, checks the credential before any request,
447+
and provides a default-branch-only, explicitly enabled recovery of that exact
448+
immutable identity through the unchanged protected publication chain. Stable
449+
remains unreleased until the recovery live, OIDC, provenance, and registry gates
450+
pass.
451+
440452
## `0.2.0`: Provider-native text adapters
441453

442454
Planned scope:
@@ -458,7 +470,7 @@ separated under `resources/` and `types/` when this milestone begins.
458470

459471
## CI/CD contract
460472

461-
The repository maintains four independently auditable workflows:
473+
The repository maintains five independently auditable workflows:
462474

463475
- `ci.yml`: offline lint, type, unit, contract, build, artifact, and clean
464476
install checks for pull requests and default-branch pushes.
@@ -468,6 +480,8 @@ The repository maintains four independently auditable workflows:
468480
- `release-please.yml`: a human-reviewed version and changelog pull request,
469481
followed by bounded API verification of the exact immutable release and a
470482
direct call into the protected publication chain.
483+
- `release-recovery.yml`: an explicitly enabled, protected-default-branch-only
484+
recovery of an independently verified existing immutable release.
471485
- `publish.yml`: reusable immutable-tag, commit, and default-branch ancestry
472486
verification, exact-release protected live smoke, artifact rebuild and
473487
verification, protected PyPI OIDC publication, provenance, and registry
@@ -483,9 +497,13 @@ Release Please requires `RELEASE_PLEASE_ENABLED=true` and remains disabled
483497
outside an explicitly authorized release sequence. Its reviewed one-time
484498
`last-release-sha` bridge established the recovery alpha boundary, generated
485499
the stable release PR, and was removed during human finalization. Release jobs
486-
must resolve an unset or empty
487-
`COMETAPI_LIVE_MODEL` to `gpt-5.4` rather than attempt a request with an empty
488-
model.
500+
must resolve an unset or empty `COMETAPI_LIVE_MODEL` to `gpt-5.4` rather than
501+
attempt a request with an empty model. Immutable-release recovery additionally
502+
requires `RELEASE_RECOVERY_TAG` and `RELEASE_RECOVERY_SHA` to equal the exact
503+
dispatch inputs; keep both variables absent except for one explicitly authorized
504+
identity and delete them immediately after success or failure. Recovery and
505+
publication jobs reject rerun attempts. Every reusable publish caller must use
506+
`secrets: inherit`.
489507

490508
## Maintenance cadence
491509

SECURITY.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,13 @@ upload is incomplete until provenance and a clean public-registry installation
4747
have been verified.
4848

4949
Third-party GitHub Actions must be pinned to full commit SHAs. The reusable
50-
workflow caller and protected publishing job may declare `id-token: write`, but
51-
only the publishing job may request the OIDC token.
50+
workflow callers and protected publishing job may declare `id-token: write`,
51+
but only the publishing job may request the OIDC token. Repository-local
52+
publication callers must use `secrets: inherit` so the called `live-smoke`
53+
environment can resolve its scoped credential; the reusable workflow may
54+
reference that credential only in its protected preflight and live-test steps.
55+
Recovery and reusable publication jobs must reject workflow reruns so an old
56+
authorization cannot be replayed.
5257

5358
## Scope
5459

scripts/check_secrets.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ def scan_workflow_scope(root: Path) -> list[str]:
118118
allowed_id_token_counts = {
119119
"publish.yml": 1,
120120
"release-please.yml": 1,
121+
"release-recovery.yml": 1,
121122
}
122123
for path in sorted(
123124
candidate

0 commit comments

Comments
 (0)