From ad3392ef9126786a68d903d046b563678e362563 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Sun, 24 May 2026 21:12:12 +0200 Subject: [PATCH] =?UTF-8?q?ci(release):=20standardize=20on=20synth=20patte?= =?UTF-8?q?rn=20=E2=80=94=20sums=20+=20cosign=20+=20SLSA=20+=20SBOM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LOOM has not shipped any release binaries since v0.5.0: every Release workflow run since at least v1.0.2 has failed at the `Build WASM (wasm32-wasip2)` step (rules_wasm_component pinned to v0.3.0, the WASM build cannot move forward). The Create-Release job had `needs: [build-native, build-wasm]`, so the failing WASM job short-circuits the upload step and all native binaries get discarded — v0.9.0 / v1.0.5 / v1.1.0 / v1.1.1 are tagged with zero assets. This rewrite adopts pulseengine/synth's release.yml as the reference implementation (per the cross-repo standardization brief): - Drop the WASM build job entirely (release-blocker; not in the standard asset list; rules_wasm_component pin issue is a separate concern). - Drop the OCI publish + OCI signing + custom-JSON SLSA path. The standard set is the single signed SHA256SUMS plus GitHub-native SLSA attestation, no OCI side-channel. - Drop per-file `*.sha256` sidecars — replaced by one signed SHA256SUMS.txt covering every asset (and the SBOM). - Trigger switches from `release: published` to `push: tags: v*` so the workflow CREATES the release atomically with its assets, instead of racing a manually-created (asset-less) release page. The artifact-generation block (Phase 6 onward) is copied verbatim from pulseengine/synth/.github/workflows/release.yml — only the SBOM manifest path is adapted (`loom-cli/Cargo.toml` instead of `crates/synth-cli/Cargo.toml`). Required assets per the brief, and only these: - loom-vX.Y.Z-.{tar.gz|zip} binary archives, per platform - loom-X.Y.Z.cdx.json CycloneDX SBOM - SHA256SUMS.txt sha256 of every other asset - SHA256SUMS.txt.sig cosign detached signature - SHA256SUMS.txt.pem Fulcio leaf certificate - SHA256SUMS.txt.cosign.bundle verifier-friendly bundle - build-env.txt rustc/cargo/cosign/runner versions Build matrix: x86_64-linux, x86_64-darwin (macos-14), aarch64-darwin (macos-latest), x86_64-windows. aarch64-linux via `cross` deferred — z3-sys cross-compile is untested in this repo. Verification one-liner (to paste in release notes after first run): cosign verify-blob \ --certificate-identity-regexp \ 'https://github.com/pulseengine/loom/.github/workflows/release.yml@.*' \ --certificate-oidc-issuer \ 'https://token.actions.githubusercontent.com' \ --bundle SHA256SUMS.txt.cosign.bundle SHA256SUMS.txt gh attestation verify loom-vX.Y.Z-.tar.gz --repo pulseengine/loom Validation plan: after merge, dispatch the workflow against tag v1.1.1 (which currently has zero assets) to populate it with the standard asset set. Trace: REQ-INFRA --- .github/workflows/release.yml | 498 ++++++++++++++-------------------- 1 file changed, 207 insertions(+), 291 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7678429..e0c7842 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,349 +1,265 @@ 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 / rivet / witness release +# workflows. concurrency: group: release-${{ github.ref }} cancel-in-progress: false on: - release: - types: [published] + 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: 'Release tag (e.g., v0.1.0)' + description: "Existing release tag to (re)build (e.g. v1.1.2)" required: true type: string +permissions: + contents: read + env: - REGISTRY: ghcr.io - IMAGE_NAME: pulseengine/loom + CARGO_TERM_COLOR: always jobs: - build-native: - name: Build Native (${{ matrix.os }}) - # Stays on ubuntu-latest: matrix spans macOS + Windows; smithy is Linux-only. + # ── Cross-platform binary builds ────────────────────────────────────── + # loom-cli is the binary `loom`. The release build pulls in loom-core + # which depends on z3-sys; z3-sys vendors and statically compiles z3, + # so no host libz3 is required on the runner. The build succeeds on + # every target in the CI matrix. + # + # No aarch64-linux for now: the rocq-of-rust + bazel/nix stack used by + # the Rocq Formal Proofs job already constrains this repo's CI runners, + # and cross-compiling z3-sys for aarch64-linux is untested. Add it as + # a follow-up once the standard matrix is green again. + build-binaries: + name: Build ${{ matrix.target }} runs-on: ${{ matrix.os }} strategy: + fail-fast: false matrix: include: - - os: ubuntu-latest - target: x86_64-unknown-linux-gnu - artifact: loom-linux-x64 + - target: x86_64-unknown-linux-gnu + os: ubuntu-latest + archive: tar.gz binary: loom - - os: macos-latest - target: x86_64-apple-darwin - artifact: loom-macos-x64 + # x86_64-apple-darwin cross-compiles on the arm64 macos-14 + # runner — matches pulseengine/synth, rivet, witness. + - target: x86_64-apple-darwin + os: macos-14 + archive: tar.gz binary: loom - - os: macos-latest - target: aarch64-apple-darwin - artifact: loom-macos-arm64 + - target: aarch64-apple-darwin + os: macos-latest + archive: tar.gz binary: loom - - os: windows-latest - target: x86_64-pc-windows-msvc - artifact: loom-windows-x64 + - target: x86_64-pc-windows-msvc + os: windows-latest + archive: zip binary: loom.exe steps: - - uses: actions/checkout@v5 + - 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: Build release binary - run: cargo build --release --target ${{ matrix.target }} + - name: Build loom (native) + run: cargo build --release --target ${{ matrix.target }} -p loom-cli + + - name: Strip binary + if: runner.os != 'Windows' + run: strip "target/${{ matrix.target }}/release/${{ matrix.binary }}" 2>/dev/null || true - - name: Create artifact directory + - name: Package archive shell: bash + env: + TARGET: ${{ matrix.target }} + BINARY: ${{ matrix.binary }} + ARCHIVE_KIND: ${{ matrix.archive }} + # 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: | - mkdir -p artifacts - cp target/${{ matrix.target }}/release/${{ matrix.binary }} artifacts/ - cd artifacts - if [ "${{ runner.os }}" = "Windows" ]; then - 7z a ../${{ matrix.artifact }}.zip ${{ matrix.binary }} + set -euo pipefail + VERSION="${INPUT_TAG:-${GITHUB_REF#refs/tags/}}" + ARCHIVE="loom-${VERSION}-${TARGET}.${ARCHIVE_KIND}" + mkdir -p staging + cp "target/${TARGET}/release/${BINARY}" staging/ + cp README.md LICENSE staging/ 2>/dev/null || true + if [ "$ARCHIVE_KIND" = "zip" ]; then + (cd staging && 7z a -tzip "../$ARCHIVE" ./*) else - tar -czvf ../${{ matrix.artifact }}.tar.gz ${{ matrix.binary }} + tar -czf "$ARCHIVE" -C staging . fi + echo "ARCHIVE=$ARCHIVE" >> "$GITHUB_ENV" - - name: Upload artifact - uses: actions/upload-artifact@v4 - with: - name: ${{ matrix.artifact }} - path: | - ${{ matrix.artifact }}.tar.gz - ${{ matrix.artifact }}.zip - - build-wasm: - name: Build WASM (wasm32-wasip2) - # Stays on ubuntu-latest: installs wasi-sdk into /opt with sudo mkdir/tar. - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - uses: dtolnay/rust-toolchain@stable - with: - targets: wasm32-wasip2 - - uses: Swatinem/rust-cache@v2 - with: - key: release-wasm - - - name: Install wasi-sdk - run: | - WASI_SDK_VERSION=25 - wget -q https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-${WASI_SDK_VERSION}/wasi-sdk-${WASI_SDK_VERSION}.0-x86_64-linux.tar.gz -O wasi-sdk.tar.gz - sudo mkdir -p /opt/wasi-sdk - sudo tar -xzf wasi-sdk.tar.gz -C /opt/wasi-sdk --strip-components=1 - rm wasi-sdk.tar.gz - - - name: Build WASM binary - env: - WASI_SDK_PREFIX: /opt/wasi-sdk - run: | - cargo build --release --target wasm32-wasip2 --package loom-cli - mkdir -p artifacts - cp target/wasm32-wasip2/release/loom.wasm artifacts/ - - - name: Upload artifact - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v4 with: - name: loom-wasm - path: artifacts/loom.wasm - - release: - name: Create Release - needs: [build-native, build-wasm] - # Stays on ubuntu-latest: uses `sudo mv` for oras install plus Cosign - # OIDC keyless signing tied to GitHub-hosted runner identity. + name: binary-${{ matrix.target }} + path: ${{ env.ARCHIVE }} + retention-days: 7 + + # ── Create the GitHub Release: checksums, provenance, signing ───────── + # Verbatim from pulseengine/synth's release.yml Phase-6 block, only the + # SBOM manifest path is adapted (loom-cli/Cargo.toml vs. + # crates/synth-cli/Cargo.toml). + 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. Mirrors pulseengine/synth. contents: write - packages: write - id-token: write # For Cosign keyless signing - + id-token: write + attestations: write steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag || github.ref }} - - name: Download all artifacts + - name: Download all build artifacts uses: actions/download-artifact@v4 with: path: artifacts - - name: Prepare release files + - name: Flatten release assets run: | - mkdir -p release - - # Move native binaries - for dir in artifacts/loom-*; do - if [ -d "$dir" ]; then - name=$(basename "$dir") - # Find the archive file (tar.gz or zip) - archive=$(find "$dir" -name "*.tar.gz" -o -name "*.zip" | head -1) - if [ -n "$archive" ]; then - cp "$archive" release/ - fi - fi - done - - # Move WASM binary - cp artifacts/loom-wasm/loom.wasm release/ - - ls -la release/ - - - name: Create SHA256 checksums + set -euo pipefail + mkdir -p release-assets + find artifacts -type f \( -name "*.tar.gz" -o -name "*.zip" \) -exec cp {} release-assets/ \; + ls -la release-assets/ + + # Phase 6: CycloneDX SBOM for the loom toolchain itself. The SBOM + # is 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 toolchain SBOM (CycloneDX) + env: + INPUT_TAG: ${{ inputs.tag }} run: | - cd release - for file in *; do - sha256sum "$file" > "$file.sha256" - done - cat *.sha256 + set -euo pipefail + VERSION="${INPUT_TAG:-${GITHUB_REF#refs/tags/}}" + BARE="${VERSION#v}" + # cargo-cyclonedx doesn't proxy cargo's `-p` package-selection + # flag; target loom-cli's manifest directly. The generated + # SBOM lands next to that Cargo.toml. + cargo cyclonedx \ + --manifest-path loom-cli/Cargo.toml \ + --format json \ + --spec-version 1.5 + SBOM_SRC="loom-cli/loom-cli.cdx.json" + if [ ! -f "$SBOM_SRC" ]; then + SBOM_SRC=$(find loom-cli -maxdepth 2 -name '*.cdx.json' | head -1) + fi + test -n "$SBOM_SRC" && test -f "$SBOM_SRC" + cp "$SBOM_SRC" "release-assets/loom-${BARE}.cdx.json" + echo "::notice::Toolchain SBOM written to release-assets/loom-${BARE}.cdx.json" + ls -la release-assets/ - - name: Install Cosign + - 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/loom`. + - name: Generate SLSA build provenance + uses: actions/attest-build-provenance@v2 + with: + subject-path: | + release-assets/*.tar.gz + release-assets/*.zip + + # ── 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). 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/loom/.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 - - - name: Log in to Container Registry - uses: docker/login-action@v3 with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Publish OCI Artifact - run: | - # Install oras - VERSION="1.2.2" - curl -LO "https://github.com/oras-project/oras/releases/download/v${VERSION}/oras_${VERSION}_linux_amd64.tar.gz" - mkdir -p oras-install/ - tar -zxf "oras_${VERSION}_linux_amd64.tar.gz" -C oras-install/ - sudo mv oras-install/oras /usr/local/bin/ - rm -rf "oras_${VERSION}_linux_amd64.tar.gz" oras-install/ + cosign-release: 'v2.4.1' - # Determine tag - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - TAG="${{ inputs.tag }}" - else - TAG="${{ github.event.release.tag_name }}" - fi - - # Create OCI artifact references - IMAGE_TAG="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${TAG}" - IMAGE_LATEST="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest" - - # Push WASM file as OCI artifact - echo "Publishing loom.wasm to OCI registry..." - oras push "${IMAGE_TAG}" \ - --artifact-type application/vnd.wasm.component.layer.v1+wasm \ - release/loom.wasm:application/vnd.wasm.component.layer.v1+wasm - - # Tag as latest - oras tag "${IMAGE_TAG}" latest - - echo "Published OCI artifacts:" - echo " ${IMAGE_TAG}" - echo " ${IMAGE_LATEST}" - - echo "IMAGE_TAG=${IMAGE_TAG}" >> $GITHUB_ENV - echo "IMAGE_LATEST=${IMAGE_LATEST}" >> $GITHUB_ENV - echo "RELEASE_TAG=${TAG}" >> $GITHUB_ENV - - - name: Sign OCI Artifact with Cosign + - name: Sign SHA256SUMS with cosign (keyless OIDC) run: | - echo "Signing OCI artifact with Cosign (keyless)..." - cosign sign --yes "${IMAGE_TAG}" - cosign sign --yes "${IMAGE_LATEST}" - echo "✅ OCI artifact signed" - - - name: Generate SLSA Provenance + 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: | - cat > provenance.json <&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: | - echo "## 🚀 Release Summary" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "### 📦 Published Artifacts" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "**Native Binaries:**" >> $GITHUB_STEP_SUMMARY - echo "- \`loom-linux-x64.tar.gz\`" >> $GITHUB_STEP_SUMMARY - echo "- \`loom-macos-x64.tar.gz\`" >> $GITHUB_STEP_SUMMARY - echo "- \`loom-macos-arm64.tar.gz\`" >> $GITHUB_STEP_SUMMARY - echo "- \`loom-windows-x64.zip\`" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "**WebAssembly:**" >> $GITHUB_STEP_SUMMARY - echo "- \`loom.wasm\` ($(ls -lh release/loom.wasm | awk '{print $5}'))" >> $GITHUB_STEP_SUMMARY - echo "- OCI: \`${IMAGE_TAG}\`" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "### 🔐 Security" >> $GITHUB_STEP_SUMMARY - echo "- ✅ OCI artifact signed with Cosign (keyless)" >> $GITHUB_STEP_SUMMARY - echo "- ✅ SLSA provenance attestation" >> $GITHUB_STEP_SUMMARY - echo "- ✅ SHA256 checksums for all files" >> $GITHUB_STEP_SUMMARY + 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 "loom $VERSION" \ + --generate-notes \ + release-assets/* + fi