From 854cfa572c0331ce266ba10d1daafa883ff5d375 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Sun, 24 May 2026 21:07:10 +0200 Subject: [PATCH] =?UTF-8?q?Add=20release.yml=20=E2=80=94=20wohl-hub=20bina?= =?UTF-8?q?ries=20with=20SBOM,=20sums,=20sig,=20attestation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First release pipeline for wohl. Adapts pulseengine/synth's release.yml (the cross-repo reference per the release-standardization brief), tailored for wohl-hub's specifics: Targets - x86_64-unknown-linux-gnu (MiniPC / generic Linux hub) - aarch64-unknown-linux-gnu (Raspberry Pi 4/5; primary deployment per spar/wohl_nodes.aadl) - x86_64-apple-darwin (dev convenience) - aarch64-apple-darwin (dev convenience) aarch64-linux uses the native `ubuntu-24.04-arm` runner instead of cross-rs — cross's docker container doesn't mount the parent dir, which would break wohl-hub's `path = "../relay/..."` sibling-path workspace deps; a native runner sidesteps that friction. relay sibling checked out with fetch-depth: 0 at the pinned RELAY_REF (kept in lock-step with ci.yml). Required release assets (matches the standardization brief exactly): wohl-hub-vX.Y.Z-.tar.gz binary archives (x 4 targets) wohl-hub-X.Y.Z.cdx.json CycloneDX SBOM (CDX 1.5) SHA256SUMS.txt sha256sum over every asset SHA256SUMS.txt.sig cosign detached signature SHA256SUMS.txt.pem Fulcio certificate SHA256SUMS.txt.cosign.bundle cosign verifier bundle build-env.txt rustc/cargo/cosign/runner SBOM is generated *before* SHA256SUMS so its digest is in the manifest; the cosign signature over SHA256SUMS.txt transitively covers the SBOM. Provenance + signing - actions/attest-build-provenance@v2 generates SLSA v1 build provenance for every .tar.gz (GitHub-native; consumers verify with `gh attestation verify --repo pulseengine/wohl`). - sigstore/cosign-installer@v3 cosign v2.4.1; keyless OIDC sign-blob over SHA256SUMS.txt producing the .sig / .pem / .cosign.bundle triple. Triggers - push: tags: ['v*'] (the primary path) - workflow_dispatch (manual re-run for a partially-failed release; requires the existing tag as input) - Concurrency group per-tag, never cancels in flight. - Idempotent on re-run via gh release upload --clobber. Permissions - Workflow-level: contents: read. - create-release job: contents: write + id-token: write + attestations: write (minimum for asset upload + keyless signing + SLSA attestation). No release has been cut yet — `gh release list` is empty. Merging this PR does NOT fire the workflow (tag trigger only). First exercise is `git tag v0.1.0 && git push --tags`. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/release.yml | 286 ++++++++++++++++++++++++++++++++++ 1 file changed, 286 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..5e7e9b0 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,286 @@ +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/synth and pulseengine/rivet +# 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.1.0)" + required: true + type: string + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + # wohl-hub depends on the relay sibling via path = "../relay/...". + # Pin the relay commit explicitly so release builds are reproducible + # against a known sibling state. Keep this SHA in lock-step with + # ci.yml's RELAY_REF — bump them together via PR. fetch-depth: 0 + # on the checkout below covers force-pushes of relay (sibling repo + # is routinely rebased; see ci.yml comment + issue #8). + RELAY_REF: 178ffd479ad863c91ece4f580379a9207c36a530 + +jobs: + # ── Cross-platform binary builds ────────────────────────────────────── + # wohl-hub builds the home-supervision orchestrator: it wires the six + # verified Wohl monitors and the Relay engines into a single binary + # that runs on the hub host (Pi / MiniPC / dev Mac). + 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 + # Native arm64 runner — avoids cross-compilation's friction + # with wohl's `path = "../relay/..."` sibling-path deps + # (cross's docker image doesn't mount the parent dir, and + # patching CROSS_CONTAINER_OPTS is more fragile than just + # using a native runner). + - target: aarch64-unknown-linux-gnu + os: ubuntu-24.04-arm + archive: tar.gz + # macOS targets for dev convenience — wohl-hub on a Mac is + # not the production deployment shape, but reviewers and + # developers want a runnable binary. + - target: x86_64-apple-darwin + os: macos-14 + archive: tar.gz + - target: aarch64-apple-darwin + os: macos-latest + archive: tar.gz + steps: + - uses: actions/checkout@v4 + with: + path: wohl + ref: ${{ inputs.tag || github.ref }} + + # relay sibling checkout: wohl's Cargo.toml has path-deps like + # `relay-lc = { path = "../relay/crates/relay-lc" }`. The build + # needs relay co-located at the workspace's parent dir. + - uses: actions/checkout@v4 + with: + repository: pulseengine/relay + path: relay + ref: ${{ env.RELAY_REF }} + fetch-depth: 0 + + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: wohl + key: release-${{ matrix.target }} + + - name: Build wohl-hub + working-directory: wohl + run: cargo build --release --target ${{ matrix.target }} -p wohl-hub + + - name: Strip binary + # Strip on the native targets; skip on cross or unusual setups. + if: runner.os == 'Linux' || runner.os == 'macOS' + working-directory: wohl + run: strip "target/${{ matrix.target }}/release/wohl-hub" 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 }} + working-directory: wohl + run: | + set -euo pipefail + VERSION="${INPUT_TAG:-${GITHUB_REF#refs/tags/}}" + ARCHIVE="wohl-hub-${VERSION}-${TARGET}.tar.gz" + mkdir -p staging + cp "target/${TARGET}/release/wohl-hub" 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: wohl/${{ 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/synth and pulseengine/sigil + # release workflows. + contents: write + id-token: write + attestations: write + steps: + - uses: actions/checkout@v4 + with: + path: wohl + ref: ${{ inputs.tag || github.ref }} + + # relay sibling needed because cargo-cyclonedx walks workspace + # path-deps when generating the SBOM. + - uses: actions/checkout@v4 + with: + repository: pulseengine/relay + path: relay + ref: ${{ env.RELAY_REF }} + fetch-depth: 0 + + - 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/ + + # CycloneDX SBOM for wohl-hub. Generated *before* SHA256SUMS so + # its digest is captured in the checksum manifest; the cosign + # signature over SHA256SUMS.txt transitively covers the SBOM. + - name: Install cargo-cyclonedx + run: cargo install --locked cargo-cyclonedx + + - name: Generate wohl-hub SBOM (CycloneDX) + env: + INPUT_TAG: ${{ inputs.tag }} + working-directory: wohl + run: | + set -euo pipefail + VERSION="${INPUT_TAG:-${GITHUB_REF#refs/tags/}}" + BARE="${VERSION#v}" + # cargo-cyclonedx doesn't proxy cargo's `-p` flag — use + # --manifest-path to target wohl-hub's Cargo.toml directly. + cargo cyclonedx \ + --manifest-path crates/wohl-hub/Cargo.toml \ + --format json \ + --spec-version 1.5 + SBOM_SRC="crates/wohl-hub/wohl-hub.cdx.json" + if [ ! -f "$SBOM_SRC" ]; then + SBOM_SRC=$(find crates/wohl-hub -maxdepth 2 -name '*.cdx.json' | head -1) + fi + test -n "$SBOM_SRC" && test -f "$SBOM_SRC" + cp "$SBOM_SRC" "../release-assets/wohl-hub-${BARE}.cdx.json" + echo "::notice::wohl-hub SBOM written to release-assets/wohl-hub-${BARE}.cdx.json" + 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/wohl`. + - 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/synth and + # pulseengine/rivet cosign sign-blob pattern. + - 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/}}" + # Verification one-liner for release notes — copy-paste-friendly: + # + # cosign verify-blob \ + # --certificate-identity-regexp \ + # 'https://github.com/pulseengine/wohl/.github/workflows/release.yml@.*' \ + # --certificate-oidc-issuer \ + # 'https://token.actions.githubusercontent.com' \ + # --bundle SHA256SUMS.txt.cosign.bundle \ + # SHA256SUMS.txt + # gh attestation verify wohl-hub-vX.Y.Z-.tar.gz \ + # --repo pulseengine/wohl + # + # 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 "wohl $VERSION" \ + --generate-notes \ + release-assets/* + fi