Hardware-in-the-loop development platform for the Commodore 64.
ultimate-tool docs RELEASING.md
24 kB
Markdown
at main

Releasing Ultimate Tool #

This document describes the local release packaging scaffolding for the Linux x86-64 preview. It covers one operator, one machine, no secrets and no network publication. Tagging, Spindle configuration changes, artifact publication and repository visibility are separate, explicitly approved actions.

Everything here produces development packaging evidence until the steps marked release candidate are complete. Packaging never changes Git state, never pushes, never publishes, never contacts a device, and never compiles the host binaries itself.

1. What is being released #

  • Two host executables: ultimate-tool and ultimate-tool-mcp.
  • A binary archive with the license, generated install instructions, the reviewed public documents and the first-run example.
  • A corresponding source archive with all five workspace crates, the C64 SDK crate the fixtures link against, the fixture and SDK sources, the embedded dashboard assets, the demo helpers, the tests and the vendored host crate sources.
  • release-manifest.json, source-manifest.json and SHA256SUMS.

The reserved preview version is 0.2.0-preview.1. The workspace version lives in Cargo.toml; the release tooling reads it and never rewrites it. All five crates inherit it through version.workspace = true, and tools/package_release.py refuses to package when a crate declares a different version.

2. Pinned build environment #

tools/release-env/Dockerfile defines the only environment whose binaries are treated as release-verified. Its Ubuntu base is pinned by digest:

ubuntu:24.04@sha256:534baea6a22c03a63003dbc8dbe78fe34bc0d7e595d9a9dc9834884ff530eb55

It installs Rust 1.93.0 (with rustfmt and Clippy), Node 22.23.3 for the JavaScript test tooling, Python 3, git and a C toolchain. The Rust and Node tool images are pulled by the exact digest references recorded in tools/release_source_manifest.json under build_environment.tool_images; each digest was observed from a Docker pull of that reference, and the image build asserts the tool versions it actually installed. The image also runs npm ci and playwright install --with-deps chromium at build time, so make check-all works inside the container without downloading anything during a test run.

The image holds no credential and requires none. It creates a non-root build user and defaults to the numeric BUILD_UID:BUILD_GID identity, so produced files belong to the invoking user instead of root.

docker build -f tools/release-env/Dockerfile -t ultimate-tool-release-env \
  --build-arg BUILD_UID="$(id -u)" --build-arg BUILD_GID="$(id -g)" \
  .

The build context is the repository root because the image needs the committed package.json and package-lock.json. The root .dockerignore is an allowlist: only the Dockerfile, the descriptor writer and those two manifests enter the context, so no .git history, run evidence, build output or local configuration is ever sent to the Docker daemon.

Rebuilding the C64 fixture PRGs and the RISC-V fixtures needs further external toolchains (the pinned Rust-MOS image and a RISC-V toolchain). Those are external prerequisites, not part of this image, and compiler-output reproducibility is not claimed for any of them.

3. Local development Make targets #

The root Makefile provides explicit setup and development packaging:

make release-env                         # explicit image/tool download setup
make dist DIST_DIR=dist-candidate         # fresh output; pinned Ubuntu build
make check-release DIST_DIR=dist-candidate
make check-release-publication DIST_DIR=dist-candidate

make dist first freezes the allowlisted corresponding-source archive, including Cargo dependency sources. tools/build_release_source.py verifies and extracts that archive into a new DIST_BUILD_DIR (default /tmp/ultimate-tool-dist-build-<DIST_DIR basename>), mounts only that extraction at /work in the prepared Ubuntu image, and builds both executables with --locked --release, vendored dependencies and no network. The packager uses those exact outputs and the observed build-environment descriptor, rather than binaries built from a potentially different checkout. A second clean extraction must pass the separate source-rebuild verification gate; the first build alone does not satisfy it.

The result is marked local-development by default; DEVELOPMENT_LABEL may change that label. No tags or publication occur. The development checker reports pending release policies; the publication checker rejects them. Keep both output directories new: existing conflicting files are never replaced.

A tagged release candidate passes --tag v0.2.0-preview.1 instead of --development LABEL.

4. Build the binaries (outside the packager) #

cargo build --locked --release -p ultimate-tool -p ultimate-tool-mcp

For a release candidate, run that command inside the pinned image with only a verified extraction of the prepared source archive mounted at /work. Use the archive's vendor-sources.toml as .cargo/config.toml and CARGO_NET_OFFLINE=true. Source preparation validates Cargo's observed source replacement configuration and serializes its directory as the archive-relative vendor, preserving the accepted replacement mapping rather than carrying the preparation host's path. tools/build_release_source.py performs the build steps with bounded commands and records both output hashes. Registry-source and vendored-source builds can embed different dependency paths, so a checkout build is not a substitute for the clean extracted-source gate. The packager never compiles and does not silently certify binaries built on another host.

Then write the observed environment descriptor:

/usr/local/bin/ultimate-tool-build-env /work/dist/build-env.json

The writer never overwrites an existing file and never follows a symlink leaf, so a descriptor path cannot be redirected into a truncating write; pass --replace to write it atomically through a staged file instead of deleting it first.

Every field is read from the running environment: /etc/os-release, ldd --version, rustc -vV, cargo --version, date -u. The descriptor is not copied from the release source manifest, because that file states what the release expects, not what actually ran. The packager compares the two.

If the descriptor is missing, or describes a different distribution, glibc version, Rust version, target triple or base image, packaging fails unless --development LABEL is given. A development package records build.environment_status = untested_development, states the problems, and can never pass the publication preflight.

5. Package #

Include linked runtime source and notices #

The host binaries and prebuilt C64 inputs include third-party runtime code. Cargo vendoring alone is not that complete corresponding source. Collect the pinned runtime inputs as described in runtime sources, then attach the independently checked archive during source preparation:

python3 tools/package_release.py --prepare-source --output NEW_DIRECTORY \
  --development local-candidate \
  --runtime-sources-report target/release-runtime-sources/runtime-sources-report.json \
  --runtime-sources-dir target/release-runtime-sources/collected

Both options are required together. For the Make target, supply RUNTIME_SOURCES_REPORT=target/release-runtime-sources/runtime-sources-report.json and RUNTIME_SOURCES_DIR=target/release-runtime-sources/collected to make dist. The full runtime source archive and notices travel inside the corresponding-source archive; attribution notices also travel beside the binaries. The checker derives mapped coverage from shipped bytes, not the report's success flag. Omitting the bundle retains an incomplete-source status. Mechanical collection does not approve licenses or privacy: human review and publication remain separate gates, and a development package remains unpublishable. See local release evidence for extracted-source rebuilding and compiler-free runtime qualification.

# Stage 1 — corresponding source: resolves the allowlist, scans every file and
# runs the real cargo vendor command inside the pinned environment.
python3 tools/package_release.py --prepare-source --output dist \
  --development "local-abc1234"

# Stage 2 — binaries: measures both artifacts, verifies the prepared source
# archive member by member, and writes the release manifest.
python3 tools/package_release.py --pack --binary-dir target/release --output dist \
  --development "local-abc1234"

--pack continues into the directory --prepare-source wrote. That directory is accepted only when it holds exactly the prepared artifacts (the source archive, source-manifest.json, vendor/, SHA256SUMS); anything else is refused and never overwritten. --pack refuses to run without a prepared source archive whose every member it can match by size and SHA-256 against source-manifest.json. Both flags may be combined into one command, which is the same two stages in sequence.

--vendor-dir DIR reuses a prepared cargo vendor tree and records it as operator-supplied rather than generated; --no-vendor omits vendored crate sources and records the archive as incomplete. An operator-supplied or generated vendor tree is treated like first-party source: no symlink, fifo, device or other non-regular entry, no symlinked ancestor, a bounded number of files and bytes, and a privacy scan whose blocking findings stop packaging before any archive is written. --dry-run validates and reports without writing. --max-members N lowers the archive member bound; it exists so the bounds can be exercised cheaply and is not a release setting.

Rules the packager enforces on the source tree:

  • A clean tree with a matching annotated tag may be packaged as a release candidate. The tag must be named v<workspace version>, must be an annotated tag object, and must point at the current HEAD.
  • A dirty tree, an untagged tree, or a tree with no Git metadata must be packaged with --development LABEL. The label is part of the archive name, so a development archive can never be mistaken for a release candidate.
  • --tag and --development are mutually exclusive.
  • The archive is written exclusively and atomically: an existing file is never overwritten, and a foreign *-source.tar.gz in the output directory is not accepted as a prepared stage — only this version's expected name is.
  • Every read is bounded and link-safe: archives are opened without following a symlink, headers, PAX or GNU extension records and padding are counted against the decompressed bound before member content is consumed, an exceeded bound stops the read immediately rather than returning a partial result, and duplicate member names are rejected.
  • Files that ship inside the binary archive (the examples) go through the same selection and privacy rules as the source tree. A hidden entry beside a reviewed example — a .env, a private corpus directory — fails the pack rather than being distributed, while the expected .ultimate-tool evidence directory is skipped.

Both archives are written deterministically: sorted members, modification time zero, ownership root, and a gzip header without a timestamp, so the same inputs produce the same bytes.

What the packager measures #

  • Each binary must be an ELF64 little-endian x86_64 executable or shared object. Its interpreter, linkage, build ID and required GLIBC_x.y symbol versions are read from the bytes.
  • If a binary requires a glibc newer than the documented baseline (2.39), the package is refused in every mode, including development.
  • Every archived member is checked for absolute paths, .. traversal, symlinks, hardlinks, device nodes and other non-regular entries.
  • Every archived file is scanned for private keys, credential assignments, absolute home paths, the pinned device identity, RFC1918 addresses and the project evidence-directory path. See section 7.
  • The repository release source manifest must be allowlisted exactly: missing paths, hidden paths that are not per-crate cargo configuration, duplicate entries, symlinks and denied paths all fail the run.

6. Correspondence between binaries and source #

The source archive is not optional. --pack refuses to run unless source-manifest.json and the source archive exist in the release directory, and SHA256SUMS always lists both archives. source-manifest.json records every archived source file with its SHA-256, the vendored crate tree with an aggregate digest, and the producer command that created it.

source_completeness is deliberately gated:

  • incomplete_missing_vendored_crate_sources — no vendored crate sources.

  • incomplete_pending_third_party_notices — the inventory still lists components whose source or notice obligations are not collected. The static inventory keeps the Rust standard library and pinned Rust-MOS runtime in this state until the verified runtime-source bundle resolves their entries. A supplied vendor directory is also not treated as an observed Cargo vendor generation. Source preparation records a resolved copy of the inventory; collected bytes and notices do not grant human license/privacy approval.

  • complete_pending_clean_rebuild_verification — sources and notices are present, but no clean extracted-source rebuild is recorded.

  • verified_by_clean_rebuild — reachable only by rebuilding the extracted source archive in the pinned environment and passing the evidence to --rebuild-status verified --rebuild-evidence PATH. The evidence is never taken on trust: it must be a bounded regular file recording the prepared source archive hash, the source file aggregate, the pinned base-image digest, rustc version and target triple, the documented build command with its exit codes, and the hashes and sizes of both shipped binaries. Any mismatch refuses the pack. release_ready requires the recorded rebuild to be verified, so a bare --rebuild-status verified is a usage error and a claim without matching evidence cannot open the gate.

    The packager registers a portable copy of that evidence inside the release directory as release-rebuild-evidence.json (exclusive, atomic write, listed in SHA256SUMS, recorded by relative name plus its SHA-256 in source_completeness.evidence_file and evidence_file_sha256). A caller that writes a release directory by hand — a test fixture, for example — must produce the same fields; package_release.register_evidence(release_dir, evidence_bytes) returns them. tools/check_release.py never trusts the recorded status: it re-reads that file, re-hashes it, and re-binds it to the shipped source archive, the recomputed source aggregate, the shipped binaries and the expected environment, so a missing, tampered or differently bound record fails the preflight.

release_source_completeness therefore means checked, not asserted. The presence of LICENSE proves nothing by itself; the root license covers the first-party code, and the notices for linked third-party components are listed separately in SOURCE-AND-LICENSES.md and release-manifest.json.

release_ready stays false while any of these hold: a development label, a dirty or untagged tree, an unverified build environment, a pending inventory entry, a review-required privacy finding, a version other than the reserved preview version, or a missing clean rebuild. release-manifest.json lists the specific blockers under publication.blocking.

7. Privacy review #

tools/release_source_manifest.json is an explicit allowlist. Nothing is included implicitly: hidden files are excluded except the per-crate .cargo/config.toml entries needed to rebuild the fixtures, and the repository's private material is excluded by name.

Denied by the shipped manifest, each with a recorded reason: AGENTS.md, the raw README.md (a sanitized short distribution README is generated instead), the internal vision and research documents, the maintainer development manifest, and the documents that carry physical-device acceptance detail. .git, .ultimate-tool/, target/, node_modules/, __pycache__/ and *.pyc are excluded unconditionally.

Sentinel severities are policy in the manifest:

Sentinel Severity Effect
private_key_block block packaging fails
credential_assignment block packaging fails
absolute_home_path block packaging fails
device_identity review_required recorded, keeps release_ready false
private_network_address review_required recorded, keeps release_ready false
private_run_evidence_reference allow counted only; the evidence directory itself is never archived

Scanning every first-party file finds mentions that no pattern can classify on its own. Each one is recorded in source-manifest.json, release-manifest.json and SOURCE-AND-LICENSES.md, and re-derived independently by tools/check_release.py; nothing is silently dropped or silently accepted. Run tools/package_release.py --prepare-source --dry-run and read privacy.findings to list the current set instead of trusting this document.

A file whose whole purpose is to contain synthetic sentinel text (a test that proves redaction works, for example) may be covered by an entry in privacy.review_exemptions. An exemption must declare which severity it overrides, and the declared severity must match the policy:

  • overrides: "review_required" — the finding is recorded as reviewed and stops counting as a human-review blocker.
  • overrides: "block" — the finding is recorded as blocking content that a review accepted. Packaging continues, but the package is never release ready, and the publication preflight fails until the fixture stops containing the literal text or the policy itself is changed.

No exemption can turn a blocking finding into a publishable package. Mechanical scanning is not a substitute for the human license and privacy review: a green scan means the listed patterns were absent or recorded, not that the release is safe to publish.

8. Independent verification #

tools/check_release.py trusts nothing the packager wrote. It re-reads every archive member, re-measures both binaries from the bytes inside the archive, re-derives the privacy findings, re-validates the recorded build environment against the recorded policy, and re-reads the Git state.

python3 tools/check_release.py --release-dir dist --mode development
python3 tools/check_release.py --release-dir dist --mode release --report dist/preflight.json

Checks have two severities:

  • integrity — facts about the bytes: manifest shape, SHA256SUMS agreement, archive safety, binary re-measurement against the recorded hashes and ELF fields, source archive contents against source-manifest.json, vendored file counts, the in-archive manifest copy, recorded privacy findings, and the glibc/target boundary. Any integrity failure fails in both modes, because a release directory that disagrees with its own manifest is broken whatever it intends to be.
  • policy — release-process requirements: annotated version tag, clean worktree, reserved preview version, completed third-party inventory, recorded clean rebuild, resolved privacy review, no development label. These are reported as pending in development mode and block in release mode.

--mode release is the publication preflight. It exits non-zero and lists the blockers unless every integrity and policy check is verified. The report is suitable evidence to keep with the release record.

Publication API #

The publication path does not shell out to the checker, because a caller must be able to gate on structured results before it obtains any secret. Import the function directly:

import check_release

result = check_release.validate_release(
    "dist",                 # release directory
    source_root=".",        # optional Git/manifest re-checks
    require_ready=True,     # default: also require the recorded release_ready
    mode="release",         # "release" blocks on policy; "development" reports
)
if not result["ready"]:
    raise SystemExit(result["blockers"])

validate_release performs no network access, opens every local file through a bounded descriptor that does not follow a symlink, validates the mandatory artifact names (both host binaries, the source archive, the binary archive and both manifests), re-measures the binaries from inside the archive, verifies every source and vendored member by size and SHA-256, enforces the recorded limits, and returns a document instead of raising. The result contains verdict, ready, release_ready_as_recorded, counts, checks (each with check, state and severity) and blockers. require_ready=True is what stops a caller from ignoring the packager's own blocking list.

9. Publication interface #

Publication is a separate tool, tools/publish_tangled_artifacts.py, and a separate approval. It consumes release-manifest.json and must reject any manifest whose release_ready is false or whose verification list contains a pending or not_verified entry. It needs exactly these fields:

  • version
  • source.tag, source.tag_object, source.tag_commit, source.revision
  • support_boundary
  • artifacts[].name, artifacts[].kind, artifacts[].path, artifacts[].sha256, artifacts[].size
  • source_archive.name, source_archive.sha256
  • verification[], release_ready

The binary archive and the source archive are both published, bound to the annotated tag object recorded in source.tag_object; publishing the binaries without the source archive does not satisfy the license. Follow the current Tangled documentation for the artifact record (https://docs.tangled.org/spindles#publish-a-release-artifact); the documented recipe is not proof of the current lexicon or of any PDS size limit, so verify those against the installed version before publishing.

Never publish from an untagged or dirty tree, never publish a development archive, and never push a tag or change repository visibility as a side effect of packaging.

10. Spindle #

The Tangled Spindle microVM, its image name, its resource limits and its container policy are not verified by this scaffolding. Nothing here assumes a hosted runner image, and no image digest recorded here describes one. Hosted CI is a separate workstream with its own discovery and approval gate; a successful local package is not hosted-CI evidence.

11. Rebuilding from the source archive #

tar -xzf ultimate-tool-<version>-<platform>-source.tar.gz
cd ultimate-tool-<version>-<platform>-source
cp vendor-sources.toml .cargo/config.toml    # only when vendored sources are included
cargo build --locked --release -p ultimate-tool -p ultimate-tool-mcp

source-manifest.json in the release directory lists every file with its hash. Rebuilding the C64 fixture PRGs additionally needs the pinned Rust-MOS toolchain image, and the RISC-V fixtures need a RISC-V toolchain; both are external prerequisites recorded in SOURCE-AND-LICENSES.md. Rebuilding the same bytes is not claimed.

12. Hardware boundary #

Packaging, verification and publication perform no device action: no probe, no reset, no deployment, no capture. Physical acceptance is a separate, separately-approved gate that requires fresh permission and its own evidence. The packaged first-run example drives the deterministic fake target only, which orchestrates a simulated run and does not execute a PRG in VICE or on hardware.