Skip to content

Immutable release controller contract

Extra CODEOWNERS includes an offline-tested state machine for creating one exact, immutable GitHub release and a concrete standard-library GitHub REST adapter. Neither is connected to the release workflow, so they cannot publish anything today. The final asset policy and privileged workflow handoff remain work under issues #25, #18, #28, and #32. The GitHub release API adapter contract documents the separate publication boundary. The immutable-release preflight contract documents the implemented but unwired Administration: read boundary.

The blocked release workflow also contains a read-only candidate assembler. Its output is deliberately not this controller's manifest: it records incomplete source evidence, an unfrozen final asset policy, and publication_allowed: false. There is no adapter from that candidate record to ReleasePlan.

The controller lives in .github/scripts/release_controller.py. Its narrow ReleaseAPI protocol deliberately has no operation for deleting a release, asset, or tag, and no operation for replacing an existing asset.

Loading a manifest validates its syntax and bounds; it does not make the manifest trusted. Before any GitHub mutation, reconcile_release() requires a separate ExpectedIdentity supplied from trusted workflow context and the reviewed asset-plan digest. Repository ID and name, tag, target commit, workflow path and commit, run ID, and manifest SHA-256 must all match. Deriving that expected identity from the untrusted manifest would defeat the boundary. The controller also reconstructs the canonical manifest from the supplied ReleasePlan and verifies its digest before comparing trusted values. A caller cannot change a frozen plan with dataclasses.replace() and keep the digest of the manifest that was originally loaded.

Release manifest

The controller accepts one single-link regular manifest no larger than 256 KiB. Its canonical JSON encoding sorts object keys, uses compact separators, escapes non-ASCII characters, and ends with one line feed. JSON objects may not repeat keys. Floating-point and non-finite numbers are invalid. The top-level value is depth 1; the maximum depth is 8, and the complete document may contain at most 4,096 JSON items.

The top-level object has exactly these fields:

Field Requirement
schema_version Integer 1.
repository_id Positive GitHub repository ID, at most 2^63 - 1.
repository Exact OWNER/REPOSITORY name.
tag Semantic tag in the form vMAJOR.MINOR.PATCH.
target_commit Lowercase, full 40-character Git commit ID.
workflow_path Workflow path under .github/workflows/.
workflow_sha Lowercase, full 40-character commit ID for the workflow.
run_id Positive GitHub Actions run ID, at most 2^63 - 1.
assets Complete, sorted asset array.

The manifest digest binds all of that identity into the release body as <!-- extra-codeowners-release-controller:v1 manifest-sha256:DIGEST -->. A draft created for another repository, tag, commit, workflow, run, manifest, or asset set is therefore foreign state, even if its name looks familiar.

Each asset has exactly name, path, size, and sha256. Names are safe basenames of at most 255 characters and cannot start or end with a period; GitHub normalizes those periods during upload, which would break exact-name identity. Paths are relative POSIX paths whose final component equals name; absolute paths, empty segments, traversal, backslashes, more than eight path segments, and unsupported characters are rejected. Names and paths must be unique, and the array must be sorted by name.

The manifest may contain 1 through 64 assets. Each file must be nonempty and no larger than 2 GiB. Their combined declared size may not exceed 16 GiB.

Local file boundary

The controller opens the asset root and every path component with no-follow semantics. Every asset must be a single-link regular file whose size and SHA-256 match the manifest. It keeps the verified read-only file descriptors open until reconciliation finishes; an API adapter must stream from those descriptors and must not reopen a pathname.

Manifest and asset file opens are nonblocking. A FIFO or another special file is rejected after inspection instead of waiting for a writer and hanging the release job.

Reads use 1 MiB chunks. The controller retains at most 64 asset descriptors and does not buffer an asset in memory. It rehashes an asset after upload and rehashes the complete set immediately before publication. A deployment budget must account for those repeated reads, the open-descriptor limit, API-client memory, and the final asset-set size. The 16 GiB input limit is not a statement that every runner can process that much data within a normal job timeout.

Reconciliation states

The state machine first binds the manifest to ExpectedIdentity, requires the live repository ID to match, and resolves the existing tag to the expected commit. It then lists a bounded number of releases and follows one of these paths:

bind identity; verify repository, tag, and local assets
  |
  +-- release absent --> create controller draft
  |
  `-- release present --> keep existing release

both paths --> inspect exact state
  |
  +-- matching draft --> upload only missing assets
  |                       --> verify remote state
  |                       --> publish once by release ID
  |                       --> require exact immutable release
  |
  +-- matching public release --> require exact immutable release
  |
  `-- foreign or mutable public state --> fail without deletion

exact immutable release --> success
anything else -----------> fail

An upload URL is accepted only when it uses HTTPS on uploads.github.com, has the exact repository and release-ID path, and has no credentials, port, query, or fragment. Remote assets must form the exact manifest name set. Every asset must be in the uploaded state, have no display label, use application/octet-stream, and have the expected nonzero size and GitHub server sha256: digest. Unexpected, duplicate, missing, empty, or mismatched assets stop reconciliation.

A previously published release is idempotent success only when its complete controller identity and asset set match and GitHub reports immutable: true. The release must also have prerelease: false. An otherwise matching mutable or prerelease publication is an integrity incident, not a state the controller repairs.

The immutable repository ID returned by the API is the live GitHub security identity. The repository name is bounded routing and audit data. The controller checks the ID at startup, before every mutation, and during final publication readback. After a rename, generate a new manifest and expected identity for the same repository ID; the controller does not silently adopt the old name.

GitHub's release mutation routes still use OWNER/REPOSITORY, and no identity check can make the following request atomic with that lookup. Future workflow wiring must supply a token whose authority is narrowed to the exact repository ID. A general personal access token or multi-repository installation token is outside this contract. Rechecks narrow rename and replacement races; the scoped token is the control that prevents a reused old name from redirecting a mutation.

After the last complete local rehash, the controller rereads the same release by ID, requires it to remain an exact non-immutable draft, rereads the complete remote asset set, and resolves the tag again before publication. It repeats those checks after GitHub reports the release immutable. A protected v* tag ruleset that denies updates and deletion is still a prerequisite.

GitHub's release API does not provide a compare-and-swap publication over all of that state. Another actor with contents: write can race a final API read and publication. Give release authority to one serialized workflow and do not share its token with unrelated jobs. An administrator who bypasses those controls could still move the tag or alter the draft in that narrow interval. The post-publication checks detect the resulting incident, but the controller cannot repair or delete an immutable release. See GitHub's immutable release contract.

The controller cannot discover safely whether immutable releases are enabled. GitHub's repository preflight endpoint requires the Administration: read repository permission. The future Contents: write publication token must not receive it. The repository now contains a separate read-only preflight contract that binds a positive result to the exact repository ID and workflow run. No workflow captures or consumes that record yet. If the setting is off, publishing first and inspecting immutable: false afterward would already have exposed a mutable release.

Ambiguous responses and recovery

Network failure can hide whether a GitHub mutation succeeded. The adapter must report that case as AmbiguousMutationError; an ordinary validation or API error must not be mislabeled as ambiguous.

Lost response Reconciliation rule
Draft creation Relist releases and continue only with the one exact controller-owned draft. Fail if it is absent or ambiguous.
Asset upload Relist assets and continue only if that exact asset now exists with the planned size and digest. Do not retry an upload whose outcome is unknown.
Publication Read the same release by ID for a bounded three polls. Accept only the exact immutable release.

Once a workflow calls the controller, a failed run can leave a private draft containing a partial or rejected asset set. The controller never removes it automatically. Until the operator runbook and privileged workflow handoff land, a maintainer must treat such a draft as evidence to inspect, not as something automation may age-clean. No current workflow calls the controller or adapter, so this recovery procedure is not yet an operational release path.

Non-claims

The controller proves state-machine and byte-identity rules in offline tests. It does not yet:

  • receive a token or call the concrete GitHub API adapter from a workflow
  • consume a verified immutable-release preflight record
  • define the final release asset set
  • prove that source, notice, SBOM, signature, or attestation evidence is complete
  • enable the repository's immutable-release setting
  • invoke gh release verify
  • make tagged publication reachable.

The repository must keep publication blocked until the privilege-separated evidence path, final asset policy, operator recovery procedure, workflow handoff, and live immutable-release verification are complete.