Raw OCI release-spine format¶
The release spine is an internal transport for a bounded, two-platform OCI descriptor graph. It lets a later job verify raw candidate bytes without opening a tar, ZIP, gzip, or Docker archive in that job.
Status: internal transport only. The CI producer builds a real, unsigned Extra CODEOWNERS candidate into a local OCI directory and never pushes it. A passing check proves the pinned BuildKit export and the two-file transport contract. It does not approve the candidate for distribution, prove what the layers mean, complete the distribution evidence, or make publication safe. The check creates no signatures or attestations.
The filename base is:
extra-codeowners-image-SOURCE_SHA-python-artifact-PYTHON_ARTIFACT_ID-run-RUN_ID-attempt-PRODUCER_ATTEMPT
The format adds one of two suffixes:
| Suffix | Media type | Purpose |
|---|---|---|
.bin |
application/vnd.stampbot.oci-release-spine.v1+octet-stream |
Concatenated, opaque OCI object bytes. |
.spine.json |
application/vnd.stampbot.oci-release-spine.v1+json |
Canonical identity, descriptor graph, and byte ranges. |
SOURCE_SHA is the exact lowercase 40-character source revision. The artifact,
run, and attempt values are canonical positive decimal strings. A producer
rerun changes PRODUCER_ATTEMPT, so it cannot collide with the previous
attempt's raw artifacts. Neither file is a supported GitHub release asset.
Boundary¶
The builder and verifier serve different trust boundaries:
selected Python artifact (immutable ID)
|
v
verify selected proof
|
v
pinned BuildKit OCI directory ---- build action root digest
| |
+--------------+-----------------+
v
unprivileged spine builder
| |
v v
raw spine artifact raw record artifact
| |
+------> read-only verifier
|
v
STOP: publication blocked
The producer downloads the selected Python proof by immutable artifact ID and
verifies it before the build. Buildx v0.35.0 and BuildKit v0.30.0 then
export one linux/amd64 and one linux/arm64 image as an OCI directory with
tar=false. The build does not push or attach provenance or SBOM descriptors
to the candidate.
The spine builder reads the small OCI index, manifest, and configuration JSON objects. It verifies each object against the descriptor that selected it. OCI layer bodies are never parsed; the builder hashes and copies them as opaque byte ranges.
The verifier treats every OCI object body as opaque. It validates only the
small canonical record, the trusted workflow inputs, the exact byte ranges,
and their SHA-256 digests. Its production import and call surface excludes
archive parsers, process execution, network clients, and high-level archive or
file-opening helpers. It uses bounded os.open, os.read, and os.pread
operations directly. It checks that the record's descriptor graph is
internally consistent, but it does not reconstruct that graph from the opaque
index and manifest bodies.
Trust anchors¶
Do not obtain a trusted value from the record it is meant to check. A consumer must receive these values through its trusted workflow context or an independently accepted job output:
| Value | Required source |
|---|---|
| Repository ID and name | GitHub workflow context. |
| Workflow run ID | GitHub workflow context. |
| Successful producer attempt | The raw producer's GITHUB_RUN_ATTEMPT, forwarded as a job output. |
| Source revision and project version | GITHUB_SHA and the version in the checked-out pyproject.toml. |
| Workflow path, ref, and workflow SHA | GitHub workflow context. |
| Candidate registry, repository, and source-bound tag | The candidate-build plan. |
| Root OCI index digest | The pinned build action's digest output. Never record.index.digest. |
| Python proof artifact ID and provider/archive SHA-256 | The accepted Python distribution job outputs. |
| Application wheel and selection-record SHA-256 values | The accepted Python distribution job outputs. |
| Record and spine artifact SHA-256 values | The two raw upload-action outputs. |
The root digest is the candidate anchor. CI takes it directly from the pinned
docker/build-push-action output, never from index.json or the spine record.
The builder requires that digest to equal the one descriptor in BuildKit's
wrapper index.json. The standalone verifier then requires
record.index.digest to equal the same out-of-band value.
The CI job exercises the real BuildKit v0.30.0 tar=false directory dialect.
Synthetic layouts remain in the unit suite because they let tests supply
hostile layer bytes and malformed descriptor graphs without teaching a
production consumer to parse an archive.
GitHub raw-artifact SHA-256 values are 64 lowercase hexadecimal characters.
OCI descriptor digests include the sha256: prefix.
Accepted OCI layout¶
build_release_spine.py accepts one directory produced by the reviewed
BuildKit v0.30.0 OCI-layout dialect with tar=false. It does not accept an
OCI archive or Docker archive.
The top-level directory contains exactly:
blobs/
sha256/
index.json
ingest/
oci-layout
The pinned BuildKit directory exporter leaves the local content store's empty
ingest/ root alongside the OCI Image Layout. The builder requires a real,
empty directory there. A missing, linked, non-directory, or nonempty ingest
root fails closed.
oci-layout contains only imageLayoutVersion: "1.0.0". blobs contains only
the sha256 directory, and that directory contains exactly the lowercase
digest-named objects reachable from the selected graph. Missing blobs, orphan
blobs, links, multiply linked files, and extra entries also fail closed.
BuildKit wrapper¶
The wrapper index.json is an OCI image index with exactly one descriptor. The
descriptor selects the trusted root OCI index and has exactly these
annotations:
io.containerd.image.name, equal toREGISTRY/REPOSITORY:release-candidate-SOURCE_SHAorg.opencontainers.image.created, matching the boundedYYYY-MM-DDTHH:MM:SS[.fraction]Zformat and numeric field ranges enforced by the builder (calendar validity is not established)org.opencontainers.image.ref.name, equal torelease-candidate-SOURCE_SHA.
The wrapper descriptor's digest must equal the trusted build-action digest.
Root index and platform manifests¶
The root index has depth one: it contains exactly two direct image-manifest descriptors, and neither descriptor may select another index. Their order is:
linux/amd64linux/arm64.
Docker media types, nested indexes, attestation descriptors, unknown fields, additional platforms, and reordered platforms are rejected. Each platform manifest has exactly one OCI configuration descriptor and between 1 and 64 OCI gzip-layer descriptors.
Only these media types are accepted:
| Object | Media type |
|---|---|
| Index | application/vnd.oci.image.index.v1+json |
| Manifest | application/vnd.oci.image.manifest.v1+json |
| Configuration | application/vnd.oci.image.config.v1+json |
| Layer | application/vnd.oci.image.layer.v1.tar+gzip |
The media type is a descriptor contract, not permission to decode the body. For example, the adversarial fixture labels bytes beginning with ZIP magic as an OCI gzip layer. The builder and verifier retain those bytes unchanged.
Each configuration must match its platform and carry these exact values:
| Label | Required value |
|---|---|
org.opencontainers.image.revision |
Trusted source revision. |
org.opencontainers.image.version |
Trusted three-part version. |
org.stampbot.extra-codeowners.application-wheel.sha256 |
Accepted application wheel SHA-256. |
org.stampbot.extra-codeowners.python-selection-record.sha256 |
Accepted selection-record SHA-256. |
The two manifests may share a layer descriptor. A shared digest is stored once in the spine and referenced by both platform records.
Canonical record encoding¶
The record is UTF-8 JSON restricted to ASCII output. Its one accepted encoding uses:
- lexicographically sorted object keys
,and:without surrounding whitespace- JSON string escapes produced with ASCII output enabled
- exactly one final line feed
- integers only; booleans are not integers for bounded fields.
Duplicate keys, byte-order marks, floats, non-finite numbers, invalid UTF-8, alternate whitespace, carriage returns, missing final line feeds, unknown fields, and oversized integers are rejected.
The top-level object has exactly these fields:
| Field | Contract |
|---|---|
schema_version |
Integer 1. |
media_type |
application/vnd.stampbot.oci-release-spine.v1+json. |
repository |
Repository identity object. |
run |
Workflow run ID and successful producer attempt. |
source |
Source revision and version object. |
workflow |
Workflow path, ref, and SHA object. |
candidate |
Candidate registry, repository, and tag object. |
python_distribution |
Accepted Python proof identities. |
spine |
Raw spine filename, media type, size, and SHA-256. |
index |
Trusted root OCI index descriptor. |
platforms |
Ordered linux/amd64 and linux/arm64 graph. |
objects |
Canonical byte-range inventory. |
Identity objects¶
| Object | Exact fields |
|---|---|
repository |
id, name |
run |
id, attempt |
source |
revision, version |
workflow |
path, ref, sha |
candidate |
registry, repository, tag |
python_distribution |
artifact_id, artifact_sha256, wheel_sha256, selection_record_sha256 |
spine |
filename, media_type, size, sha256 |
Repository, run, attempt, and artifact IDs are canonical positive decimal
strings no greater than 2^63 - 1. The version has exactly three non-negative
integer parts. The candidate tag is exactly release-candidate-SOURCE_SHA.
The workflow path is under .github/workflows/ and ends in .yml or .yaml.
The workflow ref is exactly
OWNER/REPOSITORY/WORKFLOW_PATH@refs/{heads,tags,pull}/...; it must bind the
same repository and path recorded in the other trusted fields. Its suffix may
contain ASCII letters, digits, _, ., -, and /. Empty, . and .. path
segments are invalid.
Scalar field bounds¶
The verifier applies the following exhaustive scalar rules. "ASCII" means the complete value must contain ASCII characters only.
| Field | Type and accepted form | Maximum |
|---|---|---|
schema_version |
Integer 1; boolean is rejected |
Exact value |
media_type |
Exact record media type | Exact value |
repository.id |
Positive canonical decimal string | 19 characters and 2^63 - 1 |
repository.name |
ASCII OWNER/REPOSITORY; letters, digits, _, ., and - |
512 characters |
run.id |
Positive canonical decimal string | 19 characters and 2^63 - 1 |
run.attempt |
Positive canonical decimal string | 19 characters and 2^63 - 1 |
source.revision |
Lowercase hexadecimal string | 40 characters exactly |
source.version |
ASCII MAJOR.MINOR.PATCH without leading zeroes |
64 characters |
workflow.path |
ASCII .github/workflows/NAME.yml or .yaml |
512 characters |
workflow.ref |
Bound repository/path plus @refs/heads/..., @refs/tags/..., or @refs/pull/...; empty, . and .. segments rejected |
768 characters |
workflow.sha |
Lowercase hexadecimal string | 40 characters exactly |
candidate.registry |
Lowercase ASCII registry hostname | 253 characters |
candidate.repository |
Lowercase ASCII registry path with at least two slash-separated components; each component starts and ends with an alphanumeric character and may contain ., _, or - |
512 characters total; 128 per component |
candidate.tag |
Exact release-candidate-SOURCE_SHA |
255 characters |
python_distribution.artifact_id |
Positive canonical decimal string | 19 characters and 2^63 - 1 |
python_distribution.artifact_sha256 |
Lowercase hexadecimal provider/archive digest | 64 characters exactly |
python_distribution.wheel_sha256 |
Lowercase hexadecimal wheel digest | 64 characters exactly |
python_distribution.selection_record_sha256 |
Lowercase hexadecimal record digest | 64 characters exactly |
spine.filename |
Exact filename base and .bin suffix listed above |
255 characters |
spine.media_type |
Exact spine media type | Exact value |
spine.sha256 |
Lowercase hexadecimal raw-file digest | 64 characters exactly |
spine.size |
Integer from 1 through 2 GiB; boolean is rejected | 2 GiB |
Descriptor digest |
Lowercase sha256: plus 64 hexadecimal characters |
71 characters exactly |
Descriptor media_type |
Exact media type for its kind | Exact value |
Descriptor size |
Positive integer; boolean is rejected | 4 MiB for JSON objects, 512 MiB for layers |
Platform architecture |
amd64 or arm64 in the required order |
Exact value |
Platform os |
linux |
Exact value |
Object kind |
layer, config, manifest, or index |
Exact value |
Object offset |
Integer from zero through 2 GiB; boolean is rejected | 2 GiB |
Descriptors and platforms¶
Every record descriptor has exactly:
| Field | Contract |
|---|---|
digest |
Lowercase sha256: OCI digest. |
media_type |
The one allowed media type for its object kind. |
size |
Positive bounded byte count. |
Each platform record has exactly architecture, os, manifest, config,
and layers. The manifest and configuration are descriptors. layers is the
ordered, non-empty descriptor list from that manifest.
Byte-range objects¶
Each objects entry extends a descriptor with:
| Field | Contract |
|---|---|
kind |
layer, config, manifest, or index. |
offset |
Zero-based offset in the raw spine. |
Objects are sorted first by kind in this order:
layer -> config -> manifest -> index
Objects of one kind are sorted by digest. Their ranges start at byte zero and cover the spine exactly, with no prefix, gap, overlap, alias, or trailing byte. Each digest appears once, even when both platforms share an object. Every object is referenced by the index/platform graph, and every graph descriptor has one matching object with the same kind, media type, digest, and size.
Limits¶
| Input | Limit |
|---|---|
| Canonical record file | 256 KiB |
| JSON nesting depth | 8 |
| JSON values | 4,096 |
| Layers per platform | 64 |
| Spine objects | 160 |
| Index, manifest, or configuration object | 4 MiB |
| Layer object | 512 MiB |
| Complete spine | 2 GiB |
| Verification read chunk | 1 MiB |
JSON depth counts the top-level value as depth 1. Each value inside an object or array increments the depth, including a scalar value. Depth 9 is rejected.
The record must describe exactly one index object, two manifest objects, and two configuration objects. Shared layer objects are allowed.
File and object-read rules¶
Builder inputs, the record, and the spine must be single-link regular files.
The scripts require O_NOFOLLOW, compare the opened descriptor with the path,
and recheck device, inode, mode, link count, ownership, size, modification time,
and change time after reading. Outputs use exclusive creation.
The builder parses each small metadata object from the same bytes it just hashed. It does not hash one path and reopen that path for parsing. Layer and spine copies are streamed and rehashed. Directory enumeration stops at each caller's entry limit. After parsing the bounded descriptor graph, the builder rejects an excessive unique-object count or aggregate spine size before it reads any layer body.
The verifier reads the record once from one bounded descriptor. It then opens
the raw spine once, checks the complete file and every object in canonical
stream order, and keeps that descriptor open for a bounded consumer through
open_verified_spine(). VerifiedSpine.object_chunks(DIGEST) permits reads of
recorded objects only. Each call copies one object, capped at 512 MiB, into
private immutable chunks of at most 1 MiB. It verifies the complete object
digest before returning those chunks and does not reread the source after
exposing them. Retaining several snapshots increases memory use by their total
size. A future publisher must stage one object at a time and release each
snapshot before requesting another. The 512 MiB input bound is not a process
memory budget; the publisher's memory limit must also cover the interpreter,
chunk references, and upload client.
A future publisher must consume the already verified open descriptor. It must
publish only the authenticated chunks returned by object_chunks(), and it
must not verify a path and then reopen that path for publication. Uploading a
content-addressed object may happen inside the context, but the publisher must
not finalize a manifest, tag, release, or other reference until
open_verified_spine() exits successfully. That final check detects changes
elsewhere in the spine after its initial verification. This requirement is a
seam for issue
#32; no current code
publishes a spine.
Commands¶
Both production scripts use only the Python standard library. Inspect their complete interfaces from the repository root on a POSIX system with Python 3.12 or newer:
python .github/scripts/build_release_spine.py --help
python .github/scripts/release_spine.py verify --help
The verifier requires the record and spine paths, both raw artifact SHA-256 values, and every trust-anchor field listed above. A successful command is silent and exits zero. Any mismatch writes one bounded contract error to standard error and exits non-zero.
CI transport proof¶
The CI producer has only contents: read. It downloads the selected Python
distribution by immutable ID, verifies the proof, and supplies that directory
as the Dockerfile's read-only named build context. A pinned QEMU image supplies
the arm64 emulation. The pinned build action runs Buildx v0.35.0 with
BuildKit v0.30.0 and exports exactly linux/amd64,linux/arm64 with
type=oci, tar=false, push=false, provenance=false, and sbom=false.
Its digest output is the trusted root digest.
The producer uploads the spine and record as two separate
actions/upload-artifact@v7 artifacts with archive: false. It does not set an
artifact name: in raw mode, the source filename is the artifact name. The job
exports each immutable artifact ID, each raw provider digest, the root digest,
the workflow run ID, and the successful producer attempt.
The consumer downloads each artifact separately by ID with
actions/download-artifact@v8, skip-decompress: true, and
digest-mismatch: error. It then runs the standalone verifier with the
out-of-band digests, trusted workflow identity, and internally consistent
record graph. The consumer gets the producer attempt from the producer's job
output instead of substituting its own attempt during a consumer-only rerun.
It has no Docker setup, socket, archive parser, or publication client. Both
jobs declare only contents: read. Neither receives repository secrets or a
GitHub environment, and neither requests an OpenID Connect token or package,
attestation, release, or repository write permission. The producer's only
write path is its run-scoped upload of the two raw artifacts.
What this contract does not prove¶
A valid spine does not establish any of the following:
- that a layer is valid gzip or tar
- that the record graph was independently reconstructed from the opaque root index and manifest bodies
- that configuration
rootfs.diff_idsmatch layer contents - that a filesystem replay is safe or complete
- that notices, licenses, SBOMs, or corresponding source are complete
- that the spine alone proves the selected Python files are present in the image layers
- that a candidate was scanned or approved for release
- that a signature, attestation, provenance statement, or transparency-log entry exists
- that any registry, package, chart, GitHub release, or release asset may be published.
Those remain separate gates. Issue
#32 still tracks the
retained release/evidence binding and future consumer handoff. The tagged
release workflow remains structurally blocked by issue
#28. Container-evidence
source completeness remains false while issue
#18 is open, and
distribution_approval.approved must remain false.