# 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.json` and `SHA256SUMS`. - `tools/check_release.py` present 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.artifact` lexicon 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//ultimate-tool \ --owner-did did:plc:… \ --identifier \ --tag v0.2.0-preview.1 \ --repo-dir . ``` Add `--json` for a single machine-readable envelope on stdout. Add `--reviewed-schema --max-blob-bytes ` 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//ultimate-tool \ --owner-did did:plc:… \ --identifier \ --tag v0.2.0-preview.1 \ --repo-dir . \ --publish \ --reviewed-schema \ --max-blob-bytes \ --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-origin` must be an `https` base 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-timeout` deadline. 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 `git` helper 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-manifest.json` and `--checksums` (when given) must be `/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 ` 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.