GitHub release API adapter contract¶
Extra CODEOWNERS includes a standard-library adapter for the immutable release
controller's ReleaseAPI protocol. The adapter has no command-line interface,
doesn't read a token from the environment, and isn't called by a workflow. It
cannot publish anything in the current repository.
The implementation lives in .github/scripts/github_release_api.py. It exists
so the network boundary can be reviewed and tested before any workflow receives
release authority. The final asset policy, privileged workflow handoff, and
operator recovery procedure remain separate work.
The separate immutable-release preflight contract needs the Administration: read repository permission. Do not add that permission to this adapter's Contents: write publication token.
Construction and routing¶
GitHubReleaseAPI requires a token and an exact OWNER/REPOSITORY name in its
constructor. Each repository-name component may contain at most 100 characters
and cannot be . or ... The token must be printable ASCII with no whitespace
or control characters and may contain at most 4,096 bytes. The adapter never
includes it in repr(), exceptions, or logs.
The adapter cannot inspect the token's repository selection. Future workflow wiring must mint a short-lived token restricted to the manifest's exact repository ID; a general personal access token or multi-repository App token is not acceptable. The controller also rechecks the live ID before writes and while accepting final state, but GitHub's name-based release routes offer no atomic ID-and-mutation request.
All REST requests use http.client.HTTPSConnection with these fixed values:
| Setting | Value |
|---|---|
| API host | api.github.com |
| Upload host | uploads.github.com |
| REST API version | 2026-03-10 |
| Media type | application/vnd.github+json |
| Response content encoding | identity |
| Default socket-operation timeout | 30 seconds |
| Maximum configured socket-operation timeout | 120 seconds |
The adapter opens one connection for one request. It doesn't follow redirects or retry any operation. This matters for writes: retrying after a lost response could create a second draft or collide with an asset whose upload actually finished.
The configured timeout is the http.client socket timeout, not a wall-clock
deadline. DNS resolution may outlast it, and a peer that keeps making progress
can extend the complete operation. Any future workflow that calls the adapter
must add a finite job-level timeout and treat cancellation during a mutation as
ambiguous state for the next run to reconcile.
GitHub documents the upstream contracts for releases, release assets, Git references, and annotated tags.
Protocol methods¶
The adapter implements the protocol's eight operations. There is no operation for deleting or replacing a release, asset, or Git reference.
| Method | REST request | Required success | Additional check |
|---|---|---|---|
repository_id() |
GET /repos/OWNER/REPOSITORY |
200 object |
full_name must equal the trusted constructor value; id must be positive and at most 2^63 - 1. |
resolve_tag(tag) |
GET /repos/OWNER/REPOSITORY/git/ref/tags/TAG |
200 object |
The returned ref must be exact. A lightweight tag must point to a commit. |
list_releases(page, per_page) |
GET /repos/OWNER/REPOSITORY/releases |
200 object array |
Page is 1 through 10; page size is 1 through 100. |
create_draft(plan) |
POST /repos/OWNER/REPOSITORY/releases |
201 object |
Sends the exact tag, full target commit, name, controller marker, draft: true, prerelease: false, generate_release_notes: false, and make_latest: "false". |
get_release(id) |
GET /repos/OWNER/REPOSITORY/releases/ID |
200 object |
The requested and returned IDs must match and be positive integers no greater than 2^63 - 1. |
list_assets(id, page, per_page) |
GET /repos/OWNER/REPOSITORY/releases/ID/assets |
200 object array |
Applies the same ID and pagination bounds. |
upload_asset(id, url, asset) |
POST to the exact trusted upload path |
201 object |
Adds one percent-encoded name query, sends no label, and streams the bytes as application/octet-stream from the retained descriptor. |
publish_release(id) |
PATCH /repos/OWNER/REPOSITORY/releases/ID |
200 object |
Sends {"draft":false,"make_latest":"false"} so publication cannot change the repository's latest-release pointer. |
An annotated tag adds one request to /git/tags/OBJECT_ID. Its returned object
ID and tag name must match the reference response, and its target must be a
commit. Tag chains, trees, blobs, substituted names, and malformed object IDs
fail closed.
The create body doesn't ask GitHub to generate release notes because that would add server-selected text outside the reviewed manifest identity. Creation and publication both disable GitHub's default latest-release side effect. A future workflow needs a separate reviewed policy if it intends to move that pointer. The controller still validates every returned release and asset field described in the immutable release controller contract.
Successful response bounds¶
Every successful response must be UTF-8 application/json with no content
encoding other than identity. A response may contain at most 8 MiB, 100,000
JSON items, and 16 nested levels. Duplicate object keys, floating-point and
non-finite numbers, integers outside the signed 63-bit range, malformed content
lengths, and truncated bodies are invalid.
Methods that return one record require a JSON object. List methods require an array containing only objects. The controller applies the narrower release and asset schemas after the adapter returns.
Retained descriptor uploads¶
upload_asset() accepts only this exact template, with the trusted repository
and release ID substituted:
https://uploads.github.com/repos/OWNER/REPOSITORY/releases/RELEASE_ID/assets{?name,label}
The request sends application/octet-stream with the asset's exact declared
Content-Length. It uses os.pread() in 1 MiB chunks, so it doesn't change the
descriptor's current position. The adapter never closes the descriptor, seeks
it, or reopens a pathname. The controller retains ownership and rehashes the
same descriptor after upload.
The adapter checks for early end-of-file, an extra byte beyond the declared size, and read errors. Any of these failures after the request starts has an ambiguous outcome because GitHub may have received some or all of the body.
Failure classification¶
ControllerError means the adapter has no evidence that a write was accepted.
AmbiguousMutationError means a write may have succeeded, so the controller
must use its operation-specific readback instead of retrying.
| Condition | Read request | Create, upload, or publish request |
|---|---|---|
| Transport loss | ControllerError |
AmbiguousMutationError after request start |
Unexpected informational 1xx response |
ControllerError |
AmbiguousMutationError |
HTTP 408 or 5xx |
ControllerError |
AmbiguousMutationError |
Other 4xx |
ControllerError |
ControllerError |
Unexpected 2xx |
ControllerError |
AmbiguousMutationError |
| Redirect or another unexpected status | ControllerError |
ControllerError; never follow the redirect |
| Truncated, malformed, unbounded, or unsupported successful response | ControllerError |
AmbiguousMutationError |
| Upload descriptor read failure after request start | Not applicable | AmbiguousMutationError |
An upload that receives 502 is never retried or deleted. GitHub may have left
a starter asset, so the controller relists assets and accepts only the exact
planned name, size, state, and server digest.
HTTP and transport errors contain only the method, a bounded path without its query, status, and a bounded GitHub request ID. They never include the Authorization header, token, response body, transport exception text, or asset contents.
Publication remains blocked¶
No workflow imports the adapter or controller. The release workflow's jobs with
write authority still depend directly on the unconditional
publication-block job, and that job exits unsuccessfully without permissions.
Adding this adapter does not supply a token, grant a permission, invoke a
mutation, or make tagged publication reachable.
Future wiring must consume the repository-ID-bound record produced by the immutable-release preflight before it invokes the controller. That endpoint requires the Administration: read repository permission, so it belongs in a separate job. The Contents: write publication token must not perform the check itself. Publication stays blocked until that evidence handoff and its live contract test exist.