Part 6: The CI/CD Pipeline
This is the payoff of the whole build: I commit a Containerfile change, and the runner builds, lints, and pushes new images. The workflow lives at .forgejo/workflows/build.yml.
The shape
Two jobs. The base job builds itg-base first. The children job needs: base and fans out over a matrix, one entry per host image, all building FROM the freshly-pushed base.
name: build-images
on:
push:
branches: [main]
schedule:
- cron: '0 7 * * *' # ~2 AM Central
jobs:
base:
runs-on: native
steps:
- uses: actions/checkout@v4
- run: podman login forge.itguyeric.com -u itguyeric -p "${{ secrets.FORGE_TOKEN }}"
- run: podman build -t forge.itguyeric.com/itguyeric/itg-base:latest .
- run: podman run --rm forge.itguyeric.com/itguyeric/itg-base:latest bootc container lint
- run: podman push forge.itguyeric.com/itguyeric/itg-base:latest
children:
runs-on: native
needs: base
strategy:
matrix:
include:
- { dir: bootc-dev, image: itg-dev-bootc }
- { dir: book, image: itg-prd-book }
- { dir: forge, image: itg-prd-forge }
- { dir: run, image: itg-prd-run }
steps:
- uses: actions/checkout@v4
- run: podman login forge.itguyeric.com -u itguyeric -p "${{ secrets.FORGE_TOKEN }}"
- run: podman build --pull=newer -t forge.itguyeric.com/itguyeric/${{ matrix.image }}:latest ./${{ matrix.dir }}
- run: podman run --rm forge.itguyeric.com/itguyeric/${{ matrix.image }}:latest bootc container lint
- run: podman push forge.itguyeric.com/itguyeric/${{ matrix.image }}:latest
The pieces that matter
needs: baseis the dependency that makes the children rebuild on top of the new base, and--pull=neweron the child builds guarantees they actually grab the just-pushed base rather than a cached layer.- Lint gating. Every image runs
bootc container lintbefore it is allowed to push. A broken image never reaches the registry. - The base lint bug I hit: I originally wrote the base's lint step using
${{ matrix.image }}, but the base job has no matrix, so it expanded to an empty string and producedforge.itguyeric.com/itguyeric/:latest— an invalid reference. Hardcodeitg-basein the base job. FORGE_TOKENis a Forgejo Actions secret. Important caveat I keep in mind: Actions secrets are for CI, they are not a general password vault. A real secrets vault is a separate, later project.runs-on: nativetargets my bare-metal runner label rather than a container-in-container executor, which matters because these jobs build images with podman.
A note on renaming a base image
When I renamed the base from fedora-server to itg-base, every child Containerfile's FROM and every workflow reference had to change together. On the Mac I swept it with grep -rl <old> | xargs perl -pi -e 's/old/new/g'. Watch out: BSD grep -Z on macOS does not emit null separators the way GNU does, so -Z | xargs -0 treats the whole file list as one filename. Drop the -Z/-0 and use plain grep -rl | xargs perl -pi.
Lessons
needs:plus--pull=neweris what makes "rebuild children on top of the new base" actually true.- Lint before push, always.
- A no-matrix job cannot use
matrix.*; it silently becomes empty. - CI secrets are not a vault.
Runbooks
The build
- Image Mode & Base
- Storage
- Hostnames & DNS
- Registry
- Actions Runner
- The Pipeline
- Nightly Auto-Deploy
- First Workload
- VSCode Cockpit
- Hugo Auto-Deploy
- Cloudflare & Kobo
- Runner Provisioning
- Tailscale Router
- SWAG & Website
- Split-DNS
- Plex
- Media Library Support
- The Day After
- Self-Hosted Media
- Hypervisor Joins the Fleet
- The Matrix Homeserver
Reference