This document is the canonical publication contract for action v2 and later. The source and publication repositories are fixed:
source: Joey-Tools/codex-review-gate
target: JoeyTeng/codex-review-gate-action
The target is the existing action and Marketplace repository. It retains the
v1 history and selectors while receiving v2 and later releases. The abandoned
Joey-Tools/codex-review-gate-action target is not part of this contract.
This contract describes the intended publisher. It does not claim that a
release intent is present, or that a release, test run, Marketplace update, or
production installation has already completed. The complete accepted design
and its rationale are recorded in
docs/project_journal/2026/08/2026-08-25-action-v2-grilling-plan-019ff4f8.md.
Publisher infrastructure and release intent are deliberately separate changes:
- Land and validate the publisher workflow, scripts, tests, documentation, repository rulesets, Environment, App installation, and signing setup.
- In a later reviewed PR, add or change
release-manifest.jsonfor one exact SemVer release. - After that release-intent PR reaches protected source
master, run the staged publisher and approve only its privilegedpublishjob.
The current infrastructure delivery is step 1. It carries no release intent, must not trigger publication, and does not mean that any tag, GitHub Release, floating alias, Marketplace version, or consumer installation exists. A release-intent change must not modify publisher workflow/scripts or their control tests. This keeps newly introduced privileged code from immediately receiving production credentials to publish itself.
When present in a later release-intent PR, release-manifest.json is the
committed release intent. A release PR changes its version and any
policy-bound release metadata. The normal publisher starts after that reviewed
change reaches source master. The workflow derives
the immutable full tag, prerelease/stable status, Release version, and—only for
a stable release—the floating major alias such as v2 from SemVer.
There is no publish_v2 flag and no manually supplied version. Future v3 and
later releases use the same rules. A recovery workflow_dispatch may select
the exact committed source SHA to reconcile, but the version still comes from
the manifest at that SHA. That SHA is a recovery binding, not a public consumer
selector.
The publisher workflow is .github/workflows/sync-action-subtree.yml. Its
normal trigger is a push to source master that changes
release-manifest.json. Its recovery workflow_dispatch has three required
identity inputs and one optional, narrowly scoped recovery selector:
source_sha: the exact lowercase 40-hex commit that introduced the intended manifest;admission_run_id: the positive Actions run ID of the original admittedmasterpush; andadmission_run_attempt: the exact positive successful planning attempt that persisted the admission plan; andexisting_draft_release_id(optional): the safe positive numeric ID of an already-created, manually reviewed recoverable Draft Release (an empty Draft or a machine-verified canonical uploaded prefix with at most one known zero-byte starter asset) for this exact release intent.
Before source checkout, dispatch uses the exact run-attempt and artifact REST
identities to require the original push event, master head/source SHA,
workflow path and attempt. It then requires exactly one unexpired
release-plan-<attempt>.json artifact with a server-returned positive artifact
ID and sha256:<64hex> digest, downloads by that ID from that run, and matches
the downloaded file to the REST digest. The publisher finally recomputes the
original admission and plan from Git and requires exact equality with the
persisted JSON. A dispatch with missing or mismatched identity, digest,
manifest, history or Git recomputation is rejected before publication. A
dispatch cannot infer admission from source_sha alone, freely rebuild a
rejected admission, or substitute another run/attempt/artifact.
existing_draft_release_id does not replace any admission or publication
proof. It is accepted only on workflow_dispatch, only after the immutable
full tag already exists, and only when one direct GET /releases/{id} returns
the exact expected tag, name, body, prerelease policy, draft=true,
immutable=false, and Publisher App Bot author. Before any target write, the
ordinary existing-Release verifier then accepts only either no assets or a
canonical uploaded prefix whose frozen asset IDs download to the exact
candidate bytes, plus at most one expected zero-byte Publisher-App starter
asset. The complete Release inventory may omit that Draft; if it does expose an
exact-tag object, its ID must equal the supplied ID. A fresh release path
rejects the selector. After this one adoption read, the ordinary two-read
frozen-ID/full-tag boundaries still run before any Release mutation. The
selector therefore cannot authorize creation, replace an object, adopt
arbitrary partial state,
or bypass fail-closed metadata and asset checks.
A dispatch always executes the workflow and publisher controls from the live
source master commit recorded by github.sha; it never checks out an old
commit as executable release control. The selected source_sha must be the
linear ancestor commit that changed release-manifest.json. That source freezes
the release manifest and complete packages/action payload and tree. Recovery
must first authenticate the persisted original push admission as described
above; it does not require the publisher controls at the old source commit to
match the current controls. Only after that binding succeeds does the attempt
use the current protected source master control commit to create a fresh
plan, deterministic candidates A and B, publication plan, and Environment
approval that bind that current control inventory. If source master or those
controls advance after the attempt's plan is created, publication fails closed
before App-token minting or any durable mutation and must be rematerialized and
approved again. A later release intent cannot leapfrog an older partial
release; the older source may only reconcile and resume its exact canonical
prefix. Mismatched frozen payload or remote publication state is never blessed
as recovery.
The manifest also binds the source path, target repository and branch, and the expected signing identity. The publisher validates it against release policy and current remote state before any write. Editing a baseline to bless unexpected remote drift is not recovery.
The frozen v2.0 release line records
release_contract=codex-review-gate-action-v2.0-contract-v1. Its plan,
candidate, publication-plan, and published provenance schemas are respectively
codex-review-gate-action-release-plan-v2,
codex-review-gate-action-candidate-v2,
codex-review-gate-action-publication-plan-v2, and
codex-review-gate-action-release-provenance-v2, each with schema version 2.
Published provenance selects this frozen contract for historical verification;
later publisher evolution must not reinterpret v2.0 artifacts through a newer
schema.
The dedicated publisher runs in the source repository and has these logical stages:
| Stage | Privilege | Contract |
|---|---|---|
plan |
Unprivileged | Check the exact source commit, manifest, SemVer, reachability, release policy, and immutable target-parent policy. |
candidate-a |
Unprivileged | Independently materialize and upload candidate A on a clean runner; record its tree, inventory, modes, sizes, and SHA-256 digests. |
candidate-b |
Unprivileged | Independently materialize and upload candidate B on another clean runner. |
source-validation |
Unprivileged | After both candidates are frozen, validate the exact source commit across one core cell and four Release-test shards on five clean runners. |
assemble |
Unprivileged | Require byte-identical candidates and produce the canonical candidate bundle. |
publication-plan |
Unprivileged | Reconstruct and validate the publication plan and candidate before approval; upload no privileged material. |
publish |
Privileged | After Environment approval, revalidate everything and perform signed remote publication. |
verify |
Unprivileged | Re-read public refs and Release state and report the observed result. |
The light unprivileged plan, candidate-a, candidate-b, assemble,
publication-plan, and verify jobs use ubuntu-slim with 14-minute
timeouts. GitHub imposes a separate 15-minute hard limit on that single-CPU
runner, so exact-source tests run in the source-validation matrix on five
ubuntu-24.04 runners: one core cell and four Release-test shards, each with a
14-minute timeout and fail-fast: false. This replaces two serial full-suite
validations that each took more than 17 minutes in the first live RC run while
preserving independent candidate construction. The privileged publish job
retains ubuntu-24.04 with its existing 30-minute timeout.
Only publish binds the marketplace-production Environment. Despite its
historical name, this is the production publication-credential and approval
boundary; it does not mean that the workflow publishes to or verifies
Marketplace. Its initial policy requires reviewer JoeyTeng, keeps Prevent
self-review disabled, disables administrator bypass, permits only source
branch master, and allows the approval wait to remain pending for up to 30
days. Before approval:
- no publisher App token or signing key is available;
- no job can write the target repository or a GitHub Release; and
- the candidate artifact contains no credential or signing-key material.
Artifacts are transport between jobs, not a ledger or authoritative
publication evidence. The plan artifact is retained for 90 days; candidate
A/B artifacts (candidate-a and candidate-b) are retained for one day. The
assembled canonical candidate and publication plan artifacts are each retained
for 35 days, covering the Environment's maximum 30-day approval wait. Those
are the two frozen inputs to publish. The original push run's 90-day plan is
also the bounded persisted admission used for exact dispatch recovery; there
is no separate artifact named or classified as an admission artifact. It is
accepted only after exact run/attempt/artifact REST binding, downloaded-byte
SHA-256 verification, and Git recomputation. Artifact display names alone are
not trusted. The committed manifest plus re-read Git and Release state remain
authoritative.
GitHub does not allocate the protected job's runner while it waits for required
reviewer approval, and that wait does not consume billable runner time. The
platform limit is 30 days, not an infinite wait. If approval is rejected,
cancelled, or expires, no privileged step has run. While the original 90-day
push-plan artifact remains available, dispatch with its exact source_sha,
admission_run_id, and admission_run_attempt may authenticate that admission,
rematerialize under the then-current protected controls, and request fresh
approval. Only drift after the new plan is created invalidates that attempt.
Once the original admission plan expires or is unavailable, recovery fails
closed and requires a new reviewed release intent; the workflow does not
promise indefinite replay. The privileged runner has a 30-minute timeout after
approval; the Environment wait occurs before that runner time is allocated.
The only remote writer is the private GitHub App:
JoeyTeng/codex-review-gate-action-publisher
App ID: 4700530
Client ID: Iv23liW83xfaR85dKJD3
installation ID: 156186692
It is installed only on JoeyTeng/codex-review-gate-action. Before any
credential is minted, the privileged job checks out protected source control,
downloads and safely extracts the assembled artifact, and repeats exact
admission validation. It then validates the configured owner and App slug and
uses the allowlisted official actions/create-github-app-token@v3 Action to
mint a metadata-only inventory token with the owner set but no repositories
input. That token can read the complete App-installation repository inventory,
but cannot write. The trusted source-repository generator is the private key's
only other consumer: it creates an in-memory RS256 App JWT with iat = now - 60, exp = now + 540, and iss equal to the configured App client ID, solely
for GET /app/installations/{installation_id}. That JWT is never written to a
file, GITHUB_ENV, an artifact, an output, a summary, or a log.
The inventory token reads GET /installation/repositories and must prove that
the complete installation contains exactly the expected repository, matched by
both repository ID and canonical name. The response is projected in memory to
five fixed non-secret fields before it is written locally, and the temporary
files are removed in an always() cleanup. Only after that proof does the job
mint its separate target-scoped installation token. That write token names only
the target repository and requests Administration read, Contents write, and
Metadata read. The workflow compares the frozen target head in the verified candidate receipt
with the recorded v1 baseline. Only when they match does it additionally
require and request Workflows write: the complete-tree v2 transition removes
the target repository's legacy workflow, and GitHub treats that deletion as a
workflow write. Any later target head is a non-transition release: it requires
the exact three-permission App surface and leaves the optional Workflows input
empty, so the resulting writer token cannot update workflows. Before it can
reach Git, the publisher reads the writer token's own repository scope and
requires the same exact singleton repository ID and canonical name. This second
proof binds the newly minted token to the target repository object at mint time;
matching only the App slug and installation ID would not establish that object
identity when the token Action accepts a repository name. Its response receives
the same five-field non-secret projection as the inventory response; the raw
repository object is never persisted, and the temporary projection is removed
in always() cleanup. It is the only credential exposed to Git; it is never
passed between jobs, embedded in a remote URL, stored in an artifact, or
printed.
Checkout uses persist-credentials: false. Git receives it only through an
owner-private temporary GIT_ASKPASS helper scoped to the exact target
repository. Post-step revocation is best effort; expiry is the remaining bound
if the runner is forcibly terminated.
Every uses: dependency in the publisher workflow must be an allowlisted
GitHub-official Action referenced by its floating major, such as @v4, and
never @main. Patch-level upstream drift is consciously accepted so official
fixes arrive automatically. Moving to immutable SHA pins later would make
Dependabot in Joey-Tools/codex-review-gate mandatory so important updates are
not missed. A major-alias upgrade remains an ordinary reviewed infrastructure
PR and must not share a release-intent change.
For the one-time RC transition, the installed App grants the implicit
Metadata: read plus Contents: read/write, Administration: read, and
Workflows: read/write. The publisher first requests only Metadata read for
the complete-installation inventory token, then requests all four permissions
for its one-repository transition writer token. Once that transition's
immutable Release has been read back successfully, immediately remove
Workflows: read/write before any later release intent reaches source
master, including a later RC. Recovery of the already immutable transition
does not need that permission. Its different frozen target head makes every
later run require exactly Metadata read, Contents read/write, and
Administration read; it leaves the optional Workflows input empty and fails
closed if the App still retains it.
The marketplace-production Environment provides:
RELEASE_PUBLISHER_APP_OWNER=JoeyTeng
RELEASE_PUBLISHER_APP_SLUG=codex-review-gate-action-publisher
RELEASE_PUBLISHER_APP_CLIENT_ID=Iv23liW83xfaR85dKJD3
RELEASE_PUBLISHER_APP_PRIVATE_KEY=<environment secret>
The private key exists only inside the privileged publish job after
credential-free admission succeeds. It is supplied to the official token
Action for the inventory and target-scoped tokens and, for the single
App-installation identity read described above, directly to the trusted
generator to construct the short-lived JWT in memory. It is never persisted or
exposed through GITHUB_ENV, an artifact, an output, a summary, or a log.
Publication commits, immutable tags, and floating aliases are signed by the
dedicated JoeyTeng-Codex <codex@mahane.me> key:
primary fingerprint: AD403DAB5377F9FA0F7D775EC2844D3367B8A71B
signing subkey: 4DD48552DDEAF6D961769DD4A49827EC48984E2C
secret: RELEASE_SIGNING_GPG_PRIVATE_SUBKEY
The secret contains the signing subkey material, not the unrestricted primary
secret key. It has no passphrase, so RELEASE_SIGNING_GPG_PASSPHRASE remains
absent instead of being configured as an empty secret. publish imports it
into an owner-private temporary GNUPGHOME, requires the pinned primary and
signing fingerprints, performs a fixed sign/verify probe, and destroys the
keyring afterward. Public encryption-only subkey metadata is harmless; another
usable secret signing, encryption, or authentication subkey is rejected.
The current signer inventory is a live access-policy and content boundary. It binds the pinned primary fingerprint, pinned signing-subkey fingerprint, and exact raw public certificate; it deliberately does not bind a GitHub GPG-key REST object ID. Every durable mutation fence must re-read and revalidate that live primary/subkey/certificate tuple, especially the final immutable-Release publication fence. A persistent GitHub verification result on an existing commit or tag proves that object's signature state; it does not substitute for proof that the pinned signer is still present in the current account inventory.
The explicit --test-enforce-live-signer-policy seam is test-only and doubly
environment-gated: both
CODEX_REVIEW_GATE_RELEASE_PROVENANCE_TEST_ONLY=1 and NODE_ENV=test must be
present. It is additionally accepted only with --publish on the
production-shaped GitHub Release path; combining it with the filesystem
--test-release-dir path is rejected. When enabled, the test path executes the
real GitHub inventory validator and byte-compares its raw exported certificate
with the approved certificate at the production fences. Production has no
signer-policy skip path.
Before the first write, the full-installation inventory proves the expected Publisher App installation has the sole target repository, and the separate write token is bound to that same App slug and installation ID and is reread as the exact target repository object. The target rulesets then admit that App as the only publication bypass identity. The GPG identity is the author, committer, and signer embedded in the publication Git objects, and its signatures remain independently verifiable after publication.
Do not over-read the later GitHub state checks: ref, commit, signature, and Release readback proves the resulting objects and current repository state, but GitHub does not expose an immutable historical receipt that proves which token pushed an already accepted ref update. Consequently post-write verification must not claim to reconstruct historical Publisher App pusher attribution.
Repository rules provide two distinct properties and must not be collapsed into one bypassable ruleset:
publisher-master-updaterestricts updates torefs/heads/masterand gives only the Publisher App analwaysbypass. It answers who may publish.master-integrityrequires signed commits and linear history, blocks force pushes, and restricts deletion, with no bypass actors. It answers what even the Publisher App is allowed to publish.
The currently verified rule IDs are 16454474 for
publisher-master-update and 21461558 for master-integrity. IDs are
informational read-back evidence, not substitutes for verifying each rule's
name, target, enforcement state, rules, and bypass-actor policy through the
appropriate authority.
Tag protection uses two non-overlapping rulesets:
freeze-v1-tagscoversrefs/tags/v1andrefs/tags/v1.*, has no bypass actor, and freezes existing v1 tags independently of publisher code.publisher-v2-plus-tagscoversrefs/tags/v*while excluding both v1 patterns. It gives only the Publisher App analwaysbypass needed to create signed immutable full tags and advance signed major aliases.
Both rulesets restrict creation, update, and deletion as appropriate and block unauthorized force updates. The publisher also fails closed before its first target write if an invocation would mutate any v1 tag, v1 GitHub Release, or v1 Release asset. Ruleset IDs are only informational read-back evidence; the publisher runtime verifies the active inventory, names, targets, enforcement, ref conditions, and rules.
Repository administrators can still edit rulesets; that configuration-control authority is outside a repository-hosted workflow.
The Publisher App token intentionally has Administration: read, not write.
GitHub's repository-ruleset detail API returns bypass_actors only to an
identity with write access to that ruleset. Consequently, the same correctly
configured rule can be returned to publish without that property. This is a
documented API visibility boundary,
not evidence that the bypass policy changed.
At every policy fence, publish still re-reads the exact named rules and all
observable policy fields. Its verify-runtime-rulesets check accepts only a
missing bypass_actors property as redacted; a visible wrong array, null,
or any other malformed value still fails closed. The safe diagnostic labels the
observation rather than printing actor data.
redacted is not proof that no one added another bypass actor. The runtime
cannot automatically discover an additional actor while retaining its
read-only Administration permission. Do not widen the Publisher App to
Administration write merely to make this observation visible.
Instead, an owner or administrator with ruleset write access must perform the
strict audit during Publisher App/target-ruleset setup, after every target
ruleset change, and before the first stable release of each new major. The
source command verify-rulesets remains that audit: it requires an array made
from the four individual ruleset-detail GET responses (not the paginated
/rulesets list) and proves the exact sole Publisher App bypass or an empty
array as applicable. If that credential cannot see bypass_actors, the owner
audit is incomplete and must not be reported as successful.
candidate-a and candidate-b start from the same exact source commit but run
independently. Each materializes, packs, and uploads its candidate on a
separate clean runner. Only after both uploads succeed does the
source-validation matrix create detached exact-source worktrees on five more
clean runners. Its core cell runs npm run check plus every non-Release test;
its four Release cells partition the complete Release-pipeline inventory. The
matrix preserves all cell results, and assemble depends on the entire matrix,
so any failed or missing cell blocks publication. Source test code cannot
mutate either already-uploaded candidate because validation jobs do not
download those artifacts. Candidate independence remains enforced by the two
separate builders and the later byte-identical comparison. Each candidate
emits a canonical inventory and digests. The
inventory byte-sorts every Git path and records its type, Git mode, logical
size, and SHA-256 content digest. Only the explicit
src/v2/gate-runtime.mjs v2 runtime module is admitted; reintroducing a retired
v2 module blocks candidate construction. assemble requires both
payloads and all identity records to be byte-identical, then independently
rebuilds and binds each candidate to the frozen manifest/source payload and
this attempt's current-control inventory. Identical tampering of both artifacts
therefore still fails before
the Environment approval boundary. Candidate directories contain exactly
candidate.json and the declared regular-file archive; extra entries,
directories, and symlinks are rejected before content is read. Candidate
construction also extracts the final archive under its single canonical prefix
and requires its path/type/mode/size/SHA-256 inventory to equal the frozen Git
tree exactly; committed export-ignore or export-subst attributes cannot
silently change the published payload.
publish treats the assembled candidate only as data. It revalidates the
artifact ID and basename, archive digest, path/type/mode/size ceilings,
complete inventory, and rebuilt Git tree, and never executes candidate content.
The immutable plan deliberately does not record a live observed target-master
snapshot. A manual retry may legitimately observe the parent, the already
published wrapper, or a later verified release, and putting that observation in
the plan would make candidate/provenance bytes change. Instead,
target_master_before is bound exclusively to the frozen manifest's
target.expected_head; every unprivileged and privileged stage rebinds that
field exactly, and publish separately reads and reconciles the live target
before its first mutation.
The candidate payload and Git tree are deterministic. The final release commit
is deliberately outside that deterministic byte boundary because its parent,
timestamp, and signature are established after approval. publish constructs
one signed wrapper commit:
tree: the verified candidate tree
parent: the frozen manifest target.expected_head
author: JoeyTeng-Codex <codex@mahane.me>
committer: JoeyTeng-Codex <codex@mahane.me>
signature: 4DD48552DDEAF6D961769DD4A49827EC48984E2C
The message records the release version, source repository, full source commit,
and release-manifest digest. The raw subtree-split commit is evidence used to
prove tree equality; it is not pushed as target master.
The wrapper has exactly one parent. The publisher advances master with an
ordinary non-force fast-forward push from the exact re-read parent. If target
master moved, the run stops and reconciles; it never rewrites history or
reuses an equivalent-tree force-push path. A retry that finds the intended
signed commit already published verifies and reuses it instead of creating a
second commit with a new timestamp or signature.
After approval, one fail-closed publication-input preflight runs before any
target ref or Release mutation. It reconstructs the expected plan from the
frozen manifest and source tree and exactly rebinds version/prerelease/tag
policy, target_master_before, previous_version, complete signer identity,
repositories/refs, source tree, payload inventory, immutable control inventory,
and candidate archive bytes. The control inventory is the one frozen by this
attempt's plan, not the old source commit's controls. The preflight also
re-reads live source master to determine
whether target writes remain eligible. publish then repeats the target-head,
ruleset, existing-tag, and existing-Release checks and follows this order:
Ordinary durable mutation fences revalidate the live source, effective
rulesets, and current signer immediately around the mutation. The stronger
ordered sequence—complete the governing policy reads, then perform the final
exact object boundary—is enforced specifically for immutable Release
publication and major-alias mutation. Each of those critical irreversible
fences freshly re-reads immutable-Release policy; the cached first-mutation
result is not proof of the policy at either later fence. In particular,
immediately before publishing an immutable Release, the publisher completes
its source/ruleset/current-signer fence and explicit immutable-Release-policy
re-read, then performs one final exact read of the frozen draft Release ID, its
complete asset inventory, and its tag binding. It then addresses that frozen
Release ID with a direct REST PATCH carrying the exact intended metadata. It
must not use gh release edit, whose convenience implementation may perform a
hidden read after the publisher's final boundary.
The GitHub REST 2026-03-10 endpoint documents 200 only when immutable
Releases are enabled and 404 when they are disabled. A 404 is therefore
blocked_conflict / immutable-release-policy-disabled; any other API or read
failure is inconclusive / immutable-release-policy-unreadable. A 200 body
is still schema-validated as an extensible object: documented fields enabled
and enforced_by_owner must be booleans, enabled must be true, and
enforced_by_owner=false plus additive response fields remain valid. A
non-object or missing/wrong-typed documented field fails closed before the
protected write.
- Construct and locally verify the signed, single-parent wrapper commit.
- Fast-forward target
masterwithout force, then re-read its exact commit, parent, tree, and GitHub signature result. This proves the accepted Git state, not immutable historical pusher attribution; Publisher App identity was instead bound from the minted credential and effective rulesets before the write. - Create the signed annotated immutable full tag
v<version>with an explicit create-only push guarded by an absent-ref lease (the remote ref must not exist). Among the porcelain output's tab-separated ref-status records, accept exactly one well-formed, three-field record whose first field is*and whose second field is the exact intended full-tag refspec. Non-status transport prose is not a ref-status record. A successful push exit alone is insufficient because an identical tag can be reported up to date with=. An extra, malformed, truncated, or non-target ref-status row, an up-to-date result, a rejection, or uncertain output fails closed for inspection and recovery and grants no Release-create authority. The tag points directly to the wrapper commit and is never moved or deleted. Re-read the exact tag object, peeled commit, commit tree, and GitHub signature result. Publisher identity is bound before the first write from the minted token's actual App slug and installation ID, target scope, and effective rulesets. - Enumerate the complete, paginated GitHub Release inventory with the
Publisher App. That push-authorized identity can see drafts. Require a
nonempty outer page array (
[[]]is the valid empty-repository result), safe positive globally unique numeric IDs, and zero or one exact-tag match. A pre-existing draft is resumed by its inventory ID. A fresh invocation receives draft-create authority only when the immutable full tag was absent at invocation start, that same invocation obtained a newly-created (*) create-only push receipt in exactly one such ref-status record, and then read back its exact tag object and peeled commit. Two stable complete inventories must still prove exact-tag Release absence before creation. The publisher issues exactly one direct REST create request in that invocation. A usable successful response is the immediate transaction receipt: the publisher strictly validates its positive Release ID, exact intended metadata and lifecycle state, Publisher App Bot author, and empty asset inventory, then freezes that ID without a post-create list or by-ID read. If the create attempt is nonzero, empty, malformed, or lacks a parseable safe numeric ID, the publisher does not issue a second POST; it may use a new stable complete inventory only to reconcile that ambiguous outcome. One unique eligible exact empty draft freezes its ID, while stable absence isinconclusive/release-creation-unknown. If the full tag already existed at invocation start while the stable complete inventory has no exact-tag Release, the publisher emitsinconclusive/release-create-attempt-unknownwithout issuing any create request. A manually reviewed recoverable Draft may instead be selected on a recovery dispatch throughexisting_draft_release_idunder the constraints above. Upload release assets, canonical provenance, checksums, and the detached provenance signature through the numeric-IDuploads.github.com/repos/{owner}/{repo}/releases/{frozen_id}/assetsendpoint, never through a tag-resolving upload command. Each response must identify one positive safe asset ID with the exact name anduploadedstate before the by-ID Release boundary can admit it. Existing uploaded prefix assets are adopted only after a raw byte read through their frozen asset IDs; exact-source recovery never downloads them through a tag-resolving command. - Complete the governing policy reads, then perform the final exact draft Release, asset, and tag boundary described above. Publish by directly patching the frozen Release ID with the exact metadata, then verify the immutable published Release.
- For a stable version only, collect neutral canonical raw observations A and
B of the live alias binding and compare them before interpreting tag shape
or expected policy. Only after A equals B, validate an absent creation
boundary or an annotated direct/peeled binding and bind the exact previously
observed tag object as the update lease. Then run the final
source/ruleset/current-signer policy fence, explicitly re-read immutable-
Release policy, and capture a fresh exact immutable Release/asset/full-tag
boundary. Then create the signed annotated floating major tag such as
v2, or update it with the exact lease. The alias may move only forward through version history. - Collect and compare neutral canonical raw post-mutation alias observations A
and B. A difference is
inconclusive/remote-state-changed. When A equals B, validate that the stable binding is an annotated tag with the exact planned direct object and peeled commit; a malformed or lightweight tag, or a different stable binding, isblocked_conflict/malformed-major-alias-target. Then re-read the immutable Release/asset/full-tag boundary and require it to match the pre-alias boundary. A prerelease stops before alias mutation and never changesv2.
At either pre- or post-mutation alias boundary, an unreadable command or
canonical raw projection is inconclusive / remote-read-inconclusive.
After Release-ID discovery or strict adoption within one publisher invocation,
each exact Release boundary is likewise raw-first A/B. It reads
/releases/{frozen_id} twice together with two immutable
full-tag ls-remote bindings, compares the neutral canonical observations,
requires every response .id to equal the frozen path ID, and only then
performs structural and expected-policy validation. An ID-endpoint 404 or
other unreadable result never authorizes rebinding or recreation within that
invocation. A later exact-source retry has no persisted Release-ID ledger: it
ordinarily performs a new full reconcile and, when the complete inventory
selects one unique exact-tag object, freezes that ID for the new invocation.
The optional manual selector is an operator-supplied recovery locator, not a
ledger: it must be supplied again and pass the strict one-read adoption
contract described above. Stable absence alone does not authorize recreation
when the immutable full tag existed at invocation start. The pre-existing tag
is the durable cross-run create-attempt fence, and an ordinary retry stops as
release-create-attempt-unknown. The trusted-owner boundary
forbids deletion or replacement between attempts; the publisher does not claim
historical ID continuity across runs. Its closed classification is:
- an unreadable API,
ls-remote, or canonical raw projection isinconclusive/remote-read-inconclusive; - raw observations A and B that differ are
inconclusive/remote-state-changed; and - a stable but malformed or lightweight tag, or stable Release metadata,
author, asset, tag, frozen-draft, or planned-state mismatch, is
blocked_conflict/immutable-release-mismatch.
This raw-first order is intentional: validating either observation against expected policy before comparing A and B could disguise a stable wrong state as a transient read failure.
Fresh draft creation uses the same expected-absence boundary. It reads complete
paginated inventory A, raw full-tag binding A, complete inventory B, and raw
full-tag binding B in that order. It compares both canonical raw pairs first,
then validates inventory completeness and the exact-tag mapping. Stable zero-
match is absence; a stable exact-tag match at this boundary or A/B drift is
inconclusive / remote-state-changed. A stable exact tag claimed by multiple
distinct IDs is blocked_conflict / duplicate-release-tag. Outer [],
malformed pages, an unsafe ID, a repeated numeric ID (including pagination
overlap), or any unreadable page is incomplete evidence and becomes
inconclusive / remote-read-inconclusive.
After that boundary, a successful direct create response is validated as the created object and freezes its positive numeric ID. It must carry the exact tag, name, body, prerelease and draft/immutable state, Publisher App Bot author, and zero assets. The publisher deliberately performs no immediate post-create list or by-ID retry: the POST response already supplies the created state and ID, so making success depend on a second read would add a separate visibility assumption without strengthening that transaction receipt.
If the create attempt is nonzero, empty, malformed, or lacks a parseable safe
numeric ID, the publisher may repeat the complete inventory/tag A/B boundary
only as ambiguous-outcome recovery. One unique exact-tag match with zero
assets freezes the positive numeric ID and recovers the lost response without
another POST. Stable absence is
inconclusive / release-creation-unknown, because eventual visibility is not
proof that a second create would be safe. Unreadable, drifting, malformed, or
duplicate observations retain the closed classifications above, and the pre-
create and recovery tag boundaries must match exactly.
Once a successful response carries a safe numeric ID, that response remains the only identity receipt for this invocation. A wrong metadata, author, or nonempty-asset policy does not fall back to inventory discovery; it fails closed. The ordinary frozen-ID boundary later verifies that the selected ID still resolves to the same Draft before any Release mutation, and an unreadable or mismatched ID never rebinds to an inventory object.
A later invocation that starts with the immutable full tag already present
also does not create. Without a selector, stable Release absence is
inconclusive / release-create-attempt-unknown. Once an operator has
independently reviewed an exact recoverable Draft and obtained its numeric ID, an
exact-source workflow_dispatch may supply existing_draft_release_id. The
publisher performs one direct adoption read and accepts inventory omission,
but rejects a wrong ID, any visible exact-tag ID disagreement, published or
immutable state, wrong metadata or author, unknown assets, a noncanonical
uploaded prefix, or an invalid starter. It then uses the ordinary frozen-ID/
full-tag A/B boundaries before upload or publish. Neither this recovery nor a
new Environment approval authorizes a second create, deletion, replacement,
or a different release intent.
For release-creation-unknown or release-create-attempt-unknown, the failure
summary tells the operator or agent to confirm whether the exact recoverable
Draft exists and, only when its ID, fields, and any partial assets have been
reviewed, rerun the original admitted source with the three required identity inputs plus
existing_draft_release_id, then obtain a new Environment approval. If no such
Draft can be confirmed, recovery remains stopped rather than guessing,
force-creating, recreating, or issuing another POST.
This distinction is required by GitHub's interfaces. The REST
release-by-tag endpoint
is published-only, so its 404 does not prove that no draft exists. The
complete Release list
includes drafts for callers with push access. GitHub CLI's own
FetchRelease
therefore looks up published-by-tag and draft candidates separately, then reads
a selected draft through /releases/{id}. The publisher follows the same
identity rule without relying on porcelain: inventory selects exactly one
numeric ID, and every mutable or draft boundary stays on that ID. Only public
verification of an already published Release uses release-by-tag.
Asset upload is also an identity-bound mutation. A nonzero upload result, or
a zero-exit response that is empty, malformed, has an unsafe asset ID, names a
different asset, or does not report state=uploaded, is inconclusive /
release-asset-upload-unknown: bytes may already have reached the frozen
Release. After a successful POST response, the publisher first captures the
frozen by-ID Release boundary and requires the returned asset ID to belong to
that object. It then downloads bytes directly through
/releases/assets/{asset_id} with the binary media type and compares them to
the intended file; this post-upload path never resolves a Release by tag. A
read failure or byte mismatch is also release-asset-upload-unknown because
the mutation already occurred. Within the current invocation, recovery never
rebinds the Release. A later exact-source retry starts from a complete
inventory and freezes the then-unique exact-tag object under the trusted-owner
no-replacement contract.
GitHub documents that an upload 502 can leave an empty asset in starter
state. A retry admits that state into the neutral inventory only so its typed
identity and content fields can be compared; it is not accepted as a completed
asset. Automatic recovery is limited to exactly one starter on the selected
mutable draft, with the Publisher App uploader, the planned
application/octet-stream type, zero bytes, no digest, an expected name, and
the single next slot after the verified uploaded canonical prefix. Asset names
and numeric IDs must be unique, including asset IDs across the complete
inventory. After the final policy fence, the publisher takes a fresh stable
by-ID A/B boundary. The two raw reads inside that capture must be exactly
equal. Against the selected boundary, the cross-capture comparison permits
only the documented directional metadata advancement: it ignores the
non-authoritative target_commitish presentation and permits an asset digest
only to remain unchanged or advance from null to canonical lowercase
sha256:<64hex>; the accepted boundary becomes the new baseline. It then
issues one unconditional DELETE for that frozen asset ID. Every DELETE outcome,
including 204, 404, network failure, and response loss, is reconciled
through another stable frozen-ID boundary. Publication continues only when the
exact starter ID is absent and every other change is the authorized
one-asset removal plus, at most, that same directional metadata advancement;
otherwise it returns inconclusive /
starter-asset-deletion-unknown without a second DELETE in that invocation.
Uploaded, nonzero, wrong-name, wrong-slot, wrong-uploader, wrong-content-type,
or otherwise unbound assets are never deleted.
The asset DELETE endpoint has no state-predicate compare-and-swap. The final
GET-to-DELETE interval therefore retains a small race that the client cannot
eliminate. Safe automatic recovery depends on the trusted-owner/single-writer
deployment boundary and GitHub's documented empty starter terminal orphan
shape; another Release writer in that interval violates the deployment
contract.
GitHub's official Release REST endpoint exposes no supported conditional
compare-and-swap precondition for this PATCH; the publisher does not rely on
undocumented conditional headers. Consequently, the small interval between
the final draft/asset/tag read and the publish PATCH cannot be eliminated by
the client. Workflow concurrency serializes publisher runs but cannot serialize
an independent Release writer. The deployment contract therefore makes the
private Publisher App the only automated Release writer and treats
JoeyTeng, the repository owner, as the explicit trusted manual writer. Any
other concurrent Release writer violates the deployment contract. If
post-publication readback detects a mismatch, publication remains blocked and
must not claim automatic recovery. A nonzero direct PATCH, or a zero-exit
response that is empty, malformed, or identifies a different Release ID, is
inconclusive / release-publication-unknown, because the mutation may have
applied. Reconcile must retry the same exact source, reselect the then-unique
exact-tag object from the complete inventory, and prove either that its draft
state remains a valid prefix or that the exact Release is already immutable;
it must not blindly choose a different version or claim cross-run ID
continuity. A deterministic
post-publication mismatch remains blocked.
There is no v2.0 alias in this contract. A floating alias does not receive a
separate GitHub Release; Releases belong only to immutable full-version tags.
Consumers use JoeyTeng/codex-review-gate-action@v2 after the stable release
is admitted, so later v2 patches do not require edits in every consumer repo.
Provenance binds at least the release intent, exact source commit and subtree, candidate tree and complete payload inventory, wrapper commit and parent, signing identity, immutable tag object, assets, and observed target state. It does not contain a self-referential digest. The floating-alias result is mutable post-publication state rather than data forged into already immutable provenance.
verify has no Environment and no publisher or signing secrets.
It performs a fresh public read of target master, the immutable tag and
peeled commit, signatures, Release immutability and assets, and—after a stable
release—the floating alias. Its Actions summary records the observed state and
one closed recovery result: recovery_code=none when verification is complete,
or a supported non-success recovery code with the exact next action when any
required state is incomplete, conflicting, or could not be proved. The summary
must not omit the recovery result or substitute an open-ended instruction to
guess at repair.
Public verification obtains both its initial and final Release-view metadata
from the direct REST release-by-tag endpoint using the GitHub REST 2026-03-10
contract. Historical completed-Release by-tag reads use the same explicit API
version. It projects draft, prerelease, tag_name, name, and body into
the closed view used for comparison. Its initial and final complete Release
inventories use the same structural validator as publication: at least one
outer page, array pages, positive safe numeric IDs, and global ID uniqueness.
Outer [], malformed pages, or repeated IDs are incomplete evidence and are
inconclusive / remote-read-inconclusive. A documented REST HTTP 404 is
classified as the applicable missing or disappeared Release state; other API
failures are inconclusive. Verification never infers an HTTP status from
gh release view porcelain stderr.
Every full SemVer, including every prerelease, minor, and patch version,
receives its immutable full tag and immutable GitHub Release. Marketplace is a
separate, manual, out-of-band operator task only for the first stable release
of each major: v2.0.0, later v3.0.0, and so on. On that Release page, a
human uses the Action Marketplace Release UI to publish the major's initial
listing version. Minor and patch releases require no Marketplace operation.
The publisher never waits for, reads back, or gates success on Marketplace.
Marketplace does not create or resolve the floating v2 Git ref. The accepted
tradeoff is that the listing may continue to display v2.0.0 for the lifetime
of v2 while the signed v2 alias advances to newer immutable releases.
Before consumer rollout, the full tag must be usable, stable v2 must peel to
the same admitted commit, and the immutable Release and provenance must match.
For the first stable release of a major, the separate Marketplace UI task may
be completed after publisher success; its completion is not machine read-back
evidence and is not a publisher admission condition. Dedicated
immutable-tag and floating-alias canary jobs are deferred and are not v2.0
publication or rollout gates. The manual default-branch RC admission bridge
below reuses the existing consumer workflows; it is not one of those deferred
dedicated canary jobs. Prereleases are never production selectors and do not
move v2.
Before publishing stable v2.0.0, first publish one immutable
v2.0.0-rc.N full tag and prove a complete live gate loop in the designated
test consumer repository. Do not change the production bootstrap @v2
templates or their normal floating selectors. Use this owner-reviewed,
short-lived default-branch bridge instead:
Prepare the bridge manually; do not add an RC override to the production bootstrap or activate the production v2 ruleset for this temporary admission exercise. The bridge is either the normal selector-only form below or the narrow fresh-fixture form that follows it; neither is ordinary consumer installation.
- Installed-consumer bridge. In the designated test consumer, open a
selector-only PR that changes only the Action selectors in both installed
canonical workflows—the verifier and the controller—from
@v2to the exact immutable@v2.0.0-rc.N. Existing test-repository protection and an owner review govern the change before it merges into the protected default branch. - Fresh temporary fixture. This is permitted only when the exact
pre-bridge default-branch snapshot has neither canonical workflow. Freeze
that absent-path snapshot and the protection snapshot first. Do not use the
normal installer: it installs
@v2, CODEOWNERS, and a v2 ruleset, none of which belongs to this admission exercise. If the repository has no sufficient existing pull-request protection, create one active, uniquely named repository ruleset targeted exactly to the default-branch ref, with no bypass actor, one approval, stale-approval dismissal, last-push approval, and no required status check. Record its returned ID and normalized profile, then read that same profile back before creating the bridge PR. Do not synthesize absent protection with classic branch-protectionPUT/DELETE: it replaces a whole resource and cannot safely preserve concurrent changes. For this fixture, "normalized profile" means the canonical JSON returned byrulesetWritableFingerprint()for the writable fields, plus the exact returned numeric ruleset ID,source_type=Repository, andsource=<owner>/<repository>identity. Freeze that complete tuple from the creation readback; a later read that differs in any component is drift. For each bridge and cleanup PR, independently prove that the named control-plane owner is not the PR author, that the owner's latest review isAPPROVEDon the current full head from a complete paginated review inventory, and reread that head immediately before merge. Make the merge API mutation with that same full SHA as its expectedsha; a changed SHA or rejected merge stops rather than merging an unverified head. The bridge PR adds exactly the copied verifier and controller workflows; their only permitted byte differences from the canonical templates are the two selectors, each set to the exact immutable@v2.0.0-rc.N. It does not add CODEOWNERS, a ruleset, a variable, or a secret. - From that updated default branch, open a separate harmless test PR. Exercise
the complete normal
begin-reviewandreconcilepath on its exact head, including the required Codex evidence and final gate result. - Record the harmless test PR's exact head, the controller and verifier run
IDs or URLs, and the resolved tag
v2.0.0-rc.N. Whether the live gate succeeds, fails, is cancelled, or remains inconclusive, close that harmless test PR without merging it and complete the forward cleanup. Only a successful live gate satisfies stable admission; every terminal result still requires cleanup. - Open and merge a forward PR that removes the temporary bridge and restores
the exact pre-bridge bytes of both default-branch workflows. If that state
contained the canonical production verifier and controller, both selectors
return to
@v2; otherwise remove the temporary RC workflows rather than leaving an immutable RC selector behind. A fresh fixture deletes only the two recorded workflow paths, never the whole.githubdirectory. Keep the temporary ruleset active through the cleanup PR's successful merge. Only then, in an exclusive owner maintenance window, reread its ID and require an exact normalized-profile match to the frozen fixture tuple before deleting that same ID. Without that exclusive window, or after any drift, leave the ruleset in place and stop; neither condition is permission to overwrite the repository's current policy. After deletion, reread the effective default-branch rules inventory and require that same ID to be absent. Before the cleanup merge, the two temporary workflows must still equal their recorded exact RC bytes; workflow drift likewise stops rather than being deleted.
A PR-local wrapper does not qualify: the trusted verifier and controller, including the controller's manual-dispatch contract, are loaded from the default branch. A non-default dispatch is likewise unsupported and provides no admission evidence. A fresh fixture proves the live private same-repository verifier/controller path, Codex evidence, exact-head reconciliation, and native CheckRun only. It does not prove production CODEOWNERS, all-conversations- resolved, up-to-date, v1-migration, or normal-installer closure. This temporarily merged selector bridge is a manual use of the existing consumer contract, not a publisher-integrated immutable-tag canary, floating-alias canary, dedicated canary job, or canary orchestrator.
Publication is a sequence of remote operations, not one cross-service transaction. Every privileged retry begins with a full reconcile of:
- the manifest and exact source commit;
- current target
masterand its ancestry; - the intended wrapper commit and full tag object;
- draft or published Release state and every asset digest;
- the floating alias for stable releases;
- effective branch and tag rulesets; and
- the current pinned signer primary/subkey/raw-certificate inventory.
Reconcile returns exactly one remote-state class: fresh,
resumable_partial, already_complete, superseded, blocked_conflict, or
inconclusive. It may reuse only an exact signer/parent/tree/digest-matching
prefix of the canonical publication sequence. Every durable write is re-read.
An older partial release blocks a newer release from leapfrogging it.
The fully paginated Release-inventory stability fingerprint is a closed,
decision-relevant projection. It binds Release and asset object identities,
tag and lifecycle policy, immutable metadata, asset digests and byte metadata,
and author/uploader identities. It deliberately excludes observational or
decorative API fields such as assets[].download_count, timestamps, and
profile URLs.
It canonicalizes Release/page and asset array order, so pagination placement
or response ordering alone is not treated as mutation. Within one stable
capture, the raw A/B projection, including target_commitish, asset digests,
and browser_download_url, must be exactly equal. Between separately stable
captures, every protected value remains exact except that the non-authoritative
target_commitish presentation is ignored and an asset digest may only remain
unchanged or advance from null to canonical lowercase sha256:<64hex>. For
the explicit Draft-to-published transition only, GitHub may also derive a
different per-asset browser_download_url from the Draft's untagged endpoint
to the published full-tag endpoint. The derivation must be exact: the prior
URL is https://github.com/JoeyTeng/codex-review-gate-action/releases/download/untagged-<opaque>/<encoded-asset-name>, where
<opaque> is a non-empty canonical ASCII unreserved path segment
([A-Za-z0-9._~-]+), and the
new URL is the same target and asset at
https://github.com/JoeyTeng/codex-review-gate-action/releases/download/<full-tag>/<encoded-asset-name>;
an unchanged URL is also permitted. A successful comparison rolls the baseline
forward. A non-null digest may not disappear or change, and the URL exception
does not apply to steady, asset-add, or asset-remove boundaries. This narrow
rule handles service-side derived metadata materialization without accepting a
Release or asset replacement.
Downloading an asset during reconcile can change a download counter without
changing any protected publication property; treating that counter as state
mutation would make the verifier invalidate its own otherwise stable snapshot.
A completed exact step is verified and reused; an absent next step may resume
only when that mutation's contract authorizes it. Draft Release creation is the
exception: once the immutable full tag exists across invocations, stable
Release absence returns release-create-attempt-unknown and requires an
explicitly reviewed manual recovery. When that review finds an exact
recoverable Draft (empty or a verified canonical partial prefix), the operator
supplies its numeric ID through
existing_draft_release_id; otherwise recovery stays stopped rather than
running another ordinary create attempt.
A conflicting tag, commit, signature, Release asset, unexpected target advance,
or unknown state fails closed with a specific recovery summary. The publisher
never deletes or rewrites an immutable full tag or Release, force-pushes
master, or moves a major alias backward. Recovery after an immutable
conflict is a reviewed forward release, normally a new patch version.
Each attempt's immutable plan binds the frozen release intent and the current
protected control commit and complete control-file digests used for that
attempt. If a short-lived candidate, assembled-candidate, or publication-plan
artifact expires or becomes unavailable while the original 90-day push-plan
admission remains valid, the three required identity inputs may authenticate
that persisted admission, independently rematerialize both candidates under
the then-current protected controls, construct a fresh plan and publication
plan, and obtain Environment approval again. The new run must still classify
and reconcile every remote object before writing. It may resume only an exact valid
prefix; otherwise it stops with blocked_conflict or inconclusive. If the
original push-plan admission expires or is unavailable, recovery fails closed
and requires a new reviewed release intent rather than reconstructing admission
from Git alone. If a durable immutable conflict cannot be reconciled, preserve
it for diagnosis and publish a reviewed higher version that repairs forward.
Historically restoring earlier code is allowed only through that higher
version; the floating alias never moves backward through version history.
The workflow-level concurrency contract is:
concurrency:
group: codex-review-gate-action-release
cancel-in-progress: falseThe false value is intentional and must be covered by static workflow tests.
A newer run must not automatically terminate an active release between
advancing master, creating the immutable tag, publishing the Release, and
updating the alias. Cancellation does not roll back those remote writes, and
App-token revocation on a forcibly terminated runner is only best effort. A
pending run may be superseded by a later pending run; an active run remains
uninterrupted. The owner may still cancel it deliberately after inspecting
remote state, but the next run must perform full reconcile before continuing.
Initial v2 publication deliberately keeps v1-like operational simplicity: one source-hosted publisher, two deterministic candidate builds, one protected publication stage, and one unprivileged public-verification stage. It does not introduce the old pre-activation controller, three Environment wait jobs, a scheduled consumer scan, dedicated immutable-tag or floating-alias canary jobs, or a separate canary orchestrator as release prerequisites.
Stable v2.0.0 admission additionally requires a published v2.0.0-rc.N and
the default-branch RC admission bridge described above in the designated test
consumer repository. The RC uses only its immutable full tag; it does not move
v2. An installed consumer uses the temporary selector-only bridge; a fresh
fixture uses the narrowly defined temporary canonical pair. After every
terminal gate result, the separate harmless test PR is closed unmerged and a
forward PR restores the exact pre-bridge bytes; only a successful gate satisfies
stable admission. Only a consumer that originally had the canonical
production verifier and controller returns both selectors to @v2; otherwise
the temporary RC workflows are removed. The ordinary post-installation @v2
consumer canary remains separate from the publisher.
The following are explicitly deferred and must not be represented as completed or silently promoted into the current contract:
- dedicated immutable-tag and floating-alias canary jobs, including richer multi-repository or multi-phase canary orchestration;
- Marketplace publication automation and machine-verifiable admission;
- detailed rollback and forward-recovery automation for every partial GitHub Release failure mode; and
- optional temporary numeric overrides for release or runtime soft limits.
For an unimplemented edge, stop, preserve observed state, and use reviewed forward recovery. Do not weaken a ruleset or mutate immutable history.
The following is retained as historical evidence, not as the live v2 publisher contract:
- v1 was published in
JoeyTeng/codex-review-gate-action, so v2 keeps the existing Marketplace listing instead of publishing from a second repo; - the recorded pre-v2 target
masterwas59eeda2af2a7baab3f3f15a59fbbaee015fa6c01, with tree8d909dd441b28b6915c46f60e8a144e64fd5268b; - the recorded
v1.5.1annotated tag object wasf9201d016b0abd21403550c3bf8030eb0beb76b4, and v1.1.0 through v1.5.1 release refs and assets remain historical target state; and - the recorded v1
masterwas an unsigned raw subtree-split commit, while the verifiedv1.5.1annotated release tag had a valid GPG signature; v1 also used manual release evidence and an SSH deploy-key publication path.
Those facts explain migration checks but grant no current authority. v1 refs
remain frozen; v2 uses the Publisher App, signed wrapper commits, committed
SemVer intent, immutable full-version Releases, and the floating v2 selector.