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-toolandultimate-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.jsonandSHA256SUMS.
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 currentHEAD. - 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. --tagand--developmentare mutually exclusive.- The archive is written exclusively and atomically: an existing file is never
overwritten, and a foreign
*-source.tar.gzin 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-toolevidence 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_64executable or shared object. Its interpreter, linkage, build ID and requiredGLIBC_x.ysymbol 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_readyrequires the recorded rebuild to be verified, so a bare--rebuild-status verifiedis 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 inSHA256SUMS, recorded by relative name plus its SHA-256 insource_completeness.evidence_fileandevidence_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.pynever 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,
SHA256SUMSagreement, archive safety, binary re-measurement against the recorded hashes and ELF fields, source archive contents againstsource-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
pendingin 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:
versionsource.tag,source.tag_object,source.tag_commit,source.revisionsupport_boundaryartifacts[].name,artifacts[].kind,artifacts[].path,artifacts[].sha256,artifacts[].sizesource_archive.name,source_archive.sha256verification[],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.