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
8 changes: 8 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ env:
jobs:
build:
name: Verify and build the immutable default-branch release
if: github.run_attempt == 1
runs-on: ubuntu-latest
timeout-minutes: 25
outputs:
Expand Down Expand Up @@ -93,6 +94,7 @@ jobs:

release-live-smoke:
name: Verify the exact release commit against CometAPI
if: github.run_attempt == 1
needs:
- build
concurrency:
Expand All @@ -114,6 +116,10 @@ jobs:
COMETAPI_LIVE_RUN: "1"
COMETAPI_LIVE_STOP_ON_FAILURE: "1"
steps:
- name: Require the protected live credential
env:
COMETAPI_KEY: ${{ secrets.COMETAPI_KEY }}
run: test -n "$COMETAPI_KEY"
- name: Check out the verified release commit
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
Expand All @@ -138,6 +144,7 @@ jobs:

publish:
name: Publish verified artifacts with PyPI OIDC
if: github.run_attempt == 1
needs:
- build
- release-live-smoke
Expand Down Expand Up @@ -167,6 +174,7 @@ jobs:

verify-registry:
name: Verify the public registry artifact
if: github.run_attempt == 1
needs:
- build
- publish
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/release-please.yml
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,4 @@ jobs:
release-tag: ${{ needs.release-please.outputs.release-tag }}
release-sha: ${{ needs.release-please.outputs.release-sha }}
default-branch: ${{ github.event.repository.default_branch }}
secrets: inherit
90 changes: 90 additions & 0 deletions .github/workflows/release-recovery.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
name: Recover immutable release publication

on:
workflow_dispatch:
inputs:
release-tag:
description: Exact immutable GitHub release tag
required: true
type: string
release-sha:
description: Exact commit resolved by the release tag
required: true
type: string

permissions:
contents: read

concurrency:
group: release-recovery
cancel-in-progress: false

jobs:
verify-recovery:
name: Verify the authorized immutable release recovery
if: >-
github.run_attempt == 1 &&
github.ref == format('refs/heads/{0}', github.event.repository.default_branch) &&
vars.RELEASE_RECOVERY_TAG == inputs.release-tag &&
vars.RELEASE_RECOVERY_SHA == inputs.release-sha
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
release-sha: ${{ steps.verify-release.outputs.release-sha }}
release-tag: ${{ steps.verify-release.outputs.release-tag }}
permissions:
contents: read
steps:
- name: Verify the immutable release selected for recovery
id: verify-release
env:
EXPECTED_SHA: ${{ inputs.release-sha }}
EXPECTED_TAG: ${{ inputs.release-tag }}
GH_TOKEN: ${{ github.token }}
run: |
test -n "$EXPECTED_TAG"
test -n "$EXPECTED_SHA"
release=""
for attempt in $(seq 1 12); do
release=$(gh api "repos/${{ github.repository }}/releases/tags/$EXPECTED_TAG") || true
if test -n "$release" && test "$(jq -r .immutable <<<"$release")" = "true"; then
break
fi
if test "$attempt" -ge 12; then
echo "release did not become immutable" >&2
exit 1
fi
sleep 5
done
test "$(jq -r .tag_name <<<"$release")" = "$EXPECTED_TAG"
test "$(jq -r .draft <<<"$release")" = "false"
test "$(jq -r .prerelease <<<"$release")" = "false"
test "$(jq -r .immutable <<<"$release")" = "true"
ref=$(gh api "repos/${{ github.repository }}/git/ref/tags/$EXPECTED_TAG")
tag_type=$(jq -r .object.type <<<"$ref")
tag_sha=$(jq -r .object.sha <<<"$ref")
if test "$tag_type" = "tag"; then
tag_sha=$(gh api "repos/${{ github.repository }}/git/tags/$tag_sha" --jq .object.sha)
else
test "$tag_type" = "commit"
fi
test "$tag_sha" = "$EXPECTED_SHA"
{
echo "release-tag=$EXPECTED_TAG"
echo "release-sha=$EXPECTED_SHA"
echo "release-verified=true"
} >> "$GITHUB_OUTPUT"

publish-release:
name: Run the protected publication recovery
needs: verify-recovery
if: github.run_attempt == 1
permissions:
contents: read
id-token: write
uses: ./.github/workflows/publish.yml
with:
release-tag: ${{ needs.verify-recovery.outputs.release-tag }}
release-sha: ${{ needs.verify-recovery.outputs.release-sha }}
default-branch: ${{ github.event.repository.default_branch }}
secrets: inherit
23 changes: 17 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,9 @@ Post-alpha invariants:
`LIVE_SMOKE_ENABLED=true`, and keep `RELEASE_PLEASE_ENABLED` disabled outside
an explicitly authorized release sequence. The reviewed `last-release-sha`
bridge was used once to generate the stable release PR and must remain absent
after its human finalization.
after its human finalization. Keep `RELEASE_RECOVERY_TAG` and
`RELEASE_RECOVERY_SHA` absent outside an explicitly authorized recovery of
that exact existing immutable release identity.
4. Treat the recorded public rules, security reporting, immutable releases, and
protected environments as readiness invariants. Any drift invalidates the
readiness claim until it is explicitly authorized, restored, and verified.
Expand Down Expand Up @@ -253,7 +255,13 @@ committed.
- Missing identity, credentials, environments, reviewers, protection,
publisher configuration, or approval blocks publication; no conditional
skip or mock may bypass it.
- Manual or arbitrary-branch publication is forbidden.
- Arbitrary-branch publication is forbidden. Manual publication is permitted
only through the reviewed `release-recovery.yml` workflow from the protected
default branch, with `RELEASE_RECOVERY_TAG` and `RELEASE_RECOVERY_SHA` equal
to its exact inputs, after separately verifying the existing immutable tag
and commit. The recovery and reusable publication jobs must reject every
workflow rerun. Delete both variables immediately after the recovery succeeds
or stops.
- A successful build or upload is not a release. Registry installation,
import, mocked-call smoke, and provenance must be verified separately.
- Every distribution `Project-URL` must use HTTPS. The canonical Support URL
Expand All @@ -269,10 +277,13 @@ committed.
`last-release-sha` bridge because the recovery tag's build metadata could not
be inferred from the manifest. The human-finalized stable release PR removed
that bridge and its prerelease-versioning controls; keep them absent.
- Keep third-party Actions pinned to full commit SHAs. Grant `id-token: write`
only to the reusable publication caller and the protected publishing job;
the caller passes this maximum permission and only the publishing job uses
the OIDC token.
- Keep third-party Actions pinned to full commit SHAs. Every local caller of
the reusable publication workflow must use `secrets: inherit`; GitHub-hosted
runners otherwise can resolve its job-level environment secret as empty.
Keep the semantic workflow checker's inheritance regression coverage. Grant
`id-token: write` only to a reviewed reusable publication caller and the
protected publishing job; callers pass this maximum permission and only the
publishing job uses the OIDC token.
- 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
13 changes: 13 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,19 @@ protected exact-release live job. OIDC permission is exposed only to the
protected publish job. Missing credentials, environments, approvals, or
remote configuration block publication.

The protected publication chain is reusable, and every repository-local caller
must declare `secrets: inherit`. GitHub-hosted runners can otherwise bind the
called job to the `live-smoke` environment while silently resolving its
environment secret as empty. `scripts/check_workflows.py` rejects a caller that
omits inheritance and requires a credential preflight before any live request.
`release-recovery.yml` is the sole manual recovery path for an already-created
immutable release: it runs only from the protected default branch behind a
temporary tag-and-commit identity opt-in, independently verifies that exact
release identity, and then calls the same protected build, live, OIDC,
provenance, and registry chain. Both the recovery caller and reusable
publication jobs reject rerun attempts so an old authorization cannot be
replayed through GitHub's rerun controls.

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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ automation.

## [Unreleased]

### Fixed

- Require environment-secret inheritance for every reusable publication caller
and add a fail-closed immutable-release recovery path.

## [0.1.0] - 2026-07-28

### Features
Expand Down
58 changes: 55 additions & 3 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,10 @@ configuration later used an explicit `last-release-sha` bridge to establish the
recovery alpha as the previous-release boundary. Maintainers enabled the
repository variable only to start the stable release sequence, and human
finalization removed the bridge. An unset or non-true variable prevents the
corresponding gated job from executing.
corresponding gated job from executing. `RELEASE_RECOVERY_TAG` and
`RELEASE_RECOVERY_SHA` are absent by default and may exist only during an
explicitly authorized recovery of that exact existing immutable release
identity.
The release live-model configuration resolves an unset or empty
`COMETAPI_LIVE_MODEL` to `gpt-5.4`.

Expand Down Expand Up @@ -191,6 +194,12 @@ violations in one run and still returns non-zero when any violation exists.
the GitHub API until that exact tag and commit are independently reported as
immutable, then invokes the protected publication chain directly;
workflow-token release events do not trigger a second workflow run.
- `release-recovery.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` identity opt-in
before it calls the same protected publication chain. Delete both variables
immediately after success or failure. The workflow and reusable publication
jobs reject every rerun attempt.
- `publish.yml` is called only with the independently verified immutable tag,
commit, and default branch. It resolves the tag to the checked-out commit,
fetches the protected default branch, and rejects a commit that is not
Expand All @@ -205,9 +214,14 @@ violations in one run and still returns non-zero when any violation exists.
Third-party Actions are pinned to full commit SHAs. Workflow permissions are
read-only by default. The reusable publication caller and protected publishing
job declare `id-token: write`; the caller passes the maximum permission and
only the publishing job requests the OIDC token.
only the publishing job requests the OIDC token. Every repository-local caller
of `publish.yml` declares `secrets: inherit`; without it, GitHub-hosted runners
can silently resolve the called job's environment secret as empty. The semantic
workflow checker enforces inheritance and the live job checks the credential
before making a request.
Publishing uses a protected `pypi` environment and concurrency control.
Arbitrary-branch and manual publication are forbidden.
Arbitrary-branch publication is forbidden. Manual publication is limited to the
reviewed immutable-release recovery described below.

## Alpha release checklist (completed)

Expand Down Expand Up @@ -316,3 +330,41 @@ project metadata, lock file, and changelog must remain at the exact generated
`0.1.0` version. If GitHub requires approval before checks run on the automated
pull request, approve only that reviewed workflow execution and wait for every
blocking check.

## Immutable release publication recovery

Use recovery only when an immutable GitHub release exists, its protected
publication chain stopped before PyPI accepted the version, and a reviewed fix
has already reached `main`. Do not create another tag or release, change the
existing release, bypass live smoke, or publish an artifact retained from the
failed run.

Before dispatch, verify that the exact PyPI version is absent, the release is
immutable and non-draft, its tag resolves to the supplied commit, that commit is
reachable from protected `main`, and the repository-local caller uses
`secrets: inherit`. Then enable only the one-time recovery gate and dispatch the
workflow from `main` with the exact immutable identity:

```bash
gh variable set RELEASE_RECOVERY_TAG --body '<exact-tag>'
gh variable set RELEASE_RECOVERY_SHA --body '<exact-commit>'
gh workflow run release-recovery.yml --ref main \
-f release-tag='<exact-tag>' \
-f release-sha='<exact-commit>'
```

The run must rebuild and verify the exact tag, pass the credential preflight and
bounded four-request live suite, wait for protected `pypi` approval, publish by
OIDC, verify provenance and public digests, and pass the registry clean-install
smoke. Delete the gate immediately after the run succeeds or stops:

```bash
gh variable delete RELEASE_RECOVERY_TAG
gh variable delete RELEASE_RECOVERY_SHA
```

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` at both the recovery and publication
boundaries.
26 changes: 22 additions & 4 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,18 @@ Stable 0.1 retains the alpha surface. Its additional exit criteria are:
independent post-publication install/import/mocked-call check.
- No complete credential appears in source, fixtures, artifacts, or logs.

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

## `0.2.0`: Provider-native text adapters

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

## CI/CD contract

The repository maintains four independently auditable workflows:
The repository maintains five independently auditable workflows:

- `ci.yml`: offline lint, type, unit, contract, build, artifact, and clean
install checks for pull requests and default-branch pushes.
Expand All @@ -468,6 +480,8 @@ The repository maintains four independently auditable workflows:
- `release-please.yml`: a human-reviewed version and changelog pull request,
followed by bounded API verification of the exact immutable release and a
direct call into the protected publication chain.
- `release-recovery.yml`: an explicitly enabled, protected-default-branch-only
recovery of an independently verified existing immutable release.
- `publish.yml`: reusable immutable-tag, commit, and default-branch ancestry
verification, exact-release protected live smoke, artifact rebuild and
verification, protected PyPI OIDC publication, provenance, and registry
Expand All @@ -483,9 +497,13 @@ Release Please requires `RELEASE_PLEASE_ENABLED=true` and remains disabled
outside an explicitly authorized release sequence. Its reviewed one-time
`last-release-sha` bridge established the recovery alpha boundary, generated
the stable release PR, and was removed during human finalization. Release jobs
must resolve an unset or empty
`COMETAPI_LIVE_MODEL` to `gpt-5.4` rather than attempt a request with an empty
model.
must resolve an unset or empty `COMETAPI_LIVE_MODEL` to `gpt-5.4` rather than
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. Every reusable publish caller must use
`secrets: inherit`.

## Maintenance cadence

Expand Down
9 changes: 7 additions & 2 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,13 @@ upload is incomplete until provenance and a clean public-registry installation
have been verified.

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

## Scope

Expand Down
1 change: 1 addition & 0 deletions scripts/check_secrets.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ def scan_workflow_scope(root: Path) -> list[str]:
allowed_id_token_counts = {
"publish.yml": 1,
"release-please.yml": 1,
"release-recovery.yml": 1,
}
for path in sorted(
candidate
Expand Down
Loading