From 003ebf65c5dfe5d87244bdab573e9164c677351b Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 20 Oct 2026 00:17:28 +0200 Subject: [PATCH] Record out-of-band release comparisons Publishing required receipts from every build host even when the maintainer had compared the artifacts elsewhere. Moving files between machines added unnecessary work to a release. Record out-of-band confirmations separately from rebuild receipts and retain coverage for each publication attempt. Keep Swift metadata stable when coverage changes. Require complete coverage for publication unless the maintainer explicitly overrides it. Co-Authored-By: HAL 9000 --- .github/workflows/candidate.yml | 5 +- README.md | 54 ++- docs/interfaces.md | 38 +- docs/qualification.md | 15 +- docs/releasing.md | 88 +++- docs/reproducibility.md | 7 +- skills/release/SKILL.md | 35 +- skills/release/references/procedure.md | 26 +- tools/release/README.md | 36 +- tools/release/src/candidates.rs | 67 ++- tools/release/src/main.rs | 38 +- tools/release/src/model.rs | 79 ++- tools/release/src/publish.rs | 44 +- tools/release/src/publish/swift.rs | 35 +- tools/release/tests/contracts.rs | 644 ++++++++++++++++++++++++- 15 files changed, 1072 insertions(+), 139 deletions(-) diff --git a/.github/workflows/candidate.yml b/.github/workflows/candidate.yml index 91247ca..1dfe0a6 100644 --- a/.github/workflows/candidate.yml +++ b/.github/workflows/candidate.yml @@ -184,8 +184,9 @@ jobs: ./scripts/verify.sh $candidate_id $manifest_sha256 --repo "$GITHUB_REPOSITORY" - Checksums record transport integrity; independent receipts are - required before a maintainer can publish these bytes locally. + Checksums record transport integrity. Maintainers may record optional + rebuild receipts or confirm comparisons performed out-of-band during + collection before explicitly publishing these bytes locally. Build: https://github.com/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID EOF diff --git a/README.md b/README.md index b0934b0..787d8e8 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,11 @@ distributions of [LDK Node](https://github.com/lightningdevkit/ldk-node). GitHub runners build unsigned candidates. Anyone can download those candidates, rebuild their supported targets, and compare the exact package bytes without -GitHub credentials. A maintainer publishes the verified files through a separate -local command. Registry credentials, signing keys, and personal access tokens -stay on the maintainer's machine. +GitHub credentials. A maintainer collects and publishes the CI-built files through +separate local commands. Rebuild receipts are optional; collection can record +comparisons performed on other hosts without copying their receipts. Registry +credentials, signing keys, and personal access tokens stay on the maintainer's +machine. `release.json` selects the native revision, bindings version, and toolchain. Rust source, the authoritative UDL, native tags, and crates.io publication remain @@ -77,8 +79,8 @@ output belongs in disposable directories under `/tmp`. | `jvm` | Linux x86_64 or aarch64 | JAR with all four native libraries and unsigned Maven files | JVM assembly takes `--candidate DIR` containing the native outputs. Assembly -verification records their hashes; the complete release also needs independent -verification of the Linux and Darwin groups. Python wheels support Python 3.10 +verification records their hashes; independent verification of those payloads +belongs to the Linux and Darwin groups. Python wheels support Python 3.10 and newer on Linux x86_64/aarch64 and macOS x86_64/arm64. ## Prepare a candidate @@ -216,19 +218,27 @@ and writes a receipt identifying the recipe, inputs, group, files, and hashes. Verifying a downloaded checksum alone is transport validation; it does not establish that a build reproduced the candidate. -Send the small receipts to the publishing maintainer. After deliberately -selecting the receipts to trust, collect complete coverage: +Rebuild receipts can be sent to the publishing maintainer, but copying them is +optional. A maintainer can instead observe `VERIFICATION SUCCESSFUL` on each +host or compare independently rebuilt hashes. Collection asks y/n for every +group without a supplied receipt and records yes answers as out-of-band +comparisons. No or blank leaves a group unconfirmed; collection still succeeds. + +Collect the candidate, optionally supplying a directory of trusted receipts: ```bash ./scripts/collect.sh --candidate "$candidate_url" \ - --receipts /tmp/accepted-receipts --output /tmp/verified-bindings.json + --output /tmp/collected-bindings.json ``` -The collector checks the actual candidate files against those receipts. Missing -targets, incompatible receipt hosts, different recipes, altered files, or unverified JVM dependencies prevent +Add `--receipts /tmp/accepted-receipts` to import available receipts. Use +`--non-interactive` to skip prompts or `--require-verification` to require every +group to have a receipt or an out-of-band confirmation. These options never +skip artifact hashing or validation of supplied evidence. Incompatible receipt +hosts, different recipes, altered files, or mismatched JVM imports prevent collection. See [release operation](docs/releasing.md) for the local publication commands and recovery rules. Explicit local Swift publication sends the -verified generated wrapper, `Package.swift`, licenses, provenance, and unchanged +generated wrapper, `Package.swift`, licenses, provenance, and unchanged XCFramework ZIP to `lightningdevkit/ldk-node-swift`; its version tag belongs to that distribution repository. @@ -241,15 +251,15 @@ the pinned Nix release tool and accept its existing options; they preserve relative paths and do not select a different recipe checkout. Run either with `--help` for the full interface. -After collecting complete verification coverage, explicitly publish one -destination with credentials supplied through your local environment or +After collecting the candidate and reviewing its recorded coverage, explicitly +publish one destination with credentials supplied through your local environment or credential store: ```bash candidate_id=CANDIDATE_ID candidate_url="https://github.com/lightningdevkit/ldk-node-bindings/releases/tag/$candidate_id" ./scripts/publish.sh --candidate "$candidate_url" \ - --verified /tmp/verified-bindings.json --destination maven \ + --collected /tmp/collected-bindings.json --destination maven \ --confirm "$candidate_id" ``` @@ -257,18 +267,26 @@ Choose `testpypi`, `pypi`, `maven`, or `swift`. PyPI requires the identical whee to pass the TestPyPI gate first. Swift defaults to `lightningdevkit/ldk-node-swift`; `--repo` changes that publication destination. The candidate URL independently selects the build repository, including a test repository. Publication requires -the verified ledger and exact candidate confirmation, rejects CI, and reuses the +the collection ledger and exact candidate confirmation, rejects CI, and reuses the existing publication record when resumed. See [the release guide](docs/releasing.md) for each destination's credentials, qualification requirements, and recovery. +Publication requires every group to have a receipt or an out-of-band confirmation +by default and rechecks all artifact bytes. To deliberately publish with missing +comparisons, pass `--allow-unverified` to that publication command. Each attempt +records its coverage and override in publication history. `--require-verification` +explicitly selects the default and conflicts with the override. `--verified` +remains an alias for `--collected` for existing commands. ## Verification status Pinned environments and normalized archives are the basis for reproducible builds; they do not prove equality by themselves. Every platform requires a -successful CI build and independent local comparison before production use. +successful CI build; independent comparisons can be performed on any supported +host and confirmed during collection. Missing receipts are not proof that a +comparison did not occur, and checksums alone do not prove independent rebuilding. See [qualification evidence](docs/qualification.md) for checks actually run and -outstanding platform validation. macOS recipes are supported but still require -execution and comparison on the selected Apple host before publication. +outstanding platform validation. macOS builds and native verification require +the selected Apple host. Development checks alone do not imply that a release has been built, independently reproduced, or published. diff --git a/docs/interfaces.md b/docs/interfaces.md index 61dfcfb..d9968ea 100644 --- a/docs/interfaces.md +++ b/docs/interfaces.md @@ -32,8 +32,8 @@ group from committed inputs into an empty output directory. It runs the same Nix derivations or pinned external recipes in CI and locally. No credentials, signing, publication, source mutation, or dependency updates occur here. `--candidate` is required only for JVM assembly, supplying the four named JVM -native assets. Acceptance of this assembly for final publication requires their -independent verification; CI can assemble the initial candidate beforehand. +native assets. Reproducing this assembly establishes assembly equality; the +imported libraries have their own Linux and Darwin verification groups. Generated sources and all project intermediate outputs must rebuild locally during verification, even if already present in a Nix store or build cache. @@ -61,12 +61,32 @@ Rust workspace exposes `release` via `nix run .#release -- COMMAND ...`. - `verify --candidate URL_OR_DIR --group GROUP --output DIR`: anonymous download, independent rebuild, byte comparison, JSON receipt, and explicit partial coverage. Require the local clean recipe commit and input identity to match. -- `collect --candidate URL_OR_DIR --receipts DIR --output FILE`: rehash actual - candidate files, require all groups and accepted independent receipts, - validate receipt hosts and JVM native dependencies, emit a verified ledger. +- `collect --candidate URL_OR_DIR [--receipts DIR] --output FILE + [--non-interactive] [--require-verification]`: rehash all candidate files, + validate supplied receipt identities, hosts, files, and JVM imports, then + prompt y/n for every group without a receipt. Store affirmative answers in + `out_of_band_groups`, separately from `receipts`. No or blank answers leave + the group unconfirmed; EOF fails instead of inventing an answer. Emit a + collection ledger bound to the candidate manifest hash and recipe. + `--non-interactive` skips prompts and records no additional comparisons. + `--require-verification` requires every group to have a receipt or confirmation. `scripts/collect.sh` exposes the same options through pinned Nix tools. -- `publish --verified FILE --candidate URL_OR_DIR --destination DEST - --confirm CANDIDATE_ID`: local-only final publication of verified bytes. +- `publish --collected FILE --candidate URL_OR_DIR --destination DEST + --confirm CANDIDATE_ID [--allow-unverified]`: local-only final publication + of candidate bytes. `--verified FILE` remains an alias for old commands. + Receipts are optional, but every group must have a receipt or out-of-band + confirmation by default. `--allow-unverified` explicitly permits missing + coverage for this invocation; it conflicts with `--require-verification`, + which selects the default. Validate each confirmation group and reject + duplicate/foreign claims even with the override. Before accessing a destination, + append its receipt, out-of-band, and unconfirmed groups and override choice to + publication `verification_history`. Each destination status references its + zero-based `verification_attempt`; retries never rewrite history. Preserve old + global `verification` fields without attributing them to earlier destinations. + Swift provenance contains candidate identity only, so recollection does not + change the metadata commit. Existing provenance with a historical `verification` + field remains accepted without relaxing any candidate identity or artifact check. + Old schema-1 ledgers without `out_of_band_groups` remain valid. Destinations: `testpypi`, `pypi`, `maven`, `swift`. Reject CI environments. `scripts/publish.sh` exposes the same options and requires the same explicit confirmation. Collection/publication wrappers preserve the caller's working @@ -82,7 +102,7 @@ The initial build/candidate repository is `https://github.com/lightningdevkit/ldk-node-bindings`. SwiftPM distribution is published separately to `https://github.com/lightningdevkit/ldk-node-swift`. Its version commits contain the generated Swift wrapper, `Package.swift`, -licenses, and provenance; its release assets contain the unchanged verified +licenses, and provenance; its release assets contain the unchanged candidate XCFramework ZIP. The package manifest points at that ZIP with its SHA-256. Keep the distribution Git URL and asset destination configurable for a possible Forgejo migration. The initial publication adapter supports GitHub; neither @@ -97,7 +117,7 @@ reproducible payloads. Final uploads rehash files again; publication records support checking remote state and resuming without overwriting versions. Maven publication uses the Central Portal Publisher API from the local CLI. -Sign the verified unsigned artifacts locally, assemble the repository-layout +Sign the hash-checked unsigned candidate artifacts locally, assemble the repository-layout bundle, upload with `publishingType=USER_MANAGED`, persist the deployment ID, and poll validation. The explicitly confirmed publish invocation may then publish a validated deployment and poll its final state. Keep the Portal token diff --git a/docs/qualification.md b/docs/qualification.md index d791e76..bdb7566 100644 --- a/docs/qualification.md +++ b/docs/qualification.md @@ -132,13 +132,16 @@ the candidate AAR and its native payloads are unchanged. ## Remaining release qualification -Before production publication, independently reproduce every group on its -required host. The current candidate still needs native Linux x86_64, Linux +Independent reproduction of every group provides complete reproducibility +coverage. The test candidate above still lacks native Linux x86_64, Linux aarch64, and Darwin receipts from outside GitHub; its other groups have local -reproduction evidence. Exercise consumer installation separately from checksum -comparison. Live TestPyPI staging and Maven publication have not been exercised; -registry credentials and namespace ownership remain unqualified. TestPyPI -staging must pass before PyPI publication. +reproduction evidence. The release workflow now permits optional receipts and +records out-of-band confirmations during collection. Publication requires +complete recorded coverage by default; proceeding with missing comparisons +requires an explicit `--allow-unverified` choice. Exercise consumer installation +separately from checksum comparison. Live TestPyPI staging and Maven publication +have not been exercised; registry credentials and namespace ownership remain +unqualified. TestPyPI staging must pass before PyPI publication. Repeat Android consumer qualification for each candidate, including a 16 KiB device and API 21–23 devices with the documented desugaring configuration. diff --git a/docs/releasing.md b/docs/releasing.md index 8ca5743..5c66d67 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -41,7 +41,7 @@ identity; verification does not depend on the current contents of `main`. Use a separate worktree for each preparation, retaining its local `.release` ledger. The candidate workflow must exist on the default branch, but dispatch uses the selected dated recipe branch. Publication always needs a separate -explicit local request for the verified candidate and its destinations. +explicit local request for the candidate and its destinations. ## Preparation state @@ -78,27 +78,57 @@ worktree (the full `recipe` commit in `candidate.json`) for both collection and publication. These entrypoints do not automatically switch recipes. Paths are relative to the calling directory within that worktree. -Collect all six groups: +Collect the CI-built candidate. Receipt files are optional: if a group has no +receipt, collection asks whether its artifacts were independently rebuilt and +compared on another host. Answering yes records an out-of-band confirmation +bound to this candidate; it does not manufacture a rebuild receipt. No or blank +leaves the group without recorded confirmation and still permits collection. +Observing `VERIFICATION SUCCESSFUL` on another host does not require copying its +files. Comparing hashes is equivalent only when one hash comes from an +independent rebuild, rather than a second download of the same candidate. ```bash candidate_id=CANDIDATE_ID candidate_url="https://github.com/lightningdevkit/ldk-node-bindings/releases/tag/$candidate_id" ./scripts/collect.sh --candidate "$candidate_url" \ - --receipts /tmp/accepted-receipts --output /tmp/verified-bindings.json + --output /tmp/collected-bindings.json ``` -The verified ledger binds the candidate ID, recipe, input identity, files, and -receipt coverage. JVM dependencies also need their Linux and Darwin receipts. -The complete Swift ZIP is a comparison target because SwiftPM checksums archive -bytes, including container metadata. +Add `--receipts DIR` to import selected trusted receipts; supplied receipts must +still match the exact candidate, host, files, and JVM imports. Add +`--non-interactive` to skip prompts, leaving missing evidence unconfirmed. EOF +during a prompt aborts collection rather than treating it as an answer. +`--require-verification` requires all six groups to have receipts or affirmative +out-of-band confirmations. Store the collection ledger outside the receipt +directory. It binds the candidate ID, manifest hash, recipe, input identity, +receipts, and out-of-band confirmations. Publication reports all three coverage +categories and requires every group to have a receipt or confirmation by default. + +JVM assembly covers assembly itself; Linux and Darwin groups cover its imported +native payloads. The complete Swift ZIP is a comparison target because SwiftPM +checksums archive bytes, including container metadata. All candidate files are +downloaded and hash-checked regardless of the chosen verification coverage. + +For an older candidate, keep its exact clean recipe checkout and invoke the +updated tools by path, for example `/path/to/updated-tools/scripts/collect.sh`. +The wrappers preserve the caller's working directory, so newer release tooling +can collect an existing candidate without rebuilding it or changing its recipe. ## Publication commands The publisher requires a specific candidate confirmation and rejects CI -environments. It rechecks all candidate files against the verified ledger +environments. It rechecks all candidate files against the collection ledger immediately before upload. Supply credentials locally through the indicated environment or credential store; do not place credential values in commands or -logs. All destinations use existing verified candidate bytes. +logs. All destinations use existing hash-checked candidate bytes. The canonical +ledger option is `--collected`; `--verified` remains an alias for older commands. +Every group must have a recorded receipt or out-of-band confirmation before +publication. To deliberately publish with incomplete comparisons, add +`--allow-unverified` to that invocation. It still validates supplied evidence and +all artifact hashes. `--require-verification` explicitly selects the default and +cannot be combined with the override. Receipt and manual coverage, unconfirmed +groups, and the override choice are preserved for each attempt in publication +state. Coverage is kept outside immutable Swift provenance. `scripts/collect.sh` and `scripts/publish.sh` launch the same pinned CLI as `nix run .#release -- collect` and `nix run .#release -- publish`, respectively. @@ -107,15 +137,15 @@ destination; no script adds confirmation or publishes the remaining destinations | Destination | Local authentication | Result | | --- | --- | --- | -| `testpypi` | `UV_PUBLISH_TOKEN` for TestPyPI | Stage the four verified wheels | +| `testpypi` | `UV_PUBLISH_TOKEN` for TestPyPI | Stage the four candidate wheels | | `pypi` | `UV_PUBLISH_TOKEN` for PyPI | Publish wheels after the TestPyPI gate | | `maven` | `CENTRAL_TOKEN_USERNAME` and `CENTRAL_TOKEN_PASSWORD` (or `MAVEN_CENTRAL_TOKEN`), local GPG key | Sign, submit, validate, and publish the Maven bundle through the Central Portal API | -| `swift` | `gh` authentication or `GH_TOKEN` for the Swift destination | Publish source, metadata, a version tag, and the verified ZIP to `ldk-node-swift` | +| `swift` | `gh` authentication or `GH_TOKEN` for the Swift destination | Publish source, metadata, a version tag, and the candidate ZIP to `ldk-node-swift` | Example for an explicitly authorized TestPyPI upload: ```bash -./scripts/publish.sh --verified /tmp/verified-bindings.json \ +./scripts/publish.sh --collected /tmp/collected-bindings.json \ --candidate "$candidate_url" --destination testpypi \ --confirm "$candidate_id" ``` @@ -130,7 +160,7 @@ paths. Do not rebuild or rename wheels between staging and promotion. For an explicitly authorized Maven publication: ```bash -./scripts/publish.sh --verified /tmp/verified-bindings.json \ +./scripts/publish.sh --collected /tmp/collected-bindings.json \ --candidate "$candidate_url" --destination maven \ --confirm "$candidate_id" ``` @@ -145,26 +175,26 @@ or separate portal click is required. Verify the final API status before reporting Maven completion; resume an existing deployment after an interruption instead of uploading a replacement bundle. -Swift publication uses a separate destination repository. Review the verified -generated Swift wrapper, final archive URL/checksum, licenses, and provenance +Swift publication uses a separate destination repository. Review the generated +Swift wrapper, final archive URL/checksum, licenses, and provenance before authorizing publication. The local publisher creates their distribution commit and an independent `vVERSION` tag in `lightningdevkit/ldk-node-swift`, then -publishes the unchanged verified XCFramework ZIP as that release's asset. +publishes the unchanged candidate XCFramework ZIP as that release's asset. The distribution contains `Sources/LDKNode`, `Package.swift`, `LICENSE-APACHE`, `LICENSE-MIT`, and `release-provenance.json`. Provenance identifies the native -source, bindings recipe, candidate, input identity, and verified archive hash. +source, bindings recipe, candidate, input identity, and archive hash. Consumers use the Swift repository's SwiftPM URL and version tag; the native source tag and candidate prerelease tag have distinct roles. ```bash -./scripts/publish.sh --verified /tmp/verified-bindings.json \ +./scripts/publish.sh --collected /tmp/collected-bindings.json \ --candidate "$candidate_url" --destination swift \ --repo lightningdevkit/ldk-node-swift --confirm "$candidate_id" ``` The publisher clones the distribution repository, or initializes its empty Git remote, and pushes a dated metadata branch and immutable version tag. It uploads -the verified archive to a draft destination release before publishing that +the hash-checked archive to a draft destination release before publishing that release; inspect its recorded state before retrying an interrupted publication. `--repo` selects the Swift GitHub destination and defaults to @@ -232,12 +262,24 @@ qualification checks. ELF inspection does not replace those tests. ## Resume and inspect -Publication state is recorded next to the verified ledger as -`publication-CANDIDATE_ID.json`. Keep the ledger, accepted receipts, and -publication record together. A failed or interrupted upload may already have +Publication state is recorded next to the collection ledger as +`publication-CANDIDATE_ID.json`. Keep the collection ledger, any accepted receipts, +and publication record together. You may re-collect the same candidate to add +receipts or out-of-band confirmations before resuming, including Swift publication. +The append-only `verification_history` records each destination invocation's +coverage and override before contacting that destination. A destination's status +references the history entry through its zero-based `verification_attempt`. +History records attempts, not proof that an upload completed; use destination +status and remote state to determine the outcome. Earlier entries and other +destinations' status remain intact. Legacy global `verification` fields are +retained as historical data; their destination and timing cannot be recovered. +Swift provenance binds only candidate identity and artifacts. Older commits with +an embedded coverage field are accepted with that historical field intact; +resuming never rewrites metadata commits or moves tags. +A failed or interrupted upload may already have changed the destination; inspect remote files/hashes and recorded status before retrying. Never replace an existing registry version, move a published tag, or -upload newly built bytes under an old verified ledger. +upload newly built bytes under an old collection ledger. Report exactly which destinations were staged, submitted, validated, or finally published. Candidate preparation, a successful hash comparison, and a TestPyPI diff --git a/docs/reproducibility.md b/docs/reproducibility.md index 8f3753a..a777c14 100644 --- a/docs/reproducibility.md +++ b/docs/reproducibility.md @@ -76,9 +76,10 @@ this maintenance command. A JVM JAR contains Linux and Darwin native libraries. Linux can reproduce the JAR assembly using candidate Darwin inputs, but that receipt proves only the -assembly and its recorded dependencies. The collector also requires receipts -for the independently rebuilt Darwin group and Linux native output. Otherwise -an unchanged imported binary could be mistaken for a locally verified binary. +assembly and its recorded dependencies. Darwin and Linux native comparisons +are separate groups. Collection records rebuild receipts, manual out-of-band +confirmations, and groups without recorded confirmation separately. An imported +binary does not become independently verified merely because assembly matched. Receipts are assertions by the machines/operators that made them. The maintainer selects the receipts to accept; the collector checks their consistency and file diff --git a/skills/release/SKILL.md b/skills/release/SKILL.md index e68e1f8..f375c5e 100644 --- a/skills/release/SKILL.md +++ b/skills/release/SKILL.md @@ -60,11 +60,21 @@ signing keys to CI; CI upload uses its own temporary repository token. Anyone may run `scripts/verify.sh CANDIDATE_ID MANIFEST_SHA256` anonymously on a compatible host. Share the SHA-256 of `candidate.json` with the candidate ID. The script selects the exact recipe and the host's supported groups. It independently -rebuilds and compares exact candidate bytes, then emits a receipt. Require every -group, including generated sources and JVM assembly. Darwin receipts establish -the imported Mac native payloads; no manual Mac binary copy is required for -publication. Accept receipts deliberately before `collect`; a matching download -checksum is not evidence of reproduction. +rebuilds and compares exact candidate bytes, then emits a receipt. Receipts are +optional: maintainers may observe successful verification on other hosts without +copying their files. `collect` asks y/n for each group without a receipt and records +affirmative answers as out-of-band comparisons, separately from rebuild receipts. +A negative or blank answer leaves the group unconfirmed and still permits +collection. `--non-interactive` skips those prompts; collection's +`--require-verification` requires every group to have a receipt or an out-of-band +confirmation. Publication requires this complete coverage by default. Only pass +`--allow-unverified` when the maintainer explicitly chooses publication with +missing comparisons; it does not waive validation of supplied evidence or hashes. +Keep these choices explicit in reports; a missing receipt does not +mean no comparison occurred. JVM assembly confirmation covers assembly only; +the Linux and Darwin groups account for the imported native payloads. +No manual Mac binary copy is required. Accept receipts deliberately before +`collect`; a downloaded checksum alone is not evidence of reproduction. Preparation ends with candidate links and identities, verification commands, coverage status, checks completed, and any concrete outstanding requirements. @@ -77,15 +87,20 @@ release. Preserve the local ledger so work can resume on the same candidate. identifying the candidate and destinations.** A request to prepare, verify, collect, or implement release tooling does not authorize final publication. When that authorization is absent, finish the reviewable preparation first, -then present the exact candidate, verified hashes, destinations, and command. +then present the exact candidate, artifact hashes, recorded verification +coverage, destinations, and command. Name this instruction if it is why publication is waiting. -`publish --confirm CANDIDATE_ID` runs locally, rechecks verified files, and uploads -those same bytes. Add Maven signatures locally after verification. Use TestPyPI -validation before PyPI promotion. For Swift, review the verified wrapper, +`publish --collected FILE --confirm CANDIDATE_ID` runs locally, rechecks candidate +files, and uploads those same bytes. Each publication attempt preserves its +coverage and override in local publication history. Swift provenance binds only +candidate identity, so adding evidence does not prevent resuming an existing +metadata commit. Add Maven signatures locally after candidate +hash checks. Use TestPyPI +validation before PyPI promotion. For Swift, review the generated wrapper, `Package.swift` archive URL/checksum, licenses, and provenance before publishing them as a commit/tag in the separate Swift distribution repository. Attach the -unchanged verified XCFramework ZIP there. The Swift destination does not rebuild +unchanged candidate XCFramework ZIP there. The Swift destination does not rebuild it or own the candidate CI pipeline. Read the release guide for configurable destination options and the current GitHub adapter boundary. diff --git a/skills/release/references/procedure.md b/skills/release/references/procedure.md index 6e18153..ad8071c 100644 --- a/skills/release/references/procedure.md +++ b/skills/release/references/procedure.md @@ -62,27 +62,37 @@ The verifier rebuilds project outputs even when candidate files or Nix project outputs already exist. Tool/dependency caches are permitted. Keep failed-build evidence; do not edit receipt hashes, skip files, or relax equality checks. -Provide receipts for `generated`, `linux-x86_64`, `linux-aarch64`, `android`, +Verification may cover `generated`, `linux-x86_64`, `linux-aarch64`, `android`, `darwin`, and `jvm`. JVM assembly imports the four candidate native libraries and records their identities. Matching assembly alone does not establish that the imported libraries were independently rebuilt. -Choose trusted receipts, place only those in a collection directory, and run -`collect --candidate URL --receipts DIR --output VERIFIED_FILE`. It validates -complete coverage, identities, dependencies, and the actual candidate files. +Optionally choose trusted receipts and place only those in a collection directory. +Run `collect --candidate URL --output COLLECTED_FILE`, adding `--receipts DIR` +when available. Collection validates all candidate bytes and supplied receipts, +then asks whether each remaining group's artifacts were independently rebuilt +and compared elsewhere. Record yes answers as out-of-band confirmations, not +synthetic receipts. No or blank answers leave the group unconfirmed. Use +`--non-interactive` to skip prompts, or `--require-verification` to require every +group to have either form of confirmation. Report all three categories. Consumer/platform test results should accompany the ledger even though they are distinct from hash equality. ## Publish Read [the release guide](../../../docs/releasing.md) for commands, credentials, -TestPyPI requirements, Swift metadata, and recovery. Present the verified -candidate and requested destinations before invoking the explicit publication +TestPyPI requirements, Swift metadata, and recovery. Present the candidate, +recorded verification coverage, and requested destinations before invoking the explicit publication gate in the skill. Reuse existing authorization for that exact publication; do not ask the user again when they already explicitly authorized it. -Invoke each requested destination locally with the verified ledger and exact -candidate confirmation. Upload existing verified files, never a rebuild. Treat +Invoke each requested destination locally with `--collected COLLECTED_FILE` and +the exact candidate confirmation. `--verified` remains a compatibility alias. +Publication requires complete receipt or out-of-band coverage by default. +Use `--allow-unverified` only for a maintainer's explicit choice to publish with +missing comparisons. Each attempt preserves its coverage and override in the +publication history; recollection does not change immutable Swift metadata. +Upload existing candidate files, never a rebuild. Treat TestPyPI staging, Maven upload/validation, and Swift metadata commits as distinct from a completed production publication. Inspect remote state after errors and report partial completion precisely. diff --git a/tools/release/README.md b/tools/release/README.md index c8dcf04..2b172f7 100644 --- a/tools/release/README.md +++ b/tools/release/README.md @@ -1,7 +1,8 @@ # Local release tool -`release` orchestrates public candidates, independent rebuilds, accepted receipts, -and explicit local publication. Run it from the clean bindings recipe checkout: +`release` orchestrates public candidates, optional independent rebuilds and +receipts, out-of-band comparisons, and explicit local publication. Run it from +the clean bindings recipe checkout: ```sh nix run .#release -- --help @@ -100,7 +101,30 @@ directly in its output directory; the simple entrypoint's layout is shown above. Receipts are claims from local independent rebuilds, not signatures or automatic trust decisions. A maintainer deliberately selects accepted receipts into the collection directory. Collection rejects host/group combinations that the build -recipes do not support. Keep the resulting verified ledger outside that directory. +recipes do not support. Keep the collection ledger outside that directory. + +`collect --candidate URL_OR_DIR --output FILE` does not require receipts. Add +`--receipts DIR` for available evidence; each remaining group gets a y/n prompt +asking whether its artifacts were independently rebuilt and compared elsewhere. +Yes answers are recorded in `out_of_band_groups`, separately from actual receipts. +No or blank leaves the group unconfirmed. EOF aborts without writing a ledger. +`--non-interactive` skips prompts, while `--require-verification` requires every +group to have either a receipt or an out-of-band confirmation. + +`publish --collected FILE ...` requires each group to have a receipt or out-of-band +confirmation by default. `--allow-unverified` explicitly permits incomplete +coverage for that invocation. It conflicts with `--require-verification`, which +selects the default. Every attempt appends its destination, coverage, and override +choice to `verification_history` before contacting the destination; destination +statuses reference the relevant zero-based `verification_attempt`. Retries and +other destinations preserve earlier evidence. Legacy global `verification` fields +are retained without attributing them to earlier destinations. Swift provenance +depends only on candidate identity, allowing upgrades and recollection to resume +existing commits and tags. Historical embedded coverage is accepted unchanged. +`--verified` remains a compatibility alias; +schema-1 ledgers without the new optional confirmation field remain accepted. +The scripts may be invoked by absolute path from an older candidate's exact clean +recipe checkout, so existing candidates do not need new CI builds for tool updates. ## Local state and credentials @@ -115,8 +139,8 @@ then renamed into place. On resumption, an incomplete older cache is repaired only when every existing file matches the downloaded candidate. Publication rejects CI environments and requires `--confirm CANDIDATE_ID`. -It snapshots and rehashes the verified files, then records partial outcomes in -`publication-CANDIDATE_ID.json` alongside the verified ledger. Keep this file for +It snapshots and rehashes the candidate files, then records partial outcomes in +`publication-CANDIDATE_ID.json` alongside the collection ledger. Keep this file for resuming the same command after interruptions. An operating-system file lock beside the ledger excludes simultaneous publication commands using that candidate ledger. It releases automatically @@ -143,6 +167,8 @@ and must not be removed while a publisher is running. Use one ledger per candida Git hooks are disabled for generated distribution operations. Before pushing or resuming, the committed paths, regular-file modes, and exact blob bytes must match the verified inputs; a Git filter that changes them causes a failure. + The sole compatibility exception is a historical `verification` field in + provenance; candidate identity and all artifact fields must still match. Local Git authentication, author identity, and signing settings are preserved. Swift deployment minima come from the recipe's `deployment_targets`. `--swift-git-url` and `--swift-asset-base-url` configure source and asset diff --git a/tools/release/src/candidates.rs b/tools/release/src/candidates.rs index c39b5a7..9188c84 100644 --- a/tools/release/src/candidates.rs +++ b/tools/release/src/candidates.rs @@ -525,36 +525,79 @@ fn report_groups(c: &Candidate, groups: &[&str]) { .join(", ") ); } -pub fn collect(root: &Path, source: &str, receipts: &Path, output: &Path) -> Result<()> { +pub fn collect( + root: &Path, + source: &str, + receipts: Option<&Path>, + output: &Path, + non_interactive: bool, + require_verification: bool, +) -> Result<()> { clean(root)?; let loaded = load(root, source)?; let c = &loaded.candidate; + let candidate_sha256 = hash_file(&loaded.dir.join("candidate.json"))?.sha256; let mut accepted = vec![]; - for entry in fs::read_dir(receipts)? { - let path = entry?.path(); - ensure!( - path.extension().and_then(|s| s.to_str()) == Some("json"), - "receipt directory contains a non-JSON file" - ); - accepted.push(read_json::(&path)?); + if let Some(receipts) = receipts { + for entry in fs::read_dir(receipts)? { + let path = entry?.path(); + ensure!( + path.extension().and_then(|s| s.to_str()) == Some("json"), + "receipt directory contains a non-JSON file" + ); + accepted.push(read_json::(&path)?); + } } accepted.sort_by(|a, b| a.group.cmp(&b.group)); - validate_receipts(c, &accepted)?; + let mut out_of_band_groups = vec![]; + let coverage = validate_verification(c, &accepted, &out_of_band_groups, false)?; + if !non_interactive { + println!( + "Candidate: {}\nManifest SHA-256: {}", + c.id, candidate_sha256 + ); + for group in &coverage.unconfirmed_groups { + if confirm_out_of_band(group)? { + out_of_band_groups.push(group.clone()); + } + } + } + let coverage = validate_verification(c, &accepted, &out_of_band_groups, require_verification)?; write_json( output, - &Verified { + &Collected { schema: 1, candidate_id: c.id.clone(), - candidate_sha256: hash_file(&loaded.dir.join("candidate.json"))?.sha256, + candidate_sha256, recipe: c.recipe.clone(), inputs_sha256: c.inputs_sha256.clone(), receipts: accepted, + out_of_band_groups, }, )?; - println!("Complete independent coverage accepted for {}", c.id); + println!("Collected candidate {}", c.id); + coverage.report(); Ok(()) } +fn confirm_out_of_band(group: &str) -> Result { + use std::io::{self, Write}; + loop { + print!("Have you independently rebuilt and compared all artifacts in {group} elsewhere? [y/N] "); + io::stdout().flush()?; + let mut answer = String::new(); + ensure!( + io::stdin().read_line(&mut answer)? != 0, + "confirmation input ended; use --non-interactive to collect without prompts" + ); + match answer.trim().to_ascii_lowercase().as_str() { + "y" | "yes" => return Ok(true), + "" | "n" | "no" => return Ok(false), + _ => println!("Please answer y or n."), + } + } +} + #[cfg(test)] mod tests { use super::host_groups; diff --git a/tools/release/src/main.rs b/tools/release/src/main.rs index 8e11675..10abbb7 100644 --- a/tools/release/src/main.rs +++ b/tools/release/src/main.rs @@ -8,7 +8,7 @@ use clap::{Parser, Subcommand}; use std::path::PathBuf; #[derive(Parser)] #[command( - about = "Rebuild public binding candidates and publish verified bytes locally", + about = "Collect, independently verify, and locally publish binding candidates", version )] struct Cli { @@ -72,14 +72,28 @@ enum Commands { Collect { #[arg(long)] candidate: String, + /// Optional directory containing trusted independent rebuild receipts. #[arg(long)] - receipts: PathBuf, + receipts: Option, + /// Skip prompts for comparisons performed without a receipt. + #[arg(long)] + non_interactive: bool, + /// Require every group to have a receipt or out-of-band confirmation. + #[arg(long)] + require_verification: bool, #[arg(long)] output: PathBuf, }, Publish { + /// Collection ledger; --verified remains accepted for older commands. + #[arg(long, alias = "verified")] + collected: PathBuf, + /// Explicitly select the default requirement for complete verification. + #[arg(long, conflicts_with = "allow_unverified")] + require_verification: bool, + /// Allow publication with groups lacking receipts or out-of-band confirmations. #[arg(long)] - verified: PathBuf, + allow_unverified: bool, #[arg(long)] candidate: String, #[arg(long, value_enum)] @@ -136,20 +150,32 @@ fn execute() -> Result<()> { Commands::Collect { candidate, receipts, + non_interactive, + require_verification, output, - } => candidates::collect(&process::root()?, &candidate, &receipts, &output), + } => candidates::collect( + &process::root()?, + &candidate, + receipts.as_deref(), + &output, + non_interactive, + require_verification, + ), Commands::Publish { - verified, + collected, + require_verification: _, + allow_unverified, candidate, destination, confirm, swift, } => publish::publish( &process::root()?, - &verified, + &collected, &candidate, destination, &confirm, + allow_unverified, &swift, ), } diff --git a/tools/release/src/model.rs b/tools/release/src/model.rs index 01ca6ed..e6286d5 100644 --- a/tools/release/src/model.rs +++ b/tools/release/src/model.rs @@ -105,13 +105,44 @@ pub struct Receipt { } #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(deny_unknown_fields)] -pub struct Verified { +pub struct Collected { pub schema: u32, pub candidate_id: String, pub candidate_sha256: String, pub recipe: String, pub inputs_sha256: String, pub receipts: Vec, + #[serde(default)] + pub out_of_band_groups: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct VerificationCoverage { + pub receipt_groups: Vec, + pub out_of_band_groups: Vec, + pub unconfirmed_groups: Vec, +} +impl VerificationCoverage { + pub fn report(&self) { + for (label, groups) in [ + ("Independent rebuild receipts", &self.receipt_groups), + ("Compared out-of-band", &self.out_of_band_groups), + ( + "Groups without recorded confirmation", + &self.unconfirmed_groups, + ), + ] { + println!( + "{label}: {}", + if groups.is_empty() { + "none".into() + } else { + groups.join(", ") + } + ); + } + } } pub fn safe_name(s: &str) -> Result<()> { @@ -316,7 +347,12 @@ pub fn native_records(c: &Candidate) -> Result> { }) .collect() } -pub fn validate_receipts(c: &Candidate, receipts: &[Receipt]) -> Result<()> { +pub fn validate_verification( + c: &Candidate, + receipts: &[Receipt], + out_of_band_groups: &[String], + require_complete: bool, +) -> Result { let mut seen = BTreeSet::new(); for r in receipts { ensure!( @@ -338,22 +374,35 @@ pub fn validate_receipts(c: &Candidate, receipts: &[Receipt]) -> Result<()> { ensure!(r.files == inv.files, "receipt files differ"); ensure!(r.imports == inv.imports, "JVM imports differ in receipt"); } - for group in c.groups.keys() { - if !seen.contains(group) { - bail!("missing receipt for {group}"); - } - } - for native in native_records(c)? { + let receipt_groups = seen.iter().cloned().collect(); + for group in out_of_band_groups { ensure!( - receipts - .iter() - .filter(|r| r.group != "jvm") - .flat_map(|r| &r.files) - .any(|f| *f == native), - "JVM imports require independent native receipts" + c.groups.contains_key(group), + "unknown out-of-band group: {group}" + ); + ensure!( + seen.insert(group.clone()), + "duplicate verification group: {group}" ); } - Ok(()) + let unconfirmed_groups: Vec<_> = c + .groups + .keys() + .filter(|group| !seen.contains(*group)) + .cloned() + .collect(); + if require_complete { + if let Some(group) = unconfirmed_groups.first() { + bail!("missing verification for {group}"); + } + } + let mut out_of_band_groups = out_of_band_groups.to_vec(); + out_of_band_groups.sort(); + Ok(VerificationCoverage { + receipt_groups, + out_of_band_groups, + unconfirmed_groups, + }) } #[cfg(test)] diff --git a/tools/release/src/publish.rs b/tools/release/src/publish.rs index 54aa5ec..ef1ad18 100644 --- a/tools/release/src/publish.rs +++ b/tools/release/src/publish.rs @@ -41,11 +41,23 @@ impl Destination { } } #[derive(Deserialize, Serialize)] +struct PublicationAttempt { + destination: String, + verification: VerificationCoverage, + allow_unverified: bool, +} +#[derive(Deserialize, Serialize)] struct Publication { schema: u32, candidate_id: String, candidate_sha256: String, destinations: BTreeMap, + // Preserve legacy coverage without attributing it to earlier destinations: + // old publishers overwrote this field on each invocation. + #[serde(default, skip_serializing_if = "Option::is_none")] + verification: Option, + #[serde(default)] + verification_history: Vec, } struct Session<'a> { candidate: &'a Candidate, @@ -54,7 +66,8 @@ struct Session<'a> { state: Publication, } impl Session<'_> { - fn save(&mut self, destination: &str, value: Value) -> Result<()> { + fn save(&mut self, destination: &str, mut value: Value) -> Result<()> { + value["verification_attempt"] = json!(self.state.verification_history.len() - 1); self.state.destinations.insert(destination.into(), value); write_json(&self.state_path, &self.state) } @@ -67,10 +80,11 @@ impl Session<'_> { } pub fn publish( root: &Path, - verified: &Path, + collected: &Path, source: &str, destination: Destination, confirm: &str, + allow_unverified: bool, swift_options: &SwiftOptions, ) -> Result<()> { ensure!( @@ -91,7 +105,7 @@ pub fn publish( loaded.candidate.id == confirm, "candidate-specific confirmation does not match" ); - let ledger: Verified = read_json(verified)?; + let ledger: Collected = read_json(collected)?; let c = &loaded.candidate; ensure!( ledger.schema == 1 @@ -99,9 +113,15 @@ pub fn publish( && ledger.recipe == c.recipe && ledger.inputs_sha256 == c.inputs_sha256 && ledger.candidate_sha256 == hash_file(&loaded.dir.join("candidate.json"))?.sha256, - "verified ledger identity differs" + "collection ledger identity differs" + ); + let coverage = validate_verification(c, &ledger.receipts, &ledger.out_of_band_groups, false)?; + coverage.report(); + ensure!( + allow_unverified || coverage.unconfirmed_groups.is_empty(), + "missing verification for {}; record receipts or out-of-band comparisons, or explicitly pass --allow-unverified", + coverage.unconfirmed_groups.join(", ") ); - validate_receipts(c, &ledger.receipts)?; let snapshot = tempfile::Builder::new() .prefix("bindings-publish-") .tempdir_in("/tmp")?; @@ -111,7 +131,7 @@ pub fn publish( snapshot.path().join(&file.name), )?; } - let state_path = verified + let state_path = collected .parent() .unwrap_or(Path::new(".")) .join(format!("publication-{}.json", c.id)); @@ -126,7 +146,7 @@ pub fn publish( publication_lock .try_lock() .context("another publication is using this candidate ledger; retry after it exits")?; - let state = if state_path.exists() { + let mut state = if state_path.exists() { let state: Publication = read_json(&state_path)?; ensure!( state.schema == 1 @@ -141,8 +161,15 @@ pub fn publish( candidate_id: c.id.clone(), candidate_sha256: ledger.candidate_sha256, destinations: BTreeMap::new(), + verification: None, + verification_history: Vec::new(), } }; + state.verification_history.push(PublicationAttempt { + destination: destination.name().into(), + verification: coverage, + allow_unverified, + }); let mut session = Session { candidate: c, dir: snapshot.path(), @@ -150,6 +177,9 @@ pub fn publish( state, }; session.rehash()?; + // Persist this attempt before any destination mutation, including the Swift + // metadata push. Retries append evidence instead of rewriting older claims. + write_json(&session.state_path, &session.state)?; match destination { Destination::Testpypi | Destination::Pypi => python(&mut session, destination)?, Destination::Maven => maven(&mut session)?, diff --git a/tools/release/src/publish/swift.rs b/tools/release/src/publish/swift.rs index bcf5d6c..2819cdf 100644 --- a/tools/release/src/publish/swift.rs +++ b/tools/release/src/publish/swift.rs @@ -142,7 +142,7 @@ pub(super) fn publish( add.current_dir(&tree).arg("add").args(desired.keys()); run(&mut add)?; let message = work.path().join("message"); - fs::write(&message,"Publish verified Swift package metadata\n\nSwift consumers need a version tag containing generated bindings and a\nchecksum for the independently reproduced framework.\n\nPublish the matching source, licenses, and provenance in the Swift\ndistribution repository before creating its immutable version tag.\n")?; + fs::write(&message,"Publish Swift package metadata\n\nSwift consumers need a version tag containing generated bindings and a\nchecksum for the CI-built framework.\n\nPublish the matching source, licenses, and provenance in the Swift\ndistribution repository before creating its immutable version tag.\n")?; run(git_command() .current_dir(&tree) .args(["commit", "--file"]) @@ -197,11 +197,11 @@ pub(super) fn publish( &commit, "--title", &format!("LDK Node {version}"), - "--notes", - &format!( - "Bindings version: {version}\nNative revision: {}\nBuild recipe: {}\nVerified candidate: {}", - session.candidate.native_rev, session.candidate.recipe, session.candidate.id - ), + "--notes", + &format!( + "Bindings version: {version}\nNative revision: {}\nBuild recipe: {}\nCandidate: {}", + session.candidate.native_rev, session.candidate.recipe, session.candidate.id + ), ]))?; } let draft: Value = serde_json::from_str(&output(Command::new("gh").args([ @@ -289,6 +289,8 @@ fn distribution_files( source.status.success() && !source.stdout.is_empty(), "cannot read generated Swift wrapper" ); + // Verification can grow after a release begins. Keep it in publication + // history so immutable distribution metadata depends only on the candidate. let provenance = json!({"schema":1,"candidate_id":session.candidate.id,"recipe":session.candidate.recipe,"native_rev":session.candidate.native_rev,"inputs_sha256":session.candidate.inputs_sha256,"candidate_sha256":session.state.candidate_sha256,"framework":zip,"swift_source_sha256":digest(&source.stdout)}); let mut files = BTreeMap::new(); files.insert("Sources/LDKNode/LDKNode.swift".into(), source.stdout); @@ -327,12 +329,31 @@ fn assert_distribution(tree: &Path, desired: &BTreeMap>) -> Resu .args(["cat-file", "blob", &format!("HEAD:{name}")]) .output()?; ensure!( - blob.status.success() && blob.stdout == *bytes, + blob.status.success() && distribution_matches(name, &blob.stdout, bytes)?, "existing Swift distribution content differs: {name}" ); } Ok(()) } +fn distribution_matches(name: &str, actual: &[u8], expected: &[u8]) -> Result { + if actual == expected { + return Ok(true); + } + if name != "release-provenance.json" { + return Ok(false); + } + // Accept the earlier optional-receipt tool's coverage field as historical + // evidence. Never rewrite its commit, or ignore any candidate identity field. + let mut provenance: Value = serde_json::from_slice(actual)?; + let Some(coverage) = provenance + .as_object_mut() + .and_then(|object| object.remove("verification")) + else { + return Ok(false); + }; + serde_json::from_value::>(coverage)?; + Ok(serde_json::to_vec_pretty(&provenance)? == expected) +} fn package(url: &str, checksum: &str, targets: &DeploymentTargets) -> String { let DeploymentTargets { swift_macos, diff --git a/tools/release/tests/contracts.rs b/tools/release/tests/contracts.rs index f874697..b53a376 100644 --- a/tools/release/tests/contracts.rs +++ b/tools/release/tests/contracts.rs @@ -1,8 +1,9 @@ use serde_json::{json, Value}; use std::{ fs, + io::Write, path::Path, - process::{Command, Output}, + process::{Command, Output, Stdio}, }; use tempfile::TempDir; @@ -203,6 +204,236 @@ fn candidate_and_complete_receipts_produce_ledger() { assert_eq!(ledger["receipts"].as_array().unwrap().len(), 6); } +#[test] +fn optional_collection_accepts_no_rebuild_receipts() { + let r = fixture(); + let c = build_candidate(&r); + let output = tempfile::tempdir().unwrap(); + let ledger = output.path().join("collected.json"); + let result = run( + r.dir.path(), + &[ + "collect", + "--non-interactive", + "--candidate", + r.candidate.path().to_str().unwrap(), + "--output", + ledger.to_str().unwrap(), + ], + ); + assert!( + result.status.success(), + "collection must accept a candidate without rebuild receipts: {}", + String::from_utf8_lossy(&result.stderr) + ); + let report = String::from_utf8(result.stdout).unwrap(); + assert!(report.contains("Independent rebuild receipts: none")); + assert!(report.contains("Groups without recorded confirmation: android, darwin, generated, jvm, linux-aarch64, linux-x86_64")); + let collected: Value = serde_json::from_slice(&fs::read(ledger).unwrap()).unwrap(); + assert_eq!(collected["candidate_id"], c["id"]); + assert_eq!(collected["receipts"], json!([])); +} + +#[test] +fn optional_collection_preserves_partial_rebuild_evidence() { + let r = fixture(); + let c = build_candidate(&r); + let accepted = receipts(&r, &c); + for group in c["groups"].as_object().unwrap().keys() { + if group != "jvm" { + fs::remove_file(accepted.path().join(format!("{group}.json"))).unwrap(); + } + } + let output = tempfile::tempdir().unwrap(); + let ledger = output.path().join("collected.json"); + let result = run( + r.dir.path(), + &[ + "collect", + "--non-interactive", + "--candidate", + r.candidate.path().to_str().unwrap(), + "--receipts", + accepted.path().to_str().unwrap(), + "--output", + ledger.to_str().unwrap(), + ], + ); + assert!( + result.status.success(), + "collection must preserve a partial rebuild without claiming its native imports were rebuilt: {}", + String::from_utf8_lossy(&result.stderr) + ); + let report = String::from_utf8(result.stdout).unwrap(); + assert!(report.contains("Independent rebuild receipts: jvm")); + assert!(report.contains("Groups without recorded confirmation: android, darwin, generated, linux-aarch64, linux-x86_64")); + let collected: Value = serde_json::from_slice(&fs::read(ledger).unwrap()).unwrap(); + assert_eq!(collected["receipts"].as_array().unwrap().len(), 1); + assert_eq!( + collected["receipts"][0]["imports"], + c["groups"]["jvm"]["imports"] + ); +} + +#[test] +fn out_of_band_comparisons_are_prompted_and_recorded_separately() { + let r = fixture(); + let c = build_candidate(&r); + let accepted = receipts(&r, &c); + // Keep real JVM evidence to prove only the other five groups are prompted. + for group in c["groups"].as_object().unwrap().keys() { + if group != "jvm" { + fs::remove_file(accepted.path().join(format!("{group}.json"))).unwrap(); + } + } + let output = tempfile::tempdir().unwrap(); + let ledger = output.path().join("collected.json"); + let mut child = command(env!("CARGO_BIN_EXE_release")) + .current_dir(r.dir.path()) + .args([ + "collect", + "--candidate", + r.candidate.path().to_str().unwrap(), + "--receipts", + accepted.path().to_str().unwrap(), + "--output", + ledger.to_str().unwrap(), + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + // android=yes, darwin=no, generated=yes, linux-aarch64=default no, linux-x86_64=yes. + child + .stdin + .take() + .unwrap() + .write_all(b"maybe\ny\nn\nYES\n\nY\n") + .unwrap(); + let result = child.wait_with_output().unwrap(); + assert!( + result.status.success(), + "out-of-band comparisons must be collected: {}", + String::from_utf8_lossy(&result.stderr) + ); + let report = String::from_utf8(result.stdout).unwrap(); + assert!(report.contains("Please answer y or n.")); + for group in [ + "android", + "darwin", + "generated", + "linux-aarch64", + "linux-x86_64", + ] { + assert!(report.contains(&format!( + "Have you independently rebuilt and compared all artifacts in {group} elsewhere? [y/N]" + ))); + } + assert!(!report.contains("artifacts in jvm elsewhere?")); + let collected: Value = serde_json::from_slice(&fs::read(ledger).unwrap()).unwrap(); + assert_eq!(collected["receipts"].as_array().unwrap().len(), 1); + assert_eq!( + collected["out_of_band_groups"], + json!(["android", "generated", "linux-x86_64"]) + ); + assert!(report.contains("Groups without recorded confirmation: darwin, linux-aarch64")); +} + +#[test] +fn out_of_band_prompt_eof_does_not_write_a_collection() { + let r = fixture(); + build_candidate(&r); + let output = tempfile::tempdir().unwrap(); + let ledger = output.path().join("collected.json"); + fail( + run( + r.dir.path(), + &[ + "collect", + "--candidate", + r.candidate.path().to_str().unwrap(), + "--output", + ledger.to_str().unwrap(), + ], + ), + "confirmation input ended", + ); + assert!( + !ledger.exists(), + "EOF must not manufacture an out-of-band confirmation" + ); +} + +#[test] +fn out_of_band_invalid_or_missing_confirmations_stop_strict_publication() { + let r = fixture(); + let c = build_candidate(&r); + let accepted = receipts(&r, &c); + let output = tempfile::tempdir().unwrap(); + let ledger = output.path().join("collected.json"); + ok(run( + r.dir.path(), + &[ + "collect", + "--candidate", + r.candidate.path().to_str().unwrap(), + "--receipts", + accepted.path().to_str().unwrap(), + "--output", + ledger.to_str().unwrap(), + ], + )); + let complete: Value = serde_json::from_slice(&fs::read(&ledger).unwrap()).unwrap(); + for (keep_receipts, groups, expected) in [ + (true, json!(["darwin"]), "duplicate verification group"), + ( + false, + json!(["darwin", "darwin"]), + "duplicate verification group", + ), + (false, json!(["other"]), "unknown out-of-band group"), + (false, json!([]), "missing verification"), + ] { + let mut modified = complete.clone(); + if !keep_receipts { + modified["receipts"] = json!([]); + } + modified["out_of_band_groups"] = groups; + write(&ledger, &modified); + for mode in ["--require-verification", "--allow-unverified"] { + if mode == "--allow-unverified" && expected == "missing verification" { + continue; + } + fail( + run( + r.dir.path(), + &[ + "publish", + "--candidate", + r.candidate.path().to_str().unwrap(), + "--collected", + ledger.to_str().unwrap(), + mode, + "--destination", + "testpypi", + "--confirm", + c["id"].as_str().unwrap(), + ], + ), + expected, + ); + } + } + assert!( + !output + .path() + .join(format!("publication-{}.json", c["id"].as_str().unwrap())) + .exists(), + "invalid confirmations must fail before any publication state or upload" + ); +} + #[test] fn collect_rejects_receipt_from_an_incompatible_host() { let r = fixture(); @@ -260,6 +491,8 @@ fn collector_rejects_missing_receipts_and_changed_bytes() { r.dir.path(), &[ "collect", + "--non-interactive", + "--require-verification", "--candidate", r.candidate.path().to_str().unwrap(), "--receipts", @@ -268,7 +501,7 @@ fn collector_rejects_missing_receipts_and_changed_bytes() { out.to_str().unwrap(), ], ), - "missing receipt", + "missing verification", ); fs::write(r.candidate.path().join("linux.whl"), "tampered").unwrap(); fail( @@ -430,6 +663,81 @@ fn publish_requires_local_explicit_candidate_confirmation() { .unwrap(); fail(output, "local machine"); } + +#[test] +fn review_publication_requires_coverage_before_destination_access() { + let r = fixture(); + let c = build_candidate(&r); + let accepted = receipts(&r, &c); + let output = tempfile::tempdir().unwrap(); + let ledger = output.path().join("collected.json"); + ok(run( + r.dir.path(), + &[ + "collect", + "--candidate", + r.candidate.path().to_str().unwrap(), + "--receipts", + accepted.path().to_str().unwrap(), + "--output", + ledger.to_str().unwrap(), + ], + )); + let complete: Value = serde_json::from_slice(&fs::read(&ledger).unwrap()).unwrap(); + let bin = tempfile::tempdir().unwrap(); + for program in ["curl", "gh", "uv", "gpg"] { + executable( + &bin.path().join(program), + "#!/bin/sh\ntouch \"$MOCK_CONTACTED\"\nexit 97\n", + ); + } + for count in [0, 5] { + let mut incomplete = complete.clone(); + incomplete["receipts"] + .as_array_mut() + .unwrap() + .truncate(count); + write(&ledger, &incomplete); + for destination in ["testpypi", "pypi", "maven", "swift"] { + let result = command(env!("CARGO_BIN_EXE_release")) + .current_dir(r.dir.path()) + .args([ + "publish", + "--candidate", + r.candidate.path().to_str().unwrap(), + "--collected", + ledger.to_str().unwrap(), + "--destination", + destination, + "--confirm", + c["id"].as_str().unwrap(), + ]) + .env_remove("CI") + .env_remove("GITHUB_ACTIONS") + .env( + "PATH", + format!( + "{}:{}", + bin.path().display(), + std::env::var("PATH").unwrap() + ), + ) + .env("MOCK_CONTACTED", bin.path().join("contacted")) + .env("MAVEN_CENTRAL_TOKEN", "test") + .output() + .unwrap(); + fail(result, "missing verification"); + } + } + assert!( + !bin.path().join("contacted").exists(), + "incomplete coverage must fail before accessing destinations" + ); + assert!(!output + .path() + .join(format!("publication-{}.json", c["id"].as_str().unwrap())) + .exists()); +} #[test] fn verify_requires_clean_exact_recipe() { let r = fixture(); @@ -909,6 +1217,35 @@ cp "$MOCK_CANDIDATE/${last##*/}" "$result" #[test] #[cfg(unix)] fn testpypi_uploads_only_verified_wheels_and_reconciles() { + testpypi_publication_scenario("complete"); +} + +#[test] +#[cfg(unix)] +fn publication_override_accepts_no_rebuild_receipts() { + testpypi_publication_scenario("none"); +} + +#[test] +#[cfg(unix)] +fn publication_override_accepts_partial_rebuild_receipts() { + testpypi_publication_scenario("partial"); +} + +#[test] +#[cfg(unix)] +fn out_of_band_comparisons_allow_strict_publication() { + testpypi_publication_scenario("out-of-band"); +} + +#[test] +#[cfg(unix)] +fn review_publication_retains_coverage_history() { + testpypi_publication_scenario("history"); +} + +#[cfg(unix)] +fn testpypi_publication_scenario(coverage: &str) { let r = fixture(); let c = build_candidate(&r); let accepted = receipts(&r, &c); @@ -925,6 +1262,22 @@ fn testpypi_uploads_only_verified_wheels_and_reconciles() { ledger.to_str().unwrap(), ], )); + if !["complete", "history"].contains(&coverage) { + let mut collected: Value = serde_json::from_slice(&fs::read(&ledger).unwrap()).unwrap(); + collected["receipts"] + .as_array_mut() + .unwrap() + .retain(|receipt| coverage == "partial" && receipt["group"] == "jvm"); + if coverage == "out-of-band" { + collected["out_of_band_groups"] = + json!(c["groups"].as_object().unwrap().keys().collect::>()); + } + write(&ledger, &collected); + } else { + let mut legacy: Value = serde_json::from_slice(&fs::read(&ledger).unwrap()).unwrap(); + legacy.as_object_mut().unwrap().remove("out_of_band_groups"); + write(&ledger, &legacy); + } let bin = tempfile::tempdir().unwrap(); let mut urls = vec![]; for inv in c["groups"].as_object().unwrap().values() { @@ -950,20 +1303,29 @@ printf '%s\n' "${last##*/}" >> "$MOCK_LOG" touch "$MOCK_UPLOADED" "##, ); - let invoke = || { + let invoke = |destination: &str| { command(env!("CARGO_BIN_EXE_release")) .current_dir(r.dir.path()) .args([ "publish", "--candidate", r.candidate.path().to_str().unwrap(), - "--verified", + if coverage == "out-of-band" { + "--collected" + } else { + "--verified" + }, ledger.to_str().unwrap(), "--destination", - "testpypi", + destination, "--confirm", c["id"].as_str().unwrap(), ]) + .args(if ["none", "partial"].contains(&coverage) { + vec!["--allow-unverified"] + } else { + vec![] + }) .env_remove("CI") .env_remove("GITHUB_ACTIONS") .env( @@ -981,8 +1343,95 @@ touch "$MOCK_UPLOADED" .output() .unwrap() }; - ok(invoke()); - ok(invoke()); + let result = invoke("testpypi"); + assert!( + result.status.success(), + "publication must accept {coverage} rebuild coverage: {}", + String::from_utf8_lossy(&result.stderr) + ); + if coverage == "history" { + let mut collected: Value = serde_json::from_slice(&fs::read(&ledger).unwrap()).unwrap(); + collected["receipts"] = json!([]); + collected["out_of_band_groups"] = + json!(c["groups"].as_object().unwrap().keys().collect::>()); + write(&ledger, &collected); + } + ok(invoke("testpypi")); + if coverage == "history" { + let state_path = accepted + .path() + .join(format!("publication-{}.json", c["id"].as_str().unwrap())); + let mut state: Value = serde_json::from_slice(&fs::read(&state_path).unwrap()).unwrap(); + let history = state["verification_history"] + .as_array() + .expect("publication must retain coverage from both attempts"); + assert_eq!(history.len(), 2); + assert_eq!( + history[0]["verification"]["receipt_groups"] + .as_array() + .unwrap() + .len(), + 6 + ); + assert_eq!( + history[1]["verification"]["out_of_band_groups"] + .as_array() + .unwrap() + .len(), + 6 + ); + assert_eq!(state["destinations"]["testpypi"]["verification_attempt"], 1); + let prior_history = history.clone(); + let prior_testpypi = state["destinations"]["testpypi"].clone(); + // A pre-upgrade ledger may have a global coverage field and no history. + // Preserve that field without attributing it to a destination retroactively. + let legacy_coverage = json!({"receipt_groups":[],"out_of_band_groups":[],"unconfirmed_groups":c["groups"].as_object().unwrap().keys().collect::>()}); + state["verification"] = legacy_coverage.clone(); + state["destinations"]["maven"] = + json!({"status":"uploaded","deployment_id":"deployment-123"}); + write(&state_path, &state); + executable( + &bin.path().join("curl"), + "#!/bin/sh\ncat >/dev/null\nprintf '{\"deploymentState\":\"PUBLISHED\"}'\n", + ); + let result = command(env!("CARGO_BIN_EXE_release")) + .current_dir(r.dir.path()) + .args([ + "publish", + "--candidate", + r.candidate.path().to_str().unwrap(), + "--collected", + ledger.to_str().unwrap(), + "--destination", + "maven", + "--confirm", + c["id"].as_str().unwrap(), + ]) + .env_remove("CI") + .env_remove("GITHUB_ACTIONS") + .env( + "PATH", + format!( + "{}:{}", + bin.path().display(), + std::env::var("PATH").unwrap() + ), + ) + .env("MAVEN_CENTRAL_TOKEN", "test") + .output() + .unwrap(); + ok(result); + let state: Value = serde_json::from_slice(&fs::read(&state_path).unwrap()).unwrap(); + assert_eq!( + &state["verification_history"].as_array().unwrap()[..2], + prior_history.as_slice() + ); + assert_eq!(state["verification_history"][2]["destination"], "maven"); + assert_eq!(state["destinations"]["testpypi"], prior_testpypi); + assert_eq!(state["destinations"]["maven"]["verification_attempt"], 2); + assert_eq!(state["verification"], legacy_coverage); + return; + } let uploaded = fs::read_to_string(bin.path().join("events")).unwrap(); let mut actual: Vec<_> = uploaded.lines().collect(); actual.sort(); @@ -992,6 +1441,46 @@ touch "$MOCK_UPLOADED" .collect(); expected.sort(); assert_eq!(actual, expected); + let state: Value = serde_json::from_slice( + &fs::read( + accepted + .path() + .join(format!("publication-{}.json", c["id"].as_str().unwrap())), + ) + .unwrap(), + ) + .unwrap(); + let verified = match coverage { + "complete" => 6, + "partial" => 1, + _ => 0, + }; + let history = state["verification_history"].as_array().unwrap(); + assert_eq!(history.len(), 2); + assert_eq!(history[0], history[1]); + assert_eq!(history[0]["destination"], "testpypi"); + assert_eq!( + history[0]["allow_unverified"], + ["none", "partial"].contains(&coverage) + ); + assert_eq!(state["destinations"]["testpypi"]["verification_attempt"], 1); + let recorded = &history[0]["verification"]; + assert_eq!( + recorded["receipt_groups"].as_array().unwrap().len(), + verified + ); + assert_eq!( + recorded["unconfirmed_groups"].as_array().unwrap().len(), + if coverage == "out-of-band" { + 0 + } else { + 6 - verified + } + ); + assert_eq!( + recorded["out_of_band_groups"].as_array().unwrap().len(), + if coverage == "out-of-band" { 6 } else { 0 } + ); } #[test] @@ -1182,6 +1671,31 @@ fn swift_does_not_overwrite_differing_draft_asset() { swift_publication_scenario("changed_asset"); } +#[test] +#[cfg(unix)] +fn review_swift_resumes_legacy_provenance() { + swift_publication_scenario("legacy_resume"); +} + +#[test] +#[cfg(unix)] +fn review_swift_reruns_legacy_publication() { + swift_publication_scenario("legacy_rerun"); +} + +#[test] +#[cfg(unix)] +fn review_swift_resumes_after_recollection() { + swift_publication_scenario("recollect"); +} + +#[test] +#[cfg(unix)] +fn review_swift_accepts_historical_coverage_without_changing_identity() { + swift_publication_scenario("coverage_resume"); + swift_publication_scenario("tampered_provenance"); +} + #[cfg(unix)] fn swift_publication_scenario(mode: &str) { let mut r = fixture(); @@ -1350,6 +1864,109 @@ esac if !["none", "hook"].contains(&mode) { fail(invoke(), "gh failed"); } + let state_path = accepted + .path() + .join(format!("publication-{}.json", c["id"].as_str().unwrap())); + let mut pinned_commit = None; + if [ + "legacy_resume", + "legacy_rerun", + "coverage_resume", + "tampered_provenance", + ] + .contains(&mode) + { + let mut state: Value = serde_json::from_slice(&fs::read(&state_path).unwrap()).unwrap(); + let branch = state["destinations"]["swift"]["branch"] + .as_str() + .unwrap() + .to_owned(); + let checkout = tempfile::tempdir().unwrap(); + git( + checkout.path(), + &["clone", remote.path().to_str().unwrap(), "."], + ); + git(checkout.path(), &["checkout", &branch]); + let provenance_path = checkout.path().join("release-provenance.json"); + let mut provenance: Value = + serde_json::from_slice(&fs::read(&provenance_path).unwrap()).unwrap(); + if mode.starts_with("legacy_") { + provenance.as_object_mut().unwrap().remove("verification"); + // This fixture represents an old publication ledger with no coverage. + state.as_object_mut().unwrap().remove("verification"); + state + .as_object_mut() + .unwrap() + .remove("verification_history"); + state["destinations"]["swift"] + .as_object_mut() + .unwrap() + .remove("verification_attempt"); + } else { + provenance["verification"] = json!({"receipt_groups":[],"out_of_band_groups":[],"unconfirmed_groups":c["groups"].as_object().unwrap().keys().collect::>()}); + } + if mode == "tampered_provenance" { + provenance["native_rev"] = json!("0".repeat(40)); + } + write(&provenance_path, &provenance); + git(checkout.path(), &["add", "release-provenance.json"]); + git( + checkout.path(), + &[ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.org", + "commit", + "--allow-empty", + "-m", + "Historical provenance fixture", + ], + ); + let commit = git(checkout.path(), &["rev-parse", "HEAD"]); + git(checkout.path(), &["push", "origin", &branch]); + state["destinations"]["swift"]["commit"] = json!(commit); + if mode == "legacy_rerun" { + git( + checkout.path(), + &["push", "origin", &format!("{commit}:refs/tags/v0.7.1")], + ); + state["destinations"]["swift"]["status"] = json!("published"); + executable( + &bin.path().join("curl"), + r##"#!/bin/sh +while [ "$#" -gt 0 ]; do + if [ "$1" = --output ]; then shift; cp "$MOCK_CANDIDATE/LDKNodeFFI.xcframework.zip" "$1"; exit 0; fi + shift +done +printf '{"draft":false}\n200' +"##, + ); + } + write(&state_path, &state); + pinned_commit = Some(commit); + } + if ["recollect", "coverage_resume"].contains(&mode) { + let mut collected: Value = serde_json::from_slice(&fs::read(&ledger).unwrap()).unwrap(); + collected["receipts"] = json!([]); + collected["out_of_band_groups"] = + json!(c["groups"].as_object().unwrap().keys().collect::>()); + write(&ledger, &collected); + if pinned_commit.is_none() { + let state: Value = serde_json::from_slice(&fs::read(&state_path).unwrap()).unwrap(); + pinned_commit = Some( + state["destinations"]["swift"]["commit"] + .as_str() + .unwrap() + .to_owned(), + ); + } + } + if mode == "tampered_provenance" { + fail(invoke(), "Swift distribution content differs"); + assert!(git(remote.path(), &["tag", "--list", "v0.7.1"]).is_empty()); + return; + } if mode == "changed_asset" { fs::write(bin.path().join("asset"), b"different remote asset").unwrap(); fail(invoke(), "artifact differs"); @@ -1361,6 +1978,13 @@ esac return; } ok(invoke()); + if let Some(commit) = pinned_commit { + assert_eq!( + git(remote.path(), &["rev-parse", "v0.7.1"]), + commit, + "resuming must preserve the original metadata commit" + ); + } let files = git(remote.path(), &["ls-tree", "-r", "--name-only", "v0.7.1"]); assert_eq!( files.lines().collect::>(), @@ -1393,7 +2017,11 @@ esac assert_eq!(provenance["recipe"], r.sha); assert_eq!( fs::read_to_string(bin.path().join("events")).unwrap(), - "draft\nupload\npublish\n" + if mode == "legacy_rerun" { + "draft\n" + } else { + "draft\nupload\npublish\n" + } ); }