Publishing release artifacts to Tangled #
tools/publish_tangled_artifacts.py is the local, explicit publisher for an
Ultimate Tool release. It takes a release directory already produced by
tools/package_release.py and either verifies it (the default dry run) or
uploads one binary archive and creates one sh.tangled.repo.artifact record
that points at the release's annotated tag.
It is deliberately narrow. It does not build, package, tag, push, or sign anything, and it never changes repository or Knot/Spindle settings. It publishes files that already exist and records exactly what it did.
The independent preflight is mandatory #
Before the publisher resolves any identity, opens a session, or obtains a credential, it runs the shared, independent release checker:
tools/check_release.py: validate_release(release_dir, source_root=…, require_ready=True)
The publisher does not re-implement any release check. It loads the reviewed
tools/check_release.py that ships beside it and calls validate_release
directly. That function re-reads every archive member, re-measures both host
binaries from the bytes inside the binary archive, re-derives the privacy
findings, re-validates the recorded build environment, and re-reads the Git
state — and returns a verdict document instead of raising.
The gate passes only when the checker reports ready (a passed verdict and
the manifest's recorded release_ready is true). If it does not pass, the
publisher refuses with release_preflight_failed and never touches the network
or a secret.
There is no flag and no environment variable that skips this gate. The
module is loaded from an explicit file location rather than a bare module name,
so a PYTHONPATH entry cannot shadow it with a no-op stand-in. A missing
checker is a hard failure (release_checker_missing), not a skipped step.
--release-source-root selects the source tree the checker re-derives Git state
from; it defaults to --repo-dir (the tree that holds the annotated tag).
Prerequisites #
- A release directory produced by
tools/package_release.py, containing the binary archive, the source archive,release-manifest.json,source-manifest.jsonandSHA256SUMS. tools/check_release.pypresent next to the publisher (it is part of the repository).- The Git working tree that holds the release's annotated tag (
--repo-dir). - For a real publication: a reviewed
sh.tangled.repo.artifactlexicon document, a confirmed PDS blob upload bound, and an app password.
Dry run (default) #
A dry run performs every local check — including the independent preflight — prints the exact record it would create, and makes no network call and no credential prompt.
python3 tools/publish_tangled_artifacts.py \
--release-manifest dist/release-manifest.json \
--repo https://tangled.org/<handle>/ultimate-tool \
--owner-did did:plc:… \
--identifier <handle> \
--tag v0.2.0-preview.1 \
--repo-dir .
Add --json for a single machine-readable envelope on stdout. Add
--reviewed-schema <path> --max-blob-bytes <n> to also check the reviewed
lexicon and the declared PDS upload bound without publishing.
Publishing #
Publishing is never implicit. It additionally requires --publish, a reviewed
schema, a declared blob bound, and a separately approved credential:
TANGLED_APP_PASSWORD='…' python3 tools/publish_tangled_artifacts.py \
--release-manifest dist/release-manifest.json \
--repo https://tangled.org/<handle>/ultimate-tool \
--owner-did did:plc:… \
--identifier <handle> \
--tag v0.2.0-preview.1 \
--repo-dir . \
--publish \
--reviewed-schema <reviewed-sh.tangled.repo.artifact.json> \
--max-blob-bytes <confirmed PDS bound> \
--receipt dist/publish-receipt.json
The credential comes from TANGLED_APP_PASSWORD (configurable with
--password-env) or, only from a terminal, an interactive prompt. It is never
echoed, never stored, and removed from the process environment once read.
Owner binding #
--owner-did is the DID that must own both the repository and the record. The
publisher resolves --identifier (a handle or DID) and, when --repo names the
owner with a handle, the repository's owner, and refuses to continue unless both
resolve to exactly that DID. createSession must also authenticate as the same
DID before any blob is uploaded.
Transport rules #
--pds-originmust be anhttpsbase origin with no path, query, fragment or URL credentials. Redirects are rejected outright.- TLS uses the system trust store with default verification.
- Every request is bounded by
--timeout, and the whole publication by a single finite, monotonic--total-timeoutdeadline. The deadline shuts the request socket down when it is reached, so a server that trickles a byte at a time cannot extend the run past its budget, and DNS resolution is bounded by the same deadline. Non-finite or non-positive values are rejected. - Failures are reported redacted and stable: a rejected request reports only the operation and the HTTP status, never any part of the remote body (which could echo a credential or an identity field). The app password, the session access token, the refresh token and raw remote bodies never appear in output, receipts or logs.
- The
githelper runs with the credential environment variables (TANGLED_APP_PASSWORD,C64U_PASSWORD) removed, so a read command can never inherit a secret.
What is uploaded #
A publication distributes the complete required set — nothing less:
| role | file | content type |
|---|---|---|
binary-archive |
the release's binary archive | --content-type (default application/octet-stream) |
source-archive |
the corresponding source archive | archive content type |
checksums |
SHA256SUMS |
text/plain; charset=utf-8 |
release-manifest |
release-manifest.json |
application/json |
Each file is uploaded with repo.uploadBlob and bound to the same annotated
tag object by its own sh.tangled.repo.artifact record (created with
repo.createRecord, name = the file's own name). The binary record's name can
be overridden with --artifact-name; the other three always keep their real
names. Publishing the binary archive alone is no longer possible.
Canonical inputs #
The shared checker validates exactly one directory, and the publisher uploads
exactly the files inside that directory. --release-manifest must be
<release-dir>/release-manifest.json and --checksums (when given) must be
<release-dir>/SHA256SUMS; a manifest or checksum file with any other name or
location is refused (noncanonical_manifest / noncanonical_checksums) before
any credential or network call, so the checker and the uploader can never be
pointed at different bytes.
Private staging and content-derived blobs #
Immediately after the local checks pass, the publisher copies exactly the
reviewed files the checker reads — the four distribution objects, the source
manifest and the registered rebuild evidence — into a fresh private (0700,
tool-owned) staging directory. It never recursively copies the release
directory, so an unreviewed or private file cannot travel with the release. The
shared checker then runs against the staged copy, so the bytes that are
validated are the bytes that are uploaded.
Each object is streamed from staging and hashed as it goes; the byte count and
digest must match the reviewed snapshot or the upload is abandoned
(upload_tampered) with no record created. Every object's size bound is
re-applied to the staged bytes. The blob reference the PDS returns must be a
CIDv1 (raw codec, sha2-256) whose digest equals the SHA-256 of the uploaded
bytes, and its reported size and MIME type must match (a missing MIME type is
refused) — otherwise the record is never created (blob_content_mismatch /
blob_size_mismatch / blob_mime_mismatch). The createRecord response is
accepted only when the returned URI names this owner and collection with a
well-formed record key and the returned cid is a valid CIDv1 dag-cbor
(0x71) sha2-256 reference; a missing or malformed identifier, or one that echoes
a known credential, is rejected as an ambiguous attempt (never scrubbed into
a claimed success), and the whole per-object state is scrubbed of any credential
before it reaches stdout, a receipt or a partial envelope.
Lexicon constraints #
The reviewed sh.tangled.repo.artifact lexicon is enforced client-side before
any upload. Every one of the four planned record bodies is checked against
every declared constraint — field types, string and byte lengths, the
createdAt datetime format (a real calendar date, not just a regex), and the
blob's maxSize and accept MIME list — using a locally derived placeholder
blob (the PDS returns a content-derived CID, so the reference is known without a
network call). The constraint vocabulary is a closed set: an unsupported type,
an unknown constraint key, or an unsupported format is refused
(reviewed_schema_unsupported), as is a lexicon that requires fields this tool
does not publish. The recipe posts validate: false; this client-side
enforcement is what stands in for server-side validation.
Ambiguous outcomes #
A failed or ambiguous outcome never triggers a blind retry, because a failed
upload is not a failed record and replaying it could publish twice. The
publisher tracks per-object state, records exactly which objects were uploaded
and which records were created, and reports a structured partial result — on
stdout with --json even when no --receipt was given, and in the receipt
when one was. Nothing is uploaded or recorded twice.
Receipts #
--receipt <path> writes a receipt (schema 1,
record_kind: ultimate-tool-tangled-publish-receipt). The path is reserved
before any remote call, created exclusively (O_CREAT|O_EXCL, never
truncating an existing file, a FIFO, a hard link or a symlink) with mode 0600.
It records the release facts, per-object hashes and sizes, the session DID, each
object's blob reference and created record URI/CID/rkey, and any ambiguity, and
is verified to be free of the credential and the session token before it is
written. An existing receipt path is refused (receipt_unsafe); an ambiguous
outcome still writes a partial receipt with everything known, and the
structured state is attached to the error before the file is written, so a
write or fsync failure can only mark receipt_written: false — it never loses
the remote facts or escapes as a traceback. With --json, the same partial
state is emitted on stdout even if no --receipt was given.
Exit codes #
| Code | Meaning |
|---|---|
0 |
success (dry run completed, or the record was created) |
2 |
usage error (bad arguments) |
3 |
local preflight failure, including the independent release check |
4 |
remote failure (transport, session, upload, create-record) |
5 |
refused (mock/loopback misuse) |
What the tests prove — and do not #
The publisher's tests build their release directory by running the real
packager (tools/package_release.py, two-stage --prepare-source then
--pack) over a synthetic project, so they exercise genuine archive bytes and
a genuine release manifest, not a hand-shaped stand-in. The independent checker
runs against those bytes. Publication is driven by a disposable HTTP server
bound to 127.0.0.1, and the tags are created in a throwaway local repository.
A passing suite therefore proves the local gates (including the shared independent release check and its ordering before any identity or secret), the exact XRPC request shapes, the credential handling and the failure paths.
It does not prove that any artifact was published, that a real Tangled PDS accepts these records, or that the reviewed lexicon matches what that server would accept. Those remain operator gates.