16 — Actions, secrets, and pushing an image
Read this first: this lesson takes your workflow from "prints hello" to "builds a real image and
publishes it to a registry". It covers reusing other people's steps, the token GitHub mints for every
run, the permissions: block that decides what that token may do, and the two failure modes that
look like success.
Time: about 50 minutes. Assumes lesson 15 and lesson 06.
What a registry is for
Up to now you have built images on the machine that runs them. That does not work for deployment: the server should not need Maven, a JDK, or your source code — that was the entire point of lesson 07.
A registry is where images live between being built and being run. CI builds once and pushes;
the server pulls. GitHub's is GHCR (ghcr.io), it is free for public images, and it needs no
extra credentials — which is why this course uses it.
| Term | Means |
|---|---|
| Registry | The server that stores images (ghcr.io, Docker Hub) |
| Repository | One image's name (ghcr.io/you/greeter-api) |
| Tag | A label for one version (sha-abc123def456, latest) |
| Digest | The immutable sha256:... hash of exact content |
A tag can be moved; a digest cannot. latest is a tag, which is why "it works on latest" means
nothing a week later.
uses: — someone else's step
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
- uses: docker/build-push-action@v6
An action is a reusable step. @v4 pins a major version — always pin. An unpinned action changes
under you, and "the workflow broke and I changed nothing" is nearly always this.
The token you did not create
Every workflow run gets a GITHUB_TOKEN automatically. It is minted for that run and expires when
the run ends, so there is no long-lived credential to leak.
What it may do is controlled by permissions:, and the default is deliberately narrow:
permissions:
contents: read
jobs:
build:
permissions:
packages: write # required to push to GHCR
Declaring a permissions: block sets every unlisted scope to none. That is a good default and a
sharp edge: add one permission and you may silently remove another that was working. The real
codeql.yml hits exactly this — it has to list actions: read
explicitly, with a comment, because the analyze step reads its own workflow run through the API and
gets a 403 without it.
Build and push
name: CI
on:
push:
workflow_dispatch:
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- id: tag
run: echo "tag=sha-${GITHUB_SHA:0:12}" >> "$GITHUB_OUTPUT"
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: ./greeter-api
push: true
build-args: |
APP_VERSION=${{ steps.tag.outputs.tag }}
tags: ghcr.io/${{ github.repository_owner }}/greeter-api:${{ steps.tag.outputs.tag }}
cache-from: type=gha,scope=api
cache-to: type=gha,mode=max,scope=api
Three parts worth pausing on.
The tag. ${GITHUB_SHA:0:12} is the parameter expansion from
lesson 04 — the first 12 characters of the commit sha. Writing
tag=... >> "$GITHUB_OUTPUT" publishes it so later steps (and, in
lesson 17, later jobs) can read it. Tag by commit, never by latest:
a sha tag names exactly one build, so "which code is running in production" has an answer.
build-args. This is where APP_VERSION from lesson 06 gets its real
value, so /api/ reports the commit that built it.
The cache. type=gha stores layers in GitHub's cache, so the Maven dependency layer survives
between runs. Without it every CI build downloads the world. The real
deploy.yml uses two sources:
cache-from: |
type=gha,scope=backend
type=registry,ref=ghcr.io/jomariabejo/motorph-payroll-backend:latest
because GitHub evicts cache entries after 7 idle days, and the published :latest image backfills
the gap. That comment is in the file.
Push it and watch the package appear under your repository's Packages tab. Then pull it on your laptop:
docker pull ghcr.io/<you>/greeter-api:sha-abc123def456
Break it on purpose: the green build that shipped nothing
Predict: you remove push: true. What happens?
The workflow goes green. The build succeeds. Nothing is in the registry.
This is the worst failure shape in CI: a silent success. Nothing is red, no log line looks wrong, and you find out when a deploy pulls a tag that does not exist — usually much later, and usually while something else is already going wrong.
Build a habit: after any pipeline change, verify the artifact exists, not just that the job was green. A step that pushes should be followed by something that proves the push happened.
Break it on purpose: the missing secret
Predict: a step references ${{ secrets.NOT_SET }}. Does the workflow fail?
No. A missing secret expands to an empty string. The step runs with an empty value and fails later — somewhere unrelated, with a message about whatever received the empty string.
This is why the real deploy workflow has a step that exists only to catch it:
echo "VPS_HOST length: ${#VPS_HOST}"
ok=1
[ -n "$VPS_HOST" ] || { echo "::error::VPS_HOST is empty"; ok=0; }
It prints character counts, never values — so the log is safe to share — and it fails fast with a
message naming the actual variable. The comment above it explains why it is five direct expansions
rather than a tidy loop over ${!name}: "A diagnostic that can be wrong in the same direction as
the fault it reports is worse than none." An indirect expansion could itself be the thing that is
broken.
Note also that GitHub masks secret values in logs. That is a safety net, not a strategy: it only masks values it knows about, so a secret you transform (base64, substring) can still be printed in full.
Where this shows up in MotorPH
- deploy.yml builds backend and frontend as two parallel jobs,
both computing the same
sha-tag independently. - It adds
:latestonly on main, using an expression you will meet in lesson 17. - Nothing in the repository stores a registry password. GHCR authentication uses the automatic
GITHUB_TOKEN, which is one fewer secret to rotate.
Recap
- A registry is where images live between build and run; tag by commit sha, never rely on
latest. GITHUB_TOKENis minted per run;permissions:zeroes every scope you do not list.- Forgetting
push: trueproduces a green build that shipped nothing — verify the artifact, not the colour. - A missing secret is an empty string, not an error. Check lengths early and fail loudly.