TL;DR
Part 1 set the vocabulary, Part 2 made the case for Wolfi. This one is the build log. I took the tools we use every day — Terraform, Terragrunt, AWS CLI, jq, semantic-release — and rebuilt them into a single Wolfi-based CI image: ghcr.io/hagzag/tools. SBOM is generated with syft, scanned with grype, signed with cosign, and the whole loop runs locally via task all and in GitHub Actions from the same Taskfile. The real story is in the small gotchas.
Where We Are in the Series
- Part 1 — A Supply Chain Security Primer — SBOMs, provenance, SLSA, cosign, and the FIPS/FedRAMP pressure behind them.
- Part 2 — Rebuilding on Wolfi OS — why Wolfi, apko, melange, and daily rebuilds are a credible answer to those requirements.
- Part 3 (you are here) — Building a Wolfi-based CI image and wiring its supply-chain checks into a single Task target.
If Parts 1 and 2 were the “what” and the “why”, this one is the “how” — minus the hand-waving.
One Image, Three Jobs
Most infrastructure teams I’ve consulted for end up with a dozen CI runners, each apt install-ing the same five tools on every run. That pattern is how you end up with a different Terraform minor in every pipeline, and with supply-chain evidence scattered across thirty places. The alternative: one image, versioned, signed, attested, and consumed by everything downstream.
hagzag/tools is that image. It holds:
- Terraform 1.14.8 and Terragrunt 1.0.1 — pinned binaries, checksum-verified at build time
- AWS CLI v2,
jq,git,bash,curl,unzip,ca-certificates, Node 20 — all from Wolfi apk - semantic-release + the plugins we actually use:
commit-analyzer,release-notes-generator,changelog,git,exec,github,gitlab
The repo lives at github.com/hagzag/tools. Three files carry the weight: a Dockerfile, a Taskfile.yaml, and a GitHub Actions workflow. Everything else — README, SBOMs, LICENSE — is output.

What This Mitigates — A Note for the Security-Minded Reader
Before the code, a quick threat model for the AppSec folks. An unaudited CI image is an unsigned, unscanned binary that gets executed against your production Terraform state on every merge. It’s one of the higher-blast-radius supply-chain vectors in an average platform team’s stack, and it almost never shows up on a risk register.
hagzag/tools answers four questions an auditor is going to ask:
- What is inside? → SBOM, SPDX-2.3, generated by
syftfrom the pushed digest. - Who built it? → cosign keyless signature, tied to the GitHub Actions OIDC identity of the
mainbranch. - Is it vulnerable? →
gryperuns on every build and fails the pipeline on high+ CVEs; SARIF is uploaded to GitHub Code Scanning. - Can I reproduce the check? →
cosign verify+cosign download attestationgive any consumer a way to validate the chain without touching my account.
With that framing, the implementation reads differently.
The Dockerfile — Two Kinds of Tools
Two package sources live inside a single image, and the distinction matters.
ARG WOLFI_BASE=cgr.dev/chainguard/wolfi-base:latest
FROM ${WOLFI_BASE}
ARG TERRAFORM_VERSION
ARG TERRAGRUNT_VERSION
ARG SEMANTIC_RELEASE_VERSION=24.2.3
USER root
# Wolfi apk — the stuff that has a healthy distro-level release cadence.
RUN apk add --no-cache \
bash ca-certificates curl git jq unzip \
aws-cli-2 nodejs-20 npm \
&& rm -rf /var/cache/apk/*
Anything where Wolfi’s daily rebuild cadence is a feature (the TLS stack, Node, AWS CLI, git) comes from apk. This is where the Part 2 argument cashes out: every one of those packages has its own upstream-sourced SBOM, and Wolfi rebuilds the catalog on a cadence that closes the CVE exposure window to hours, not weeks.
The other bucket is tools where we need to pin an exact upstream version because our consumers do:
# Terraform — pinned binary, checksum-verified against the publisher's SHA256SUMS.
RUN set -eux; \
curl -fsSL -o /tmp/tf.zip \
"https://releases.hashicorp.com/terraform/${TERRAFORM_VERSION}/terraform_${TERRAFORM_VERSION}_linux_amd64.zip"; \
curl -fsSL -o /tmp/tf.sha \
"https://releases.hashicorp.com/terraform/${TERRAFORM_VERSION}/terraform_${TERRAFORM_VERSION}_SHA256SUMS"; \
(cd /tmp && grep "linux_amd64.zip" tf.sha | sha256sum -c -); \
unzip -q /tmp/tf.zip -d /usr/local/bin; \
terraform -version
We’d rather trust HashiCorp’s SHA256SUMS and pin exact semver than wait for a Wolfi rebuild to reach the specific Terraform patch a Terragrunt version expects. The same pattern applies to Terragrunt against Gruntwork’s SHA256SUMS. This gives us deterministic rebuilds — but it also creates the subtlety we’ll come back to in the SBOM section.
semantic-release and its plugins are the third category: dynamic language dependencies, installed globally via npm, so semantic-release --version Just Works in every downstream pipeline without a package.json.
The Taskfile — One Loop, Two Homes
The Taskfile is the smallest thing in the repo and the most important. It turns “build this securely” from a wiki page into one command.
tasks:
install-tools:
desc: Install every optional dependency that's not already on PATH
cmds:
- task: install-syft
- task: install-grype
- task: install-cosign
- task: install-hadolint
- task: install-yamllint
install-syft:
status: [command -v syft]
cmds:
- |
if [ "$(uname)" = "Darwin" ] && command -v brew; then
brew install syft
else
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh \
| sh -s -- -b "$HOME/.local/bin"
fi
all:
desc: lint -> build -> smoke -> sbom -> scan
cmds: [{task: lint}, {task: build}, {task: smoke}, {task: sbom}, {task: scan}]
Three details worth lingering on:
status:guards make each installer idempotent. Ifsyftis already on PATH, the target short-circuits — no reinstall, no version drift from a laptop that ran it last month.deps:on every high-level task (sbom,scan,sign,lint) point back to the installer. First run on a clean laptop self-heals; every subsequent run is instant.task allis the same sequence the GitHub Actions workflow runs. The laptop and CI produce comparable artifacts from the same definition, which is the line that makes supply-chain claims auditable rather than aspirational.

SBOM — and the Gotcha You’ll Hit
syft runs against the pushed digest and emits both SPDX and CycloneDX. On a fresh build the SBOM comes in at 2,054 packages across four ecosystems:
| Ecosystem | Packages | Why |
|---|---|---|
pkg:apk | 61 | Wolfi base + our explicit adds |
pkg:golang | 474 | AWS CLI v2 is a Go binary and drags its dep tree in |
pkg:npm | 1,517 | semantic-release pulls a forest |
pkg:pypi, pkg:oci | 2 | stragglers |
Then the subtlety: Terraform and Terragrunt don’t show up. Raw binaries dropped into /usr/local/bin don’t carry package metadata, and syft’s binary classifier doesn’t always recognize them. The SBOM is technically complete — those binaries are there on disk — they just aren’t first-class spdx:package entries.
That’s why the validation target asserts two kinds of evidence, not one:
validate-sbom:
cmds:
- |
for p in aws-cli-2 jq nodejs-20 npm ca-certificates git bash; do
jq -e --arg p "$p" '.packages[] | select(.name|ascii_downcase|startswith($p))' \
sbom.spdx.json >/dev/null
done
for m in semantic-release @semantic-release/commit-analyzer \
@semantic-release/github @semantic-release/gitlab; do
jq -e --arg m "$m" '.packages[] | select(.name==$m)' \
sbom.spdx.json >/dev/null
done
And the GitHub workflow adds a runtime smoke job that boots the pushed digest and runs terraform -version, terragrunt --version, aws --version, semantic-release --version. The SBOM proves what the apk and npm ecosystems declared; the runtime smoke proves the binaries that exist outside those ecosystems actually work. Belt and braces — and the kind of check you only discover you need the first time an auditor asks.

Grype and Cosign — Small Commands, Load-Bearing
# Fail the build on high+ findings, upload SARIF to GitHub Code Scanning.
grype "ghcr.io/hagzag/tools@${DIGEST}" --fail-on high
# Keyless sign + attach the SBOM as an in-toto attestation.
cosign sign --yes "ghcr.io/hagzag/tools@${DIGEST}"
cosign attest --yes --predicate sbom.spdx.json --type spdxjson \
"ghcr.io/hagzag/tools@${DIGEST}"
Two short steps, a lot of weight. grype’s --fail-on high converts “we should be careful” into an actual gate. cosign sign uses the GitHub Actions OIDC identity — no long-lived keys in a secret store, and the Rekor transparency log gives any consumer a way to verify who signed, when, and from which branch. cosign attest ties the SBOM to the exact image digest so the package list can’t be silently swapped.

Putting It Into Play — Consumer Side
The proof the image is actually useful is what the consumer workflow looks like. In hagzag/tf-modules every job that used to hashicorp/setup-terraform now just names the image:
jobs:
terraform_fmt:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
container:
image: ghcr.io/hagzag/tools:latest
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@v4
- run: terraform fmt -check
No setup steps, no cached dependency trees, no per-job npm install. The workflow reads like what it actually does — check the format — because the toolchain is an implementation detail of the image, not the pipeline.
What I’d Tell a Peer Building Theirs
A few things worth passing on:
- Binaries outside apk need their own verification. Checksum them at build, prove them at runtime. The SBOM alone won’t catch them.
- “Zero high CVEs” is a moving target. The value is rebuilding every time the base moves, not achieving it once. Daily rebuild, daily scan, same target.
- Keep local and CI identical. The Taskfile is the cheapest insurance policy in the repo. When something behaves differently on a laptop, the first question should be answerable.
- Pin everything that consumers pin. Terraform minors, Terragrunt minors, even Node majors. The image exists to be deterministic for downstream.
Wrap-Up
What Part 2 argued conceptually — that the open-source Wolfi stack delivers SBOMs, provenance, and low-CVE images as defaults — Part 3 makes concrete in about 300 lines of Dockerfile + Taskfile + workflow. The nice part is how little exotic tooling it takes. syft, grype, and cosign are well-behaved binaries, Wolfi does most of the heavy lifting, and the whole loop is runnable on a laptop before it’s ever pushed.
If you’re building one of these for your team, steal whatever’s useful in hagzag/tools. Part 4 will cover the consumer side in more depth — what changes for the pipelines that adopt the image, and the migration patterns that held up versus the ones that didn’t.
📖 Further Reading
- hagzag/tools — the repo behind this post
- Part 1 — A Supply Chain Security Primer
- Part 2 — Rebuilding on Wolfi OS
- Part 4 — From Signed Image to Verified Pipeline
- Syft · Grype · Cosign
- Taskfile — the underrated build runner that made this series practical
- Chainguard Wolfi base images
Discussion