# 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. ```sh 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: ```sh 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-`), 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) ```sh 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: ```sh /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](RUNTIME_SOURCES.md), then attach the independently checked archive during source preparation: ```sh 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](LOCAL_RELEASE_EVIDENCE.md) for extracted-source rebuilding and compiler-free runtime qualification. ```sh # 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`, 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. ```sh 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: ```python 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 (); 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 ```sh tar -xzf ultimate-tool---source.tar.gz cd ultimate-tool---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.