chore(deps): update actions/checkout action to v7.0.1 (#318) #426
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| --- | |
| # 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)" |