Skip to content

Commit 9cd6041

Browse files
authored
fix: preserve selected release execution (#23)
1 parent ec420af commit 9cd6041

9 files changed

Lines changed: 151 additions & 19 deletions

File tree

.github/workflows/publish.yml

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@ jobs:
153153
- verify-recovery
154154
if: >-
155155
always() &&
156+
!cancelled() &&
156157
github.run_attempt == 1 &&
157158
(
158159
(
@@ -205,7 +206,11 @@ jobs:
205206
206207
build:
207208
name: Verify and build the immutable default-branch release
208-
if: github.run_attempt == 1
209+
if: >-
210+
always() &&
211+
!cancelled() &&
212+
github.run_attempt == 1 &&
213+
needs.select-release.result == 'success'
209214
needs:
210215
- select-release
211216
runs-on: ubuntu-latest
@@ -275,7 +280,11 @@ jobs:
275280

276281
release-live-smoke:
277282
name: Verify the exact release commit against CometAPI
278-
if: github.run_attempt == 1
283+
if: >-
284+
always() &&
285+
!cancelled() &&
286+
github.run_attempt == 1 &&
287+
needs.build.result == 'success'
279288
needs:
280289
- build
281290
concurrency:
@@ -325,7 +334,12 @@ jobs:
325334

326335
publish:
327336
name: Publish verified artifacts with PyPI OIDC
328-
if: github.run_attempt == 1
337+
if: >-
338+
always() &&
339+
!cancelled() &&
340+
github.run_attempt == 1 &&
341+
needs.build.result == 'success' &&
342+
needs.release-live-smoke.result == 'success'
329343
needs:
330344
- build
331345
- release-live-smoke
@@ -355,7 +369,12 @@ jobs:
355369

356370
verify-registry:
357371
name: Verify the public registry artifact
358-
if: github.run_attempt == 1
372+
if: >-
373+
always() &&
374+
!cancelled() &&
375+
github.run_attempt == 1 &&
376+
needs.build.result == 'success' &&
377+
needs.publish.result == 'success'
359378
needs:
360379
- build
361380
- publish

AGENTS.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,12 @@ Post-alpha invariants:
118118
attestation's Build Config URI to match the workflow identity used for the
119119
Trusted Publisher exchange. The workflow inventory and semantic tests must
120120
fail if this single-publisher boundary changes.
121+
8. Every release job downstream of the mutually exclusive release selector
122+
must use `always() && !cancelled()` so GitHub evaluates it after the unused
123+
release path is skipped, must reject workflow reruns, and must require every
124+
direct dependency's `result` to equal `success`. A skipped, cancelled,
125+
failed, or missing dependency must never make build, live smoke,
126+
publication, or registry verification eligible.
121127

122128
## Repository independence
123129

@@ -291,7 +297,9 @@ committed.
291297
`COMETAPI_KEY` scoped only to the protected live credential preflight and
292298
test. The semantic checker must reject reusable publication, split workflow
293299
identities, additional OIDC consumers, and raw dispatch inputs downstream of
294-
the verified release selector.
300+
the verified release selector. Every selector descendant must explicitly
301+
evaluate skipped ancestry, reject cancellation and reruns, and require each
302+
direct dependency to succeed.
295303
- Keep README, roadmap, compatibility matrix, examples, and changelog aligned
296304
with shipped behavior. Use currently supported model IDs.
297305
- All repository documentation is written in English.

ARCHITECTURE.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,15 @@ checks its credential before checkout or any request. `scripts/check_workflows.p
153153
rejects split or reusable publisher identities, unverified selector inputs,
154154
additional OIDC consumers, and missing first-attempt guards.
155155

156+
The Release Please and recovery paths are mutually exclusive, so one selector
157+
dependency is intentionally skipped on every run. GitHub propagates that
158+
skipped ancestry to later jobs even after the selector succeeds unless each
159+
selector descendant explicitly asks to be evaluated. Build, live smoke,
160+
publication, and registry verification therefore use `always() && !cancelled()`,
161+
reject every rerun, and require every direct dependency's result to equal
162+
`success`. This crosses only the unused branch's skipped ancestry; cancellation,
163+
failure, a skipped direct dependency, or a missing result remains fail-closed.
164+
156165
The initial alpha has one release-identity exception. GitHub's immutable
157166
release tombstone permanently reserves `v0.1.0-alpha.1`, so the reviewed
158167
recovery release uses SemVer build metadata in

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ automation.
1313
- Execute PyPI Trusted Publishing directly in the single top-level
1414
`publish.yml` identity, and add regression gates that reject reusable
1515
publication, split attestation identities, and unverified recovery inputs.
16+
- Prevent GitHub's skipped-ancestry propagation from silently skipping the
17+
release chain after a successful selector, while continuing to reject
18+
cancellation, reruns, and every non-successful direct dependency.
1619

1720
## [0.1.0] - 2026-07-28
1821

RELEASING.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,11 @@ violations in one run and still returns non-zero when any violation exists.
210210
against the exact pre-publication digests and Trusted Publisher provenance
211211
before a clean install explicitly from `https://pypi.org/simple/`. An unset
212212
or empty live-model repository variable resolves to `gpt-5.4`.
213+
Because the unused Release Please or recovery path is intentionally skipped,
214+
every job after the selector must use `always() && !cancelled()`, reject
215+
reruns, and require each direct dependency's result to equal `success`. This
216+
makes GitHub evaluate the successful path without accepting cancellation,
217+
failure, or a skipped direct dependency.
213218

214219
Third-party Actions are pinned to full commit SHAs. Workflow permissions are
215220
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
368373
fix before requesting another explicit recovery authorization; do not rerun a
369374
failed job merely to obtain a different result. The workflow enforces this by
370375
allowing only `github.run_attempt == 1` for verification, selection, build,
371-
live smoke, publication, and registry verification.
376+
live smoke, publication, and registry verification. Every selector descendant
377+
also evaluates skipped ancestry with `always() && !cancelled()` and requires
378+
each direct dependency's result to equal `success`.
372379

373380
[Recovery run 30353657522](https://github.com/cometapi-dev/cometapi-python/actions/runs/30353657522)
374381
passed immutable identity verification, the exact artifact rebuild, credential
@@ -378,3 +385,14 @@ caller produced an attestation Build Config URI for `release-recovery.yml`
378385
while the Trusted Publisher expected `publish.yml`. The permanent correction
379386
keeps attestations enabled and moves the PyPI action into the single top-level
380387
`publish.yml`; it does not weaken or reconfigure the Trusted Publisher.
388+
389+
[Recovery run 30357111315](https://github.com/cometapi-dev/cometapi-python/actions/runs/30357111315)
390+
then passed immutable recovery verification and the shared release selector,
391+
but GitHub propagated the intentionally skipped Release Please ancestry to the
392+
plain downstream job conditions. Build, live smoke, publication, and registry
393+
verification were all skipped while the overall workflow incorrectly reported
394+
success. No live request or PyPI upload occurred, and `cometapi==0.1.0` remained
395+
absent. The permanent correction explicitly evaluates every selector descendant
396+
and requires all of its direct dependencies to succeed. Do not dispatch another
397+
recovery until that fix reaches `main` and a new recovery is explicitly
398+
authorized.

ROADMAP.md

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
Status: `0.1.0a1` released; `0.1.0` recovery in progress
44
Last updated: 2026-07-28
55
Repository contract: this roadmap is self-contained.
6-
Current gate: land and remotely verify the single-workflow stable publisher,
7-
then complete the explicitly authorized `0.1.0` recovery.
6+
Current gate: land and remotely verify the selector-descendant control-flow
7+
fix, then complete a newly authorized `0.1.0` recovery.
88

99
## Product target
1010

@@ -463,6 +463,18 @@ existing Trusted Publisher intact. Stable remains unreleased until this change
463463
passes pull-request CI, reaches `main`, and a newly authorized recovery passes
464464
OIDC, provenance, and registry gates.
465465

466+
[Recovery run 30357111315](https://github.com/cometapi-dev/cometapi-python/actions/runs/30357111315)
467+
verified the exact immutable release and passed the shared selector from the
468+
correct top-level workflow identity. GitHub nevertheless propagated the
469+
intentionally skipped Release Please ancestry to the selector descendants, so
470+
build, live smoke, publication, and registry verification were all skipped and
471+
the overall run incorrectly reported success. No live request or registry side
472+
effect occurred, and PyPI still returned 404 for `cometapi==0.1.0`. The
473+
permanent control-flow fix makes every selector descendant explicitly evaluate
474+
skipped ancestry while rejecting cancellation and reruns and requiring every
475+
direct dependency to succeed. Another recovery remains blocked until that fix
476+
passes review and reaches `main`, followed by fresh explicit authorization.
477+
466478
## `0.2.0`: Provider-native text adapters
467479

468480
Planned scope:
@@ -515,10 +527,13 @@ attempt a request with an empty model. Immutable-release recovery additionally
515527
requires `RELEASE_RECOVERY_TAG` and `RELEASE_RECOVERY_SHA` to equal the exact
516528
dispatch inputs; keep both variables absent except for one explicitly authorized
517529
identity and delete them immediately after success or failure. Recovery and
518-
publication jobs reject rerun attempts. The PyPI action must execute directly in
519-
top-level `publish.yml`; workflow inventory, semantic checks, and mutation tests
520-
must reject reusable publishing, split publisher identities, additional OIDC
521-
consumers, or downstream use of raw dispatch inputs.
530+
publication jobs reject rerun attempts. Every job downstream of the mutually
531+
exclusive selector must explicitly evaluate skipped ancestry, reject
532+
cancellation, and require every direct dependency's result to equal `success`.
533+
The PyPI action must execute directly in top-level `publish.yml`; workflow
534+
inventory, semantic checks, and mutation tests must reject reusable publishing,
535+
split publisher identities, additional OIDC consumers, downstream use of raw
536+
dispatch inputs, or weakened selector-descendant conditions.
522537

523538
## Maintenance cadence
524539

SECURITY.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,10 @@ the protected publishing job may declare `id-token: write`. The protected live
5454
job may reference `COMETAPI_KEY` only in its credential preflight and live-test
5555
steps. Recovery verification, release selection, and downstream publication
5656
jobs must reject workflow reruns so an old authorization cannot be replayed.
57+
Every job downstream of the mutually exclusive selector must explicitly
58+
evaluate skipped ancestry, reject cancellation, and require every direct
59+
dependency to succeed; skipped or failed release work must never be represented
60+
as an eligible publication path.
5761

5862
## Scope
5963

scripts/check_workflows.py

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -76,13 +76,29 @@
7676
"vars.RELEASE_RECOVERY_SHA == inputs.release-sha"
7777
)
7878
SELECT_RELEASE_CONDITION = (
79-
"always() && github.run_attempt == 1 && "
79+
"always() && !cancelled() && github.run_attempt == 1 && "
8080
"( ( github.event_name == 'push' && needs.release-please.result == 'success' && "
8181
"needs.release-please.outputs.release-created == 'true' && "
8282
"needs.release-please.outputs.release-verified == 'true' ) || "
8383
"( github.event_name == 'workflow_dispatch' && "
8484
"needs.verify-recovery.result == 'success' ) )"
8585
)
86+
BUILD_JOB_CONDITION = (
87+
"always() && !cancelled() && github.run_attempt == 1 && "
88+
"needs.select-release.result == 'success'"
89+
)
90+
RELEASE_LIVE_JOB_CONDITION = (
91+
"always() && !cancelled() && github.run_attempt == 1 && needs.build.result == 'success'"
92+
)
93+
PUBLISH_JOB_CONDITION = (
94+
"always() && !cancelled() && github.run_attempt == 1 && "
95+
"needs.build.result == 'success' && "
96+
"needs.release-live-smoke.result == 'success'"
97+
)
98+
REGISTRY_JOB_CONDITION = (
99+
"always() && !cancelled() && github.run_attempt == 1 && "
100+
"needs.build.result == 'success' && needs.publish.result == 'success'"
101+
)
86102
SELECT_RELEASE_COMMAND = """\
87103
case "$EVENT_NAME" in
88104
push)
@@ -1066,7 +1082,10 @@ def check_publish_workflow(text: str, live_smoke_text: str) -> None:
10661082
"steps.release.outputs.release_created == 'true'",
10671083
RECOVERY_JOB_CONDITION,
10681084
SELECT_RELEASE_CONDITION,
1069-
*(["github.run_attempt == 1"] * 4),
1085+
BUILD_JOB_CONDITION,
1086+
RELEASE_LIVE_JOB_CONDITION,
1087+
PUBLISH_JOB_CONDITION,
1088+
REGISTRY_JOB_CONDITION,
10701089
]
10711090
if sorted(conditions) != sorted(expected_conditions):
10721091
raise CheckError(
@@ -1224,15 +1243,25 @@ def check_publish_workflow(text: str, live_smoke_text: str) -> None:
12241243
"steps",
12251244
},
12261245
}
1246+
expected_release_conditions = {
1247+
"build": BUILD_JOB_CONDITION,
1248+
"release-live-smoke": RELEASE_LIVE_JOB_CONDITION,
1249+
"publish": PUBLISH_JOB_CONDITION,
1250+
"verify-registry": REGISTRY_JOB_CONDITION,
1251+
}
12271252
for name, job in (
12281253
("build", build),
12291254
("release-live-smoke", release_live),
12301255
("publish", publish),
12311256
("verify-registry", registry),
12321257
):
12331258
_require_exact_keys(job, expected_release_job_keys[name], f"release {name} job")
1234-
if job["if"] != "github.run_attempt == 1":
1235-
raise CheckError(f"release {name} job must run only on the first workflow attempt")
1259+
condition = " ".join(_scalar(job["if"], f"release {name} condition").split())
1260+
if condition != expected_release_conditions[name]:
1261+
raise CheckError(
1262+
f"release {name} job must evaluate skipped ancestry, reject cancellation "
1263+
"and reruns, and require every direct dependency to succeed"
1264+
)
12361265
_require_step_working_directories(
12371266
job,
12381267
({"Recheck immutable artifact digests": "release-bundle"} if name == "publish" else {}),

tests/test_release_workflow.py

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -310,7 +310,30 @@ def _remove_live_credential_preflight(text: str) -> str:
310310

311311

312312
def _allow_publication_rerun(text: str) -> str:
313-
return text.replace(" if: github.run_attempt == 1\n", " if: always()\n", 1)
313+
build_start = text.index(" build:")
314+
return text[:build_start] + text[build_start:].replace(
315+
" github.run_attempt == 1 &&\n",
316+
" github.run_attempt >= 1 &&\n",
317+
1,
318+
)
319+
320+
321+
def _allow_cancelled_release_job(text: str) -> str:
322+
build_start = text.index(" build:")
323+
return text[:build_start] + text[build_start:].replace(
324+
" !cancelled() &&\n",
325+
"",
326+
1,
327+
)
328+
329+
330+
def _accept_skipped_release_dependency(text: str) -> str:
331+
build_start = text.index(" build:")
332+
return text[:build_start] + text[build_start:].replace(
333+
"needs.select-release.result == 'success'",
334+
"needs.select-release.result != 'failure'",
335+
1,
336+
)
314337

315338

316339
def _remove_release_selector_dependency(text: str) -> str:
@@ -362,6 +385,8 @@ def _disable_publish_attestations(text: str) -> str:
362385
_remove_live_model_fallback,
363386
_remove_live_credential_preflight,
364387
_allow_publication_rerun,
388+
_allow_cancelled_release_job,
389+
_accept_skipped_release_dependency,
365390
_remove_release_selector_dependency,
366391
_use_unverified_release_input,
367392
_disable_publish_attestations,
@@ -476,6 +501,8 @@ def test_workflow_contract_rejects_unpinned_docker_action() -> None:
476501
"empty-live-model-bypass",
477502
"missing-live-credential-preflight",
478503
"publication-rerun-bypass",
504+
"cancelled-release-job-bypass",
505+
"skipped-release-dependency-bypass",
479506
"release-selector-dependency-bypass",
480507
"unverified-release-input-bypass",
481508
"disabled-publish-attestations-bypass",
@@ -876,8 +903,8 @@ def test_publisher_rejects_reusable_workflow_call_identity() -> None:
876903
("needle", "replacement", "message"),
877904
[
878905
(
879-
"always() &&\n github.run_attempt == 1",
880-
"github.run_attempt == 1",
906+
"always() &&\n !cancelled() &&\n github.run_attempt == 1",
907+
"!cancelled() &&\n github.run_attempt == 1",
881908
"successfully verified path",
882909
),
883910
(

0 commit comments

Comments
 (0)