Skip to content

Move to automating VEX #87

Move to automating VEX

Move to automating VEX #87

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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install vexctl
run: make vexctl-install
- name: Parse every .vex/*.json via vexctl merge
run: make vex-validate
# ─────────────────────────────────────────────────────────────────────────────
# 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- 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@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.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@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.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@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.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@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.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@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
- 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 }}
# Release only: generate a CycloneDX SBOM for the published image.
- name: Generate Docker SBOM for ${{ matrix.variant.name }}
if: github.event_name == 'release'
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 == 'release'
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@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.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
# ─────────────────────────────────────────────────────────────────────────────
# 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.
# ─────────────────────────────────────────────────────────────────────────────
build-vex:
name: 🧾 Assemble OpenVEX
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
needs: [docker, extract-version]
permissions:
contents: read
id-token: write
attestations: write
packages: write
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install vexctl
run: make vexctl-install
# Canonical @id varies by event so downstream consumers can link back
# to the exact commit or release tag that produced the document.
- 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
vexctl merge \
--id "$vex_id" \
--author "${{ github.event.release.author.login || github.actor }}" \
.vex/*.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@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
- name: Log in to GHCR (for cosign attest)
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Run Trivy config scan
uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.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@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Run cargo-deny
uses: EmbarkStudios/cargo-deny-action@91bf2b620e09e18d6eb78b92e7861937469acedb # v2.0.17
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Log in to GHCR
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.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@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- 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