From 98db2d3f84f700ba4ac29083c96192b72b5f7a58 Mon Sep 17 00:00:00 2001 From: Bartek Kus <7887446+bartekus@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:53:08 -0600 Subject: [PATCH] feat(007): tag-gated binary releases with SBOMs and attestations - specs/007: release-distribution spec (implementation in progress until the first live release is verified) - release.yml: fail-fast tag-vs-Cargo.toml version guard, five-triple build matrix (--locked), per-target .sha256 sidecar, CycloneDX SBOM (fail-closed on zero components), SLSA build-provenance attestation, idempotent publish with generated notes; actions SHA-pinned - install.sh: curl|sh installer; checksum verification required, provenance attestation best-effort with STATECRAFT_REQUIRE_ATTESTATION escalation; musl refused with a cargo install pointer - README: install section; status updated to reflect implemented 002-006 - .derived: recompiled shards, including the stagecraft-cli -> statecraft-cli by-package rename fallout --- ...tagecraft-cli.json => statecraft-cli.json} | 2 +- .../by-spec/001-cli-mcp-thesis.json | 2 +- .../by-spec/002-crate-scaffold.json | 2 +- .../by-spec/003-auth-api-client.json | 2 +- .../by-spec/004-governance-verbs.json | 2 +- .../by-spec/005-mcp-server.json | 2 +- .../by-spec/006-template-upgrade-verb.json | 2 +- .../by-spec/007-release-distribution.json | 49 ++++++ .../by-spec/001-cli-mcp-thesis.json | 2 +- .../by-spec/002-crate-scaffold.json | 2 +- .../by-spec/003-auth-api-client.json | 2 +- .../by-spec/004-governance-verbs.json | 2 +- .../spec-registry/by-spec/005-mcp-server.json | 2 +- .../by-spec/006-template-upgrade-verb.json | 2 +- .../by-spec/007-release-distribution.json | 36 ++++ .github/workflows/release.yml | 164 ++++++++++++++++++ README.md | 25 ++- install.sh | 137 +++++++++++++++ specs/007-release-distribution/spec.md | 128 ++++++++++++++ 19 files changed, 548 insertions(+), 17 deletions(-) rename .derived/codebase-index/by-package/{stagecraft-cli.json => statecraft-cli.json} (71%) create mode 100644 .derived/codebase-index/by-spec/007-release-distribution.json create mode 100644 .derived/spec-registry/by-spec/007-release-distribution.json create mode 100644 .github/workflows/release.yml create mode 100755 install.sh create mode 100644 specs/007-release-distribution/spec.md diff --git a/.derived/codebase-index/by-package/stagecraft-cli.json b/.derived/codebase-index/by-package/statecraft-cli.json similarity index 71% rename from .derived/codebase-index/by-package/stagecraft-cli.json rename to .derived/codebase-index/by-package/statecraft-cli.json index b830d5e..d47c821 100644 --- a/.derived/codebase-index/by-package/stagecraft-cli.json +++ b/.derived/codebase-index/by-package/statecraft-cli.json @@ -8,5 +8,5 @@ "version": "0.1.0" }, "schemaVersion": "1.1.0", - "shardHash": "5150ae59ab5b77d17d616c92d89619dc93ffab501fce31b711c357720a932467" + "shardHash": "24063d448ed63b33cb2698dc9ceb4575e508d1893ba652b600aa02ec7e9b30cc" } diff --git a/.derived/codebase-index/by-spec/001-cli-mcp-thesis.json b/.derived/codebase-index/by-spec/001-cli-mcp-thesis.json index 01718bf..88d6066 100644 --- a/.derived/codebase-index/by-spec/001-cli-mcp-thesis.json +++ b/.derived/codebase-index/by-spec/001-cli-mcp-thesis.json @@ -28,5 +28,5 @@ "specStatus": "approved" }, "schemaVersion": "1.1.0", - "shardHash": "6d5733b1b155ee7432d710a1558c55336777afbd6c2f325c54b709a207d33403" + "shardHash": "b43ccdb7944f7be531e44e3ddba623598f36ebb2654f363cb9c89b0921d181d4" } diff --git a/.derived/codebase-index/by-spec/002-crate-scaffold.json b/.derived/codebase-index/by-spec/002-crate-scaffold.json index 67f6f98..6ad8bbb 100644 --- a/.derived/codebase-index/by-spec/002-crate-scaffold.json +++ b/.derived/codebase-index/by-spec/002-crate-scaffold.json @@ -83,5 +83,5 @@ "specStatus": "approved" }, "schemaVersion": "1.1.0", - "shardHash": "c3dc2fca3ab2f6819289a8b5e27199169855bce3389d810dfd8b87d9fee7acc8" + "shardHash": "02af7cee124cbb9979d72cb8022a06bbc3632b672553c04b016e0fac8d6ecdf2" } diff --git a/.derived/codebase-index/by-spec/003-auth-api-client.json b/.derived/codebase-index/by-spec/003-auth-api-client.json index 5ce8c44..422c8e3 100644 --- a/.derived/codebase-index/by-spec/003-auth-api-client.json +++ b/.derived/codebase-index/by-spec/003-auth-api-client.json @@ -49,5 +49,5 @@ "specStatus": "approved" }, "schemaVersion": "1.1.0", - "shardHash": "322881dfb8679f2c3bcb478e841ddd668c73a599587eb7f55140b5a372e11c9b" + "shardHash": "aa4f92f23e82eeb882a12fbb6a165d505584d8e9ce454db397d3526a9dabd473" } diff --git a/.derived/codebase-index/by-spec/004-governance-verbs.json b/.derived/codebase-index/by-spec/004-governance-verbs.json index 030422f..de8be87 100644 --- a/.derived/codebase-index/by-spec/004-governance-verbs.json +++ b/.derived/codebase-index/by-spec/004-governance-verbs.json @@ -32,5 +32,5 @@ "specStatus": "approved" }, "schemaVersion": "1.1.0", - "shardHash": "08ad0ffb9d6ed4b83972d0a82befdb156e73abad7ace2fd4e87e93e7c895c8a9" + "shardHash": "ccc353fc6d80c1ef4980f693a287abdd6f42bdd8ed622864c779cd5ccbf1e740" } diff --git a/.derived/codebase-index/by-spec/005-mcp-server.json b/.derived/codebase-index/by-spec/005-mcp-server.json index 4a071f7..b789073 100644 --- a/.derived/codebase-index/by-spec/005-mcp-server.json +++ b/.derived/codebase-index/by-spec/005-mcp-server.json @@ -32,5 +32,5 @@ "specStatus": "approved" }, "schemaVersion": "1.1.0", - "shardHash": "1eae84bbae7e6a80af5ea54ff5c22132af97ca52a6faa540fe14e205f682cee4" + "shardHash": "b1ecba27b7769327ccab653070a957e5be1224e1fe0cdc86e0288a12c86d2b39" } diff --git a/.derived/codebase-index/by-spec/006-template-upgrade-verb.json b/.derived/codebase-index/by-spec/006-template-upgrade-verb.json index 4e4decc..afcdca9 100644 --- a/.derived/codebase-index/by-spec/006-template-upgrade-verb.json +++ b/.derived/codebase-index/by-spec/006-template-upgrade-verb.json @@ -32,5 +32,5 @@ "specStatus": "approved" }, "schemaVersion": "1.1.0", - "shardHash": "86726ee96b3b570bce68cddf8d6c1ef11caa5e5b2154a4999ddfd9c4f24d0bad" + "shardHash": "2270f95ef02870d652785f7eadc2bbec80b5c261adb92195c369af4b490eff08" } diff --git a/.derived/codebase-index/by-spec/007-release-distribution.json b/.derived/codebase-index/by-spec/007-release-distribution.json new file mode 100644 index 0000000..04aef8c --- /dev/null +++ b/.derived/codebase-index/by-spec/007-release-distribution.json @@ -0,0 +1,49 @@ +{ + "mapping": { + "dependsOn": [ + "002-crate-scaffold" + ], + "implementingPaths": [ + { + "path": ".github/workflows/release.yml", + "source": "spec-edge" + }, + { + "path": "install.sh", + "source": "spec-edge" + } + ], + "resolvedUnits": [ + { + "locations": [ + { + "file": ".github/workflows/release.yml" + } + ], + "ownership": true, + "sourceField": "establishes", + "unit": { + "kind": "file", + "path": ".github/workflows/release.yml" + } + }, + { + "locations": [ + { + "file": "install.sh" + } + ], + "ownership": true, + "sourceField": "establishes", + "unit": { + "kind": "file", + "path": "install.sh" + } + } + ], + "specId": "007-release-distribution", + "specStatus": "approved" + }, + "schemaVersion": "1.1.0", + "shardHash": "775d293277f36d26bd48854630315b34d9525eca17f5a4504b42528fc7d0deae" +} diff --git a/.derived/spec-registry/by-spec/001-cli-mcp-thesis.json b/.derived/spec-registry/by-spec/001-cli-mcp-thesis.json index cb27b73..48e4cfa 100644 --- a/.derived/spec-registry/by-spec/001-cli-mcp-thesis.json +++ b/.derived/spec-registry/by-spec/001-cli-mcp-thesis.json @@ -24,6 +24,6 @@ "summary": "The successor to OPC (the retired Tauri desktop cockpit): a single binary named statecraft that exposes the platform's governance verbs twice, as CLI subcommands for humans and as an MCP server for agents. The MCP face is the product's genuinely unique surface: any agent (Claude Code first) operates natively under Statecraft governance, requesting approvals, checking spec-code coupling, and triggering factory stages. This is milestone M4 in the Statecraft ladder; the spec records the thesis and the decided constraints so the repo is born governed ahead of its build.\n", "title": "statecraft-cli: one binary, two faces (CLI verbs + MCP server)" }, - "shardHash": "a58ad16200ff335e20b8daa248e375284e48b2c62b668b75ce5f9cbb211f4862", + "shardHash": "44bcacd7382fc9a9245569ebebc1e3037e4b64b9ae7e3acbe82b69b8f2fe452a", "specVersion": "1.1.0" } diff --git a/.derived/spec-registry/by-spec/002-crate-scaffold.json b/.derived/spec-registry/by-spec/002-crate-scaffold.json index 0926e1b..b56a668 100644 --- a/.derived/spec-registry/by-spec/002-crate-scaffold.json +++ b/.derived/spec-registry/by-spec/002-crate-scaffold.json @@ -36,6 +36,6 @@ "summary": "The Rust crate for the single binary named statecraft: clap-based command tree, layered configuration (flags > env > config file), structured output discipline (human tables on TTY, JSON with --output json), and a CI workflow (fmt, clippy -D warnings, test, release build). No network calls yet; spec 003 adds auth and the API client. After this spec, `cargo install --path .` yields a binary whose skeleton every later verb hangs off.\n", "title": "The statecraft binary: crate scaffold, config, CI" }, - "shardHash": "8576aef9f1f550842270168da67a5fc80a2c886fdbecac54781f3963a854f3d2", + "shardHash": "60231e5bb6a52376587bd94a8625242cd040ccc2de120f0f28500b53a3f8deeb", "specVersion": "1.1.0" } diff --git a/.derived/spec-registry/by-spec/003-auth-api-client.json b/.derived/spec-registry/by-spec/003-auth-api-client.json index 3e0fa16..3d6d14a 100644 --- a/.derived/spec-registry/by-spec/003-auth-api-client.json +++ b/.derived/spec-registry/by-spec/003-auth-api-client.json @@ -30,6 +30,6 @@ "summary": "The binary learns to authenticate against a Statecraft control plane and speak its API. Auth v1 is a browser-assisted session-cookie handoff (the control plane's chassis auth is cookie based, and the embedded rauthy exposes OIDC; the exact mechanism is DECIDE-AT-IMPLEMENTATION between OAuth device-flow-style polling and a localhost callback, constrained below). Tokens/cookies are stored in a 0600 credentials file, never in the config file. An api module gives every later verb a typed, authenticated request path with consistent error mapping.\n", "title": "Auth + control-plane API client" }, - "shardHash": "46b6c05e0ca45e03fc21761df69431c89464e6a11e5e9088c4f76bf97e65cd59", + "shardHash": "a4ebb7d479a77aff10fad99fd38045e42563fe690793de244ab403a0bcf9297c", "specVersion": "1.1.0" } diff --git a/.derived/spec-registry/by-spec/004-governance-verbs.json b/.derived/spec-registry/by-spec/004-governance-verbs.json index 0b441c9..d62974e 100644 --- a/.derived/spec-registry/by-spec/004-governance-verbs.json +++ b/.derived/spec-registry/by-spec/004-governance-verbs.json @@ -30,6 +30,6 @@ "summary": "The CLI face becomes useful: the stub commands from spec 002 gain real implementations over the API client, mirroring the control plane's tenants (statecraft spec 004), factory (spec 005), and fleet (spec 006) services. Every verb has a stable JSON output shape, because spec 005 exposes these same verbs as MCP tools and the JSON is the shared contract between both faces.\n", "title": "Governance verbs v1: tenants, stamps, fleet" }, - "shardHash": "c8282f2410f0072e4a10578af7d13cff12921806b154d05fe5144ef07cd12148", + "shardHash": "d4bbc4533f622959b2495d1d337e09754e840a698e52c3e26df3bba6d7a84a4b", "specVersion": "1.1.0" } diff --git a/.derived/spec-registry/by-spec/005-mcp-server.json b/.derived/spec-registry/by-spec/005-mcp-server.json index 70d3525..a37c105 100644 --- a/.derived/spec-registry/by-spec/005-mcp-server.json +++ b/.derived/spec-registry/by-spec/005-mcp-server.json @@ -25,6 +25,6 @@ "summary": "Milestone M4's core: `statecraft mcp` runs a Model Context Protocol server over stdio exposing the governance verbs as tools, so a coding agent (Claude Code first) operates under Statecraft governance natively: listing tenants, launching and watching stamps, inspecting and operating fleets, all with the same auth, the same guards, and the same JSON shapes as the CLI face. The MCP face is not a privileged side door: it calls the identical verb layer from spec 004, and destructive guards (explicit posture, confirm-name) pass through to the agent verbatim.\n", "title": "The MCP face: statecraft mcp (stdio server)" }, - "shardHash": "3b2d09c65623c532afd2e5329891a969ea1f4efe279772974e0bf832a6220252", + "shardHash": "9f949920b71fe9c60819a798101e42314e01354db95f5b3fcc98478976f13a4f", "specVersion": "1.1.0" } diff --git a/.derived/spec-registry/by-spec/006-template-upgrade-verb.json b/.derived/spec-registry/by-spec/006-template-upgrade-verb.json index 2303923..930fe2a 100644 --- a/.derived/spec-registry/by-spec/006-template-upgrade-verb.json +++ b/.derived/spec-registry/by-spec/006-template-upgrade-verb.json @@ -25,6 +25,6 @@ "summary": "The upgrade half of the 2026-07-14 packaging decision: templates stay small because the chassis ships as versioned npm packages (enrahitu spec 018), and upgrading a stamped app is a verb, not a migration project. `statecraft template upgrade`, run in a stamped app checkout, reads template.toml, bumps the chassis package pins, applies template-shipped codemods, runs the contract verify verb, and commits on a branch. The CLI orchestrates; all structure knowledge stays in the template and its packages. This verb is the boundary that keeps the CLI from ever becoming a build daemon.\n", "title": "statecraft template upgrade: chassis upgrades as a governed verb" }, - "shardHash": "5c1f7935e130ca8d1a42dfe060bf18f01e8a19f24be733760bc9d37cfc98f646", + "shardHash": "7ad323fce9c96006b9804b90069aa11eb436cdd447d7120cf09b524f0fb4375c", "specVersion": "1.1.0" } diff --git a/.derived/spec-registry/by-spec/007-release-distribution.json b/.derived/spec-registry/by-spec/007-release-distribution.json new file mode 100644 index 0000000..de0bc90 --- /dev/null +++ b/.derived/spec-registry/by-spec/007-release-distribution.json @@ -0,0 +1,36 @@ +{ + "record": { + "created": "2026-07-22", + "dependsOn": [ + "002-crate-scaffold" + ], + "establishes": [ + { + "kind": "file", + "path": ".github/workflows/release.yml" + }, + { + "kind": "file", + "path": "install.sh" + } + ], + "id": "007-release-distribution", + "implementation": "in-progress", + "sectionHeadings": [ + "007: Release distribution", + "1. Purpose", + "2. Territory", + "3. Behavior: the pipeline", + "4. Behavior: install.sh", + "5. Release procedure", + "6. Acceptance", + "7. Out of scope" + ], + "specPath": "specs/007-release-distribution/spec.md", + "status": "approved", + "summary": "Closes the gap between \"builds from source\" and \"installable product\": pushing a v tag builds a per-triple archive for the five supported targets, attaches checksums, a CycloneDX SBOM, and a SLSA build-provenance attestation to a GitHub Release, and install.sh (curl | sh) consumes the archives. Mirrors the spec-spine release pipeline (the family precedent this repo's own CI already consumes) plus the fail-fast version guard OPC's release workflow learned the hard way. No registry publishing: the crate is a binary product, not a library.\n", + "title": "Release distribution: tag-gated prebuilt binaries and installer" + }, + "shardHash": "905bd5059b673e380bf011d965d61dbc18be5ec2821715c5aa110e76b6cbdda1", + "specVersion": "1.1.0" +} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..38039ed --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,164 @@ +# Tag-gated prebuilt-binary release (spec 007). Pushing a `v*` tag builds a +# per-triple archive (with a .sha256 sidecar) for each of the five supported +# targets, generates a per-target CycloneDX SBOM and a SLSA build-provenance +# attestation, and attaches everything to the GitHub Release. install.sh +# (curl | sh) consumes the archives. +# +# The version guard dies fast (zero build minutes) when the pushed tag does +# not match the committed Cargo.toml version, so a release can never ship +# assets whose --version output disagrees with its envelope. +# +# Four triples build natively on a matching-arch runner; x86_64-apple-darwin +# cross-compiles on the Apple Silicon runner (Xcode ships the x86_64 SDK) +# because the Intel macos-13 runner is deprecated and queues badly. +# +# Actions are pinned to a full commit SHA with a version comment (same rule +# as ci.yml) so an upstream tag rewrite cannot alter a release build. +# Re-running a tag is idempotent: the publish step updates the same release. +name: release + +on: + push: + tags: ["v*"] + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + +jobs: + version-guard: + name: version guard + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - name: Tag matches the committed Cargo.toml version + run: | + set -euo pipefail + tag_version="${GITHUB_REF_NAME#v}" + crate_version="$(cargo metadata --no-deps --format-version 1 | jq -r '.packages[] | select(.name == "statecraft-cli") | .version')" + echo "tag=${GITHUB_REF_NAME} crate=${crate_version}" + if [ "$tag_version" != "$crate_version" ]; then + echo "::error::tag ${GITHUB_REF_NAME} does not match Cargo.toml version ${crate_version}; bump the version, merge, then retag" + exit 1 + fi + + build: + name: build / ${{ matrix.triple }} + needs: version-guard + strategy: + fail-fast: false + matrix: + include: + - { os: ubuntu-latest, triple: x86_64-unknown-linux-gnu } + - { os: ubuntu-24.04-arm, triple: aarch64-unknown-linux-gnu } + # x86_64-apple-darwin cross-compiles on the Apple Silicon runner. + - { os: macos-latest, triple: x86_64-apple-darwin } + - { os: macos-latest, triple: aarch64-apple-darwin } + - { os: windows-latest, triple: x86_64-pc-windows-msvc } + runs-on: ${{ matrix.os }} + # id-token + attestations are for the build-provenance attestation over + # each archive; nothing in this job writes repo contents. + permissions: + contents: read + id-token: write + attestations: write + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - name: Add target (no-op if native) + run: rustup target add ${{ matrix.triple }} + - name: Build release binary + run: cargo build --release --locked --bin statecraft --target ${{ matrix.triple }} + + - name: Stage archive contents + shell: bash + run: | + set -euo pipefail + EXT=""; [ "${{ runner.os }}" = "Windows" ] && EXT=".exe" + mkdir -p staging + cp "target/${{ matrix.triple }}/release/statecraft${EXT}" staging/ + cp LICENSE README.md staging/ + echo "ARCHIVE_BASE=statecraft-${GITHUB_REF_NAME}-${{ matrix.triple }}" >> "$GITHUB_ENV" + + - name: Package (tar.gz + sha256) for Unix + if: runner.os != 'Windows' + shell: bash + run: | + set -euo pipefail + tar -C staging -czf "${ARCHIVE_BASE}.tar.gz" . + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "${ARCHIVE_BASE}.tar.gz" > "${ARCHIVE_BASE}.tar.gz.sha256" + else + shasum -a 256 "${ARCHIVE_BASE}.tar.gz" > "${ARCHIVE_BASE}.tar.gz.sha256" + fi + cat "${ARCHIVE_BASE}.tar.gz.sha256" + echo "ARCHIVE_FILE=${ARCHIVE_BASE}.tar.gz" >> "$GITHUB_ENV" + + - name: Package (zip + sha256) for Windows + if: runner.os == 'Windows' + shell: pwsh + run: | + $base = $env:ARCHIVE_BASE + Compress-Archive -Path staging/* -DestinationPath "$base.zip" -Force + $hash = (Get-FileHash "$base.zip" -Algorithm SHA256).Hash.ToLower() + "$hash $base.zip" | Out-File -Encoding ascii -NoNewline "$base.zip.sha256" + Get-Content "$base.zip.sha256" + Add-Content -Path $env:GITHUB_ENV -Value "ARCHIVE_FILE=$base.zip" + + # Supply-chain evidence (spec 007 §3.4): per-target CycloneDX SBOM read + # from the committed Cargo.lock, then a provenance attestation whose + # subject is the archive itself. + - name: Generate per-target CycloneDX SBOM + uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 + with: + path: . + format: cyclonedx-json + output-file: ${{ env.ARCHIVE_BASE }}.cdx.json + upload-artifact: false + - name: Fail closed if the SBOM has no components + shell: bash + run: | + set -euo pipefail + n="$(jq '.components | length' "${ARCHIVE_BASE}.cdx.json")" + echo "SBOM components: $n" + if [ "$n" -eq 0 ]; then + echo "::error::refusing to ship an SBOM with zero components (cataloger regression?)" + exit 1 + fi + - name: Attest build provenance for the archive + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + with: + subject-path: ${{ env.ARCHIVE_FILE }} + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: archive-${{ matrix.triple }} + path: | + statecraft-*.tar.gz + statecraft-*.tar.gz.sha256 + statecraft-*.zip + statecraft-*.zip.sha256 + statecraft-*.cdx.json + if-no-files-found: error + + publish: + name: publish GitHub Release + needs: build + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + path: dist + pattern: archive-* + merge-multiple: true + - name: List release assets + run: ls -la dist + - name: Create or update the GitHub Release + uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1 + with: + files: dist/* + fail_on_unmatched_files: true + generate_release_notes: true diff --git a/README.md b/README.md index edf44b1..fcf5782 100644 --- a/README.md +++ b/README.md @@ -12,10 +12,27 @@ desktop app is retired, the governance verbs live on here. ## Status -Born governed, pre-code. This is milestone M4 in the Statecraft ladder; -the thesis and decided constraints (binary name, Rust, stdio MCP, -Apache-2.0, no TUI) live in `specs/001-cli-mcp-thesis/spec.md`. The crate -lands when M4 starts. +Milestone M4 in the Statecraft ladder, implemented: the crate scaffold +(002), auth + API client (003), the governance verbs (004), the MCP +stdio server (005), and the template upgrade verb (006). The thesis and +decided constraints (binary name, Rust, stdio MCP, Apache-2.0, no TUI) +live in `specs/001-cli-mcp-thesis/spec.md`. + +## Install + +Prebuilt binaries for macOS and Linux (spec 007): + +```sh +curl -fsSL https://raw.githubusercontent.com/statecrafting/statecraft-cli/main/install.sh | sh +``` + +Windows: download the `.zip` from the +[Releases](https://github.com/statecrafting/statecraft-cli/releases) +page. Every release archive ships a `.sha256` sidecar, a CycloneDX +SBOM, and a SLSA build-provenance attestation; the installer verifies +the checksum and (best-effort) the attestation. From source: +`cargo install --git https://github.com/statecrafting/statecraft-cli` +(rustls only, no OpenSSL). ## Governance diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..7e94ad2 --- /dev/null +++ b/install.sh @@ -0,0 +1,137 @@ +#!/bin/sh +# statecraft installer: curl -fsSL https://raw.githubusercontent.com/statecrafting/statecraft-cli/main/install.sh | sh +# +# Detects your platform/arch, downloads the matching release archive and its +# .sha256 sidecar from GitHub Releases, verifies the checksum, and drops the +# `statecraft` binary on your PATH. Spec 007 owns this file. +# +# Environment overrides: +# STATECRAFT_VERSION release tag to install (default: latest), e.g. v0.1.0 +# STATECRAFT_BIN_DIR install dir (default: ~/.local/bin, or /usr/local/bin if writable & in PATH) +# STATECRAFT_REQUIRE_ATTESTATION=1 hard-fail if the build-provenance attestation cannot be verified +# STATECRAFT_SKIP_ATTESTATION=1 skip the provenance check entirely (checksum still enforced) +# +# The .sha256 sidecar proves integrity; provenance verification (via `gh`) +# proves authenticity. musl-based Linux (e.g. Alpine) is refused with a +# pointer to `cargo install`, since the prebuilt Linux binaries are glibc-only. +# +# Windows: use the .zip from the Releases page (this script targets macOS/Linux). + +set -eu + +REPO="statecrafting/statecraft-cli" +BIN="statecraft" + +say() { printf 'statecraft: %s\n' "$1" >&2; } +die() { printf 'statecraft: error: %s\n' "$1" >&2; exit 1; } +have() { command -v "$1" >/dev/null 2>&1; } + +# --- pick a downloader ------------------------------------------------------- +if have curl; then + dl() { curl -fsSL "$1" -o "$2"; } + dl_stdout(){ curl -fsSL "$1"; } +elif have wget; then + dl() { wget -qO "$2" "$1"; } + dl_stdout(){ wget -qO - "$1"; } +else + die "need curl or wget on PATH" +fi + +# --- detect platform / arch -------------------------------------------------- +os="$(uname -s)" +arch="$(uname -m)" +case "$os" in + Darwin) plat="apple-darwin" ;; + Linux) + plat="unknown-linux-gnu" + # The prebuilt Linux archives are glibc-only. On musl (Alpine, etc.) a glibc + # binary fails at runtime with a cryptic dynamic-loader error, so refuse up + # front with an actionable message. + if { ldd --version 2>&1 | grep -qi musl; } \ + || [ -e /lib/ld-musl-x86_64.so.1 ] || [ -e /lib/ld-musl-aarch64.so.1 ]; then + die "musl libc detected (e.g. Alpine): the prebuilt binaries are glibc-only. Build from source with 'cargo install --git https://github.com/${REPO}' (needs the Rust toolchain), or use a glibc-based distro." + fi + ;; + *) die "unsupported OS '$os' (use the .zip from the Releases page on Windows)" ;; +esac +case "$arch" in + x86_64|amd64) cpu="x86_64" ;; + arm64|aarch64) cpu="aarch64" ;; + *) die "unsupported architecture '$arch'" ;; +esac +triple="${cpu}-${plat}" + +# --- resolve version --------------------------------------------------------- +tag="${STATECRAFT_VERSION:-latest}" +if [ "$tag" = "latest" ]; then + say "resolving latest release…" + tag="$(dl_stdout "https://api.github.com/repos/${REPO}/releases/latest" \ + | grep -m1 '"tag_name"' \ + | sed -E 's/.*"tag_name"[ ]*:[ ]*"([^"]+)".*/\1/')" + [ -n "$tag" ] || die "could not resolve the latest release tag (set STATECRAFT_VERSION)" +fi + +archive="${BIN}-${tag}-${triple}.tar.gz" +base_url="https://github.com/${REPO}/releases/download/${tag}" +say "installing ${BIN} ${tag} for ${triple}" + +# --- download archive + checksum --------------------------------------------- +tmp="$(mktemp -d "${TMPDIR:-/tmp}/statecraft.XXXXXX")" +trap 'rm -rf "$tmp"' EXIT INT TERM + +dl "${base_url}/${archive}" "${tmp}/${archive}" \ + || die "download failed: ${base_url}/${archive}" +dl "${base_url}/${archive}.sha256" "${tmp}/${archive}.sha256" \ + || die "checksum download failed: ${base_url}/${archive}.sha256" + +# --- verify checksum --------------------------------------------------------- +expected="$(awk '{print $1}' "${tmp}/${archive}.sha256")" +[ -n "$expected" ] || die "empty checksum sidecar" +if have sha256sum; then actual="$(sha256sum "${tmp}/${archive}" | awk '{print $1}')" +elif have shasum; then actual="$(shasum -a 256 "${tmp}/${archive}" | awk '{print $1}')" +elif have openssl; then actual="$(openssl dgst -sha256 "${tmp}/${archive}" | awk '{print $NF}')" +else die "need sha256sum, shasum, or openssl to verify the download"; fi +[ "$expected" = "$actual" ] || die "checksum mismatch (expected ${expected}, got ${actual})" +say "checksum verified" + +# --- verify provenance attestation (authenticity, not just integrity) -------- +# The .sha256 sidecar is fetched from the same release as the archive, so it +# proves integrity but NOT authenticity: a rewritten release ships a matching +# sidecar. GitHub build-provenance attestations (spec 007) close that gap. Use +# `gh attestation verify` when available. Best-effort by default (many curl|sh +# users have no authenticated `gh`); set STATECRAFT_REQUIRE_ATTESTATION=1 to +# make an unverifiable download a hard failure. +if [ "${STATECRAFT_SKIP_ATTESTATION:-0}" = "1" ]; then + say "provenance attestation check skipped (STATECRAFT_SKIP_ATTESTATION=1)" +elif have gh && gh attestation verify "${tmp}/${archive}" --repo "${REPO}" >/dev/null 2>&1; then + say "provenance attestation verified" +elif [ "${STATECRAFT_REQUIRE_ATTESTATION:-0}" = "1" ]; then + die "provenance attestation could NOT be verified and STATECRAFT_REQUIRE_ATTESTATION=1 is set (rewritten release, or 'gh' missing/unauthenticated)" +else + say "note: provenance attestation not verified (install 'gh' and authenticate for authenticity checks; checksum was verified). Set STATECRAFT_REQUIRE_ATTESTATION=1 to enforce." +fi + +# --- extract ----------------------------------------------------------------- +tar -C "$tmp" -xzf "${tmp}/${archive}" || die "extract failed" +[ -f "${tmp}/${BIN}" ] || die "archive did not contain ${BIN}" +chmod +x "${tmp}/${BIN}" + +# --- choose an install dir --------------------------------------------------- +bindir="${STATECRAFT_BIN_DIR:-}" +if [ -z "$bindir" ]; then + if [ -w /usr/local/bin ] && printf '%s' "$PATH" | tr ':' '\n' | grep -qx /usr/local/bin; then + bindir="/usr/local/bin" + else + bindir="${HOME}/.local/bin" + fi +fi +mkdir -p "$bindir" || die "could not create install dir ${bindir}" +mv "${tmp}/${BIN}" "${bindir}/${BIN}" || die "could not install to ${bindir} (try sudo, or set STATECRAFT_BIN_DIR)" + +say "installed ${bindir}/${BIN}" +if printf '%s' "$PATH" | tr ':' '\n' | grep -qx "$bindir"; then + say "run: ${BIN} --version" +else + say "NOTE: ${bindir} is not on your PATH. Add it, e.g.:" + printf ' export PATH="%s:$PATH"\n' "$bindir" >&2 +fi diff --git a/specs/007-release-distribution/spec.md b/specs/007-release-distribution/spec.md new file mode 100644 index 0000000..f20c6ab --- /dev/null +++ b/specs/007-release-distribution/spec.md @@ -0,0 +1,128 @@ +--- +id: "007-release-distribution" +title: "Release distribution: tag-gated prebuilt binaries and installer" +status: approved +created: "2026-07-22" +implementation: in-progress +depends_on: + - "002-crate-scaffold" +establishes: + - ".github/workflows/release.yml" + - "install.sh" +summary: > + Closes the gap between "builds from source" and "installable + product": pushing a v tag builds a per-triple archive for + the five supported targets, attaches checksums, a CycloneDX SBOM, + and a SLSA build-provenance attestation to a GitHub Release, and + install.sh (curl | sh) consumes the archives. Mirrors the + spec-spine release pipeline (the family precedent this repo's own + CI already consumes) plus the fail-fast version guard OPC's + release workflow learned the hard way. No registry publishing: + the crate is a binary product, not a library. +--- + +# 007: Release distribution + +## 1. Purpose + +Specs 002-006 produce a binary that only a Rust toolchain can obtain. +The CLI's consumers (operators without cargo; agents installing the +MCP server) need prebuilt binaries with integrity and authenticity +evidence. The family already has a proven shape: spec-spine's +tag-gated matrix release and installer, which this repo's +spec-spine.yml consumes on every PR. This spec adopts that shape for +`statecraft`, trimmed to what a single-binary product needs. + +## 2. Territory + +`.github/workflows/release.yml` (the pipeline) and `install.sh` (the +consumer). `Cargo.toml` stays spec 002's territory: a release is cut +against the committed version, never by editing the manifest from the +workflow. + +## 3. Behavior: the pipeline + +Trigger: pushing a tag matching `v*`. This is a single-product repo, +so the bare `v` grammar is enough (OPC needed product-prefixed +tags; we do not). + +1. **Version guard (fail-fast, zero build minutes).** A standalone + first job compares the tag against the committed Cargo.toml + version (via `cargo metadata`) and dies on mismatch, so a release + can never ship assets whose `--version` output disagrees with its + envelope. Lesson imported from OPC spec 193. +2. **Build matrix (five triples).** x86_64/aarch64 linux-gnu, + x86_64/aarch64 apple-darwin, x86_64 windows-msvc. Four build + natively on a matching-arch runner; x86_64-apple-darwin + cross-compiles on the Apple Silicon runner (Xcode ships the x86_64 + SDK; the Intel macos-13 runner is deprecated and queues badly). + Builds are `--locked`: the committed Cargo.lock is authoritative. +3. **Archives.** `statecraft--.tar.gz` (`.zip` on + Windows) containing the binary, LICENSE, and README.md, each with + a `.sha256` sidecar. +4. **Supply-chain evidence.** A per-target CycloneDX SBOM + (fail-closed if it catalogs zero components) and a SLSA + build-provenance attestation whose subject is the archive; verify + with `gh attestation verify --repo + statecrafting/statecraft-cli`. +5. **Publish.** One job attaches every archive, sidecar, and SBOM to + the GitHub Release with generated notes; an unmatched file pattern + fails the job rather than shipping a partial asset set. Re-running + a tag updates the same release (idempotent). +6. **Pinning.** Every action is pinned to a full commit SHA with a + version comment (same rule as ci.yml), so an upstream tag rewrite + cannot alter a release build. + +## 4. Behavior: install.sh + +`curl -fsSL .../install.sh | sh`: detect platform and arch, download +the matching archive and its `.sha256` sidecar from GitHub Releases, +verify the checksum (hard requirement), verify the provenance +attestation best-effort via `gh` when available, and install to +`~/.local/bin` (or `/usr/local/bin` when writable and already on +PATH). Environment overrides: `STATECRAFT_VERSION` (release tag, +default latest), `STATECRAFT_BIN_DIR`, +`STATECRAFT_REQUIRE_ATTESTATION=1` (hard-fail without verified +provenance), `STATECRAFT_SKIP_ATTESTATION=1` (checksum still +enforced). musl-based Linux is refused up front with a pointer to +`cargo install --git` (the prebuilt Linux binaries are glibc-only). +Windows users take the `.zip` from the Releases page; the script +targets macOS/Linux. + +The installer env prefix overlaps the CLI's own `STATECRAFT_*` config +prefix by design; the CLI reads only `STATECRAFT_BASE_URL` and +`STATECRAFT_OUTPUT`, so no installer variable collides with a config +variable. Any future config key must keep clear of the four installer +names above. + +## 5. Release procedure + +1. Bump `version` in Cargo.toml (Cargo.lock follows) on a branch; + merge through the normal gates. +2. Tag the merge commit `v` and push the tag; the pipeline + does the rest. +3. A failed run is safe to re-run from the tag: every step is + idempotent against the same release. + +## 6. Acceptance + +- The first tag produces a GitHub Release carrying five archives, + five `.sha256` sidecars, five SBOMs, and attestations that + `gh attestation verify` accepts. +- Live check: install.sh installs that release on a dev machine and + `statecraft --version` reports the tag's version; the transcript is + recorded in this spec's status section and flips + `implementation` to complete. +- ci.yml + spine gates green. + +## 7. Out of scope + +- Registry publishing (crates.io, npm, PyPI, Homebrew): the crate is + a binary product, not a library. spec-spine's npm/pypi lanes exist + for toolchain embedding that this CLI does not need; revisit only + with a concrete consumer. +- macOS notarization and Windows code signing (unsigned archives plus + provenance attestations for now; the attestation is the + authenticity story). +- Self-update (re-run install.sh or use the Releases page; no + in-binary updater, matching the no-bypass posture of the verbs).