diff --git a/.gitforge.yml b/.gitforge.yml index 7d707e0..bc7dbca 100644 --- a/.gitforge.yml +++ b/.gitforge.yml @@ -87,6 +87,10 @@ jobs: export CARGO_TARGET_DIR=/tmp/oc-test-target export RUSTFLAGS="-D warnings -C debuginfo=0" cargo test --doc + - name: Prebuilt installer and attestation contract + run: | + sh -n scripts/install-prebuilt.sh + python3 -m unittest discover -s tests -p 'test_*.py' -v - name: doc image: opencodifier-ci-rust:2 diff --git a/.github/CHANGELOG.md b/.github/CHANGELOG.md index 0993c2e..18bf423 100644 --- a/.github/CHANGELOG.md +++ b/.github/CHANGELOG.md @@ -6,6 +6,38 @@ project does not yet guarantee a stable public API — version `0.x` releases may break, and every breaking change is recorded in this file (and reflected in `docs/DECISIONS.md` where a binding decision moves). +## [0.6.2] — Unreleased + +The planned 0.6.2 release will package the current engine/CLI from a clean +tagged source tree and add a verified prebuilt installation path. It will +not include model weights in the binary release. + +### Added + +- Prebuilt CLI installer for Linux x86_64/aarch64 and macOS x86_64/arm64. + It verifies the adjacent release attestation, reported platform, file + size, SHA-256, clean source record, and binary version before atomically + replacing a user-local executable. Installing does not require Rust or + Cargo and does not start services. +- CLI first-run flow (`init`, `doctor`), selectable lane presets, an + agent-facing `codify_gate` tool, feedback capture, and reusable CI + recipe controls, carried forward from the post-0.6.1 mainline. +- Certified early exit for the rotation readout, with deterministic + release-binary regression coverage. +- Version identity changes re-key the recorded decision cache hashes; the + three affected WASM conformance fixtures are re-recorded with their + answers/confidence/outcomes unchanged. + +### Release validation + +- Gate of record: GitForge pipeline receipt for the release commit. +- Full target build matrix and per-target attestations: recorded in the + attached generated release notes and `sha256sums.txt`. +- Linux x86_64 prebuilt installation and the 30-check serve/MCP E2E suite: + recorded from a disposable Fedora 44 validation environment. +- Cross-target artifacts are labeled with their actual verification level; + targets not run on native hardware are not described as runtime-tested. + ## [0.6.1] — 2026-10-15 > Version note: `v0.6.0` was cut as a draft release and its tag pattern was @@ -629,7 +661,9 @@ history. (563ad3f; folded from Unreleased — this item shipped in 0.1.0 but was left unstaged there). -[Unreleased]: https://github.com/aliasfoxkde/OpenCodifier/compare/v0.4.0...HEAD +[Unreleased]: https://github.com/aliasfoxkde/OpenCodifier/compare/v0.6.2...HEAD +[0.6.2]: https://github.com/aliasfoxkde/OpenCodifier/compare/v0.6.1...v0.6.2 +[0.6.1]: https://github.com/aliasfoxkde/OpenCodifier/compare/v0.5.0...v0.6.1 [0.4.0]: https://github.com/aliasfoxkde/OpenCodifier/releases/tag/v0.4.0 [0.3.0]: https://github.com/aliasfoxkde/OpenCodifier/releases/tag/v0.3.0 [0.2.0]: https://github.com/aliasfoxkde/OpenCodifier/releases/tag/v0.2.0 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e74a504..d160eba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,8 @@ jobs: - uses: dtolnay/rust-toolchain@stable - run: cargo test --workspace - run: cargo test --doc + - run: sh -n scripts/install-prebuilt.sh + - run: python3 -m unittest discover -s tests -p 'test_*.py' -v doc: runs-on: ubuntu-latest diff --git a/Cargo.lock b/Cargo.lock index 5611d38..b67b58b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1451,7 +1451,7 @@ checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" [[package]] name = "opencodifier-cli" -version = "0.6.1" +version = "0.6.2" dependencies = [ "assert_cmd", "clap", @@ -1472,7 +1472,7 @@ dependencies = [ [[package]] name = "opencodifier-config" -version = "0.6.1" +version = "0.6.2" dependencies = [ "opencodifier-core", "opencodifier-engine", @@ -1484,7 +1484,7 @@ dependencies = [ [[package]] name = "opencodifier-core" -version = "0.6.1" +version = "0.6.2" dependencies = [ "base64 0.22.1", "serde", @@ -1494,7 +1494,7 @@ dependencies = [ [[package]] name = "opencodifier-engine" -version = "0.6.1" +version = "0.6.2" dependencies = [ "criterion", "opencodifier-core", @@ -1510,7 +1510,7 @@ dependencies = [ [[package]] name = "opencodifier-ffi" -version = "0.6.1" +version = "0.6.2" dependencies = [ "opencodifier-core", "opencodifier-engine", @@ -1520,7 +1520,7 @@ dependencies = [ [[package]] name = "opencodifier-http" -version = "0.6.1" +version = "0.6.2" dependencies = [ "axum", "criterion", @@ -1537,7 +1537,7 @@ dependencies = [ [[package]] name = "opencodifier-itemgen" -version = "0.6.1" +version = "0.6.2" dependencies = [ "clap", "opencodifier-core", @@ -1548,7 +1548,7 @@ dependencies = [ [[package]] name = "opencodifier-mcp" -version = "0.6.1" +version = "0.6.2" dependencies = [ "opencodifier-core", "opencodifier-engine", @@ -1562,7 +1562,7 @@ dependencies = [ [[package]] name = "opencodifier-model" -version = "0.6.1" +version = "0.6.2" dependencies = [ "image", "opencodifier-core", @@ -1578,7 +1578,7 @@ dependencies = [ [[package]] name = "opencodifier-runtime" -version = "0.6.1" +version = "0.6.2" dependencies = [ "ort", "thiserror", @@ -1586,7 +1586,7 @@ dependencies = [ [[package]] name = "opencodifier-schema" -version = "0.6.1" +version = "0.6.2" dependencies = [ "base64 0.22.1", "opencodifier-core", @@ -1599,7 +1599,7 @@ dependencies = [ [[package]] name = "opencodifier-wasm" -version = "0.6.1" +version = "0.6.2" dependencies = [ "opencodifier-core", "opencodifier-engine", diff --git a/Cargo.toml b/Cargo.toml index 431ac4b..86dccb2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ members = [ ] [workspace.package] -version = "0.6.1" +version = "0.6.2" edition = "2024" # MSRV policy: the oldest stable toolchain the workspace is tested against # in CI. Bumps are breaking changes and recorded in .github/CHANGELOG.md. diff --git a/README.md b/README.md index 2bac33c..4241540 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,7 @@ are broken into sections — read top-down only as deep as you need. **Running it** +- [`scripts/install-prebuilt.sh`](scripts/install-prebuilt.sh) — install a verified release binary without Rust or Cargo; supports Linux x86_64/aarch64 and macOS x86_64/arm64. - [`recipes/`](recipes/) — runnable graphs + captured responses. - [`docs/FORK_LLAMA_SERVER.md`](docs/FORK_LLAMA_SERVER.md) — the llama.cpp fork that serves decision rungs. @@ -152,6 +153,10 @@ are broken into sections — read top-down only as deep as you need. - [`docs/OPERATOR_ACTIONS.md`](docs/OPERATOR_ACTIONS.md) — items only the operator can do. +**Portable binary installation** + +- [Prebuilt binary release and onboarding plan](docs/planning/prebuilt-binary-release-and-onboarding-2026-10-29.md) — audit, installer contract, provenance gates, rollout, and Amortyx integration. + **Research** - [`docs/RESEARCH.md`](docs/RESEARCH.md) — sectioned research log @@ -167,12 +172,30 @@ policy): [`docs/planning/`](docs/planning/), including the and the [offline campaign](docs/planning/offline-campaign-2026-10-23.md) tracker. +## Install a prebuilt CLI + +On a supported Linux/macOS host, clone this repository and run: + +```sh +sh scripts/install-prebuilt.sh +``` + +The script downloads the pinned stable release binary and its adjacent +attestation, checks target, SHA-256, source cleanliness, and version, then +atomically installs `opencodifier` into `~/.local/bin`. It requires `curl` +and Python 3's standard library; it does **not** install Rust/Cargo, download +model weights, or start a service. Override the target directory with +`--install-dir`; select a specific release with `--version vX.Y.Z`. See the +[binary release plan](docs/planning/prebuilt-binary-release-and-onboarding-2026-10-29.md) +for the provenance, support, update, rollback, and validation contract. + ## Development ```bash git clone https://github.com/aliasfoxkde/OpenCodifier cd OpenCodifier just ci # fmt + clippy (strict) + tests + doc + cargo-deny + aegis +python3 -m unittest discover -s tests -p 'test_*.py' -v # binary installer contracts just coverage # line coverage (cargo llvm-cov) ``` diff --git a/crates/opencodifier-config/Cargo.toml b/crates/opencodifier-config/Cargo.toml index 8986b73..aa46630 100644 --- a/crates/opencodifier-config/Cargo.toml +++ b/crates/opencodifier-config/Cargo.toml @@ -9,8 +9,8 @@ authors.workspace = true description = "Configuration discovery, precedence, and strict validation for OpenCodifier" [dependencies] -opencodifier-core = { path = "../opencodifier-core" } -opencodifier-engine = { path = "../opencodifier-engine" } +opencodifier-core = { path = "../opencodifier-core", version = "0.6.2" } +opencodifier-engine = { path = "../opencodifier-engine", version = "0.6.2" } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } diff --git a/docs/PLAN.md b/docs/PLAN.md index 46cb9d9..c13b95f 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -67,6 +67,7 @@ Remaining risks tracked in §6 below. | 24 | RESEARCH §15.6 enhancement program | **in progress** (2026-10-07) — items 1–4, 8–10 + §15.8 fold-ins done (ledger: RESEARCH §15.6.1); items 5/6 sized, model-rung options, deferred per the §15.6 note | | 25 | The gate program: escalation, lanes, first-run detection, CI/CD | **opened** (2026-10-21) — design `docs/ESCALATION_LANES_PLAN.md`; #188 premise validation first, tasks #188–#197 | | 26 | Amortyx pre-router integration + token-reduction stack | **in progress** (2026-10-26) — phase-2 consult LIVE in shadow (§14 of INTEGRATION_AMORTYX.md: consult p50 ~3 ms, task-signal agreement/divergence counters collecting, abstain-degrades verified on provider ranking); dual-arm parity baseline run (dispatch unchanged = arm parity by construction; shadow counters are the labeled evidence for enforce promotion after ≥1 week); token-reduction design of record `docs/planning/token-reduction-router-stack.md` (M1–M8, build queue 1–5) | +| 27 | Prebuilt binary releases + no-Rust onboarding | **in progress** (2026-10-29) — design of record `docs/planning/prebuilt-binary-release-and-onboarding-2026-10-29.md`; v0.6.1 Linux x86_64 binary passes 30/30 E2E on Fedora but its attestation reports 29 dirty paths; verified installer and provenance contract underway; new clean patch release + Amortyx-Stack integration remain | ## Phase 2 — schema adapters + fixtures (done, 5952769) diff --git a/docs/RELEASE_NOTES_TEMPLATE.md b/docs/RELEASE_NOTES_TEMPLATE.md index 3e97ac8..befbb94 100644 --- a/docs/RELEASE_NOTES_TEMPLATE.md +++ b/docs/RELEASE_NOTES_TEMPLATE.md @@ -106,6 +106,7 @@ just release-preflight # toolchain readiness per target just release-matrix # build + stage + attest every target just release-verify # re-hash every artifact vs its attestation just release-notes # this file, from the attestations +just release-set-verify # exact tag, clean source, complete matrix, checksums just ci # the gate of record, before tagging just e2e # the run-tested claim, on the host target ``` diff --git a/docs/planning/prebuilt-binary-release-and-onboarding-2026-10-29.md b/docs/planning/prebuilt-binary-release-and-onboarding-2026-10-29.md new file mode 100644 index 0000000..3b6eae7 --- /dev/null +++ b/docs/planning/prebuilt-binary-release-and-onboarding-2026-10-29.md @@ -0,0 +1,121 @@ +# Prebuilt binary releases and clean OpenCodifier onboarding + +> **Status:** active plan and implementation record — 2026-10-29. The v0.6.2 candidate is built and locally validated for Linux x86_64 GNU and musl; the release is not published yet. + +## Objective + +Make OpenCodifier's deterministic engine easy to install and update from a prebuilt CLI binary. A normal setup must not clone the Rust workspace, install Rust/Cargo, or compile OpenCodifier. The no-AI engine remains the default; model weights and local model backends remain separate and opt-in. + +The upstream OpenCodifier project owns and improves the engine. Amortyx consumes the stable CLI/service boundary and remains responsible for policy, provider eligibility, routing, failover, and orchestration. Users can later compose deterministic classification, a local OpenCodifier model rung, an external JEV-compatible endpoint, and Amortyx enforcement in user/team/enterprise workflows without turning Amortyx into a monolith. + +## Audit findings + +Observed 2026-10-29 from source, release metadata, and a real Fedora-host run: + +- OpenCodifier `v0.6.1` is already published with CLI binaries for Linux x86_64 (GNU and musl), Linux aarch64, macOS x86_64/arm64, Windows x86_64 GNU, Android, iOS static library, and WASM. Each asset has a JSON SHA-256 attestation. The release is not missing binaries. +- `v0.6.1`'s x86_64 GNU asset hash matched its attestation, reported version `opencodifier 0.6.1`, and passed the current `scripts/e2e_validate.py` suite on Fedora 44: **30/30 checks**. +- That same attestation says `working_tree_clean: false`, `dirty_path_count: 29`. Its recorded source commit differs from the GitHub tag commit, although both resolve to the same Git tree hash. Therefore the asset is a working compatibility baseline, but its build record does not meet the clean-source provenance standard for the next release. +- The GitForge `.gitforge.yml` pipeline is the quality-gate authority. GitHub Actions is explicitly a mirror and must not become a competing CI/release gate. +- The Amortyx-Stack bootstrap currently clones OpenCodifier and invokes Cargo. That makes the target host install Rust and compile the full workspace even though prebuilt CLI assets already exist. +- The existing per-binary attestation verifies artifact bytes and records build metadata, but it is not itself a cryptographic signature. GitHub documents release immutability and signed build attestations as stronger provenance/integrity mechanisms; configure/enable these where the hosting policy supports them. + +## Validation evidence (2026-10-29) + +- PR `#11` is based on `origin/main`. The Linux artifact test below ran on clean code commit `9fc6e64`; later commits only changed planning/changelog docs. GitHub mirror checks for lint, tests, docs, E2E, supply chain, and code analysis passed on `9fc6e64`; the current tip still needs its mirror checks and final release artifacts rebuilt from the release tag commit. +- Fedora 44 built both `x86_64-unknown-linux-gnu` and the installer-selected `x86_64-unknown-linux-musl` assets from the clean PR commit. Their generated per-asset attestations verify. Both binaries passed **30/30** E2E checks. +- A fresh `python:3.12-slim` consumer container received curl and CA certificates, with no Rust compiler or Cargo present. The actual installer downloaded the staged musl asset from a local release-shaped directory, verified its attestation, installed `opencodifier 0.6.2`, and passed **30/30** E2E checks. No daemon or service was started. +- The installer, attestation, and release-set contract suite passes **20/20** tests. It covers the complete release set plus missing tag/target, dirty source, commit mismatch, toolchain drift, checksum omissions, release-note placeholders, installer integrity failures, and previous-install preservation. Shell syntax, Python byte-compilation, locked Cargo metadata, and `git diff --check` pass. +- Fedora `just ci` passed format, clippy, workspace tests, docs, cargo-deny, and cargo-audit, then stopped at Aegis. The host Aegis is 0.6.1 while the cached CI image is 0.6.3. That image also reports zero baseline suppressions and 20,832 unmatched findings on `origin/main`; this is pre-existing scanner/baseline drift, not a clean PR receipt. The baseline was left unchanged rather than regenerated to silence the mismatch. +- GitForge reports degraded/build-drift service state. Its OpenCodifier pipeline has no observed run for the PR commit; the listed run is pinned to another commit. The required GitForge receipt is still outstanding. +- Fedora's release preflight has all listed targets ready except Android NDK and `wasm-pack`. The full release matrix therefore remains incomplete. + +## Target behavior + +1. A user runs one documented installer command on a supported desktop OS. +2. The installer detects OS/architecture, downloads one pinned release binary plus its attestation, verifies schema/target/size/SHA-256 and clean source provenance, checks the binary's `--version`, then atomically installs to a user-writable directory. +3. It never installs Rust, downloads model weights, starts a daemon, changes a system service, or enables a provider. +4. OpenCodifier's local deterministic HTTP/MCP services remain explicit operator choices. Amortyx falls back safely when OpenCodifier is disabled or unavailable. +5. A failed download, unsupported platform, corrupt/incorrect attestation, incompatible binary, or failed self-check leaves the current installation untouched and gives a precise error. + +## Platform and artifact contract + +The existing release matrix includes more targets than the first-party installer needs. First-party CLI installation initially covers: + +| Host | Target asset | Notes | +|---|---|---| +| Linux x86_64 | `x86_64-unknown-linux-musl` | Prefer static binary for portable Linux use; validate on Fedora and a second Linux environment before claiming broad compatibility. | +| Linux aarch64 | `aarch64-unknown-linux-gnu` | Validate on native ARM64 hardware/runner before claiming runtime-tested support. | +| macOS x86_64 | `x86_64-apple-darwin` | Validate on a native Intel macOS runner. | +| macOS arm64 | `aarch64-apple-darwin` | Validate on native Apple Silicon. | +| Windows x86_64 | `x86_64-pc-windows-gnu.exe` | Provide a PowerShell installer or document manual download; do not route a PE executable through the POSIX shell installer. | + +Android, iOS static library, and WASM remain downloadable library/runtime artifacts, not host CLI installs. The asset contract is `--[.exe]` plus an adjacent `.attestation.json` using schema `opencodifier.attestation/1`. + +## Release integrity and quality gates + +A release candidate must satisfy all of these before the public GitHub mirror is published: + +- GitForge `just ci` receipt matches the exact release commit. It remains the CI gate of record. +- Release source is a clean, immutable commit and tag; every target attestation names that same commit, Cargo.lock digest, dependency-tree digest, and version. +- `just release-preflight`, `just release-matrix`, `just release-verify`, `just release-checksums`, and `just release-notes` complete. The release set includes every target currently listed in `release_targets`; no partial matrix can be mistaken for a full release. +- Native-host CLI runs `--version`, `--help`, the E2E suite, and the release binary's real serve/MCP contract. Cross-compiled binaries earn only their measured verification level; link/file-magic is not called runtime testing. +- A clean disposable Fedora environment installs the published candidate without Cargo/Rust, verifies the per-binary attestation, starts no service by default, and passes E2E. +- Release notes contain the gate receipt, source commit, explicit verification level per artifact, checksums, and unsupported/unrun target caveats. +- Publish as a draft first; install and validate the exact downloaded bytes; then publish the release. Prefer immutable releases and GitHub release attestations when enabled. GitHub's general artifact/release attestations are distinct from this repository's self-described JSON SHA manifest. + +## Phased implementation and task ledger + +### Phase 0 — Audit and baseline (complete) + +- [x] Inspect release policy, current GitForge gate, release matrix, and published assets. +- [x] Verify the existing Linux x86_64 release binary on Fedora, including E2E. +- [x] Record the dirty provenance and installer/build-time Rust dependency gaps. + +### Phase 1 — Installer and consumer contract (in progress) + +- [x] Add a user-local prebuilt installer with platform detection, version pinning, bounded downloads, strict attestation checks, atomic install, and non-destructive failure behavior. +- [x] Add contract tests for supported/unsupported platforms, wrong target, malformed schema, SHA/size mismatch, dirty provenance, version mismatch, and preserving the previous install. +- [ ] Document offline/manual asset installation and exact requirements (`curl`, Python 3 standard library, a supported shell); Rust is not an install-time dependency. +- [x] Re-key the version-dependent WASM conformance cache hashes for 0.6.2; the three regenerated diffs change only cache hashes, not answers/confidence/outcomes. + +### Phase 2 — Release provenance and runbook + +- [x] Strengthen release-set verification to require clean source, tag/version agreement, one commit/lockfile/dependency tree across all targets, and a complete matrix. +- [x] Verify the checksum manifest itself against every staged artifact and reject omitted, duplicate, or unexpected entries. +- [ ] Add a reproducible release runbook using GitForge as gate authority, Fedora for heavy release builds, checksum/attestation verification, draft upload, clean consumer install, then publication. +- [x] Add an update/rollback contract: explicit version pin, verify-before-replace, retain prior binary or provide a one-command reinstall of the previous pinned version. +- [x] Document signed provenance/immutable release options and the limitation of unsigned JSON SHA records. + +### Phase 3 — Clean release candidate + +- [ ] Merge the reviewed implementation through the normal PR path and obtain a GitForge gate receipt for the exact commit. +- [ ] Build a new patch release from a clean tagged commit; build every existing release target and record native versus link-only evidence honestly. +- [x] Validate the staged Linux x86_64 musl candidate in a fresh consumer container without Rust/Cargo; installation and E2E passed. +- [ ] Re-download the exact draft-release asset and repeat attestation/install/E2E before publication. +- [ ] Publish a draft release, verify the exact downloaded bytes and metadata, and publish only after the consumer test passes. + +### Phase 4 — Amortyx-Stack integration + +- [ ] Replace source clone/Cargo build in the recommended clean-host setup with the verified prebuilt installer and a pinned OpenCodifier version. +- [ ] Preserve the Amortyx-only path and an explicit offline/manual binary path. +- [ ] Validate the complete setup in a clean Fedora container/VM with no Rust toolchain, no provider secrets, and no service/cloud startup. + +## Rollback and operations + +- Keep prior release assets immutable and available. +- The installer validates a candidate in a temporary location before replacing `opencodifier`; failed validation never overwrites a working installation. +- Record installed version, target, source commit, and attestation digest in a local non-secret install receipt. +- Roll back by reinstalling the prior pinned version. Do not delete user config, local model artifacts, or state during binary updates. +- Release publication is separate from merge: a green code gate does not prove all cross-platform binaries were runtime tested. + +## Research references + +- OpenCodifier's release assets and per-target attestations: [GitHub releases](https://github.com/aliasfoxkde/OpenCodifier/releases). +- Repository-defined release matrix and attestation contract: `justfile`, `scripts/generate_attestation.py`, `scripts/generate_release_notes.py`, `.gitforge.yml`, and `docs/RELEASE_NOTES_TEMPLATE.md`. +- GitHub recommends immutable releases to prevent released tags/assets from changing and describes release attestations as a verifiable association between tag, source commit, and assets: [Immutable releases](https://docs.github.com/en/code-security/concepts/supply-chain-security/immutable-releases). +- GitHub Actions build attestations record repository, workflow, commit, event, and artifact provenance; these provide stronger provenance than a self-generated checksum record when generated and verified through the configured platform: [Artifact attestations](https://docs.github.com/en/actions/concepts/security/artifact-attestations). +- Current GitHub-hosted runners include native Linux ARM64 and distinct macOS Intel/Apple Silicon labels; actual runner availability and policy must be checked when adding CI target lanes: [Choosing a runner](https://docs.github.com/en/actions/how-tos/write-workflows/choose-where-workflows-run/choose-the-runner-for-a-job). + +## Current handoff + +The worktree/branch for implementation is `codex/prebuilt-release-installer-20261029` in `/var/tmp/opencodifier-prebuilt-release-20261029`, based on current GitHub `origin/main` at `dacddc6`. A local `main` checkout is seven commits ahead of that mirror tip and has an unrelated untracked benchmark file; neither is included in this worktree. diff --git a/fixtures/conformance/abstain-no-evidence.json b/fixtures/conformance/abstain-no-evidence.json index b5f02bb..c158ed0 100644 --- a/fixtures/conformance/abstain-no-evidence.json +++ b/fixtures/conformance/abstain-no-evidence.json @@ -66,7 +66,7 @@ }, "key": { "kind": "text", - "value": "6f33820831734b563226a77f856ed5d347a0d576087e5281c686b380d32a8d02" + "value": "d461e999c326422b72330590fe7ff507b9c973488ac06eb8ce9010f192835f63" } }, "node": "cache" diff --git a/fixtures/conformance/choice-lexical-accept.json b/fixtures/conformance/choice-lexical-accept.json index 7c124ba..5deb100 100644 --- a/fixtures/conformance/choice-lexical-accept.json +++ b/fixtures/conformance/choice-lexical-accept.json @@ -58,7 +58,7 @@ }, "key": { "kind": "text", - "value": "dc3d4b7f46c45042998eea736ee198736ceb0720942133f4adc1312c92cd38ef" + "value": "613d224b49c5d27306b06624e8c7fd1e085904dd7f6cd2a464a12d7ce4d01cb8" } }, "node": "cache" diff --git a/fixtures/conformance/relational-proof.json b/fixtures/conformance/relational-proof.json index f198d62..cb912ec 100644 --- a/fixtures/conformance/relational-proof.json +++ b/fixtures/conformance/relational-proof.json @@ -62,7 +62,7 @@ }, "key": { "kind": "text", - "value": "144bc3b3a6e810ccf95590617e00affc524c15887ff4e2b80ed977da88d386f6" + "value": "8e668376ebcd0671fe32de35ebc9e742d5877de4095872ba08350a7f3256711d" } }, "node": "cache" diff --git a/justfile b/justfile index 94a9c11..d4b9869 100644 --- a/justfile +++ b/justfile @@ -30,6 +30,8 @@ lint: test: cargo test --workspace cargo test -p opencodifier-model --features llamacpp,tokenizers + sh -n scripts/install-prebuilt.sh + python3 -m unittest discover -s tests -p 'test_*.py' -v # Build and test documentation examples. doc: @@ -473,6 +475,12 @@ release-notes: --force \ "${args[@]}" +# Fail closed before publication: release tag/commit/version, clean source, +# complete target set, identical dependencies/toolchain, generated notes, +# and checksums must all agree. +release-set-verify: + python3 scripts/verify_release_set.py + # Sync the benchmark board CSV into the site deploy directory. Run after any # `runner/board_csv.py` regeneration and before `wrangler pages deploy site` # so the live board page serves the same rows the docs of record do. diff --git a/scripts/install-prebuilt.sh b/scripts/install-prebuilt.sh new file mode 100755 index 0000000..e88703e --- /dev/null +++ b/scripts/install-prebuilt.sh @@ -0,0 +1,96 @@ +#!/bin/sh +# Download and verify an OpenCodifier release binary. No Rust toolchain needed. +set -eu + +VERSION=${OPENCODIFIER_VERSION:-v0.6.2} +INSTALL_DIR=${OPENCODIFIER_INSTALL_DIR:-${HOME:?HOME must be set}/.local/bin} +RELEASE_BASE_URL=${OPENCODIFIER_RELEASE_BASE_URL:-https://github.com/aliasfoxkde/OpenCodifier/releases/download} + +usage() { + cat <<'USAGE' +Usage: scripts/install-prebuilt.sh [--version VERSION] [--install-dir DIR] + +Downloads the prebuilt deterministic OpenCodifier CLI and its release +attestation, verifies both, checks the reported CLI version, and atomically +installs it as opencodifier. No Rust/Cargo installation or service startup. + +Options can also be set with OPENCODIFIER_VERSION and OPENCODIFIER_INSTALL_DIR. +USAGE +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --version) + [ "$#" -ge 2 ] || { usage >&2; exit 2; } + VERSION=$2 + shift 2 + ;; + --install-dir) + [ "$#" -ge 2 ] || { usage >&2; exit 2; } + INSTALL_DIR=$2 + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +case "$VERSION" in + v[0-9]*.[0-9]*.[0-9]*) VERSION_NUMBER=${VERSION#v} ;; + [0-9]*.[0-9]*.[0-9]*) VERSION_NUMBER=$VERSION; VERSION=v$VERSION ;; + *) echo "Version must be a stable numeric version, for example v0.6.2" >&2; exit 2 ;; +esac +case "$VERSION_NUMBER" in + *[!0-9.]*|.*|*..*|*.) echo "Invalid version: $VERSION" >&2; exit 2 ;; +esac +DOT_COUNT=$(printf '%s' "$VERSION_NUMBER" | tr -cd '.' | wc -c | tr -d ' ') +[ "$DOT_COUNT" -eq 2 ] || { echo "Version must have major.minor.patch form: $VERSION" >&2; exit 2; } + +command -v curl >/dev/null 2>&1 || { echo "curl is required to download the release" >&2; exit 1; } +command -v python3 >/dev/null 2>&1 || { echo "Python 3 is required for standard-library attestation verification; Rust is not required" >&2; exit 1; } +command -v uname >/dev/null 2>&1 || { echo "uname is required for platform detection" >&2; exit 1; } + +OS=$(uname -s) +ARCH=$(uname -m) +case "$OS:$ARCH" in + Linux:x86_64|Linux:amd64) TARGET=x86_64-unknown-linux-musl ;; + Linux:aarch64|Linux:arm64) TARGET=aarch64-unknown-linux-gnu ;; + Darwin:x86_64|Darwin:amd64) TARGET=x86_64-apple-darwin ;; + Darwin:arm64|Darwin:aarch64) TARGET=aarch64-apple-darwin ;; + *) echo "No first-party prebuilt CLI installer target for $OS/$ARCH" >&2; exit 1 ;; +esac + +ASSET="opencodifier-${VERSION_NUMBER}-${TARGET}" +URL="${RELEASE_BASE_URL%/}/${VERSION}/${ASSET}" +TMP_ROOT=${TMPDIR:-/tmp} +TMP_DIR=$(mktemp -d "$TMP_ROOT/opencodifier-install.XXXXXX") +cleanup() { rm -rf "$TMP_DIR"; } +trap cleanup EXIT HUP INT TERM + +printf 'Downloading OpenCodifier %s for %s\n' "$VERSION_NUMBER" "$TARGET" +curl --fail --location --silent --show-error --retry 3 --connect-timeout 15 --max-time 180 "$URL" -o "$TMP_DIR/opencodifier" +curl --fail --location --silent --show-error --retry 3 --connect-timeout 15 --max-time 60 "${URL}.attestation.json" -o "$TMP_DIR/attestation.json" +python3 "$(dirname "$0")/verify_prebuilt_attestation.py" "$TMP_DIR/attestation.json" "$TMP_DIR/opencodifier" "$TARGET" +chmod 755 "$TMP_DIR/opencodifier" +REPORTED_VERSION=$("$TMP_DIR/opencodifier" --version) +EXPECTED_VERSION="opencodifier $VERSION_NUMBER" +if [ "$REPORTED_VERSION" != "$EXPECTED_VERSION" ]; then + echo "Binary reports '$REPORTED_VERSION'; expected '$EXPECTED_VERSION'" >&2 + exit 1 +fi + +mkdir -p "$INSTALL_DIR" +TEMP_INSTALL="$INSTALL_DIR/.opencodifier.$$" +trap 'rm -f "$TEMP_INSTALL"; cleanup' EXIT HUP INT TERM +cp "$TMP_DIR/opencodifier" "$TEMP_INSTALL" +chmod 755 "$TEMP_INSTALL" +mv -f "$TEMP_INSTALL" "$INSTALL_DIR/opencodifier" +printf 'Installed %s to %s/opencodifier\n' "$REPORTED_VERSION" "$INSTALL_DIR" +printf 'No service was started. Add %s to PATH if needed.\n' "$INSTALL_DIR" diff --git a/scripts/verify_prebuilt_attestation.py b/scripts/verify_prebuilt_attestation.py new file mode 100755 index 0000000..7e03006 --- /dev/null +++ b/scripts/verify_prebuilt_attestation.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Validate an OpenCodifier release binary against its adjacent JSON attestation.""" + +from __future__ import annotations + +import hashlib +import json +import pathlib +import re +import sys + + +def fail(message: str) -> int: + print(f"FAIL {message}", file=sys.stderr) + return 1 + + +def main() -> int: + if len(sys.argv) != 4: + return fail("usage: verify_prebuilt_attestation.py ATTESTATION BINARY TARGET") + attestation_path = pathlib.Path(sys.argv[1]) + binary_path = pathlib.Path(sys.argv[2]) + expected_target = sys.argv[3] + try: + document = json.loads(attestation_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + return fail(f"cannot read valid attestation: {exc}") + if not isinstance(document, dict) or document.get("schema_version") != "opencodifier.attestation/1": + return fail("unsupported attestation schema") + + artifact = document.get("artifact") + source = document.get("source") + build = document.get("build") + if not all(isinstance(value, dict) for value in (artifact, source, build)): + return fail("attestation is missing artifact, source, or build objects") + assert isinstance(artifact, dict) and isinstance(source, dict) and isinstance(build, dict) + + if artifact.get("name") != "opencodifier": + return fail("attestation does not describe the opencodifier CLI") + if artifact.get("target") != expected_target: + return fail(f"target mismatch: expected {expected_target}, got {artifact.get('target')!r}") + digest = artifact.get("sha256") + if not isinstance(digest, str) or not re.fullmatch(r"[0-9a-f]{64}", digest): + return fail("attestation contains an invalid SHA-256 digest") + if source.get("working_tree_clean") is not True or source.get("dirty_path_count") != 0: + return fail("release was not built from a clean source tree") + commit = source.get("commit") + if not isinstance(commit, str) or not re.fullmatch(r"[0-9a-f]{40}", commit): + return fail("attestation contains an invalid source commit") + lock_digest = build.get("cargo_lock_sha256") + if not isinstance(lock_digest, str) or not re.fullmatch(r"[0-9a-f]{64}", lock_digest): + return fail("attestation contains an invalid Cargo.lock digest") + if not binary_path.is_file(): + return fail(f"binary not found: {binary_path}") + + actual_size = binary_path.stat().st_size + if artifact.get("bytes") != actual_size: + return fail(f"size mismatch: attestation says {artifact.get('bytes')!r}, file is {actual_size}") + hasher = hashlib.sha256() + with binary_path.open("rb") as binary: + for chunk in iter(lambda: binary.read(1024 * 1024), b""): + hasher.update(chunk) + actual_digest = hasher.hexdigest() + if digest != actual_digest: + return fail(f"SHA-256 mismatch: expected {digest}, got {actual_digest}") + print(f"PASS {binary_path.name}: target={expected_target} sha256={actual_digest} source={commit[:12]}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify_release_set.py b/scripts/verify_release_set.py new file mode 100644 index 0000000..b42b2d9 --- /dev/null +++ b/scripts/verify_release_set.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""Fail closed unless a staged release is complete, clean, and single-source.""" +from __future__ import annotations + +import json +import hashlib +import pathlib +import re +import subprocess +import sys + +ROOT = pathlib.Path(__file__).resolve().parents[1] + + +def run(*args: str) -> str: + proc = subprocess.run(args, cwd=ROOT, capture_output=True, text=True, check=False) + if proc.returncode: + raise RuntimeError(proc.stderr.strip() or proc.stdout.strip()) + return proc.stdout.strip() + + +def main() -> int: + try: + metadata = json.loads(run("cargo", "metadata", "--no-deps", "--format-version", "1")) + version = next(p["version"] for p in metadata["packages"] if p["name"] == "opencodifier-cli") + tag = f"v{version}" + head = run("git", "rev-parse", "HEAD") + tag_ref = subprocess.run( + ["git", "show-ref", "--verify", "--quiet", f"refs/tags/{tag}"], + cwd=ROOT, check=False, + ) + if tag_ref.returncode: + raise RuntimeError(f"release tag {tag} does not exist") + tagged = run("git", "rev-parse", f"{tag}^{{commit}}") + if head != tagged: + raise RuntimeError(f"tag {tag} points to {tagged}, not HEAD {head}") + if run("git", "status", "--porcelain", "--untracked-files=all"): + raise RuntimeError("working tree is dirty; release artifacts must be built from a clean checkout") + + justfile = (ROOT / "justfile").read_text(encoding="utf-8") + target_match = re.search(r"^release_targets\s*:=\s*'([^']+)'", justfile, re.MULTILINE) + if not target_match: + raise RuntimeError("cannot read release_targets from justfile") + expected_targets = set(target_match.group(1).split()) + stage_dirs = sorted((ROOT / "dist").glob(f"v{version}-*")) + found: dict[str, tuple[pathlib.Path, pathlib.Path, dict[str, object]]] = {} + for stage in stage_dirs: + for attestation_path in stage.glob("*.attestation.json"): + binary_path = pathlib.Path(str(attestation_path)[:-len(".attestation.json")]) + check = subprocess.run( + [sys.executable, str(ROOT / "scripts" / "generate_attestation.py"), + "--check", str(attestation_path), str(binary_path)], + cwd=ROOT, capture_output=True, text=True, check=False, + ) + if check.returncode: + raise RuntimeError(check.stderr.strip() or check.stdout.strip()) + document = json.loads(attestation_path.read_text(encoding="utf-8")) + artifact = document["artifact"] + source = document["source"] + target = artifact["target"] + if target in found: + raise RuntimeError(f"duplicate attestation for target {target}") + if source.get("commit") != head: + raise RuntimeError(f"{target}: source commit does not match release HEAD") + if source.get("working_tree_clean") is not True or source.get("dirty_path_count") != 0: + raise RuntimeError(f"{target}: source attestation reports a dirty tree") + if stage.name != f"v{version}-{target}": + raise RuntimeError(f"{target}: artifact is staged in unexpected directory {stage.name}") + found[target] = (binary_path, attestation_path, document) + + missing = expected_targets - set(found) + extra = set(found) - expected_targets + if missing or extra: + raise RuntimeError(f"incomplete release target matrix; missing={sorted(missing)} extra={sorted(extra)}") + + baseline: tuple[object, ...] | None = None + for target, (_, _, document) in sorted(found.items()): + build = document["build"] + current = (build.get("cargo_lock_sha256"), build.get("cargo_tree_sha256"), + build.get("rustc"), build.get("cargo")) + if baseline is None: + baseline = current + elif current != baseline: + raise RuntimeError(f"{target}: build toolchain/dependency provenance differs from other targets") + notes = ROOT / "dist" / "RELEASE_NOTES.md" + checksums = ROOT / "dist" / "sha256sums.txt" + if not notes.is_file() or not checksums.is_file(): + raise RuntimeError("generate release notes and checksum manifest before publishing") + checksum_rows: dict[str, str] = {} + for line in checksums.read_text(encoding="utf-8").splitlines(): + parts = line.split(maxsplit=1) + if len(parts) != 2 or not re.fullmatch(r"[0-9a-f]{64}", parts[0]): + raise RuntimeError(f"malformed checksum row: {line!r}") + name = parts[1].lstrip("* ") + if name in checksum_rows: + raise RuntimeError(f"duplicate checksum entry: {name}") + checksum_rows[name] = parts[0] + expected_checksums: dict[str, str] = {} + for binary_path, _, document in found.values(): + digest = hashlib.sha256(binary_path.read_bytes()).hexdigest() + if digest != document["artifact"]["sha256"]: + raise RuntimeError(f"{binary_path.name}: checksum differs from its attestation") + expected_checksums[binary_path.name] = digest + if checksum_rows != expected_checksums: + raise RuntimeError("sha256sums.txt entries do not exactly match the staged binaries") + notes_text = notes.read_text(encoding="utf-8") + if f"# OpenCodifier {tag}" not in notes_text or head not in notes_text: + raise RuntimeError("release notes must name the exact tag and full commit") + if "" in notes_text or "" in notes_text or "" in notes_text: + raise RuntimeError("release notes contain unfilled evidence fields") + print(f"PASS {tag}: {len(found)} targets; clean source {head}; unified lock/toolchain; notes and checksums present") + except (OSError, StopIteration, KeyError, TypeError, json.JSONDecodeError, RuntimeError) as error: + print(f"FAIL {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_install_prebuilt.py b/tests/test_install_prebuilt.py new file mode 100644 index 0000000..e12d022 --- /dev/null +++ b/tests/test_install_prebuilt.py @@ -0,0 +1,152 @@ +"""Integration contract tests for the no-Rust prebuilt installer.""" +from __future__ import annotations + +import hashlib +import http.server +import json +import os +import pathlib +import socketserver +import subprocess +import tempfile +import threading +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +INSTALLER = ROOT / "scripts" / "install-prebuilt.sh" +VERSION = "9.8.7" +TARGET = "x86_64-unknown-linux-musl" +BINARY = ( + "#!/bin/sh\n" + "[ \"${1:-}\" = --version ] && printf 'opencodifier %s\\n' '9.8.7'\n" +).encode() + + +def attestation(binary: bytes = BINARY) -> bytes: + document = { + "schema_version": "opencodifier.attestation/1", + "artifact": { + "name": "opencodifier", + "target": TARGET, + "sha256": hashlib.sha256(binary).hexdigest(), + "bytes": len(binary), + }, + "source": { + "commit": "c" * 40, + "working_tree_clean": True, + "dirty_path_count": 0, + }, + "build": {"cargo_lock_sha256": "d" * 64}, + } + return json.dumps(document).encode() + + +class QuietHandler(http.server.SimpleHTTPRequestHandler): + def log_message(self, format: str, *args: object) -> None: + pass + + +class InstallPrebuiltTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.temp = tempfile.TemporaryDirectory() + cls.release_root = pathlib.Path(cls.temp.name) + release_dir = cls.release_root / f"v{VERSION}" + release_dir.mkdir() + cls.asset_name = f"opencodifier-{VERSION}-{TARGET}" + (release_dir / cls.asset_name).write_bytes(BINARY) + (release_dir / f"{cls.asset_name}.attestation.json").write_bytes(attestation()) + + handler = lambda *args, **kwargs: QuietHandler( # noqa: E731 + *args, directory=str(cls.release_root), **kwargs + ) + cls.server = socketserver.TCPServer(("127.0.0.1", 0), handler) + cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True) + cls.thread.start() + cls.base_url = f"http://127.0.0.1:{cls.server.server_address[1]}" + + @classmethod + def tearDownClass(cls) -> None: + cls.server.shutdown() + cls.server.server_close() + cls.thread.join(timeout=3) + cls.temp.cleanup() + + def setUp(self) -> None: + self.install_root = tempfile.TemporaryDirectory() + self.install_dir = pathlib.Path(self.install_root.name) / "bin" + + def tearDown(self) -> None: + self.install_root.cleanup() + + def run_installer(self, path: pathlib.Path | None = None, + version: str = f"v{VERSION}", + env_overrides: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]: + env = os.environ.copy() + env["OPENCODIFIER_RELEASE_BASE_URL"] = self.base_url + if env_overrides: + env.update(env_overrides) + args = ["sh", str(INSTALLER), "--version", version, + "--install-dir", str(path or self.install_dir)] + return subprocess.run(args, env=env, check=False, capture_output=True, text=True) + + def test_installs_verified_binary_without_cargo(self) -> None: + env_path = "/usr/bin:/bin" + result = self.run_installer(env_overrides={"PATH": env_path}) + self.assertEqual(result.returncode, 0, result.stderr) + installed = self.install_dir / "opencodifier" + self.assertEqual(installed.read_bytes(), BINARY) + self.assertTrue(os.access(installed, os.X_OK)) + self.assertIn("No service was started", result.stdout) + + def test_bad_digest_preserves_existing_install(self) -> None: + old_binary = b"existing usable binary" + self.install_dir.mkdir() + installed = self.install_dir / "opencodifier" + installed.write_bytes(old_binary) + asset = self.release_root / f"v{VERSION}" / self.asset_name + asset.write_bytes(b"X" * len(BINARY)) + try: + result = self.run_installer() + self.assertNotEqual(result.returncode, 0) + self.assertIn("SHA-256 mismatch", result.stderr) + self.assertEqual(installed.read_bytes(), old_binary) + finally: + asset.write_bytes(BINARY) + + def test_runtime_version_mismatch_does_not_install(self) -> None: + asset = self.release_root / f"v{VERSION}" / self.asset_name + wrong = BINARY.replace(b"9.8.7", b"9.8.6") + asset.write_bytes(wrong) + attestation_path = asset.with_name(f"{asset.name}.attestation.json") + attestation_path.write_bytes(attestation(wrong)) + try: + result = self.run_installer() + self.assertNotEqual(result.returncode, 0) + self.assertIn("Binary reports", result.stderr) + self.assertFalse((self.install_dir / "opencodifier").exists()) + finally: + asset.write_bytes(BINARY) + attestation_path.write_bytes(attestation()) + + def test_unsupported_host_fails_before_download_or_install(self) -> None: + with tempfile.TemporaryDirectory() as fake_bin: + fake_uname = pathlib.Path(fake_bin) / "uname" + fake_uname.write_text("#!/bin/sh\n[ \"${1:-}\" = -s ] && echo Plan9 || echo strange\n") + fake_uname.chmod(0o755) + result = self.run_installer(env_overrides={ + "PATH": f"{fake_bin}:{os.environ['PATH']}" + }) + self.assertNotEqual(result.returncode, 0) + self.assertIn("No first-party prebuilt CLI installer target", result.stderr) + self.assertFalse((self.install_dir / "opencodifier").exists()) + + def test_rejects_non_semver_release_before_download(self) -> None: + result = self.run_installer(version="v9.8.7.1") + self.assertNotEqual(result.returncode, 0) + self.assertIn("major.minor.patch", result.stderr) + self.assertFalse((self.install_dir / "opencodifier").exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_prebuilt_attestation.py b/tests/test_prebuilt_attestation.py new file mode 100644 index 0000000..e94a05d --- /dev/null +++ b/tests/test_prebuilt_attestation.py @@ -0,0 +1,88 @@ +"""Contract tests for verifying downloaded OpenCodifier release binaries.""" +from __future__ import annotations + +import hashlib +import json +import pathlib +import subprocess +import tempfile +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +VERIFIER = ROOT / "scripts" / "verify_prebuilt_attestation.py" +TARGET = "x86_64-unknown-linux-musl" +BINARY = b"fixture executable bytes" + + +def fixture() -> dict[str, object]: + return { + "schema_version": "opencodifier.attestation/1", + "artifact": { + "name": "opencodifier", + "target": TARGET, + "sha256": hashlib.sha256(BINARY).hexdigest(), + "bytes": len(BINARY), + }, + "source": { + "commit": "a" * 40, + "working_tree_clean": True, + "dirty_path_count": 0, + }, + "build": {"cargo_lock_sha256": "b" * 64}, + } + + +class PrebuiltAttestationTests(unittest.TestCase): + def verify(self, document: dict[str, object], binary: bytes = BINARY, + target: str = TARGET) -> subprocess.CompletedProcess[str]: + with tempfile.TemporaryDirectory() as temp: + root = pathlib.Path(temp) + attestation = root / "asset.attestation.json" + artifact = root / "asset" + attestation.write_text(json.dumps(document), encoding="utf-8") + artifact.write_bytes(binary) + return subprocess.run( + ["python3", str(VERIFIER), str(attestation), str(artifact), target], + check=False, capture_output=True, text=True, + ) + + def test_accepts_matching_clean_artifact(self) -> None: + result = self.verify(fixture()) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("PASS", result.stdout) + + def test_rejects_platform_mismatch(self) -> None: + result = self.verify(fixture(), target="aarch64-unknown-linux-gnu") + self.assertNotEqual(result.returncode, 0) + self.assertIn("target mismatch", result.stderr) + + def test_rejects_digest_mismatch(self) -> None: + result = self.verify(fixture(), binary=b"X" * len(BINARY)) + self.assertNotEqual(result.returncode, 0) + self.assertIn("SHA-256 mismatch", result.stderr) + + def test_rejects_size_mismatch(self) -> None: + document = fixture() + document["artifact"]["bytes"] += 1 # type: ignore[index] + result = self.verify(document) + self.assertNotEqual(result.returncode, 0) + self.assertIn("size mismatch", result.stderr) + + def test_rejects_dirty_source(self) -> None: + document = fixture() + document["source"]["working_tree_clean"] = False # type: ignore[index] + document["source"]["dirty_path_count"] = 1 # type: ignore[index] + result = self.verify(document) + self.assertNotEqual(result.returncode, 0) + self.assertIn("clean source tree", result.stderr) + + def test_rejects_unknown_schema(self) -> None: + document = fixture() + document["schema_version"] = "opencodifier.attestation/99" + result = self.verify(document) + self.assertNotEqual(result.returncode, 0) + self.assertIn("unsupported attestation schema", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_release_set.py b/tests/test_release_set.py new file mode 100644 index 0000000..787642a --- /dev/null +++ b/tests/test_release_set.py @@ -0,0 +1,176 @@ +"""Contract tests for the release-set command's fail-closed behavior.""" +from __future__ import annotations + +import contextlib +import hashlib +import importlib.util +import io +import json +import pathlib +import shutil +import subprocess +import tempfile +import unittest +from unittest.mock import patch + +SCRIPT = pathlib.Path(__file__).resolve().parents[1] / "scripts" / "verify_release_set.py" +HEAD = "a" * 40 +VERSION = "0.6.2" + +SPEC = importlib.util.spec_from_file_location("verify_release_set", SCRIPT) +assert SPEC is not None and SPEC.loader is not None +VERIFY = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(VERIFY) + + +class ReleaseSetTests(unittest.TestCase): + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory() + self.root = pathlib.Path(self.temp.name) + (self.root / "dist").mkdir() + (self.root / "scripts").mkdir() + (self.root / "scripts" / "generate_attestation.py").write_text("# mocked boundary\n") + (self.root / "justfile").write_text( + "release_targets := 'target-a target-b'\n", encoding="utf-8" + ) + self.tag_exists = True + self.status = "" + self.tagged_commit = HEAD + self._add_target("target-a") + self._add_target("target-b") + self._write_release_notes() + self._write_checksums() + + def tearDown(self) -> None: + self.temp.cleanup() + + def _add_target(self, target: str, *, commit: str = HEAD, + tree_digest: str = "c" * 64) -> None: + stage = self.root / "dist" / f"v{VERSION}-{target}" + stage.mkdir(parents=True, exist_ok=True) + asset = stage / f"opencodifier-{VERSION}-{target}" + content = f"fixture binary for {target}".encode() + asset.write_bytes(content) + attestation = { + "artifact": { + "target": target, + "sha256": hashlib.sha256(content).hexdigest(), + }, + "source": { + "commit": commit, + "working_tree_clean": True, + "dirty_path_count": 0, + }, + "build": { + "cargo_lock_sha256": "b" * 64, + "cargo_tree_sha256": tree_digest, + "rustc": "rustc 1.99.1", + "cargo": "cargo 1.99.1", + }, + } + (stage / f"{asset.name}.attestation.json").write_text( + json.dumps(attestation), encoding="utf-8" + ) + + def _write_release_notes(self, text: str | None = None) -> None: + notes = text or f"# OpenCodifier v{VERSION}\nSource commit: {HEAD}\n" + (self.root / "dist" / "RELEASE_NOTES.md").write_text(notes, encoding="utf-8") + + def _write_checksums(self, *, omit: str | None = None) -> None: + rows = [] + for asset in sorted((self.root / "dist").glob(f"v{VERSION}-*/*")): + if asset.name.endswith(".attestation.json") or asset.name == omit: + continue + rows.append(f"{hashlib.sha256(asset.read_bytes()).hexdigest()} {asset.name}") + (self.root / "dist" / "sha256sums.txt").write_text( + "\n".join(rows) + "\n", encoding="utf-8" + ) + + def _run_command(self, *args: str) -> str: + if args[:2] == ("cargo", "metadata"): + return json.dumps({"packages": [{"name": "opencodifier-cli", "version": VERSION}]}) + if args[:2] == ("git", "rev-parse"): + if args[2] == "HEAD": + return HEAD + if args[2] == f"v{VERSION}^{{commit}}": + return self.tagged_commit + if args[:2] == ("git", "status"): + return self.status + raise AssertionError(f"unexpected command: {args!r}") + + def _run_process(self, args: list[str], **_: object) -> subprocess.CompletedProcess[str]: + if args[:3] == ["git", "show-ref", "--verify"]: + return subprocess.CompletedProcess(args, 0 if self.tag_exists else 1) + if args[1:3] == [str(self.root / "scripts" / "generate_attestation.py"), "--check"]: + return subprocess.CompletedProcess(args, 0, stdout="", stderr="") + raise AssertionError(f"unexpected process: {args!r}") + + def _verify(self) -> tuple[int, str, str]: + out, err = io.StringIO(), io.StringIO() + with patch.object(VERIFY, "ROOT", self.root), \ + patch.object(VERIFY, "run", side_effect=self._run_command), \ + patch.object(VERIFY.subprocess, "run", side_effect=self._run_process), \ + contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + result = VERIFY.main() + return result, out.getvalue(), err.getvalue() + + def test_complete_clean_release_set_passes(self) -> None: + result, out, err = self._verify() + self.assertEqual(result, 0, err) + self.assertIn("2 targets", out) + + def test_missing_tag_fails_closed(self) -> None: + self.tag_exists = False + result, _, err = self._verify() + self.assertEqual(result, 1) + self.assertIn("release tag v0.6.2 does not exist", err) + + def test_tag_must_point_at_head(self) -> None: + self.tagged_commit = "d" * 40 + result, _, err = self._verify() + self.assertEqual(result, 1) + self.assertIn("not HEAD", err) + + def test_dirty_checkout_fails_closed(self) -> None: + self.status = " M Cargo.toml" + result, _, err = self._verify() + self.assertEqual(result, 1) + self.assertIn("working tree is dirty", err) + + def test_missing_target_fails_closed(self) -> None: + shutil.rmtree(self.root / "dist" / f"v{VERSION}-target-b") + result, _, err = self._verify() + self.assertEqual(result, 1) + self.assertIn("incomplete release target matrix", err) + + def test_target_attestation_must_match_release_commit(self) -> None: + stage = self.root / "dist" / f"v{VERSION}-target-a" + path = next(stage.glob("*.attestation.json")) + document = json.loads(path.read_text(encoding="utf-8")) + document["source"]["commit"] = "d" * 40 + path.write_text(json.dumps(document), encoding="utf-8") + result, _, err = self._verify() + self.assertEqual(result, 1) + self.assertIn("source commit does not match release HEAD", err) + + def test_target_toolchain_and_dependency_tree_must_match(self) -> None: + self._add_target("target-b", tree_digest="e" * 64) + result, _, err = self._verify() + self.assertEqual(result, 1) + self.assertIn("build toolchain/dependency provenance differs", err) + + def test_checksum_manifest_must_cover_exact_assets(self) -> None: + self._write_checksums(omit="opencodifier-0.6.2-target-b") + result, _, err = self._verify() + self.assertEqual(result, 1) + self.assertIn("sha256sums.txt entries do not exactly match", err) + + def test_release_notes_must_name_commit_and_have_no_placeholders(self) -> None: + self._write_release_notes(f"# OpenCodifier v{VERSION}\n{HEAD}\n\n") + result, _, err = self._verify() + self.assertEqual(result, 1) + self.assertIn("release notes contain unfilled evidence fields", err) + + +if __name__ == "__main__": + unittest.main()