Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .github/actions/setup-nix/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
name: Setup Nix
description: Install Nix with Determinate Systems and configure Nix store cache

runs:
using: composite
steps:
- name: Install Nix
uses: DeterminateSystems/determinate-nix-action@v3
with:
diagnostic-endpoint: ""

- name: Cache Nix store
uses: nix-community/cache-nix-action@v7
with:
primary-key: nix-${{ runner.os }}-${{ hashFiles('flake.lock') }}
restore-prefixes-first-match: nix-${{ runner.os }}-
7 changes: 7 additions & 0 deletions .github/sbom-policy.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"max_added": 50,
"max_removed": 50,
"deny_licenses": [],
"require_licenses": true,
"deny_duplicates": true
}
Original file line number Diff line number Diff line change
@@ -1,96 +1,86 @@
name: Generate and upload SBOMs
name: Generate SBOMs

# Runs after OCI images are built, or on direct pushes that touch image sources.
#
# Prerequisites (one-time setup):
# 1. Add SBOMIFY_TOKEN secret to the repository (Settings → Secrets → Actions).
# Generate the token at https://app.sbomify.com.
# 2. Create one sbomify component per image and replace the placeholder
# sbomify_component_id values in the matrix below with the real IDs.
# Generates and enriches CycloneDX SBOMs for all Nix-built OCI images.
# Enriched SBOMs are uploaded as artifacts for downstream workflows
# (quality gate on PRs, upload to sbomify on merge).

on:
workflow_run:
workflows: ["Build and publish OCI images"]
types: [completed]
pull_request:
branches: [main]
push:
branches:
- main
paths:
paths: &sbom-paths
- images/**
- deployments/sbomify/**
- flake.nix
- flake.lock
- bin/patch-sbom-root
push:
branches: [main]
paths: *sbom-paths
workflow_dispatch:

permissions:
actions: write
contents: read

jobs:
generate-and-upload:
# Skip if triggered by a failed workflow_run
if: >-
github.event_name != 'workflow_run' ||
github.event.workflow_run.conclusion == 'success'
runs-on: blacksmith-4vcpu-ubuntu-2404
generate:
runs-on: blacksmith-2vcpu-ubuntu-2404
strategy:
fail-fast: false
matrix:
image:
- name: postgres
sbom_package: postgres-sbom
nixpkg: postgresql_17
license: PostgreSQL
sbomify_component_id: M8rixM6mMEPe
- name: redis
sbom_package: redis-sbom
nixpkg: redis
license: AGPL-3.0-only
sbomify_component_id: ABBCcw2YiYrG
- name: minio
sbom_package: minio-sbom
nixpkg: minio
license: AGPL-3.0-or-later
sbomify_component_id: PLACEHOLDER_MINIO
- name: minio-client
sbom_package: minio-client-sbom
nixpkg: minio-client
license: Apache-2.0
sbomify_component_id: PLACEHOLDER_MINIO_CLIENT
- name: sbomify-app
sbom_package: sbomify-app-sbom
nixpkg: python313
license: Apache-2.0
sbomify_component_id: VP42I4XQgpDE
- name: sbomify-keycloak
sbom_package: sbomify-keycloak-sbom
nixpkg: keycloak
license: Apache-2.0
sbomify_component_id: N4agQD8pvej8
- name: sbomify-caddy-dev
sbom_package: sbomify-caddy-dev-sbom
nixpkg: caddy
license: Apache-2.0
sbomify_component_id: zYDq6NtrOBuo
- name: sbomify-minio-init
sbom_package: sbomify-minio-init-sbom
nixpkg: minio-client
license: Apache-2.0
sbomify_component_id: PLACEHOLDER_SBOMIFY_MINIO_INIT

steps:
- name: Checkout
uses: actions/checkout@v5

- name: Install Nix
uses: DeterminateSystems/determinate-nix-action@v3
with:
diagnostic-endpoint: ""

- name: Cache Nix store
uses: nix-community/cache-nix-action@v7
with:
primary-key: nix-${{ runner.os }}-${{ hashFiles('flake.lock') }}
restore-prefixes-first-match: nix-${{ runner.os }}-
- name: Setup Nix
uses: ./.github/actions/setup-nix

- name: Get upstream version
id: version
run: |
if [[ "${{ matrix.image.name }}" == sbomify-* ]]; then
# sbomify images use the pinned sbomify source version
VERSION=$(grep 'github:sbomify/sbomify/' flake.nix | grep -oP '/v\K[0-9.]+')
else
VERSION=$(nix eval --raw nixpkgs#${{ matrix.image.nixpkg }}.version)
Expand All @@ -102,7 +92,19 @@ jobs:
nix build .#${{ matrix.image.sbom_package }}
cp -L result ${{ matrix.image.name }}.cdx.json

- name: Generate and Upload SBOM to sbomify
- name: Patch root component metadata
run: |
VERSION="${{ steps.version.outputs.upstream }}"
SBOM="${{ matrix.image.name }}.cdx.json"

bin/patch-sbom-root \
--name "wellmaintained/packages/${{ matrix.image.name }}-image" \
--version "$VERSION" \
--purl "pkg:docker/wellmaintained/packages/${{ matrix.image.name }}@${VERSION}" \
--license "${{ matrix.image.license }}" \
< "$SBOM" > "${SBOM}.tmp" && mv "${SBOM}.tmp" "$SBOM"

- name: Enrich SBOM
if: ${{ !startsWith(matrix.image.sbomify_component_id, 'PLACEHOLDER') }}
uses: sbomify/sbomify-action@master
continue-on-error: true
Expand All @@ -115,12 +117,17 @@ jobs:
SBOM_FORMAT: cyclonedx
AUGMENT: true
ENRICH: true
UPLOAD: true
UPLOAD: false
OUTPUT_FILE: ${{ matrix.image.name }}.enriched.cdx.json

- name: Upload enriched SBOM as artifact
- name: Fall back to raw SBOM if enrichment skipped
run: |
if [ ! -f "${{ matrix.image.name }}.enriched.cdx.json" ]; then
cp "${{ matrix.image.name }}.cdx.json" "${{ matrix.image.name }}.enriched.cdx.json"
fi

- name: Upload enriched SBOM
uses: actions/upload-artifact@v6
with:
name: sbom-${{ matrix.image.name }}
path: ${{ matrix.image.name }}.enriched.cdx.json
if-no-files-found: warn
173 changes: 173 additions & 0 deletions .github/workflows/sbom-quality-gate.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
name: SBOM quality gate

# Scores the PR's SBOMs and compares against the baseline from main.
# Blocks merging if any image's quality score regresses.
#
# Requires the "Generate SBOMs" workflow to have run first on this PR
# (it produces the enriched SBOM artifacts we score here).

on:
workflow_run:
workflows: ["Generate SBOMs"]
types: [completed]

permissions:
actions: read
contents: read
pull-requests: write

jobs:
score:
if: >-
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.conclusion == 'success'
runs-on: blacksmith-2vcpu-ubuntu-2404
strategy:
fail-fast: false
matrix:
image:
- name: postgres
- name: redis
- name: minio
- name: minio-client
- name: sbomify-app
- name: sbomify-keycloak
- name: sbomify-caddy-dev
- name: sbomify-minio-init

steps:
- name: Checkout
uses: actions/checkout@v5

- name: Setup Nix
uses: ./.github/actions/setup-nix

- name: Build sbomqs and sbomlyze
run: |
nix build .#sbomqs -o sbomqs-bin
nix build .#sbomlyze -o sbomlyze-bin
echo "$PWD/sbomqs-bin/bin" >> "$GITHUB_PATH"
echo "$PWD/sbomlyze-bin/bin" >> "$GITHUB_PATH"

- name: Download PR SBOM
uses: actions/download-artifact@v4
with:
name: sbom-${{ matrix.image.name }}
path: current/
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ github.token }}

- name: Score current SBOM
run: |
mkdir -p results
SBOM=$(ls current/*.cdx.json | head -1)
bin/sbom-score --image "${{ matrix.image.name }}" "$SBOM" \
> results/score-${{ matrix.image.name }}.json

- name: Fetch baseline SBOM
id: baseline
continue-on-error: true
env:
GH_TOKEN: ${{ github.token }}
run: |
# Find the latest successful Generate SBOMs run on main
RUN_ID=$(gh run list \
--workflow="Generate SBOMs" \
--branch=main \
--status=success \
--limit=1 \
--json databaseId \
--jq '.[0].databaseId')

if [ -z "$RUN_ID" ] || [ "$RUN_ID" = "null" ]; then
echo "has_baseline=false" >> "$GITHUB_OUTPUT"
exit 0
fi

gh run download "$RUN_ID" \
--name "sbom-${{ matrix.image.name }}" \
--dir baseline/ || {
echo "has_baseline=false" >> "$GITHUB_OUTPUT"
exit 0
}

echo "has_baseline=true" >> "$GITHUB_OUTPUT"

- name: Score baseline SBOM
if: steps.baseline.outputs.has_baseline == 'true'
run: |
SBOM=$(ls baseline/*.cdx.json | head -1)
bin/sbom-score --image "${{ matrix.image.name }}" "$SBOM" \
> results/baseline-${{ matrix.image.name }}.json

- name: Compare SBOMs
if: steps.baseline.outputs.has_baseline == 'true'
run: |
BASELINE=$(ls baseline/*.cdx.json | head -1)
CURRENT=$(ls current/*.cdx.json | head -1)
bin/sbom-compare \
--baseline "$BASELINE" \
--current "$CURRENT" \
--image "${{ matrix.image.name }}" \
--policy .github/sbom-policy.json \
> results/compare-${{ matrix.image.name }}.json

- name: Upload results
uses: actions/upload-artifact@v6
with:
name: sbom-quality-gate-${{ matrix.image.name }}
path: results/

report:
needs: score
# Run even if some score jobs failed, but not if the entire score job
# was skipped (which happens on direct pushes to main where there's no PR).
if: always() && needs.score.result != 'skipped'
runs-on: blacksmith-2vcpu-ubuntu-2404
permissions:
pull-requests: write
actions: read

steps:
- name: Checkout
uses: actions/checkout@v5

- name: Download all results
uses: actions/download-artifact@v4
with:
pattern: sbom-quality-gate-*
merge-multiple: true
path: results/

- name: Generate report
id: report
run: |
bin/sbom-report --scores-dir results/ > pr-comment.md 2>report-stderr.txt || {
cat report-stderr.txt >&2
echo "failed=true" >> "$GITHUB_OUTPUT"
}

- name: Resolve PR number
id: pr
env:
GH_TOKEN: ${{ github.token }}
run: |
# The workflow_run event doesn't directly give us the PR number.
# Look it up from the head branch.
HEAD_BRANCH="${{ github.event.workflow_run.head_branch }}"
PR_NUMBER=$(gh pr list --head "$HEAD_BRANCH" --json number --jq '.[0].number')
echo "number=${PR_NUMBER}" >> "$GITHUB_OUTPUT"

- name: Post PR comment
if: steps.pr.outputs.number
uses: marocchino/sticky-pull-request-comment@v3
with:
header: sbom-quality-gate
number: ${{ steps.pr.outputs.number }}
path: pr-comment.md

- name: Fail on regression
if: steps.report.outputs.failed == 'true'
run: |
echo "SBOM quality regression detected. See PR comment for details."
exit 1
Loading
Loading