The brief
The workflow, part by part
When it runs. Only when the API or the workflow itself changes, plus a manual button:
on:
push:
branches: [main]
paths:
- "project/docker/api/**"
- ".github/workflows/notes-api.yml"
pull_request:
paths:
- "project/docker/api/**"
- ".github/workflows/notes-api.yml"
workflow_dispatch:
permissions:
contents: read
packages: writepermissions gives the job's automatic GITHUB_TOKEN exactly what it needs: read the code, write packages. No
personal token is stored anywhere.
Build and scan before publishing. The image is built once for the runner's platform and loaded locally, so the scan runs before anything leaves the runner:
- uses: actions/checkout@v7
- uses: docker/setup-qemu-action@v4
- uses: docker/setup-buildx-action@v4
- name: Build for scanning
uses: docker/build-push-action@v7
with:
context: ${{ env.CONTEXT }}
load: true
tags: notes-api:scan
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Scan with Trivy
run: |
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
aquasec/trivy:0.76.1 image --scanners vuln --severity HIGH,CRITICAL \
--ignore-unfixed --exit-code 1 notes-api:scanThe scan is the same gate as in 07.03 Scanning images, with a pinned Trivy version so results don't change underneath you.
Tags. docker/metadata-action turns the commit into tags:
- name: Image tags
id: meta
uses: docker/metadata-action@v6
with:
images: ${{ env.IMAGE }}
tags: |
type=sha,prefix=sha-
type=raw,value=latest,enable={{is_default_branch}}Publish. Log in to GHCR with the built-in token, then build both platforms and push. The amd64 layers come straight from the cache the scan build just wrote:
- name: Log in to GHCR
if: github.event_name != 'pull_request' && vars.PUBLISH_IMAGE == 'true'
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push (amd64 + arm64)
if: github.event_name != 'pull_request' && vars.PUBLISH_IMAGE == 'true'
uses: docker/build-push-action@v7
with:
context: ${{ env.CONTEXT }}
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=maxTwo guards on publishing: pull requests never publish (code from a fork shouldn't push images), and a repository
variable, PUBLISH_IMAGE, must be true. That makes publishing a deliberate switch. Set it under the repo's
Settings → Secrets and variables → Actions → Variables, or with gh variable set PUBLISH_IMAGE --body true.
What the real runs looked like
$ gh run view 41526039809✓ image in 1m29s✓ Run actions/checkout@v5✓ Run docker/setup-qemu-action@v3✓ Run docker/setup-buildx-action@v3✓ Build for scanning✓ Scan with Trivy✓ Image tags- Log in to GHCR- Build and push (amd64 + arm64)ANNOTATIONS! Node.js 20 is deprecated. The following actions target Node.js 20 but are being forced to run on Node.js 24:docker/build-push-action@v6, docker/metadata-action@v5, docker/setup-buildx-action@v3, docker/setup-qemu-action@v3.
Output trimmed. The first run passed the scan and skipped publishing (the variable wasn't set yet). It also flagged that
the action versions were a major behind, so the workflow moved to checkout@v7, setup-qemu-action@v4,
setup-buildx-action@v4, build-push-action@v7, metadata-action@v6 and login-action@v4. The second run had no
warnings and reused the cache (07.05 Build cache in CI). Read your CI warnings; they're cheap to fix early.
Then publishing was switched on and the workflow started by hand:
$ gh variable set PUBLISH_IMAGE --body true$ gh workflow run notes-api.yml --ref main$ gh run view 41528110439✓ Build for scanning 7s✓ Scan with Trivy 13s✓ Image tags 1s✓ Log in to GHCR 0s✓ Build and push (amd64 + arm64) 59s
(Step timings from the run.) The scan build took 7 seconds because every layer came from the cache. Then, from a laptop that wasn't logged in to GHCR:
$ docker buildx imagetools inspect ghcr.io/sehajmakkar/notes-api:latestName: ghcr.io/sehajmakkar/notes-api:latestMediaType: application/vnd.oci.image.index.v1+jsonPlatform: linux/amd64Platform: linux/arm64Platform: unknown/unknownPlatform: unknown/unknown$ docker pull ghcr.io/sehajmakkar/notes-api:sha-9f30a1d$ docker image inspect -f '{{.Os}}/{{.Architecture}} user={{.Config.User}}' ghcr.io/sehajmakkar/notes-api:sha-9f30a1dlinux/arm64 user=node$ docker run --rm --platform linux/amd64 --entrypoint node ghcr.io/sehajmakkar/notes-api:sha-9f30a1d -p 'process.arch'x64$ docker inspect -f '{{index .Config.Labels "org.opencontainers.image.revision"}}' ghcr.io/sehajmakkar/notes-api:sha-9f30a1d9f30a1da127b447ea9efb68edd3864afb195c9e1
Output trimmed. Both platforms are there (the unknown/unknown entries are the build's attestations,
07.04 Multi-platform builds). The Mac pulled the arm64 image, the amd64 one runs under emulation, and the label added by
metadata-action records the exact commit the image came from. Because this repository is public, the package was
readable without logging in.
A pull request from someone's fork changes the API. What does this workflow do?
- Add the workflow to your fork, push, and watch it run in the Actions tab or with
gh run watch. - Open a PR that changes
server.tsand confirm it builds and scans without publishing. - Set
PUBLISH_IMAGEtotrue, push tomain, and find your image under your GitHub profile's Packages. Pull it on a machine with the other architecture. - Check the package's visibility in its settings. If it's private, the server needs
docker login ghcr.iobefore it can pull (07.07 Project: deploy to a VPS); make it public if you'd rather not.
STUCK? ▸
Push fails with denied: permission_denied: check permissions: packages: write in the workflow, and that the repository is allowed to write to the package (the package's Settings → Manage Actions access). The scan fails: read the table Trivy printed; it lists each package with its fixed version.
Interview questions
Describe a CI pipeline for building and publishing Docker images.