Skip to content

chore(deps): update actions/checkout action to v7.0.1 (#318) #426

chore(deps): update actions/checkout action to v7.0.1 (#318)

chore(deps): update actions/checkout action to v7.0.1 (#318) #426

Workflow file for this run

---
# Margine bootc image build, on a GitHub-hosted ubuntu-24.04 runner.
#
# History (2026-06-01): we used to run this on a self-hosted PVE VM
# (margine-builder, VM 170). After two freezes — the second one
# taking the entire PVE host down with ZFS spacemap corruption (see
# proxmox-pve1/docs/operations/zfs-spacemap-corruption-recovery.md)
# — the self-hosted runner has been decommissioned. GitHub-hosted
# is exactly the "container that wakes up when a job arrives and
# shuts down after" model we wanted.
#
# Pipeline shape (2026-06-01 split, see
# the project's CI split rule):
#
# build_push ──┐
# │ (digest output, fanout)
# ▼
# sign ──┐
# ▼
# notify (if: always())
#
# build_push does the heavy work (buildah + rechunk + skopeo push,
# ~25 min). sign is a separate cheap job (~1 min) that signs the
# pushed manifest *by digest* instead of by tag — cosign warns
# against by-tag signing as it's racy.
#
# On a failed sign step, `gh run rerun --failed <run-id>` re-runs
# only the sign job (~1 min) instead of redoing the whole build.
# That's the whole point of the split — failure cost dominates
# the few seconds of cross-job overhead.
#
# Triggers: push to main, weekly cron (sun 04:00 UTC), and on-demand
# via workflow_dispatch.
#
# Output: publishes to `:candidate` and `:candidate.<date>`. The
# smoke-boot.yml workflow then runs an end-to-end QEMU boot test on
# the candidate; only if QEMU reaches multi-user.target does the
# candidate digest get promoted to `:stable` (skopeo copy
# --preserve-digests).
name: Build Margine image
on:
push:
branches: [main]
# Don't rebuild the image for changes that can't affect it (see list).
paths-ignore:
# Only build.yml builds the :stable/:candidate image. Editing any
# OTHER workflow (ISO/IA, smoke, NVIDIA variant, pin-bumpers, lint,
# security, cleanup, status) must NOT trigger a full image rebuild.
# paths-ignore can't negate ("all workflows except build.yml"), so
# every non-image workflow is listed explicitly — add new ones here.
- '.github/workflows/build-disk.yml'
- '.github/workflows/build-nvidia.yml'
- '.github/workflows/build-test-stock-kernel.yml'
- 'Containerfile.test-stock-kernel'
- '.github/workflows/check-upstream-pins.yml'
- '.github/workflows/countme-tracker.yml'
- '.github/scripts/publish-countme-json.sh'
- '.github/scripts/publish-status-json.sh'
- '.github/scripts/bump-site-iso-date.sh'
- '.github/workflows/ghcr-cleanup.yml'
- '.github/workflows/ia-prune-history.yml'
- '.github/workflows/lint.yml'
- '.github/workflows/otiling-pin-sha.yml'
- '.github/workflows/scorecard.yml'
- '.github/workflows/smoke-boot.yml'
- '.github/workflows/status-json.yml'
- '.github/workflows/validate-flatpak-refs.yml'
- '.github/workflows/wsf-pin-sha.yml'
- 'README.md'
- 'CHANGELOG.md'
- 'docs/**'
- 'tools/**'
pull_request:
# Opt-in per-PR build: only PRs explicitly labeled `vm-test` build
# the image and publish a `:pr-N` tag, so daniel can rebase the
# lab VM (5-min smoke) instead of merging blind. Every other PR
# stays cheap. Label, push fix, the next push to the labeled PR
# rebuilds.
types: [labeled, synchronize]
branches: [main]
paths-ignore:
# Only build.yml builds the :stable/:candidate image. Editing any
# OTHER workflow (ISO/IA, smoke, NVIDIA variant, pin-bumpers, lint,
# security, cleanup, status) must NOT trigger a full image rebuild.
# paths-ignore can't negate ("all workflows except build.yml"), so
# every non-image workflow is listed explicitly — add new ones here.
- '.github/workflows/build-disk.yml'
- '.github/workflows/build-nvidia.yml'
- '.github/workflows/build-test-stock-kernel.yml'
- 'Containerfile.test-stock-kernel'
- '.github/workflows/check-upstream-pins.yml'
- '.github/workflows/countme-tracker.yml'
- '.github/scripts/publish-countme-json.sh'
- '.github/scripts/publish-status-json.sh'
- '.github/scripts/bump-site-iso-date.sh'
- '.github/workflows/ghcr-cleanup.yml'
- '.github/workflows/ia-prune-history.yml'
- '.github/workflows/lint.yml'
- '.github/workflows/otiling-pin-sha.yml'
- '.github/workflows/scorecard.yml'
- '.github/workflows/smoke-boot.yml'
- '.github/workflows/status-json.yml'
- '.github/workflows/validate-flatpak-refs.yml'
- '.github/workflows/wsf-pin-sha.yml'
- 'README.md'
- 'CHANGELOG.md'
- 'docs/**'
- 'tools/**'
schedule:
# Weekly nightly: Sunday 04:00 UTC = 06:00 CEST. Picks up upstream
# Bluefin DX changes even if there are no commits to this repo.
- cron: '0 4 * * 0'
workflow_dispatch:
env:
IMAGE_REGISTRY: "ghcr.io/${{ github.repository_owner }}"
IMAGE_NAME: margine
IMAGE_DESC: "Margine — Bluefin DX + CachyOS signed kernel + Margine deltas"
IMAGE_KEYWORDS: "bootc,ublue,bluefin,cachyos,margine"
CANDIDATE_TAG: "candidate"
# Least privilege (Scorecard Token-Permissions): nothing at the workflow
# level; every job below declares exactly what it needs.
permissions: {}
concurrency:
group: ${{ github.workflow }}-${{ github.ref || github.run_id }}
cancel-in-progress: true
jobs:
build_push:
name: Build + rechunk + push
# On pull_request, only build for PRs explicitly labeled `vm-test`.
# All other PRs (docs, CI tweaks, etc.) skip the 30-min image build.
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'vm-test')
runs-on: ubuntu-24.04
timeout-minutes: 90
permissions:
contents: read
packages: write
# No id-token: nothing here is OIDC/keyless (signing is key-based
# and lives in the sign job anyway). Least privilege (review P2.6).
outputs:
digest: ${{ steps.push.outputs.digest }}
image_ref: ${{ steps.push.outputs.image_ref }}
tags: ${{ steps.metadata.outputs.tags }}
steps:
- name: Maximize build space
# ubuntu-24.04 has ~14 GiB of free disk by default; we need
# ~30+ GiB for the buildah cache + base image + Margine layers
# + rechunk staging. This action removes Android/Haskell/.NET/
# Swift/CodeQL/GHC pre-installed bundles, freeing ~30 GiB.
uses: ublue-os/remove-unwanted-software@cc0becac701cf642c8f0a6613bbdaf5dc36b259e # v9
with:
remove-codeql: true
- name: Checkout
# SHA-pinned for supply-chain safety. Comment is the human-readable
# version Dependabot uses to bump both fields in lockstep. See the
# tj-actions/changed-files incident (2025-03) for why @vN alone
# is unsafe.
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 1
- name: Pre-build login to GHCR
# Authenticated pull of ghcr.io/ublue-os/bluefin-dx:stable
# (5000 req/h vs 100 anonymous).
run: |
echo "${{ secrets.GITHUB_TOKEN }}" | podman login ghcr.io \
-u "${{ github.actor }}" --password-stdin
- name: Resolve base image digest (best-effort)
# Stamp the bluefin-dx:stable digest this image is built FROM as the
# standard OCI base.digest label. The status.json producer compares
# it against the *current* bluefin-dx:stable digest to tell whether
# Margine is on the latest upstream (baseDigestMatchesBluefin on the
# /status page). Best-effort: continue-on-error keeps a skopeo/network
# hiccup from ever failing the critical build path — the label just
# ends up empty and the page shows "unknown".
id: basedigest
continue-on-error: true
run: |
command -v skopeo >/dev/null 2>&1 || { sudo apt-get update -qq && sudo apt-get install -y -qq skopeo; }
DIGEST="$(skopeo inspect --no-tags docker://ghcr.io/ublue-os/bluefin-dx:stable --format '{{.Digest}}' 2>/dev/null || echo '')"
echo "digest=${DIGEST}" >> "$GITHUB_OUTPUT"
echo "base (bluefin-dx:stable) digest: ${DIGEST:-<unresolved>}"
- name: Stage MOK secrets for BuildKit
# Key + cert only. The enrollment PASSPHRASE is public by design
# (printed in the live-ISO dialog and the install docs) and is a
# constant in custom-kernel/install.sh since 2026-06-12 — the
# MOK_PASSWORD repo secret is no longer consumed and can be
# deleted from the repository settings.
env:
MOK_KEY: ${{ secrets.MOK_KEY }}
MOK_CERT: ${{ secrets.MOK_CERT }}
run: |
mkdir -p /tmp/margine-secrets
chmod 700 /tmp/margine-secrets
printf '%s' "$MOK_KEY" > /tmp/margine-secrets/MOK.key
printf '%s' "$MOK_CERT" > /tmp/margine-secrets/MOK.pem
chmod 600 /tmp/margine-secrets/*
- name: Get current date
id: date
run: |
echo "date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> $GITHUB_OUTPUT
echo "ymd=$(date -u +%Y%m%d)" >> $GITHUB_OUTPUT
- name: Image metadata
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
id: metadata
with:
# On push/schedule/dispatch → :candidate + :candidate.YYYYMMDD.
# On pull_request (only when labeled `vm-test`) → :pr-N
# (transient, expected to be GC'd by the stale-tag cleanup
# job; users rebase the lab VM to it for smoke testing).
tags: |
type=raw,value=${{ env.CANDIDATE_TAG }},enable=${{ github.event_name != 'pull_request' }}
type=raw,value=${{ env.CANDIDATE_TAG }}.${{ steps.date.outputs.ymd }},enable=${{ github.event_name != 'pull_request' }}
type=ref,event=pr,prefix=pr-,enable=${{ github.event_name == 'pull_request' }}
labels: |
io.artifacthub.package.readme-url=https://raw.githubusercontent.com/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}/${{ github.sha }}/README.md
org.opencontainers.image.created=${{ steps.date.outputs.date }}
org.opencontainers.image.description=${{ env.IMAGE_DESC }}
org.opencontainers.image.source=https://github.com/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}/blob/${{ github.sha }}/Containerfile
org.opencontainers.image.title=${{ env.IMAGE_NAME }}
org.opencontainers.image.url=https://github.com/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}/tree/${{ github.sha }}
org.opencontainers.image.vendor=${{ github.repository_owner }}
org.opencontainers.image.version=${{ env.CANDIDATE_TAG }}.${{ steps.date.outputs.ymd }}
io.artifacthub.package.deprecated=false
io.artifacthub.package.keywords=${{ env.IMAGE_KEYWORDS }}
io.artifacthub.package.license=Apache-2.0
io.artifacthub.package.prerelease=false
containers.bootc=1
org.opencontainers.image.base.digest=${{ steps.basedigest.outputs.digest }}
org.opencontainers.image.base.name=ghcr.io/ublue-os/bluefin-dx:stable
sep-tags: " "
sep-annotations: " "
- name: Build image (kernel signing via BuildKit secrets)
# Direct `sudo buildah build` shell call instead of the
# redhat-actions/buildah-build wrapper action. Replicates the
# action's behaviour with finer control:
# - containerfiles → --file ./Containerfile
# - image+tags → expanded into per-tag --tag args
# - labels → expanded into per-line --label args
# - oci: false → --format docker
# - extra-args → inlined as --secret mounts
# Pattern mirrors how Bazzite drives buildah in its own build
# workflow (ublue-os/bazzite#.github/workflows/build.yml). Wins:
# 1. No more "Node.js 20 deprecated" runner warning — there's
# no JavaScript action involved at all.
# 2. No more dependency on the buildah-build action publishing
# a Node-24 tag (upstream redhat-actions/buildah-build#155
# is still open).
# 3. Direct shell = trivial to debug locally; same flags work
# on a developer laptop.
# All env vars referenced below are expanded by GHA before the
# shell runs; the build is hermetic at that point.
id: build_image
run: |
set -euo pipefail
IMG="${{ env.IMAGE_NAME }}"
# The metadata-action emits tags space-separated thanks to
# `sep-tags: " "` in the metadata step above.
TAG_ARGS=()
for tag in ${{ steps.metadata.outputs.tags }}; do
TAG_ARGS+=(--tag "${IMG}:${tag}")
done
# Labels come back newline-separated (no sep override exists
# for labels) — read line by line, skip empty.
LABEL_ARGS=()
while IFS= read -r line; do
[[ -z "$line" ]] && continue
LABEL_ARGS+=(--label "$line")
done <<< "${{ steps.metadata.outputs.labels }}"
sudo -E buildah build \
--file ./Containerfile \
--format docker \
--layers \
--secret id=mok-key,src=/tmp/margine-secrets/MOK.key \
--secret id=mok-cert,src=/tmp/margine-secrets/MOK.pem \
"${TAG_ARGS[@]}" \
"${LABEL_ARGS[@]}" \
.
# Sanity: assert the image is in root buildah storage where
# the rechunk step will find it. The action used to do this
# implicitly; doing it explicitly keeps the contract obvious.
sudo buildah images "${IMG}:${{ steps.metadata.outputs.version }}"
# NOTE: no "Move to root storage" step here — `sudo buildah build`
# above already writes to /var/lib/containers (root storage),
# which is exactly where rechunk's `sudo podman create` looks.
# The previous step that round-tripped through an oci-archive
# existed because the buildah-build action ran rootless and
# left the image in ~/.local/share/containers/. Going direct
# removes that bounce.
# (A stale comment block describing a containers-storage: syft
# approach lived here for weeks while the real SBOM step below
# documents the opposite — podman export + tar + syft dir, see
# its own comment. Deleted 2026-06-12.)
# Phase-4 unification (2026-06-12): run the SHIPPED validators
# inside the freshly built container with the new `image` context
# (margine-fedora-atomic#56). Single source of truth: the same
# checks `ujust margine-doctor` and Layer C run on real systems.
# Runs ALONGSIDE the grep sentinels below per the adoption policy
# (two green runs, then the duplicated greps get deleted) — and it
# implicitly asserts the validators were fetched executable, which
# nothing else does.
- name: Run shipped validators in-container (image context)
run: |
set -euo pipefail
IMG="localhost/${{ env.IMAGE_NAME }}:${{ steps.metadata.outputs.version }}"
for v in margine-validate-margine-system margine-validate-branding; do
echo "=== $v (MARGINE_VALIDATE_CONTEXT=image) ==="
sudo podman run --rm \
-e MARGINE_VALIDATE_CONTEXT=image \
--entrypoint "/usr/bin/$v" \
"$IMG"
done
- name: Validate first-boot assets in built image (blocks rechunk)
# Layer A file-level guardrail — full check suite extracted to
# .github/scripts/validate-image-rootfs.sh (shellcheck-gated,
# runnable locally against any built image). Fails fast at
# ~22 min, BEFORE rechunk/push/SBOM/sign.
run: |
set -euo pipefail
sudo .github/scripts/validate-image-rootfs.sh \
"localhost/${{ env.IMAGE_NAME }}:${{ steps.metadata.outputs.version }}"
- name: Install syft (SBOM generator)
uses: anchore/sbom-action/download-syft@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0
- name: Generate SBOM (Bluefin pattern — podman export + tar + syft dir)
id: sbom
# syft's `podman:` / `containers-storage:` providers still pull all
# layers and expand the in-memory layer tree past the runner's 16 GB
# RAM — verified in PRs #58/#60/#65.
# Bluefin's documented workaround (https://github.com/anchore/syft/
# issues/3800, ublue-os/bluefin Justfile gen-sbom recipe) is to
# `podman export` the container's filesystem to a flat tar, expand
# it on disk, then point syft at the directory. syft `dir:` provider
# walks files instead of reconstructing the layer model — peak RAM
# ~1 GB instead of OOM.
run: |
set -euo pipefail
ROOTFS=/tmp/margine-sbom-rootfs
sudo rm -rf "$ROOTFS"
sudo mkdir -p "$ROOTFS"
# Create (don't start) a container off the image — `podman export`
# only needs the filesystem, not a running process. Idempotent
# name via --replace.
sudo podman container create --replace --name sbom-export \
--entrypoint /bin/true \
"localhost/${{ env.IMAGE_NAME }}:${{ steps.metadata.outputs.version }}"
sudo podman export sbom-export | sudo tar -C "$ROOTFS" -xf -
sudo podman container rm sbom-export
# syft as user against the on-disk dir. --source-name puts the
# original image ref in the SBOM metadata so the consumer can
# tell which image it describes.
sudo "$(which syft)" --source-name "${{ env.IMAGE_NAME }}:${{ steps.metadata.outputs.version }}" \
"$ROOTFS" -o spdx-json=sbom.spdx.json
sudo chown "$(id -u):$(id -g)" sbom.spdx.json
ls -la sbom.spdx.json
echo "SBOM package count: $(jq '.packages | length' sbom.spdx.json)"
# Piggyback: capture the image's kernel version for the
# dev.margine.kernel OCI label (rechunk step below). This is the
# only place the built rootfs is already expanded on disk, and the
# image ships exactly one /usr/lib/modules/<kver>/ directory.
# Historically the status page read a place.the-empty.margine.kernel
# label that NOTHING ever produced (and pre-rechunk labels are
# dropped by rechunk anyway), so /status relied on a manually
# curated carry-over. This closes that loop.
KVER="$(sudo ls "$ROOTFS/usr/lib/modules" | head -1)"
[ -n "$KVER" ] || { echo "::error::no kernel dir under /usr/lib/modules in the built image"; exit 1; }
echo "kver=$KVER" >> "$GITHUB_OUTPUT"
echo "Kernel in image: $KVER"
# Reclaim the ~14 GB used by the expanded rootfs before rechunk
# runs — rechunk needs disk for its own staging.
sudo rm -rf "$ROOTFS"
- name: Upload SBOM as workflow artifact (handoff to sign job)
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: sbom-${{ env.IMAGE_NAME }}-${{ github.run_id }}
path: sbom.spdx.json
retention-days: 1
if-no-files-found: error
- name: ReChunk image
id: rechunk
uses: hhd-dev/rechunk@5fbe1d3a639615d2548d83bc888360de6267b1a2 # v1.2.4
with:
ref: ${{ env.IMAGE_NAME }}:${{ steps.metadata.outputs.version }}
version: ${{ env.CANDIDATE_TAG }}.${{ steps.date.outputs.ymd }}
labels: |
org.opencontainers.image.title=${{ env.IMAGE_NAME }}
org.opencontainers.image.description=${{ env.IMAGE_DESC }}
org.opencontainers.image.source=https://github.com/${{ github.repository }}
org.opencontainers.image.url=https://github.com/${{ github.repository }}
org.opencontainers.image.vendor=${{ github.repository_owner }}
org.opencontainers.image.licenses=Apache-2.0
io.artifacthub.package.keywords=${{ env.IMAGE_KEYWORDS }}
io.artifacthub.package.license=Apache-2.0
containers.bootc=1
dev.margine.kernel=${{ steps.sbom.outputs.kver }}
revision: ${{ github.sha }}
- name: Login to GHCR
run: |
echo "${{ secrets.GITHUB_TOKEN }}" | sudo skopeo login \
-u "${{ github.actor }}" --password-stdin ghcr.io
- name: Push rechunked image to GHCR
id: push
env:
IMAGE_REGISTRY: ${{ env.IMAGE_REGISTRY }}
IMAGE_NAME: ${{ env.IMAGE_NAME }}
run: |
set -euo pipefail
IMG_FULL="${IMAGE_REGISTRY,,}/${IMAGE_NAME,,}"
DIGEST=""
# Copy each metadata tag → GHCR. All tags point at the same
# manifest, so we capture the digest from the first copy
# (--digestfile) and reuse it for sign's by-digest reference.
for tag in ${{ steps.metadata.outputs.tags }}; do
sudo skopeo copy --retry-times 3 \
--dest-creds="${{ github.actor }}:${{ secrets.GITHUB_TOKEN }}" \
--digestfile=/tmp/digest.txt \
"${{ steps.rechunk.outputs.ref }}" \
"docker://${IMG_FULL}:${tag}"
if [[ -z "$DIGEST" ]]; then
DIGEST="$(cat /tmp/digest.txt)"
fi
done
if [[ -z "$DIGEST" || ! "$DIGEST" =~ ^sha256: ]]; then
echo "::error::failed to capture manifest digest"
exit 1
fi
echo "digest=$DIGEST" >> "$GITHUB_OUTPUT"
echo "image_ref=${IMG_FULL}@${DIGEST}" >> "$GITHUB_OUTPUT"
echo "Pushed manifest: ${IMG_FULL}@${DIGEST}"
- name: Wipe staged secrets
if: always()
run: rm -rf /tmp/margine-secrets
sign:
name: Cosign sign (by digest) + SBOM attach + sign
needs: build_push
runs-on: ubuntu-24.04
# 30 min: cosign sign is ~10s, but syft on the 14 GB Margine image
# is 5-8 min (RPM database scan + Flatpak inventory + OCI layer
# walk), and `oras attach` re-uploads the SBOM blob (~5 MB) +
# cosign-signs it. The original 10 min budget was set before the
# SBOM sub-pipeline landed (PR #49) — that hit the cap and the
# job got cancelled twice tonight. 30 min keeps healthy slack.
timeout-minutes: 30
permissions:
contents: read
packages: write
# No id-token: cosign signs with a long-lived key (env://), not
# keyless OIDC. Least privilege (review P2.6).
steps:
- name: Maximize build space
# syft on a 14 GB rechunked Margine OCI image needs to pull all
# layers (~14 GB compressed, more expanded for the SBOM scan)
# AND keep the in-memory SBOM tree. Default ubuntu-24.04 disk
# ~14 GB free → not enough. Previously this job hit a runner
# shutdown signal at ~11 min into syft (OOM / no space) before
# we added this step. Audit §6.1 + PR #52/#53 follow-up.
uses: ublue-os/remove-unwanted-software@cc0becac701cf642c8f0a6613bbdaf5dc36b259e # v9
with:
remove-codeql: true
- name: Install Cosign
# SHA-pinned for supply-chain safety. v3.10.1 pulls cosign v3.0.6
# which patches CVE-2026-39395 (GHSA-w6c6-c85g-mmv6, verify-blob-
# attestation false-positive on malformed payloads). Floating
# @v3 doesn't guarantee >= v3.0.6. See audit §6.2.
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
- name: Cosign login to GHCR
# cosign sign reads ~/.docker/config.json; nothing in this fresh
# job has populated it, so do an explicit login before signing.
# Token via stdin like every other login in this repo (the -p
# argv form was the lone exception).
run: |
echo "${{ secrets.GITHUB_TOKEN }}" | cosign login ghcr.io \
-u "${{ github.actor }}" --password-stdin
- name: Sign image by digest
# Sign the pushed manifest *by digest* (image@sha256:...), not by
# tag — cosign warns that tag-based signing is racy ("This can
# lead you to sign a different image than the intended one").
# build_push captured the digest from `skopeo copy --digestfile`.
env:
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
COSIGN_PASSWORD: ""
run: |
set -euo pipefail
IMAGE_REF="${{ needs.build_push.outputs.image_ref }}"
if [[ -z "$IMAGE_REF" ]]; then
echo "::error::needs.build_push.outputs.image_ref is empty"
exit 1
fi
echo "Signing: ${IMAGE_REF}"
cosign sign -y --key env://COSIGN_PRIVATE_KEY "${IMAGE_REF}"
# ----- SBOM attach + sign (artifact handoff from build_push) -----
# SBOM was generated inside build_push from local container storage
# (~5-15s, <500 MB peak RAM) and uploaded as workflow artifact.
# Here we just download it, attach it to the pushed manifest as
# an OCI 1.1 referrer, and cosign-sign the SBOM artifact's digest
# so consumers can `oras discover` → `oras pull` → `cosign verify`
# against the same public key as the image. See docs/
# sbom-revisit-plan.md for why this lives split across two jobs.
- name: Download SBOM artifact (from build_push)
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: sbom-${{ env.IMAGE_NAME }}-${{ github.run_id }}
path: .
# CVE visibility (2026-06-12, review item F): grype over the SBOM
# we already generate — report-only, never gates. The summary
# lands in the job's step summary; fixable Critical/High counts
# give a weekly pulse without blocking ships (base-image CVEs are
# Bluefin/Fedora's cadence, not ours).
- name: CVE scan (grype, report-only)
continue-on-error: true
run: |
set -euo pipefail
# SHA-pinned installer + pinned grype version. This runs inside
# the job that holds the cosign key and writes to a PATH dir
# that precedes cosign's, so the installer itself must be
# immutable: a moving `v0.114.0` tag (or `main`) is exactly the
# supply-chain shape this repo's SHA-pin policy exists to
# prevent (review P2.3). ef8e65a = the commit tag v0.114.0
# points at; the -b arg still names the release to download.
curl -sSfL https://raw.githubusercontent.com/anchore/grype/ef8e65adb2dec760f1f923e635da4c7696d3c295/install.sh \
| sh -s -- -b /usr/local/bin v0.114.0 >/dev/null
SBOM_FILE="$(ls *.spdx.json sbom*.json 2>/dev/null | head -1)"
[[ -n "$SBOM_FILE" ]] || { echo "::warning::no SBOM file found for grype"; exit 0; }
grype "sbom:${SBOM_FILE}" -o table > grype.txt || true
CRIT=$(grep -c ' Critical' grype.txt || true)
HIGH=$(grep -c ' High' grype.txt || true)
{
echo "## Grype CVE report (report-only)"
echo "Critical: ${CRIT} — High: ${HIGH} (full table below)"
echo '~~~'
head -100 grype.txt
echo '~~~'
} >> "$GITHUB_STEP_SUMMARY"
echo "Critical=${CRIT} High=${HIGH}"
- name: Install ORAS
uses: oras-project/setup-oras@1d808f7d7f6995cc68b7bf507bfe5c5446e1dc9d # v2.0.1
- name: Attach + cosign-sign SBOM
env:
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
COSIGN_PASSWORD: ""
run: |
set -euo pipefail
IMAGE_REF="${{ needs.build_push.outputs.image_ref }}"
ls -la sbom.spdx.json
echo "${{ secrets.GITHUB_TOKEN }}" | oras login ghcr.io \
-u "${{ github.actor }}" --password-stdin
# oras 2.x: --format go-template='{{.Digest}}' returns
# "<no value>" (the JSON key is "digest" lowercase but the
# go-template path the docs suggest does not resolve). Use
# --format json + jq to get the manifest digest of the new
# subject-attached SBOM artifact, reliably.
# PR #73 follow-up after 2026-06-06 build #27065187939 fail:
# "Signing SBOM: ghcr.io/.../margine@<no value>".
ATTACH_JSON="$(oras attach \
--artifact-type application/vnd.spdx+json \
--annotation org.opencontainers.artifact.created="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--annotation org.opencontainers.artifact.description="SPDX SBOM for ${IMAGE_REF}" \
--format json \
"${IMAGE_REF}" \
sbom.spdx.json:application/spdx+json)"
echo "oras attach output: ${ATTACH_JSON}"
ATTACH_DIGEST="$(jq -r '.reference | split("@")[1] // .digest // empty' <<<"$ATTACH_JSON")"
if [[ -z "$ATTACH_DIGEST" ]]; then
echo "::error::oras attach did not return a digest (raw: $ATTACH_JSON)"
exit 1
fi
IMG_BASE="${IMAGE_REF%@*}"
SBOM_REF="${IMG_BASE}@${ATTACH_DIGEST}"
echo "Signing SBOM: ${SBOM_REF}"
cosign sign -y --key env://COSIGN_PRIVATE_KEY "${SBOM_REF}"
notify:
name: ntfy notification
needs: [build_push, sign]
runs-on: ubuntu-24.04
timeout-minutes: 5
# Run after both upstream jobs settle, regardless of their outcome.
if: always()
# Sends one curl; needs nothing from the token.
permissions: {}
steps:
- name: Notify ntfy (aggregated)
env:
NTFY_URL: ${{ secrets.NTFY_TOPIC_URL }}
BUILD_RESULT: ${{ needs.build_push.result }}
SIGN_RESULT: ${{ needs.sign.result }}
run: |
if [[ -z "${NTFY_URL:-}" ]]; then
echo "NTFY_TOPIC_URL secret not set, skipping notification."
exit 0
fi
RUN_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
# Decision matrix. The interesting case is partial success
# (build pushed but signing failed) — surface it explicitly so
# daniel can `gh run rerun --failed` instead of guessing.
if [[ "$BUILD_RESULT" == "success" && "$SIGN_RESULT" == "success" ]]; then
TITLE="Build OK → :candidate pushed + signed"
PRIO="default"
TAGS="white_check_mark,construction"
MSG="Run #${{ github.run_number }} done. Smoke-boot will now QEMU-validate and promote to :stable if green."
elif [[ "$BUILD_RESULT" == "failure" ]]; then
TITLE="Build FAILED (image push)"
PRIO="high"
TAGS="x,fire"
MSG="The buildah/rechunk/push job failed. See run for details."
elif [[ "$SIGN_RESULT" == "failure" ]]; then
TITLE="Build PARTIAL → image pushed, sign FAILED"
PRIO="high"
TAGS="x,closed_lock_with_key"
MSG="The image is on GHCR but unsigned. Retry just the sign job: gh run rerun --failed ${{ github.run_id }}"
elif [[ "$BUILD_RESULT" == "cancelled" || "$SIGN_RESULT" == "cancelled" ]]; then
TITLE="Build cancelled"
PRIO="low"
TAGS="warning"
MSG=""
else
TITLE="Build status: build=${BUILD_RESULT}, sign=${SIGN_RESULT}"
PRIO="default"
TAGS="warning"
MSG=""
fi
curl --silent --show-error --fail \
-H "Title: ${TITLE}" \
-H "Priority: ${PRIO}" \
-H "Tags: ${TAGS}" \
-H "Click: ${RUN_URL}" \
-d "$MSG" \
"$NTFY_URL" || echo "ntfy notify failed (non-fatal)"