Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 23 additions & 4 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ jobs:
- verify-recovery
if: >-
always() &&
!cancelled() &&
github.run_attempt == 1 &&
(
(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down
9 changes: 9 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
20 changes: 19 additions & 1 deletion RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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.
27 changes: 21 additions & 6 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
37 changes: 33 additions & 4 deletions scripts/check_workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -1224,15 +1243,25 @@ 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),
("publish", publish),
("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 {}),
Expand Down
33 changes: 30 additions & 3 deletions tests/test_release_workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
),
(
Expand Down