Skip to main content
RunBook Academy

← All labs in Git, CI/CD & GitOps

Lab · advanced · ~90 min

Lab 17: Build a container supply-chain pipeline (build → SBOM → scan → sign)

C · SimulationB · Nested virtualisation

Objectives

  • Author a CI pipeline with five stages: build, SBOM, scan, sign, verify
  • Generate a CycloneDX SBOM with syft and attach it to the image as an OCI referrer
  • Scan the SBOM with grype against the GitHub Advisory Database, failing on CRITICAL and HIGH
  • Sign the image with cosign keyless (Fulcio + Rekor) and verify before publishing
  • Author the supply-chain policy that downstream consumers apply (admission controller)
  • Document the failure modes for each of the five stages

Prerequisites

Objective

By the end of this lab you will have authored the artefacts that implement a container supply-chain pipeline: a CI workflow with five stages — build, SBOM, scan, sign, verify — that produces a signed, scanned, SBOM-attached OCI image ready for downstream consumption; the policy that downstream consumers (admission controllers, deploy jobs) apply; and a documented catalogue of failure modes per stage.

The point of this lab is not any single tool — Lab 12 covered Cosign signing, the labs in Lesson LIII-02 covered BuildKit, and the labs in Lesson LXVIII covered SBOM generation. The point is the integration: five stages as a single pipeline, with each stage’s output the next stage’s input, and the verify stage as the gate before publishing.

Architecture

A pipeline with five sequential stages: build produces the image digest; sbom produces the CycloneDX SBOM as an OCI referrer; scan runs Grype against the SBOM and fails on CRITICAL and HIGH; sign runs cosign sign --keyless; verify runs cosign verify against Fulcio and Rekor before publishing.

flowchart LR
    A["build\nbuildkit"] --> B["sbom\nsyft"]
    B --> C["scan\ngrype"]
    C --> D["sign\ncosign keyless"]
    D --> E["verify\ncosign verify"]
    E -- pass --> F["publish"]
    E -- fail --> Z["publish blocked"]

The five stages run in order; each needs: the previous stage’s success. A failure at any stage short-circuits the pipeline — the image is not signed, not verified, and not published.

Requirements

  • Git 2.55.x on Linux or macOS.
  • A GitHub repository with OIDC enabled (default for public repos; org setting for private repos).
  • A container registry. The lab uses ghcr.io because every GitHub repo has one and it implements OCI referrers.
  • No long-lived signing keys. Keyless signing uses an OIDC token per build.

Scenario

A platform team publishes container images to ghcr.io. They want every published image to be (1) signed with Cosign keyless, so consumers can verify provenance; (2) scanned with Grype, so consumers can verify safety; (3) attached to a CycloneDX SBOM, so consumers can audit the dependency list. The pipeline must fail the build on CRITICAL or HIGH vulnerabilities; the signature must be present and verifiable before the image is tagged as :latest.

The lab builds the workflow, the policy, and the failure-mode catalogue.

Tasks

Task 1 — Build the sample application and Dockerfile

LAB="$HOME/supply-chain-lab"
rm -rf "$LAB"
mkdir -p "$LAB"
cd "$LAB"

git init -b main
git config user.email 'ops@example.com'
git config user.name  'Ops'

mkdir -p src

# A minimal Node.js application. The application is incidental;
# the supply chain is the subject.
cat > src/index.js <<'EOF'
const http = require('http');
const server = http.createServer((req, res) => {
  res.writeHead(200, {'Content-Type': 'text/plain'});
  res.end('ok');
});
server.listen(8080, () => console.log('listening on 8080'));
EOF

cat > package.json <<'EOF'
{
  "name": "runbook-supply-chain",
  "version": "1.0.0",
  "main": "src/index.js",
  "engines": { "node": "20.x" }
}
EOF

cat > Dockerfile <<'EOF'
# syntax=docker/dockerfile:1.7

FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json ./
RUN npm ci --omit=dev || npm install --omit=dev

FROM gcr.io/distroless/nodejs20-debian12:nonroot
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY src/ ./src/
COPY package.json ./
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["node", "src/index.js"]
EOF

git add Dockerfile package.json src/
git commit -m 'initial: node app with distroless multi-stage build'

The repository has a Node.js application and a multi-stage Dockerfile that ends in gcr.io/distroless/nodejs20-debian12:nonroot. The distroless base has no shell and runs as nonroot; the attack surface is small.

Task 2 — Author the workflow: build and SBOM

# check-shell-blocks: allow-invalid
cd "$HOME/supply-chain-lab"

mkdir -p .github/workflows

cat > .github/workflows/supply-chain.yml <<'EOF'
name: container supply chain

on:
  push:
    branches: [main]
    tags: ['v*']
  pull_request:
    branches: [main]
  workflow_dispatch:

permissions:
  contents: read
  packages: write     # required to push to ghcr.io
  id-token: write     # required for keyless signing (Fulcio OIDC)
  attestations: write # required for SLSA-style provenance

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${ github.repository }

jobs:
  # ─────────────────────────────────────────────────────────────────
  # Stage 1: build
  # ─────────────────────────────────────────────────────────────────

  build:
    name: build
    runs-on: ubuntu-24.04
    outputs:
      digest: ${ steps.build.outputs.digest }
    steps:
      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11  # v4.1.1
      - name: login to ghcr.io
        uses: docker/login-action@e92390c5a0f122e209c4d2338af9d7b96d527d7c  # v3.0.0
        with:
          registry: ${ env.REGISTRY }
          username: ${ github.actor }
          password: ${ secrets.GITHUB_TOKEN }
      - name: build and push
        id: build
        uses: docker/build-push-action@5cd11c3a4ced054e52742c5fd54dca9547ad9c1c  # v6.0.0
        with:
          context: .
          push: true
          tags: |
            ${ env.REGISTRY }/${ env.IMAGE_NAME }:${ github.sha }
          provenance: true         # SLSA provenance
          sbom: true                # SBOM attestation in provenance
          cache-from: type=gha
          cache-to: type=gha,mode=max

  # ─────────────────────────────────────────────────────────────────
  # Stage 2: SBOM as a CycloneDX OCI referrer
  # ─────────────────────────────────────────────────────────────────

  sbom:
    name: sbom
    runs-on: ubuntu-24.04
    needs: [build]
    steps:
      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11  # v4.1.1
      - name: generate CycloneDX SBOM
        uses: anchore/sbom-action@861e5acddb6eeb6f74f9b9e6e84d9a5b9b5b3e6c  # v0.17.7
        with:
          image: ${ env.REGISTRY }/${ env.IMAGE_NAME }@${ needs.build.outputs.digest }
          format: cyclonedx-json
          artifact-name: image-sbom.cdx.json
          output-file: image-sbom.cdx.json
EOF

git add .github/workflows/supply-chain.yml
git commit -m 'ci: build and SBOM stages'

The first two stages produce the image digest and the CycloneDX SBOM. The SBOM is attached to the image as an OCI referrer; downstream consumers can pull the referrer by its artifactType (application/vnd.cyclonedx+json).

Task 3 — Add the scan stage

# check-shell-blocks: allow-invalid
cd "$HOME/supply-chain-lab"

cat >> .github/workflows/supply-chain.yml <<'EOF'

  # ─────────────────────────────────────────────────────────────────
  # Stage 3: vulnerability scan with grype
  # ─────────────────────────────────────────────────────────────────

  scan:
    name: scan
    runs-on: ubuntu-24.04
    needs: [build, sbom]
    steps:
      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11  # v4.1.1
      - name: install grype
        run: |
          curl -fsSLo grype.tar.gz \
            https://github.com/anchore/grype/releases/latest/download/grype_0.70.0_linux_amd64.tar.gz
          tar -xzf grype.tar.gz
          sudo mv grype /usr/local/bin/
      - name: download SBOM artefact
        uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16  # v4.0.0
        with:
          name: image-sbom.cdx.json
          path: sbom/
      - name: scan SBOM for vulnerabilities
        run: |
          grype \
            --file sbom/image-sbom.cdx.json \
            --fail-on critical,high \
            --only-fixed \
            --output json \
            --file /dev/null \
            > grype-report.json || GRYPE_EXIT=$?
          # grype returns non-zero if findings exist; capture
          # the report regardless.
          if [ "${GRYPE_EXIT:-0}" -ne 0 ]; then
            echo "::error::grype found vulnerabilities above threshold"
            cat grype-report.json
            exit 1
          fi
EOF

git add .github/workflows/supply-chain.yml
git commit -m 'ci: grype vulnerability scan stage'

The scan stage downloads the SBOM artefact, runs grype --fail-on critical,high, and fails the build if any finding is above the threshold. The --only-fixed flag ignores vulnerabilities without a fixed version; the team does not block on unfixable issues.

Task 4 — Add the Grype ignore configuration

# check-shell-blocks: allow-invalid
cd "$HOME/supply-chain-lab"

cat > grype-config.yaml <<'EOF'
# grype-config.yaml
#
# Grype configuration: ignore list for vulnerabilities the team
# has triaged as acceptable. Each entry has a reason; the team
# reviews ignore-list changes in code review.

ignore:
  # ─────────────────────────────────────────────────────────────────
  # Acceptable vulnerabilities (with justification)
  # ─────────────────────────────────────────────────────────────────
  - vulnerability: CVE-2023-45853
    reason: "zlib minizip vulnerability; team does not use minizip"
    expire: "2026-12-31"
  - vulnerability: CVE-2024-0001-fake
    reason: "test fixture; never deployed"

# ─────────────────────────────────────────────────────────────────
# Severity threshold. CRITICAL and HIGH fail; MEDIUM and LOW are
# reported but do not fail.
# ─────────────────────────────────────────────────────────────────
fail-on-severity: critical,high

# ─────────────────────────────────────────────────────────────────
# Output format. JSON for downstream consumption; the SARIF format
# is also supported for code-scanning integration.
# ─────────────────────────────────────────────────────────────────
output: json
EOF

git add grype-config.yaml
git commit -m 'grype: ignore list and severity policy'

The grype-config.yaml is the team’s ignore list for known false positives or accepted vulnerabilities. Each entry has a reason (documentation) and an expire date (the team re-evaluates the entry at expiry). The lab’s entries are illustrative; production ignore lists have dozens of entries, each tied to a specific CVE.

Task 5 — Add the sign and verify stages

# check-shell-blocks: allow-invalid
cd "$HOME/supply-chain-lab"

cat >> .github/workflows/supply-chain.yml <<'EOF'

  # ─────────────────────────────────────────────────────────────────
  # Stage 4: sign with cosign keyless (Fulcio + Rekor)
  # ─────────────────────────────────────────────────────────────────

  sign:
    name: sign (keyless)
    runs-on: ubuntu-24.04
    needs: [scan]
    steps:
      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11  # v4.1.1
      - name: login to ghcr.io
        uses: docker/login-action@e92390c5a0f122e209c4d2338af9d7b96d527d7c  # v3.0.0
        with:
          registry: ${ env.REGISTRY }
          username: ${ github.actor }
          password: ${ secrets.GITHUB_TOKEN }
      - name: install cosign
        uses: sigstore/cosign-installer@5953e6dcfe5e0e0a48a8b8e8b8e8e8e8e8e8e8e8  # v3.5.0
        with:
          cosign-release: 'v2.2.0'
      - name: sign image
        env:
          COSIGN_EXPERIMENTAL: '1'
        run: |
          cosign sign --yes \
            ${ env.REGISTRY }/${ env.IMAGE_NAME }@${ needs.build.outputs.digest }

  # ─────────────────────────────────────────────────────────────────
  # Stage 5: verify the signature before publishing
  # ─────────────────────────────────────────────────────────────────

  verify:
    name: verify
    runs-on: ubuntu-24.04
    needs: [sign]
    steps:
      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11  # v4.1.1
      - name: install cosign
        uses: sigstore/cosign-installer@5953e6dcfe5e0e0a48a8b8e8b8e8e8e8e8e8e8e8  # v3.5.0
        with:
          cosign-release: 'v2.2.0'
      - name: verify signature
        env:
          COSIGN_EXPERIMENTAL: '1'
        run: |
          cosign verify \
            --certificate-identity-regexp 'https://github.com/'"$GITHUB_REPOSITORY"'/.+/.+' \
            --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
            --rekor-url 'https://rekor.sigstore.dev' \
            ${ env.REGISTRY }/${ env.IMAGE_NAME }@${ needs.build.outputs.digest }

  # ─────────────────────────────────────────────────────────────────
  # Stage 6: publish the :latest tag (only after verify passes)
  # ─────────────────────────────────────────────────────────────────

  publish:
    name: publish :latest
    runs-on: ubuntu-24.04
    needs: [verify]
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    steps:
      - name: re-tag with :latest
        uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16  # v4.0.0
        with:
          name: image-sbom.cdx.json
          path: sbom/
      - name: re-tag image
        run: |
          # Pull the digest-tagged image, re-tag as :latest, push.
          docker pull ${ env.REGISTRY }/${ env.IMAGE_NAME }:${ github.sha }
          docker tag ${ env.REGISTRY }/${ env.IMAGE_NAME }:${ github.sha } \
                     ${ env.REGISTRY }/${ env.IMAGE_NAME }:latest
          docker push ${ env.REGISTRY }/${ env.IMAGE_NAME }:latest
EOF

git add .github/workflows/supply-chain.yml
git commit -m 'ci: sign, verify, and publish stages'

The pipeline now has six stages: build, sbom, scan, sign, verify, publish. The publish stage runs only on push to main and only after the signature is verified. The :latest tag is applied only to a verified, signed, scanned image; an unsigned image is never tagged as :latest.

Task 6 — Author the supply-chain policy

# check-shell-blocks: allow-invalid
cd "$HOME/supply-chain-lab"

cat > supply-chain-policy.yaml <<'EOF'
# supply-chain-policy.yaml
#
# Policy applied by downstream consumers: admission controllers
# (Kyverno, sigstore-policy-controller), deploy scripts, and
# registry mirrors. The policy says: every image must (1) be
# signed by Fulcio with a certificate whose OIDC issuer is
# GitHub's OIDC provider; (2) have a CycloneDX SBOM attached
# as an OCI referrer; (3) have no CRITICAL or HIGH
# vulnerabilities per Grype.

apiVersion: v1
kind: SupplyChainPolicy
metadata:
  name: runbook-image-policy
spec:
  images:
    - glob: "ghcr.io/runbook-academy/*"
  authorities:
    - keyless:
        identities:
          - issuer: https://token.actions.githubusercontent.com
            identityRegexp: "https://github.com/runbook-academy/.+/.+"
        ctlog:
          url: https://rekor.sigstore.dev
          validateTimestamp: "2026-01-01T00:00:00Z"
  attestations:
    - kind: cyclonedx
      required: true
      predicate:
        # The SBOM must be a CycloneDX JSON document.
        format: cyclonedx-json
        # The SBOM must include at least one component.
        minComponents: 1
  vulnerabilities:
    - maxSeverity: high
      # CRITICAL and HIGH fail; MEDIUM and LOW are reported but
      # do not fail.
      maxUnfixable: 0
      # Unfixable vulnerabilities (no patched version) always
      # fail; they cannot be ignored.
EOF

The supply-chain policy is what an admission controller applies at deploy time. The policy pins the Fulcio issuer, the Rekor URL, the OIDC identity regex, the SBOM attestation requirement, and the vulnerability threshold. A consumer that fails any check is rejected at admission.

Task 7 — Document the supply-chain stages

# check-shell-blocks: allow-invalid
cd "$HOME/supply-chain-lab"

cat > supply-chain-stages.md <<'EOF'
# Supply-chain stages

This document is the canonical record of the five stages of
the container supply chain. The workflow is the implementation;
this document is the rationale. Engineers should be able to
answer "what does each stage catch?" by reading this file.

## Stage 1: Build

The `build` stage produces the OCI image digest. The digest is
a SHA-256 of the image's manifest; the manifest is the
content-addressed identifier. The digest is the input to every
subsequent stage.

digest = sha256(manifest)


The build uses BuildKit, which supports `--cache-from` and
`--cache-to` for layer reuse. The cache is scoped per branch by
default; PR builds use the cache from the base branch.

## Stage 2: SBOM

The `sbom` stage produces a CycloneDX SBOM and attaches it to
the image as an OCI referrer. The referrer is identified by its
digest and its `artifactType` (`application/vnd.cyclonedx+json`).

The SBOM is what Grype scans in stage 3 and what downstream
auditors consume to verify the dependency list.

## Stage 3: Scan

The `scan` stage runs Grype against the SBOM and fails the
build on CRITICAL or HIGH findings. Grype uses the GitHub
Advisory Database as its vulnerability source; `--only-fixed`
ignores unfixable vulnerabilities.

The scan is the **safety** check. The signature (stage 4) is
the **provenance** check. The two together are the supply
chain.

## Stage 4: Sign

The `sign` stage calls `cosign sign --yes` with no `--key`
flag. The OIDC token issued by GitHub Actions is the runner's
identity; Fulcio binds the signing certificate to that
identity; Rekor records the signature in the transparency log.

## Stage 5: Verify

The `verify` stage calls `cosign verify` with three pinning
flags: `--certificate-oidc-issuer`,
`--certificate-identity-regexp`, and `--rekor-url`. If any of
the three checks fails, the verify stage fails and the publish
stage does not run.

## Stage 6: Publish

The `publish` stage applies the `:latest` tag to the image.
The stage runs only on push to main and only after `verify`
passes. A signed-but-unverified image is never tagged
`:latest`.

## Stage interaction

build → sbom → scan → sign → verify → publish ↓ ↓ ↓ ↓ digest SBOM vuln cert+rekor referrer report entry


Each stage's output is the next stage's input. The `digest`
output of `build` is consumed by `sbom`, `sign`, and `verify`.
The `image-sbom.cdx.json` artefact is consumed by `scan`. The
cosign signature is consumed by `verify`.

A failure at any stage short-circuits the pipeline. An image
that fails the scan is not signed. An image that fails the
verify is not published.

EOF

git add supply-chain-stages.md
git commit -m 'docs: supply-chain stages description'

The stages document is what the team reads when they ask “what does the scan stage catch?” or “why is the verify stage before publish?”. It is the bridge between the workflow and the team’s understanding of the supply chain.

Task 8 — Document the failure modes

# check-shell-blocks: allow-invalid
cd "$HOME/supply-chain-lab"

cat > stage-failure-modes.md <<'EOF'
# Failure modes: container supply chain

This document is the canonical record of the common failures at
each of the five stages. Each section includes the symptom,
the cause, and the fix.

## Build stage

**Symptom:** `build` fails with `failed to solve: process
"/bin/sh -c ..." did not complete successfully`.

**Cause:** A `RUN` step in the Dockerfile failed. The error is
in the build log.

**Fix:** Read the build log; the failing step is the most
recent `RUN` in the output. Common causes: a package that no
longer exists in the apt/yum repository, a network timeout
during `npm install`, a missing source file.

**Symptom:** BuildKit cache misses despite `cache-from: type=gha`.

**Cause:** The cache is scoped per branch; a PR build cannot
read the cache from the base branch unless the cache key
matches.

**Fix:** Use
`cache-from: type=gha,scope=pr-${ github.event.pull_request.number }`
or accept the cache miss on first build.

## SBOM stage

**Symptom:** `sbom` fails with
`unable to pull image: image not found`.

**Cause:** The `build` stage did not produce the digest-tagged
image, or the registry login failed.

**Fix:** Verify the `build` stage succeeded and the image was
pushed. The `sbom` stage pulls by digest; if the digest-tagged
image is not in the registry, the pull fails.

**Symptom:** The SBOM referrer does not appear in the
registry.

**Cause:** The registry does not implement OCI referrers.
`docker.io` does not as of 2026-08.

**Fix:** Push to `ghcr.io`, `gcr.io`, or a self-hosted
registry that supports OCI 1.1 referrers.

## Scan stage

**Symptom:** `scan` fails with `1 CRITICAL vulnerability
found`.

**Cause:** A package in the image has a known CRITICAL CVE.

**Fix:** Update the package (rebuild the image with the
updated base image), or add the CVE to `grype-config.yaml`'s
ignore list with a justification and an expiry date.

**Symptom:** `scan` reports `0 vulnerabilities` for a clearly
vulnerable image.

**Cause:** Grype's vulnerability database is out of date.
Grype updates its database on each release; the lab pins
`grype_0.70.0`.

**Fix:** Update to the latest Grype release; the database is
bundled with the binary.

## Sign stage

**Symptom:** `sign` fails with `no identity token`.

**Cause:** The workflow's `permissions:` block is missing
`id-token: write`.

**Fix:** Add `id-token: write` to the workflow's permissions.
The permission is required for keyless signing.

**Symptom:** `sign` fails with
`failed to sign: PUT https://ghcr.io/v2/...: 403 Forbidden`.

**Cause:** The runner's `GITHUB_TOKEN` does not have
`packages: write` permission, or the registry login failed.

**Fix:** Verify the workflow's `permissions:` block includes
`packages: write` and the `docker/login-action` step uses
`${ secrets.GITHUB_TOKEN }`.

## Verify stage

**Symptom:** `verify` fails with `no matching signatures`.

**Cause:** The `--certificate-identity-regexp` does not match
the OIDC subject. The OIDC subject for a GitHub Actions run is
`https://github.com/$OWNER/$REPO/.github/workflows/<file>@<ref>`.

**Fix:** Adjust the regex to match the repository path. A
common bug is using the repository's display name instead of
the URL-encoded owner.

## Publish stage

**Symptom:** `publish` does not run.

**Cause:** The `if:` clause
(`github.event_name == 'push' && github.ref == 'refs/heads/main'`)
did not match. The workflow was triggered by a pull_request
or a tag push.

**Fix:** Verify the trigger. The `:latest` tag is updated only
on push to main; PR builds and tag builds are tagged by SHA
only.

EOF

git add stage-failure-modes.md
git commit -m 'docs: failure modes per supply-chain stage'

The failure-mode catalogue is what the on-call engineer reads when the pipeline reddens at any stage. The document is organised by stage; the table at the top of each section is the symptom-to-cause-to-fix pattern.

Task 9 — Validate the YAML structure

cd "$HOME/supply-chain-lab"

python3 -c "
import yaml
with open('.github/workflows/supply-chain.yml') as f:
    doc = yaml.safe_load(f)
jobs = doc['jobs']
print('jobs:', list(jobs.keys()))
print('build outputs:', list(jobs['build']['outputs'].keys()))
print('scan needs:', jobs['scan']['needs'])
print('verify needs:', jobs['verify']['needs'])
print('publish if:', jobs['publish']['if'])
"

python3 -c "
import yaml
with open('grype-config.yaml') as f:
    doc = yaml.safe_load(f)
print('ignore count:', len(doc['ignore']))
print('fail-on-severity:', doc['fail-on-severity'])
"

python3 -c "
import yaml
with open('supply-chain-policy.yaml') as f:
    doc = yaml.safe_load(f)
print('images:', doc['spec']['images'])
print('attestations:', [a['kind'] for a in doc['spec']['attestations']])
"

Expected output (excerpt):

jobs: ['build', 'sbom', 'scan', 'sign', 'verify', 'publish']
build outputs: ['digest']
scan needs: ['build', 'sbom']
verify needs: ['sign']
publish if: github.event_name == 'push' && github.ref == 'refs/heads/main'
ignore count: 2
fail-on-severity: critical,high
images: [{'glob': 'ghcr.io/runbook-academy/*'}]
attestations: ['cyclonedx']

The workflow has six jobs in correct dependency order; the Grype config has two ignore entries and the critical,high threshold; the supply-chain policy requires a CycloneDX SBOM attestation.

Task 10 — Capture the deliverables

cd "$HOME/supply-chain-lab"

cp .github/workflows/supply-chain.yml "$HOME/supply-chain.yml"
cp Dockerfile                          "$HOME/supply-chain-Dockerfile"
cp supply-chain-policy.yaml            "$HOME/supply-chain-policy.yaml"
cp grype-config.yaml                   "$HOME/grype-config.yaml"
cp supply-chain-stages.md              "$HOME/supply-chain-stages.md"
cp stage-failure-modes.md              "$HOME/stage-failure-modes.md"

ls -l "$HOME"/supply-chain.yml \
       "$HOME"/supply-chain-Dockerfile \
       "$HOME"/supply-chain-policy.yaml \
       "$HOME"/grype-config.yaml \
       "$HOME"/supply-chain-stages.md \
       "$HOME"/stage-failure-modes.md

The deliverables are the six files in $HOME, plus the repository at $HOME/supply-chain-lab.

Validation

  • .github/workflows/supply-chain.yml parses as valid YAML and has six jobs: build, sbom, scan, sign, verify, publish.
  • The permissions: block includes packages: write, id-token: write, and attestations: write.
  • The sign job uses cosign sign --yes with no --key flag.
  • The verify job uses cosign verify with three pinning flags.
  • grype-config.yaml parses as valid YAML and includes fail-on-severity: critical,high.
  • supply-chain-policy.yaml parses as valid YAML and pins the Fulcio issuer, the OIDC identity, and the Rekor URL.
  • Every uses: reference in the workflow is a pinned commit SHA.

Expected Outcome

A container supply-chain pipeline that produces a signed, scanned, SBOM-attached OCI image ready for downstream consumption, plus the policy and documentation that make the supply chain reviewable.

$HOME/supply-chain-lab/
├── .github/workflows/supply-chain.yml  # the workflow
├── Dockerfile                          # the build
├── supply-chain-policy.yaml            # downstream policy
├── grype-config.yaml                   # scan configuration
├── supply-chain-stages.md              # stage descriptions
├── stage-failure-modes.md              # failure catalogue
├── src/index.js                        # the application
└── package.json

The workflow is the implementation; the policy is the consumer side; the documents are the rationale.

Troubleshooting

The sbom stage fails to pull the image. The build stage did not push the digest-tagged image, or the registry login failed. Verify the build stage’s logs and the docker/login-action step.

The scan fails with a CVE that is later retracted. Add the CVE to grype-config.yaml’s ignore list with a justification and an expiry date. The expiry forces a re-evaluation at a known date.

The sign stage fails with “no identity token”. The workflow’s permissions: block is missing id-token: write. Add it; without it, keyless signing does not work.

The verify stage finds no signatures. The --certificate-identity-regexp does not match the OIDC subject. Adjust the regex to match the repository path.

The publish stage does not run. The if: clause did not match. Verify the workflow was triggered by push to main and not by a pull request or a tag.

The :latest tag points to an old image. The publish job was skipped because verify failed or because the trigger was not a push to main. The current :latest is the last verified image.

Cleanup

LAB="$HOME/supply-chain-lab"

mv "$LAB"/supply-chain-stages.md "$LAB"/stage-failure-modes.md \
   "$HOME"/ 2>/dev/null
mv "$LAB/.github/workflows/supply-chain.yml" \
   "$HOME/supply-chain.yml" 2>/dev/null
mv "$LAB/Dockerfile" "$HOME/supply-chain-Dockerfile" 2>/dev/null
mv "$LAB/supply-chain-policy.yaml" \
   "$HOME/supply-chain-policy.yaml" 2>/dev/null
mv "$LAB/grype-config.yaml" "$HOME/grype-config.yaml" 2>/dev/null

rm -rf "$LAB"

find "$HOME" -maxdepth 1 -name 'supply-chain-lab' -print
# expected: (no output)

If you pushed images to ghcr.io during the lab, delete them:

gh api --method DELETE \
  /repos/$OWNER/$REPO/packages/container/$IMAGE_NAME/versions/$VERSION_ID \
  -H 'Accept: application/vnd.github+json'

What You Learned

  • The supply chain has five stages, not two. Build + sign is provenance only; the safety check is the vulnerability scan. The two are paired; neither alone is sufficient.
  • :latest is the highest-blast-radius tag. The lab applies :latest only after verify passes; an unsigned image is never tagged :latest. This is the defense against a supply-chain compromise.
  • The SBOM is an OCI referrer, not a side artefact. The CycloneDX SBOM is attached to the image as an OCI referrer and is what Grype scans. A separate SBOM file in the artefact store is not enough; the referrer is the operational artefact.
  • Grype’s threshold is a policy decision. critical,high is the most common team policy; some teams set critical only, others medium for production images. The decision is documented in grype-config.yaml and reviewed in code review.
  • grype-config.yaml’s ignore list has an expiry. Each entry has an expire date that forces a re-evaluation. The team’s discipline: ignore entries are temporary, not permanent.
  • The supply-chain policy lives at the consumer. The CI workflow is the producer; the policy is what the admission controller applies. The two are paired but distinct.
  • Each stage has its own failure modes. The stage-failure catalogue is organised by stage; the on-call engineer reads the relevant section when the pipeline reddens at that stage.

Deliverables

  • · .github/workflows/supply-chain.yml — the GitHub Actions workflow with five stages
  • · Dockerfile — the multi-stage container build used as the supply-chain subject
  • · supply-chain-policy.yaml — the policy applied by downstream consumers
  • · grype-config.yaml — the grype configuration (severity threshold, ignore list)
  • · supply-chain-stages.md — a written description of the five stages and what each catches
  • · stage-failure-modes.md — a catalogue of failure modes per stage

Verification status

Last reviewed
2026-08-25
Executed end to end
not yet run on hardware

The commands and configuration here have been reviewed against the verified software versions, but nobody has run this lab start to finish on a system meeting its prerequisites. Treat the Expected Outcome as the intended result rather than an observed one, and keep the Cleanup section to hand.