From 45b6760fbed6a81a5774a6eb15eb0949c90e6ef7 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Wed, 20 May 2026 07:01:16 +0200 Subject: [PATCH 1/2] =?UTF-8?q?ci(release):=20add=20release=20pipeline=20?= =?UTF-8?q?=E2=80=94=20binaries,=20SLSA=20provenance,=20cosign=20signing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds .github/workflows/release.yml triggered on v* tags: cross-platform synth CLI builds (linux x86_64/aarch64, macOS x86_64/aarch64), SHA256SUMS, GitHub-native SLSA build provenance (actions/attest-build-provenance), and Sigstore keyless cosign signature over SHA256SUMS. Modelled on the sibling pulseengine/witness + rivet release workflows, with the sigil supply-chain permissions block (contents/id-token/attestations: write). The release build uses only the riscv feature; the verify feature (z3-sys) is intentionally excluded to keep the build fast and network-free — the CLI degrades gracefully without it. docs/release-process.md documents how to cut a release, the provenance and signing verification commands, the release checklist, the CHANGELOG mapping, and a 5-phase rollout plan (Phases 1-3 implemented; 4 crates.io and 5 ELF output signing via sigil noted as future work). Co-Authored-By: Claude Opus 4.7 --- .github/workflows/release.yml | 228 ++++++++++++++++++++++++++++++++++ docs/release-process.md | 207 ++++++++++++++++++++++++++++++ 2 files changed, 435 insertions(+) create mode 100644 .github/workflows/release.yml create mode 100644 docs/release-process.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..1e87445 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,228 @@ +name: Release + +# Release variant: serialize per-tag, never cancel. A cancelled release +# mid-publish leaves the GitHub Release page, build attestations, and +# per-target binary archives in an inconsistent state — better to queue +# than abort. Mirrors the pulseengine/rivet and pulseengine/witness +# release workflows. +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +on: + push: + tags: + - "v*" + # Manual re-run for a tag whose initial run failed partway. The tag + # must already exist; this does not create tags. + workflow_dispatch: + inputs: + tag: + description: "Existing release tag to (re)build (e.g. v0.3.1)" + required: true + type: string + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + +jobs: + # ── Cross-platform binary builds ────────────────────────────────────── + # synth-cli builds with `--features riscv` only (the workspace default). + # The `verify` feature (Z3 / z3-sys) is intentionally NOT enabled here: + # z3-sys vendors and compiles z3 from source, needs network + a large + # C++ build, and the CLI degrades gracefully without it (`synth verify` + # prints "rebuild with --features verify"). Keeping it out makes the + # release build fast, hermetic, and free of the libz3-dev / temp-disk + # problems documented in ci.yml. + build-binaries: + name: Build ${{ matrix.target }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - target: x86_64-unknown-linux-gnu + os: ubuntu-latest + archive: tar.gz + - target: aarch64-unknown-linux-gnu + os: ubuntu-latest + archive: tar.gz + cross: true + - target: x86_64-apple-darwin + os: macos-15-intel + archive: tar.gz + - target: aarch64-apple-darwin + os: macos-latest + archive: tar.gz + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag || github.ref }} + + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - uses: Swatinem/rust-cache@v2 + with: + key: release-${{ matrix.target }} + + - name: Install cross + if: matrix.cross + run: cargo install cross --git https://github.com/cross-rs/cross --locked + + - name: Build synth (native) + if: ${{ !matrix.cross }} + run: cargo build --release --target ${{ matrix.target }} -p synth-cli + + - name: Build synth (cross) + if: matrix.cross + run: cross build --release --target ${{ matrix.target }} -p synth-cli + + - name: Strip binary + if: ${{ !matrix.cross }} + run: strip "target/${{ matrix.target }}/release/synth" 2>/dev/null || true + + - name: Package archive + env: + TARGET: ${{ matrix.target }} + # Resolve the version once: tag push -> refs/tags/vX.Y.Z; + # workflow_dispatch -> the user-supplied tag input. Bound via + # env: and dereferenced as $VERSION below — never expand + # ${{ ... }} directly inside run: (command-injection vector). + INPUT_TAG: ${{ inputs.tag }} + run: | + set -euo pipefail + VERSION="${INPUT_TAG:-${GITHUB_REF#refs/tags/}}" + ARCHIVE="synth-${VERSION}-${TARGET}.tar.gz" + mkdir -p staging + cp "target/${TARGET}/release/synth" staging/ + cp README.md LICENSE staging/ 2>/dev/null || true + tar -czf "$ARCHIVE" -C staging . + echo "ARCHIVE=$ARCHIVE" >> "$GITHUB_ENV" + + - uses: actions/upload-artifact@v4 + with: + name: binary-${{ matrix.target }} + path: ${{ env.ARCHIVE }} + retention-days: 7 + + # ── Create the GitHub Release: checksums, provenance, signing ───────── + create-release: + name: Create GitHub Release + needs: [build-binaries] + runs-on: ubuntu-latest + permissions: + # Keyless signing + provenance need an OIDC token; release-asset + # upload needs contents: write; build provenance attestation + # needs attestations: write. This block mirrors the permissions + # set used by the pulseengine/sigil release workflow. + contents: write + id-token: write + attestations: write + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag || github.ref }} + + - name: Download all build artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Flatten release assets + run: | + set -euo pipefail + mkdir -p release-assets + find artifacts -type f -name "*.tar.gz" -exec cp {} release-assets/ \; + ls -la release-assets/ + + - name: Generate SHA256 checksums + run: | + set -euo pipefail + cd release-assets + sha256sum ./* > SHA256SUMS.txt + cat SHA256SUMS.txt + + # ── SLSA build provenance (GitHub-native) ────────────────────────── + # actions/attest-build-provenance generates an in-toto SLSA v1 + # provenance statement for every binary archive, signs it keyless + # via Sigstore (Fulcio cert bound to this workflow's OIDC identity), + # and records it in the Rekor transparency log. Consumers verify + # with `gh attestation verify --repo pulseengine/synth`. + # GitHub-native attestation (not the standalone SLSA generator) + # keeps the workflow self-contained — see docs/release-process.md. + - name: Generate SLSA build provenance + uses: actions/attest-build-provenance@v2 + with: + subject-path: "release-assets/*.tar.gz" + + # ── Sigstore keyless signing (cosign) ────────────────────────────── + # Signs SHA256SUMS.txt so a consumer can verify the checksum file + # itself was produced by this workflow (closes the gap where an + # attacker who can replace a release asset could also replace the + # plain checksum file). Mirrors the pulseengine/witness and + # pulseengine/rivet cosign sign-blob pattern. The .cosign.bundle is + # the verifier-friendly artifact; .sig + .pem are the detached + # signature and Fulcio certificate. Verify with: + # cosign verify-blob \ + # --certificate-identity-regexp \ + # 'https://github.com/pulseengine/synth/.github/workflows/release.yml@.*' \ + # --certificate-oidc-issuer \ + # 'https://token.actions.githubusercontent.com' \ + # --bundle SHA256SUMS.txt.cosign.bundle \ + # SHA256SUMS.txt + - name: Install cosign + uses: sigstore/cosign-installer@v3 + with: + cosign-release: 'v2.4.1' + + - name: Sign SHA256SUMS with cosign (keyless OIDC) + run: | + set -euo pipefail + cd release-assets + cosign sign-blob \ + --yes \ + --bundle SHA256SUMS.txt.cosign.bundle \ + --output-signature SHA256SUMS.txt.sig \ + --output-certificate SHA256SUMS.txt.pem \ + SHA256SUMS.txt + echo "::notice::SHA256SUMS signed via Sigstore keyless flow." + ls -la ./* + + - name: Capture build environment + run: | + set -euo pipefail + { + echo "rustc: $(rustc --version)" + echo "cargo: $(cargo --version)" + echo "cosign: $(cosign version 2>&1 | head -1)" + echo "runner: $(uname -srm)" + } > release-assets/build-env.txt + cat release-assets/build-env.txt + + - name: Create or update GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Untrusted-input safety: the tag name flows in via env: and is + # dereferenced through $VERSION, never expanded into the shell. + INPUT_TAG: ${{ inputs.tag }} + run: | + set -euo pipefail + VERSION="${INPUT_TAG:-${GITHUB_REF#refs/tags/}}" + # Idempotent: re-running the workflow for an existing release + # uploads/overwrites assets rather than failing. --clobber lets + # a re-run replace assets a previous partial run left behind. + if gh release view "$VERSION" >/dev/null 2>&1; then + echo "::notice::Release $VERSION exists; uploading assets" + gh release upload "$VERSION" --clobber release-assets/* + else + echo "::notice::Creating Release $VERSION with assets" + gh release create "$VERSION" \ + --title "synth $VERSION" \ + --generate-notes \ + release-assets/* + fi diff --git a/docs/release-process.md b/docs/release-process.md new file mode 100644 index 0000000..505e0fe --- /dev/null +++ b/docs/release-process.md @@ -0,0 +1,207 @@ +# Release Process + +How synth cuts a release: cross-platform binaries, SHA256 checksums, SLSA +build provenance, and Sigstore keyless signatures. The pipeline lives in +`.github/workflows/release.yml` and is modelled on the sibling PulseEngine +release workflows (`pulseengine/witness`, `pulseengine/rivet`, and the +supply-chain reference `pulseengine/sigil`). + +## TL;DR — cutting a release + +```bash +# 1. land all changes on main, with a green CI run +# 2. bump the workspace version + update CHANGELOG (see checklist below) +# 3. tag and push +git tag -a v0.3.1 -m "synth v0.3.1" +git push origin v0.3.1 +``` + +Pushing a `v*` tag triggers `release.yml`. No further manual steps are +required — the workflow builds the binaries, signs them, and publishes the +GitHub Release. If a run fails partway, re-run it from the Actions tab via +the `workflow_dispatch` trigger, passing the existing tag name. + +## What the workflow does + +Trigger: `push` of a tag matching `v*` (or manual `workflow_dispatch` with +an existing tag). + +1. **`build-binaries`** — builds the `synth` CLI (`cargo build --release -p + synth-cli`) for a host matrix: + - `x86_64-unknown-linux-gnu` + - `aarch64-unknown-linux-gnu` (via `cross`) + - `x86_64-apple-darwin` + - `aarch64-apple-darwin` + + Each target is stripped, packaged as `synth--.tar.gz` + (with `README.md` + `LICENSE`), and uploaded as a workflow artifact. + + The build uses only the `riscv` feature (the workspace default). The + `verify` feature is **not** enabled — it pulls `z3-sys`, which vendors + and compiles Z3 from source (slow, needs network, large C++ build). The + CLI degrades gracefully: `synth verify` without the feature prints + "rebuild with `--features verify`". This keeps the release build fast and + free of the `libz3-dev` / temp-disk issues documented in `ci.yml`. + +2. **`create-release`** — collects all archives, then: + - Generates `SHA256SUMS.txt` over every archive. + - Generates **SLSA build provenance** for every `.tar.gz` via + `actions/attest-build-provenance` (GitHub-native attestation). + - Signs `SHA256SUMS.txt` with **cosign keyless** (Sigstore), emitting + `SHA256SUMS.txt.cosign.bundle`, `.sig`, and `.pem`. + - Captures a `build-env.txt` (rustc / cargo / cosign / runner versions). + - Creates (or updates, idempotently) the GitHub Release and attaches all + assets. + +## Provenance and signing model + +synth uses **two complementary mechanisms**, matching the sibling repos: + +### SLSA build provenance (GitHub-native) + +`actions/attest-build-provenance` produces an in-toto SLSA v1 provenance +statement for every release binary archive. The attestation is signed +keyless via Sigstore (Fulcio mints a short-lived certificate bound to the +workflow's OIDC identity) and logged in the Rekor transparency log. GitHub +also stores it as a first-class attestation on the repo. + +Consumer verification: + +```bash +gh attestation verify synth-v0.3.1-x86_64-unknown-linux-gnu.tar.gz \ + --repo pulseengine/synth +``` + +This proves the archive was built by `release.yml` in `pulseengine/synth`, +from a specific commit, without anyone holding a long-lived signing key. + +### Sigstore keyless signature on SHA256SUMS + +`SHA256SUMS.txt` is signed with `cosign sign-blob` (keyless OIDC). This +closes the gap where an attacker able to replace a release asset could also +replace a plain, unsigned checksum file. The `.cosign.bundle` is the +self-contained verification artifact (no Rekor round-trip needed). + +Consumer verification: + +```bash +cosign verify-blob \ + --certificate-identity-regexp \ + 'https://github.com/pulseengine/synth/.github/workflows/release.yml@.*' \ + --certificate-oidc-issuer \ + 'https://token.actions.githubusercontent.com' \ + --bundle SHA256SUMS.txt.cosign.bundle \ + SHA256SUMS.txt + +# then verify your downloaded binary against the trusted checksums +sha256sum --check --ignore-missing SHA256SUMS.txt +``` + +No long-lived keys exist anywhere: the trust anchor is the GitHub Actions +OIDC identity (workflow ref + repo + commit). There is nothing to rotate. + +## Release checklist + +Before pushing a `v*` tag: + +- [ ] All target changes merged to `main`; CI green on the merge commit. +- [ ] `cargo test --workspace` passes locally. +- [ ] `cargo clippy --workspace --all-targets -- -D warnings` clean. +- [ ] `cargo fmt --check` clean. +- [ ] Version bumped in `Cargo.toml` (`[workspace.package] version`) — all + crates inherit it via `version.workspace = true`. +- [ ] `Cargo.lock` updated (run `cargo build` after the bump, commit it). +- [ ] `CHANGELOG.md` updated (see mapping below). +- [ ] Tag name matches the `Cargo.toml` version exactly (`v0.3.1` ↔ `0.3.1`). + +After the workflow finishes: + +- [ ] GitHub Release page shows 4 `.tar.gz` archives + `SHA256SUMS.txt` + + `SHA256SUMS.txt.{cosign.bundle,sig,pem}` + `build-env.txt`. +- [ ] Spot-check one binary: download, `gh attestation verify`, run + `synth --version`. + +## CHANGELOG.md mapping + +synth keeps a [Keep a Changelog](https://keepachangelog.com/) file. The +`v0.3.x` series has per-version sections (`## [0.3.0] - `, etc.). + +When cutting `vX.Y.Z`: + +1. Rename the existing `## [Unreleased]` section to + `## [X.Y.Z] - YYYY-MM-DD`. +2. Add a fresh empty `## [Unreleased]` section above it. +3. Keep the `### Added` / `### Fixed` / `### Changed` subsection structure. + +The workflow's GitHub Release body is auto-generated from commit history +(`gh release create --generate-notes`); the hand-curated `CHANGELOG.md` +section remains the canonical, human-readable record. The two are +complementary — do not drop the CHANGELOG. + +## Build tooling decision: hand-rolled, not cargo-dist + +None of the three sibling repos (`witness`, `rivet`, `sigil`) use +`cargo-dist`. They all hand-roll a build matrix with `dtolnay/rust-toolchain` ++ `cross` + `actions/upload-artifact`, and publish via `gh release`. synth +follows the same pattern for consistency across the org and to keep the +workflow auditable. If the org later standardises on `cargo-dist`, synth can +migrate — but adopting it unilaterally here would diverge from the siblings. + +--- + +# Release Pipeline — Phased Plan + +The maintainer asked for the rollout to be staged. The committed +`release.yml` already implements **Phases 1–3**; Phases 4–5 are future work. + +## Phase 1 — Binaries + checksums + GitHub Release ✅ implemented + +- **Delivers:** cross-platform `synth` binaries (4 host targets) packaged as + `.tar.gz`, a `SHA256SUMS.txt`, and an automated GitHub Release on tag push. +- **Effort:** ~0.5 day (done). +- **Depends on:** nothing. This is the foundation. + +## Phase 2 — SLSA build provenance ✅ implemented + +- **Delivers:** an in-toto SLSA v1 provenance attestation per binary + archive, via `actions/attest-build-provenance`, verifiable with + `gh attestation verify`. +- **Effort:** ~0.25 day (done) — one workflow step + the `attestations: + write` and `id-token: write` permissions. +- **Depends on:** Phase 1. + +## Phase 3 — cosign / Sigstore keyless signing ✅ implemented + +- **Delivers:** a Sigstore keyless signature over `SHA256SUMS.txt` + (`.cosign.bundle` + `.sig` + `.pem`), verifiable with `cosign + verify-blob`. Trust anchored to the workflow's OIDC identity. +- **Effort:** ~0.25 day (done) — `sigstore/cosign-installer` + one + `sign-blob` step. +- **Depends on:** Phases 1–2 and the `id-token: write` permission. + +## Phase 4 — crates.io publishing ⬜ future + +- **Delivers:** `cargo publish` of the workspace crates on tag push, so + `cargo install synth-cli` works. +- **Effort:** ~0.5–1 day. The 17-crate workspace must be published in + dependency order; `sigil` solves this with a `scripts/publish.rs` helper — + synth would mirror that, or use a publish-ordering tool. +- **Depends on:** a `CRATES_IO_TOKEN` secret (or crates.io **trusted + publishing** via OIDC, which `sigil` uses and is preferred — no token to + store). All crate metadata (`description`, `license`, `repository`) must be + complete and every dependency must be a published version, not a `path`. +- **Decision needed:** confirm whether synth crates should go to crates.io + at all; if yes, set up trusted publishing on crates.io for the repo. + +## Phase 5 — signing synth's *output* ELF binaries ⬜ future + +- **Delivers:** synth invokes `sigil` to sign the ARM/RISC-V ELF binaries it + *produces* (not the compiler itself). This is the natural PulseEngine + integration: synth compiles, `sigil` attests the firmware artifact. +- **Effort:** unknown — a design spike is needed first. `sigil` already has + an ELF signing format (`sigil sign --format elf --keyless`); the work is + wiring a `synth compile --sign` path and deciding the trust model for + embedded firmware. +- **Depends on:** Phases 1–4 and a separate design doc. **Out of scope for + the current release-pipeline work** — noted here only as the intended + future direction. From f50e8cefb716e075d18b31e9472a75280217a597 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Wed, 20 May 2026 07:21:38 +0200 Subject: [PATCH 2/2] ci(release): use macos-14 for x86_64-apple-darwin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aligns the macOS Intel target's runner with pulseengine/rivet and pulseengine/witness — both Rust-workspace CLIs, the closest analogs to synth — which build `x86_64-apple-darwin` by cross-compiling on the arm64 `macos-14` runner. The initial draft used `macos-15-intel` (pulseengine/sigil's choice — a native Intel runner), but the rivet/witness majority is the better fit for a Rust CLI: the existing build step already does `cargo build --target x86_64-apple-darwin` with the target installed via `dtolnay/rust-toolchain`, so the arm64 host cross-compiles cleanly with no extra steps. aarch64-apple-darwin stays on `macos-latest` (all three siblings agree). --- .github/workflows/release.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1e87445..dc27cfa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -51,8 +51,11 @@ jobs: os: ubuntu-latest archive: tar.gz cross: true + # x86_64-apple-darwin cross-compiles on the arm64 macos-14 + # runner — matches pulseengine/rivet and pulseengine/witness + # (both Rust-workspace CLIs, the closest analogs to synth). - target: x86_64-apple-darwin - os: macos-15-intel + os: macos-14 archive: tar.gz - target: aarch64-apple-darwin os: macos-latest