Skip to content

SpotSchedule: Add new 'capital markets' provider and remaining work for docs #196

SpotSchedule: Add new 'capital markets' provider and remaining work for docs

SpotSchedule: Add new 'capital markets' provider and remaining work for docs #196

Workflow file for this run

# Copyright (c) 2025 Erick Bourgeois, firestoned
# SPDX-License-Identifier: Apache-2.0
name: Build
on:
pull_request:
branches:
- main
paths:
- 'src/**'
- 'Cargo.toml'
- 'Cargo.lock'
- 'Dockerfile*'
- '.github/workflows/build.yaml'
- '.github/actions/**'
- '.vex/**'
push:
branches:
- main
release:
types:
- published
# Top-level GITHUB_TOKEN is read-only by default (OpenSSF Scorecard
# Token-Permissions rule). Jobs that need elevated scopes — id-token:write
# for keyless Cosign / Sigstore, security-events:write for SARIF upload to
# Code Scanning, packages:write for GHCR push, attestations:write for
# actions/attest-build-provenance, contents:write for release-asset upload
# — declare them explicitly at job level. See the docker / attest /
# build-vex / iac-scan / grype / sign-artifacts / slsa-provenance /
# upload-release-assets jobs below. (Semgrep SAST runs from sast.yaml.)
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
# Override the cross-compilation linkers set in .cargo/config.toml.
# config.toml specifies the Homebrew cross-compilers needed when a macOS
# developer runs `cargo build --target x86_64-unknown-linux-gnu` locally.
# On Linux CI runners the native target IS x86_64/aarch64-unknown-linux-gnu,
# so cargo would try to invoke those cross-compilers — which are not
# installed. Setting these vars to `cc` restores the default system linker.
CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER: cc
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: cc
jobs:
# ─────────────────────────────────────────────────────────────────────────────
# License headers — always runs (PRs are pre-filtered by path at the trigger).
# ─────────────────────────────────────────────────────────────────────────────
license-check:
name: ⚖️ Verify SPDX License Headers
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Check license headers
uses: firestoned/github-actions/security/license-check@53b483254bc648903c364ee3c73a546d0936a91e # v1.3.6
with:
copyright-holder: "Erick Bourgeois, firestoned"
license-id: "Apache-2.0"
# ─────────────────────────────────────────────────────────────────────────────
# Commit signatures — verify-mode differs by event type.
# ─────────────────────────────────────────────────────────────────────────────
verify-commits:
name: 🔐 Verify Signed Commits
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
- name: Verify commits (PR)
if: github.event_name == 'pull_request'
uses: firestoned/github-actions/security/verify-signed-commits@53b483254bc648903c364ee3c73a546d0936a91e # v1.3.6
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
base-ref: origin/${{ github.base_ref }}
verify-mode: pr
- name: Verify commits (push to main)
if: github.event_name == 'push'
uses: firestoned/github-actions/security/verify-signed-commits@53b483254bc648903c364ee3c73a546d0936a91e # v1.3.6
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
verify-mode: push
- name: Verify commits (release)
if: github.event_name == 'release'
uses: firestoned/github-actions/security/verify-signed-commits@53b483254bc648903c364ee3c73a546d0936a91e # v1.3.6
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
verify-mode: release
# ─────────────────────────────────────────────────────────────────────────────
# PR only: formatting + clippy.
# ─────────────────────────────────────────────────────────────────────────────
format:
name: 🎨 Check Formatting
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
needs: [license-check, verify-commits]
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable (moving ref — re-pin quarterly)
with:
components: rustfmt
- name: Check formatting
run: cargo fmt -- --check
clippy:
name: 📎 Clippy
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
needs: [format]
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable (moving ref — re-pin quarterly)
with:
components: clippy
- name: Cache cargo dependencies
uses: firestoned/github-actions/rust/cache-cargo@53b483254bc648903c364ee3c73a546d0936a91e # v1.3.6
- name: Run clippy
run: cargo clippy --all-targets --all-features -- -D warnings
# ─────────────────────────────────────────────────────────────────────────────
# VEX parse gate — PR only.
# Every .vex/<id>.json is a single-statement OpenVEX document (the native
# OpenVEX shape). vexctl merge parses each one as input; any malformed file
# fails the merge, giving us free schema + enum + structural validation
# from upstream tooling. No custom validator to maintain.
# ─────────────────────────────────────────────────────────────────────────────
validate-vex:
name: 🧾 Validate VEX Statements
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
needs: [verify-commits]
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Install vexctl
run: make vexctl-install
- name: Parse every .vex/*.json via vexctl merge
run: make vex-validate
# ─────────────────────────────────────────────────────────────────────────────
# Auto-VEX pre-submission gate — PR + push (ADR 0008).
# The auto-VEX suppressions must be generated, reviewed, and signed off
# (committed) BEFORE submission. This job regenerates .vex/auto/* from the
# frozen .vex/snapshot/ with pinned @id/author/timestamp and byte-exact diffs
# the result against the committed output. A mismatch hard-fails the build:
# the contributor must run `make vex-auto`, review the suppressions, and commit.
# This is the upstream human checkpoint; the Security team's release-time
# counter-signature (see docs/src/security/vex.md) remains the downstream net.
# ─────────────────────────────────────────────────────────────────────────────
validate-autovex:
name: 🔏 Validate Auto-VEX Sign-off
if: github.event_name == 'pull_request' || github.event_name == 'push'
runs-on: ubuntu-latest
needs: [verify-commits]
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable (moving ref — re-pin quarterly)
- name: Cache cargo dependencies
uses: firestoned/github-actions/rust/cache-cargo@53b483254bc648903c364ee3c73a546d0936a91e # v1.3.6
# Regenerate .vex/auto/* from the committed .vex/snapshot/ and fail if the
# committed output is stale. Output is byte-deterministic (pinned
# @id/author/timestamp + CVE-sorted generators), so a plain git diff is the
# whole gate.
- name: Verify committed auto-VEX matches a fresh regeneration
run: make vex-auto-check
# ─────────────────────────────────────────────────────────────────────────────
# Extract version — all events; downstream build jobs depend on this.
# format/clippy are independent PR quality checks and do not gate the build.
# ─────────────────────────────────────────────────────────────────────────────
extract-version:
name: 🏷️ Extract Version Information
runs-on: ubuntu-latest
needs: [license-check, verify-commits]
outputs:
version: ${{ steps.version-chainguard.outputs.version }}
tag_name: ${{ steps.version-chainguard.outputs.tag-name }}
image-tag-chainguard: ${{ steps.version-chainguard.outputs.image-tag }}
image-tag-distroless: ${{ steps.version-distroless.outputs.image-tag }}
image-repository-chainguard: ${{ steps.version-chainguard.outputs.image-repository }}
image-repository-distroless: ${{ steps.version-distroless.outputs.image-repository }}
short-sha: ${{ steps.version-chainguard.outputs.short-sha }}
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Extract version for Chainguard
id: version-chainguard
uses: ./.github/actions/extract-version
with:
repository: ${{ github.repository }}
workflow-type: ${{ github.event_name == 'pull_request' && 'pr' || github.event_name == 'push' && 'main' || 'release' }}
pr-number: ${{ github.event.pull_request.number }}
release-tag: ${{ github.event.release.tag_name }}
image-tag-suffix: ""
- name: Extract version for Distroless
id: version-distroless
uses: ./.github/actions/extract-version
with:
repository: ${{ github.repository }}
workflow-type: ${{ github.event_name == 'pull_request' && 'pr' || github.event_name == 'push' && 'main' || 'release' }}
pr-number: ${{ github.event.pull_request.number }}
release-tag: ${{ github.event.release.tag_name }}
image-tag-suffix: "-distroless"
# ─────────────────────────────────────────────────────────────────────────────
# Build binary — all events.
# On release the Cargo.toml version is bumped to match the release tag.
# ─────────────────────────────────────────────────────────────────────────────
build:
name: 🦀 Build - ${{ matrix.platform.name }}
runs-on: ${{ matrix.platform.runner }}
needs: [extract-version]
strategy:
fail-fast: false
matrix:
platform:
- name: Linux x86_64
runner: ubuntu-24.04
artifact_name: 5spot-linux-amd64
binary_name: 5spot
- name: Linux ARM64
runner: ubuntu-24.04-arm
artifact_name: 5spot-linux-arm64
binary_name: 5spot
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Update Cargo.toml version
if: github.event_name == 'release'
run: |
perl -i -pe 's/^version = ".*"/version = "${{ needs.extract-version.outputs.version }}"/' Cargo.toml
echo "Updated Cargo.toml to version ${{ needs.extract-version.outputs.version }}"
grep '^version = ' Cargo.toml
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable (moving ref — re-pin quarterly)
- name: Cache cargo dependencies
uses: firestoned/github-actions/rust/cache-cargo@53b483254bc648903c364ee3c73a546d0936a91e # v1.3.6
- name: Build binary
run: cargo build --release
- name: Install cargo-cyclonedx
run: cargo install cargo-cyclonedx --locked --quiet
- name: Generate SBOM
run: |
cargo cyclonedx --format json
find . -maxdepth 1 -name "*.cdx.json" -exec mv {} target/release/ \;
# PR and push artifacts expire after 1 day — they are not release assets.
- name: Upload artifacts (PR / push)
if: github.event_name != 'release'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ${{ matrix.platform.artifact_name }}
path: |
target/release/${{ matrix.platform.binary_name }}
target/release/*.cdx.json
retention-days: 1
# Release artifacts keep the default retention so re-runs remain possible.
- name: Upload artifacts (release)
if: github.event_name == 'release'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ${{ matrix.platform.artifact_name }}
path: |
target/release/${{ matrix.platform.binary_name }}
target/release/*.cdx.json
# ─────────────────────────────────────────────────────────────────────────────
# Tests — all events; running unconditionally lets docker depend on it simply.
# ─────────────────────────────────────────────────────────────────────────────
test:
name: 🧪 Test
runs-on: ubuntu-latest
needs: [build]
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable (moving ref — re-pin quarterly)
- name: Download x86_64 build artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: 5spot-linux-amd64
path: target/release/
- name: Cache cargo dependencies
uses: firestoned/github-actions/rust/cache-cargo@53b483254bc648903c364ee3c73a546d0936a91e # v1.3.6
- name: Run tests
run: cargo test --all --verbose
# ─────────────────────────────────────────────────────────────────────────────
# Docker build and push — all events.
#
# Metadata tags vary by event (three separate metadata steps, only one runs
# per invocation; empty outputs from skipped steps are filtered out by
# docker/build-push-action). On release, Cosign signing and SBOM generation
# steps are also enabled.
# ─────────────────────────────────────────────────────────────────────────────
docker:
name: 🐳 Build and Push Docker Image - ${{ matrix.variant.name }}
runs-on: ubuntu-latest
needs: [build, extract-version, test]
permissions:
contents: read
packages: write
id-token: write
outputs:
digest-chainguard: ${{ steps.export-digest.outputs.digest-chainguard }}
digest-distroless: ${{ steps.export-digest.outputs.digest-distroless }}
strategy:
fail-fast: false
matrix:
variant:
- name: Chainguard
dockerfile: Dockerfile.chainguard
suffix: ""
description: "5-Spot Chainguard Zero-CVE (glibc, FIPS-ready)"
image-tag: ${{ needs.extract-version.outputs.image-tag-chainguard }}
image-repository: ${{ needs.extract-version.outputs.image-repository-chainguard }}
- name: Distroless
dockerfile: Dockerfile
suffix: "-distroless"
description: "5-Spot Google Distroless (glibc)"
image-tag: ${{ needs.extract-version.outputs.image-tag-distroless }}
image-repository: ${{ needs.extract-version.outputs.image-repository-distroless }}
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Prepare Docker binaries
uses: ./.github/actions/prepare-docker-binaries
- name: Setup Docker environment
uses: firestoned/github-actions/docker/setup-docker@53b483254bc648903c364ee3c73a546d0936a91e # v1.3.6
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
# Only one of the three metadata steps below runs per job instance.
# docker/build-push-action concatenates all tags/labels outputs and
# silently drops the empty lines produced by the two skipped steps.
- name: Extract metadata (PR)
id: meta-pr
if: github.event_name == 'pull_request'
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
with:
images: ghcr.io/${{ matrix.variant.image-repository }}
tags: |
type=raw,value=${{ matrix.variant.image-tag }}
type=raw,value=sha-${{ needs.extract-version.outputs.short-sha }}
flavor: |
latest=false
- name: Extract metadata (push to main)
id: meta-main
if: github.event_name == 'push'
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
with:
images: ghcr.io/${{ matrix.variant.image-repository }}
tags: |
type=raw,value=${{ matrix.variant.image-tag }}
type=raw,value=main-{{date 'YYYY-MM-DD'}}
type=raw,value=sha-${{ needs.extract-version.outputs.short-sha }}
type=raw,value=latest
flavor: |
latest=false
- name: Extract metadata (release)
id: meta-release
if: github.event_name == 'release'
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
with:
images: ghcr.io/${{ matrix.variant.image-repository }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value=sha-${{ needs.extract-version.outputs.short-sha }}${{ matrix.variant.suffix }}
flavor: |
latest=false
prefix=v
suffix=${{ matrix.variant.suffix }}
- name: Build and push Docker image (${{ matrix.variant.name }})
id: docker_build
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
file: ${{ matrix.variant.dockerfile }}
platforms: linux/amd64,linux/arm64
push: true
tags: |
${{ steps.meta-pr.outputs.tags }}
${{ steps.meta-main.outputs.tags }}
${{ steps.meta-release.outputs.tags }}
labels: |
${{ steps.meta-pr.outputs.labels }}
${{ steps.meta-main.outputs.labels }}
${{ steps.meta-release.outputs.labels }}
org.opencontainers.image.description=${{ matrix.variant.description }}
build-args: |
${{ github.event_name == 'release' && format('VERSION={0}', needs.extract-version.outputs.version) || '' }}
cache-from: type=gha,scope=${{ matrix.variant.name }}
cache-to: type=gha,mode=max,scope=${{ matrix.variant.name }}
sbom: true
provenance: true
# Push to main and release: sign the image with Cosign (keyless, Sigstore).
# PR images are ephemeral and not deployed, so signing them is skipped.
# Sign by digest (not tag) to guarantee we sign exactly what was pushed.
- name: Install Cosign
if: github.event_name != 'pull_request'
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
- name: Sign container image with Cosign
if: github.event_name != 'pull_request'
run: |
cosign sign --yes \
ghcr.io/${{ matrix.variant.image-repository }}@${{ steps.docker_build.outputs.digest }}
# Push + release: generate a CycloneDX SBOM for the published image.
# Needed on push (not just release) because auto-vex-presence
# (roadmap Phase 2) uses it to decide whether a Grype finding's purl
# is actually present in the product.
- name: Generate Docker SBOM for ${{ matrix.variant.name }}
if: github.event_name != 'pull_request'
uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0
with:
image: ghcr.io/${{ matrix.variant.image-repository }}:${{ matrix.variant.image-tag }}
format: cyclonedx-json
output-file: docker-sbom-${{ matrix.variant.name }}.json
upload-release-assets: false
- name: Upload Docker SBOM as artifact
if: github.event_name != 'pull_request'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: docker-sbom-${{ matrix.variant.name }}
path: docker-sbom-${{ matrix.variant.name }}.json
- name: Export image digest
id: export-digest
run: |
case "${{ matrix.variant.name }}" in
Chainguard) echo "digest-chainguard=${{ steps.docker_build.outputs.digest }}" >> "$GITHUB_OUTPUT" ;;
Distroless) echo "digest-distroless=${{ steps.docker_build.outputs.digest }}" >> "$GITHUB_OUTPUT" ;;
esac
# ─────────────────────────────────────────────────────────────────────────────
# GitHub Artifact Attestation — all events.
# ─────────────────────────────────────────────────────────────────────────────
attest:
name: 🔏 Attest Docker Image - ${{ matrix.variant.name }}
runs-on: ubuntu-latest
needs: [docker, extract-version]
permissions:
id-token: write
attestations: write
packages: write
strategy:
fail-fast: false
matrix:
variant:
- name: Chainguard
image-repository: ${{ needs.extract-version.outputs.image-repository-chainguard }}
digest: ${{ needs.docker.outputs.digest-chainguard }}
- name: Distroless
image-repository: ${{ needs.extract-version.outputs.image-repository-distroless }}
digest: ${{ needs.docker.outputs.digest-distroless }}
steps:
- name: Log in to GHCR
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Attest ${{ matrix.variant.name }} image provenance
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
with:
subject-name: ghcr.io/${{ matrix.variant.image-repository }}
subject-digest: ${{ matrix.variant.digest }}
push-to-registry: true
# ─────────────────────────────────────────────────────────────────────────────
# Grype triage scan — push + release.
# Runs Grype against each image variant WITHOUT VEX suppression and emits
# JSON (not SARIF). Its output feeds auto-vex-presence so we can see the
# raw set of CVEs before any suppression. The subsequent `grype` job
# (after build-vex) still runs with the final VEX for SARIF upload to
# Code Scanning. Two passes; costs ~30 s per variant; keeps the
# dependency graph acyclic (presence-check needs grype output; final
# grype needs the VEX that includes the presence-check output).
# ─────────────────────────────────────────────────────────────────────────────
grype-triage:
name: 🔭 Grype Triage Scan - ${{ matrix.variant.name }}
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
needs: [docker, extract-version]
permissions:
contents: read
packages: read
strategy:
fail-fast: false
matrix:
variant:
- name: Chainguard
image-tag: ${{ needs.extract-version.outputs.image-tag-chainguard }}
image-repository: ${{ needs.extract-version.outputs.image-repository-chainguard }}
- name: Distroless
image-tag: ${{ needs.extract-version.outputs.image-tag-distroless }}
image-repository: ${{ needs.extract-version.outputs.image-repository-distroless }}
steps:
- name: Log in to GHCR
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Pinned version matches the later grype job. Bump both together.
- name: Install Grype
env:
GRYPE_VERSION: '0.87.0'
run: |
set -euo pipefail
url="https://github.com/anchore/grype/releases/download/v${GRYPE_VERSION}/grype_${GRYPE_VERSION}_linux_amd64.tar.gz"
curl -fsSLo grype.tgz "$url"
tar -xzf grype.tgz grype
sudo install -m 0755 grype /usr/local/bin/grype
grype version
- name: Run Grype triage (no VEX)
env:
IMAGE_REF: ghcr.io/${{ matrix.variant.image-repository }}:${{ matrix.variant.image-tag }}
run: |
grype "$IMAGE_REF" \
--output json \
--file grype-triage-${{ matrix.variant.name }}.json
- name: Upload triage artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: grype-triage-${{ matrix.variant.name }}
path: grype-triage-${{ matrix.variant.name }}.json
if-no-files-found: error
# ─────────────────────────────────────────────────────────────────────────────
# Auto-VEX (presence-based) — push + release. Roadmap Phase 2.
#
# For each Grype triage finding whose affected purl is absent from every
# image SBOM and whose CVE id is not already covered by a hand-authored
# .vex/*.json statement, emit a `not_affected + component_not_present`
# OpenVEX statement. The result is uploaded as the `vex-auto-presence`
# artifact and is also merged unconditionally into the signed VEX
# document by `build-vex`. Verification is downstream (Security team
# counter-signs after their own re-evaluation); our side emits
# aggressively.
# ─────────────────────────────────────────────────────────────────────────────
auto-vex-presence:
name: 🤖 Auto-VEX (presence)
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
needs: [docker, extract-version, grype-triage]
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable (moving ref — re-pin quarterly)
- name: Cache cargo dependencies
uses: firestoned/github-actions/rust/cache-cargo@53b483254bc648903c364ee3c73a546d0936a91e # v1.3.6
- name: Build auto-vex-presence
run: cargo build --release --bin auto-vex-presence
- name: Download triage scan artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: grype-triage-*
path: ./triage
merge-multiple: true
- name: Download Docker SBOM artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: docker-sbom-*
path: ./sboms
merge-multiple: true
# One auto-presence document per image variant, then merged at the
# end. We keep variants separate because Grype's triage scan is
# per-image and we want each statement's `products` to correctly
# scope to the image it was derived from. For simplicity in Phase 2,
# both variants use the same `pkg:oci/5-spot` product purl (matching
# the existing hand-authored statements).
- name: Run auto-vex-presence
run: |
set -euo pipefail
vex_id="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}/auto-vex-presence"
ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
per_variant=()
for variant in Chainguard Distroless; do
triage="triage/grype-triage-${variant}.json"
sbom="sboms/docker-sbom-${variant}.json"
if [ ! -f "$triage" ] || [ ! -f "$sbom" ]; then
echo "missing $triage or $sbom; skipping variant $variant"
continue
fi
out="vex.auto-presence-${variant}.json"
./target/release/auto-vex-presence \
--grype-json "$triage" \
--sbom "$sbom" \
--vex-dir .vex \
--product-purl "pkg:oci/5-spot" \
--id "${vex_id}/${variant}" \
--author "auto-vex-presence" \
--timestamp "$ts" \
--output "$out"
per_variant+=("$out")
done
if [ ${#per_variant[@]} -eq 0 ]; then
echo "ERROR: no variant produced an auto-presence document"
exit 1
fi
- name: Merge per-variant auto-presence documents
run: |
make vexctl-install
vexctl merge \
--id "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}/auto-vex-presence" \
--author "auto-vex-presence" \
vex.auto-presence-*.json > vex.auto-presence.json
echo "── vex.auto-presence.json ─────────────────────────────────────────"
cat vex.auto-presence.json
- name: Upload auto-presence artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: vex-auto-presence
path: vex.auto-presence.json
if-no-files-found: error
# ─────────────────────────────────────────────────────────────────────────────
# Auto-VEX (symbol-import reachability) — push + release. Roadmap Phase 3.
#
# For each Grype triage finding whose CVE id maps to a list of
# affected library function names in `.vex/.affected-functions.json`,
# check whether the release binary's dynamic symbol-import table
# contains any of those names. If none → emit a `not_affected +
# vulnerable_code_not_in_execute_path` statement. The result is
# uploaded as the `vex-auto-reachability` artifact and merged
# unconditionally into the signed VEX document by `build-vex`.
#
# Why "symbol-import reachability" instead of full LLVM-IR call graph:
# cargo audit reports zero open Rust-level vulnerabilities for this
# codebase; every CVE we triage is a base-image glibc/zlib finding
# surfaced by Grype. Those CVEs are not tracked in RustSec, so the
# original roadmap's RustSec-affected-functions approach addresses
# zero current findings. Symbol-import absence is the mechanical
# equivalent for our actual data: if our Rust binary doesn't link
# against `glob`/`scanf`/`iconv`, the affected glibc code paths
# cannot be reached through documented entry points.
# ─────────────────────────────────────────────────────────────────────────────
auto-vex-reachability:
name: 🤖 Auto-VEX (reachability)
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
needs: [build, extract-version, grype-triage]
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable (moving ref — re-pin quarterly)
- name: Cache cargo dependencies
uses: firestoned/github-actions/rust/cache-cargo@53b483254bc648903c364ee3c73a546d0936a91e # v1.3.6
- name: Build auto-vex-reachability
run: cargo build --release --bin auto-vex-reachability
- name: Download release binary (5spot-linux-amd64)
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: 5spot-linux-amd64
path: ./bin
- name: Download triage scan artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: grype-triage-*
path: ./triage
merge-multiple: true
# Capture the binary's dynamic symbol-import table. This is the
# ELF view; on Linux runners we can use nm -D --undefined-only
# directly. The output becomes the evidence the Security team
# re-derives downstream during counter-signing.
- name: Capture binary symbol imports
run: |
set -euo pipefail
chmod +x bin/5spot
nm -D --undefined-only bin/5spot > symbols.txt
echo "── imported symbol count ──"
wc -l symbols.txt
echo "── first 20 symbols ──"
head -20 symbols.txt
# Run once per variant (binary symbols are identical across
# variants but Grype findings differ), merge per-variant outputs
# at the end.
- name: Run auto-vex-reachability
run: |
set -euo pipefail
vex_id="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}/auto-vex-reachability"
ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
per_variant=()
for variant in Chainguard Distroless; do
triage="triage/grype-triage-${variant}.json"
if [ ! -f "$triage" ]; then
echo "missing $triage; skipping variant $variant"
continue
fi
out="vex.auto-reachability-${variant}.json"
./target/release/auto-vex-reachability \
--grype-json "$triage" \
--binary-symbols symbols.txt \
--affected-functions .vex/.affected-functions.json \
--vex-dir .vex \
--product-purl "pkg:oci/5-spot" \
--id "${vex_id}/${variant}" \
--author "auto-vex-reachability" \
--timestamp "$ts" \
--output "$out"
per_variant+=("$out")
done
if [ ${#per_variant[@]} -eq 0 ]; then
echo "ERROR: no variant produced an auto-reachability document"
exit 1
fi
- name: Merge per-variant auto-reachability documents
run: |
make vexctl-install
vexctl merge \
--id "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}/auto-vex-reachability" \
--author "auto-vex-reachability" \
vex.auto-reachability-*.json > vex.auto-reachability.json
echo "── vex.auto-reachability.json ─────────────────────────────────"
cat vex.auto-reachability.json
- name: Upload auto-reachability artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: vex-auto-reachability
path: vex.auto-reachability.json
if-no-files-found: error
# The symbols.txt evidence is uploaded separately so the Security
# team can re-run the same reachability analysis against the
# release-attested binary digest and confirm the auto-emitted
# statements.
- name: Upload symbol-imports evidence
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: vex-auto-reachability-evidence
path: symbols.txt
if-no-files-found: error
# ─────────────────────────────────────────────────────────────────────────────
# VEX document — push to main + release.
#
# Reads hand-authored per-CVE triage from .vex/*.json (each file is a
# single-statement native OpenVEX document), merges them into a single
# document via `vexctl merge`, and (on push/release only) Cosign-attests
# it to both image digests. The `openvex` artifact is always uploaded so
# the `grype` container scan job can consume it via `--vex` and suppress
# already-triaged findings from GitHub Code Scanning. On release, the
# GitHub build-provenance bundle is also emitted and attached to the
# release.
#
# vexctl merge fails on any malformed input, so there's no separate
# validator step — a bad merge slipping past the PR gate would also fail
# the assembly here.
#
# Trust model: this job emits and Cosign-attests the VEX document. The
# auto-presence (Phase 2) and auto-reachability (Phase 3) artifacts are
# merged unconditionally alongside the hand-authored `.vex/*.json`
# statements. Verification of the resulting claims is performed
# downstream by the Security team, which independently re-evaluates the
# VEX against the attached evidence (SBOMs, symbol-imports,
# Cosign attestations, SLSA provenance) and counter-signs if they
# agree. There is no feature-flag gate on our side — automation is
# aggressive, verification is external.
# ─────────────────────────────────────────────────────────────────────────────
build-vex:
name: 🧾 Assemble OpenVEX
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
needs: [docker, extract-version, auto-vex-presence, auto-vex-reachability]
permissions:
contents: read
id-token: write
attestations: write
packages: write
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Install vexctl
run: make vexctl-install
- name: Download auto-presence artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: vex-auto-presence
path: ./auto
- name: Download auto-reachability artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: vex-auto-reachability
path: ./auto
# Canonical @id varies by event so downstream consumers can link back
# to the exact commit or release tag that produced the document.
# Both auto-* documents are merged alongside hand-authored
# statements on every build.
- name: Assemble OpenVEX document
id: assemble
run: |
set -euo pipefail
case "${{ github.event_name }}" in
release)
vex_id="https://github.com/${{ github.repository }}/releases/tag/${{ needs.extract-version.outputs.tag_name }}/vex"
;;
push)
vex_id="https://github.com/${{ github.repository }}/commit/${{ github.sha }}/vex"
;;
*)
vex_id="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}/vex"
;;
esac
test -f auto/vex.auto-presence.json || { echo "ERROR: auto-presence artifact missing"; exit 1; }
test -f auto/vex.auto-reachability.json || { echo "ERROR: auto-reachability artifact missing"; exit 1; }
vexctl merge \
--id "$vex_id" \
--author "${{ github.event.release.author.login || github.actor }}" \
.vex/*.json auto/vex.auto-presence.json auto/vex.auto-reachability.json > vex.openvex.json
echo "── vex.openvex.json ─────────────────────────────────────────"
cat vex.openvex.json
# Always upload the assembled document so the `grype` scan job can
# download it via actions/download-artifact@v4 regardless of event.
- name: Upload OpenVEX document artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: openvex
path: vex.openvex.json
if-no-files-found: error
- name: Install Cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
- name: Log in to GHCR (for cosign attest)
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Cosign-attest the OpenVEX document to each image digest (keyless OIDC,
# writes to the Sigstore transparency log + registry). Runs on both
# main push and release so staging images have the same VEX posture
# as release images.
- name: Cosign attest OpenVEX to Chainguard image
run: |
cosign attest --yes \
--predicate vex.openvex.json \
--type openvex \
ghcr.io/${{ needs.extract-version.outputs.image-repository-chainguard }}@${{ needs.docker.outputs.digest-chainguard }}
- name: Cosign attest OpenVEX to Distroless image
run: |
cosign attest --yes \
--predicate vex.openvex.json \
--type openvex \
ghcr.io/${{ needs.extract-version.outputs.image-repository-distroless }}@${{ needs.docker.outputs.digest-distroless }}
# GitHub-native attestation parity with other release artifacts.
# Release-only because the bundle is attached as a release asset and
# the attestation links to the release tag.
- name: Attest OpenVEX build provenance
if: github.event_name == 'release'
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
id: attest-vex
with:
subject-path: vex.openvex.json
# The attest-build-provenance action writes the sigstore bundle to a
# path exposed via its `bundle-path` output. Copy it next to the VEX
# document so the release-assets job can pick it up.
- name: Stage VEX bundle beside document
if: github.event_name == 'release'
run: |
set -euo pipefail
if [ -n "${{ steps.attest-vex.outputs.bundle-path }}" ]; then
cp "${{ steps.attest-vex.outputs.bundle-path }}" vex.openvex.json.bundle
fi
ls -lh vex.openvex.json.bundle
- name: Upload OpenVEX bundle artifact (release)
if: github.event_name == 'release'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: openvex-bundle
path: vex.openvex.json.bundle
if-no-files-found: error
# ─────────────────────────────────────────────────────────────────────────────
# Security scan — all events.
# Gitleaks secret scan only runs on PRs; release uploads the audit report.
# ─────────────────────────────────────────────────────────────────────────────
security:
name: 🛡️ Security Vulnerability Scan
runs-on: ubuntu-latest
needs: [verify-commits]
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable (moving ref — re-pin quarterly)
- name: Run security scan (PR / push)
if: github.event_name != 'release'
uses: firestoned/github-actions/rust/security-scan@53b483254bc648903c364ee3c73a546d0936a91e # v1.3.6
with:
cargo-audit-version: "0.22.0"
- name: Run security scan (release)
if: github.event_name == 'release'
uses: firestoned/github-actions/rust/security-scan@53b483254bc648903c364ee3c73a546d0936a91e # v1.3.6
with:
cargo-audit-version: "0.22.0"
upload-artifact-name: cargo-audit-report-release
- name: Run gitleaks (secret scanning)
if: github.event_name == 'pull_request'
run: make gitleaks
# Semgrep SAST moved to .github/workflows/sast.yaml so it runs on every
# PR and push with no `paths:` filter (Scorecard's SAST check records
# skipped commits as uncovered otherwise).
# ─────────────────────────────────────────────────────────────────────────────
# IaC security scan (Trivy config) — PR + push to main.
# Scans Kubernetes manifests under deploy/ and the Dockerfiles for
# misconfigurations. Uploads SARIF to the Code Scanning dashboard.
# ─────────────────────────────────────────────────────────────────────────────
iac-scan:
name: 🏗️ IaC Security Scan (Trivy config)
if: github.event_name != 'release'
runs-on: ubuntu-latest
needs: [verify-commits]
permissions:
contents: read
security-events: write
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Run Trivy config scan
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
scan-type: config
scan-ref: .
format: sarif
output: trivy-iac.sarif
severity: CRITICAL,HIGH,MEDIUM
exit-code: '0'
# Justified suppressions live in .trivyignore at the repo root;
# each entry is commented with an architectural rationale.
trivyignores: ./.trivyignore
- name: Upload Trivy IaC SARIF to Code Scanning
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with:
sarif_file: trivy-iac.sarif
category: trivy-iac
# ─────────────────────────────────────────────────────────────────────────────
# cargo-deny — PR + push to main.
# Enforces the license allow-list, RustSec advisories (same DB as
# cargo-audit), and forbids dependencies from unknown registries or git
# URLs. Configured via deny.toml at repo root.
# ─────────────────────────────────────────────────────────────────────────────
cargo-deny:
name: 🧪 cargo-deny (License + Advisory + Sources)
if: github.event_name != 'release'
runs-on: ubuntu-latest
needs: [verify-commits]
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Run cargo-deny
uses: EmbarkStudios/cargo-deny-action@bb137d7af7e4fb67e5f82a49c4fce4fad40782fe # v2.0.20
with:
log-level: warn
command: check
arguments: --all-features
# ─────────────────────────────────────────────────────────────────────────────
# Container security scan (Grype) — all events.
#
# Grype is the canonical Anchore/Chainguard scanner and natively consumes
# OpenVEX via --vex to suppress already-triaged CVEs (Trivy's VEX support
# is less mature and requires a different input shape). On push + release
# the build-vex job assembles a VEX document; on PR there is no VEX yet,
# so the scan runs without suppression. SARIF is uploaded to GitHub Code
# Scanning under a category segmented by event + variant so historical
# findings remain separable across branches.
#
# Ordering note: build-vex is listed in `needs` so the VEX artifact is
# ready before grype runs. On PR events build-vex is skipped, so the `if`
# guard lets grype proceed regardless of that skipped status.
# ─────────────────────────────────────────────────────────────────────────────
grype:
name: 🔭 Container Security Scan - ${{ matrix.variant.name }}
runs-on: ubuntu-latest
needs: [docker, extract-version, build-vex]
if: |
always() &&
needs.docker.result == 'success' &&
needs.extract-version.result == 'success' &&
(needs.build-vex.result == 'success' || needs.build-vex.result == 'skipped')
permissions:
contents: read
packages: read
id-token: write
security-events: write
strategy:
fail-fast: false
matrix:
variant:
- name: Chainguard
image-tag: ${{ needs.extract-version.outputs.image-tag-chainguard }}
image-repository: ${{ needs.extract-version.outputs.image-repository-chainguard }}
- name: Distroless
image-tag: ${{ needs.extract-version.outputs.image-tag-distroless }}
image-repository: ${{ needs.extract-version.outputs.image-repository-distroless }}
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Log in to GHCR
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Download the VEX document assembled by build-vex. Only present on
# push + release events — PR events have no document to consume.
- name: Download OpenVEX document
if: github.event_name != 'pull_request'
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: openvex
path: ./vex
# Pinned-version install from the Anchore release tarball. Bump via
# GRYPE_VERSION and record in
# ~/dev/roadmaps/5spot-vex-generation-and-signing.md § Dependencies.
- name: Install Grype
env:
GRYPE_VERSION: '0.87.0'
run: |
set -euo pipefail
url="https://github.com/anchore/grype/releases/download/v${GRYPE_VERSION}/grype_${GRYPE_VERSION}_linux_amd64.tar.gz"
curl -fsSLo grype.tgz "$url"
tar -xzf grype.tgz grype
sudo install -m 0755 grype /usr/local/bin/grype
grype version
# Build --vex only when we actually have a document. Grype ignores
# statements whose product purl does not match the scanned image, so
# passing an over-inclusive VEX is safe. A missing VEX simply means
# no suppressions apply (PR events).
- name: Run Grype scan on ${{ matrix.variant.name }} image
env:
IMAGE_REF: ghcr.io/${{ matrix.variant.image-repository }}:${{ matrix.variant.image-tag }}
SARIF_FILE: grype-${{ github.event_name }}-${{ matrix.variant.name }}.sarif
run: |
set -euo pipefail
vex_args=()
if [ -f vex/vex.openvex.json ]; then
echo "Using VEX document: vex/vex.openvex.json"
vex_args+=(--vex vex/vex.openvex.json)
else
echo "No VEX document available — scanning without suppressions."
fi
grype "$IMAGE_REF" \
"${vex_args[@]}" \
--output sarif \
--file "$SARIF_FILE"
- name: Upload Grype SARIF to Code Scanning
if: always()
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with:
sarif_file: grype-${{ github.event_name }}-${{ matrix.variant.name }}.sarif
category: grype-${{ github.event_name }}-${{ matrix.variant.name }}
- name: Upload Grype SARIF artifact
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ${{ github.event_name == 'release' && format('grype-release-{0}', matrix.variant.name) || format('grype-{0}-{1}-{2}', github.event_name, matrix.variant.name, github.run_number) }}
path: grype-${{ github.event_name }}-${{ matrix.variant.name }}.sarif
if-no-files-found: error
# ─────────────────────────────────────────────────────────────────────────────
# Push-to-main and release: sign binaries with Cosign and emit GitHub
# artifact attestations. Skipped on PRs.
# ─────────────────────────────────────────────────────────────────────────────
sign-artifacts:
name: ✍️ Sign Binary Artifacts
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
needs: [build, extract-version]
permissions:
id-token: write
attestations: write
contents: read
strategy:
fail-fast: false
matrix:
platform:
- artifact_name: 5spot-linux-amd64
binary_name: 5spot
- artifact_name: 5spot-linux-arm64
binary_name: 5spot
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Download binary artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: ${{ matrix.platform.artifact_name }}
path: ./artifacts/${{ matrix.platform.artifact_name }}
- name: Create tarball for signing
run: |
cd artifacts/${{ matrix.platform.artifact_name }}
chmod +x ${{ matrix.platform.binary_name }}
tar czf ${{ matrix.platform.artifact_name }}.tar.gz ${{ matrix.platform.binary_name }}
ls -lh ${{ matrix.platform.artifact_name }}.tar.gz
- name: Sign binary tarball with Cosign
uses: firestoned/github-actions/security/cosign-sign@53b483254bc648903c364ee3c73a546d0936a91e # v1.3.6
with:
artifact-path: artifacts/${{ matrix.platform.artifact_name }}/${{ matrix.platform.artifact_name }}.tar.gz
- name: Attest binary tarball provenance
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
with:
subject-path: artifacts/${{ matrix.platform.artifact_name }}/${{ matrix.platform.artifact_name }}.tar.gz
- name: Upload signed artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ${{ matrix.platform.artifact_name }}-signed
path: |
artifacts/${{ matrix.platform.artifact_name }}/${{ matrix.platform.artifact_name }}.tar.gz
artifacts/${{ matrix.platform.artifact_name }}/${{ matrix.platform.artifact_name }}.tar.gz.bundle
# ─────────────────────────────────────────────────────────────────────────────
# Push-to-main and release: compute SHA-256 hashes of signed tarballs for SLSA.
# ─────────────────────────────────────────────────────────────────────────────
generate-provenance-subjects:
name: 🔬 Generate SLSA Provenance Subjects
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
needs: [sign-artifacts, extract-version]
outputs:
hashes: ${{ steps.hash.outputs.hashes }}
steps:
- name: Download signed artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: 5spot-*-signed
path: ./artifacts
merge-multiple: true
- name: Generate hashes for SLSA provenance
id: hash
run: |
cd artifacts
sha256sum *.tar.gz > checksums.txt
cat checksums.txt
echo "hashes=$(cat checksums.txt | base64 -w0)" >> "$GITHUB_OUTPUT"
# ─────────────────────────────────────────────────────────────────────────────
# Push-to-main and release: SLSA Level 3 provenance via the official generator.
# `upload-assets` only applies on release (there is no GitHub Release object
# on a push-to-main to attach assets to); on push the provenance lands as a
# workflow artifact, verifiable with slsa-verifier or `gh attestation verify`.
# ─────────────────────────────────────────────────────────────────────────────
slsa-provenance:
name: 🏛️ Generate SLSA Provenance
if: github.event_name != 'pull_request'
needs: [generate-provenance-subjects, extract-version]
permissions:
actions: read
id-token: write
contents: write
# MUST remain on a semver tag, not a SHA: SLSA Level 3 verification
# requires the generator ref to be one of the approved released versions.
# SHA-pinning this line would break slsa-verifier attestation validation.
# See https://github.com/slsa-framework/slsa-github-generator#verification-of-provenance
uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v2.1.0
with:
base64-subjects: "${{ needs.generate-provenance-subjects.outputs.hashes }}"
upload-assets: ${{ github.event_name == 'release' }}
provenance-name: "${{ needs.extract-version.outputs.version }}.intoto.jsonl"
# ─────────────────────────────────────────────────────────────────────────────
# Release only: regenerate and package the CRD deploy manifests.
# ─────────────────────────────────────────────────────────────────────────────
package-deploy-manifests:
name: 📦 Package Deployment Manifests
if: github.event_name == 'release'
runs-on: ubuntu-latest
needs: [extract-version]
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable (moving ref — re-pin quarterly)
- name: Cache cargo dependencies
uses: firestoned/github-actions/rust/cache-cargo@53b483254bc648903c364ee3c73a546d0936a91e # v1.3.6
- name: Regenerate CRDs
run: cargo run --quiet --bin crdgen > deploy/crds/scheduledmachine.yaml
- name: Package deploy manifests
run: tar czf deploy-manifests.tar.gz deploy/
- name: Upload deploy manifests
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: deploy-manifests
path: deploy-manifests.tar.gz
# ─────────────────────────────────────────────────────────────────────────────
# Release only: collate all artifacts and upload to the GitHub Release.
# ─────────────────────────────────────────────────────────────────────────────
upload-release-assets:
name: 🚀 Upload Release Assets
if: github.event_name == 'release'
runs-on: ubuntu-latest
needs: [build, docker, attest, sign-artifacts, slsa-provenance, package-deploy-manifests, build-vex]
permissions:
contents: write
steps:
- name: Download all artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: ./artifacts
pattern: "{5spot-*,docker-sbom-*,*.intoto.jsonl,deploy-manifests,openvex,openvex-bundle}"
- name: 🧹 Organize release artifacts
run: |
cd artifacts
mkdir -p release sboms signatures provenance
# Copy signed tarballs and signature bundles
for dir in 5spot-*-signed; do
if [ -d "$dir" ]; then
platform="${dir%-signed}"
if compgen -G "$dir/*.tar.gz" > /dev/null; then
cp "$dir"/*.tar.gz release/
fi
if compgen -G "$dir/*.tar.gz.bundle" > /dev/null; then
cp "$dir"/*.tar.gz.bundle signatures/
fi
fi
done
# Collect binary SBOMs (Linux builds only)
for dir in 5spot-linux-amd64 5spot-linux-arm64; do
if [ -d "$dir" ] && compgen -G "$dir/*.cdx.json" > /dev/null; then
cp "$dir"/*.cdx.json "sboms/${dir}-sbom.json"
fi
done
# Copy Docker SBOMs
for variant in Chainguard Distroless; do
if [ -d "docker-sbom-${variant}" ] && [ -f "docker-sbom-${variant}/docker-sbom-${variant}.json" ]; then
cp "docker-sbom-${variant}/docker-sbom-${variant}.json" "sboms/docker-sbom-${variant}.json"
fi
done
# Copy SLSA provenance
find . -name "*.intoto.jsonl" -type f -exec cp {} provenance/ \;
# Copy deploy manifests
if [ -f "deploy-manifests/deploy-manifests.tar.gz" ]; then
cp deploy-manifests/deploy-manifests.tar.gz release/
fi
# Copy OpenVEX document (release/) and its attestation bundle
# (signatures/). The release/ location keeps the document browseable
# next to the binaries; the bundle sits with the other Sigstore
# bundles so consumers can find them in one place. The document
# ships via the always-uploaded `openvex` artifact; the signed
# bundle ships via the release-only `openvex-bundle` artifact.
if [ -f "openvex/vex.openvex.json" ]; then
cp openvex/vex.openvex.json release/
fi
if [ -f "openvex-bundle/vex.openvex.json.bundle" ]; then
cp openvex-bundle/vex.openvex.json.bundle signatures/
fi
# Generate SHA256 checksums
cd release && sha256sum * > checksums.sha256
cd ../sboms && sha256sum *.json >> ../release/checksums.sha256
cd ../signatures && sha256sum *.bundle >> ../release/checksums.sha256
cd ../provenance && compgen -G "*.intoto.jsonl" > /dev/null && sha256sum *.intoto.jsonl >> ../release/checksums.sha256 || true
echo "Release artifacts:"
ls -lh ../release/
- name: Upload release assets
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
with:
files: |
artifacts/release/*.tar.gz
artifacts/release/vex.openvex.json
artifacts/sboms/*.json
artifacts/signatures/*.bundle
artifacts/provenance/*.intoto.jsonl
artifacts/release/checksums.sha256