Skip to main content

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.

TermMeans
RegistryThe server that stores images (ghcr.io, Docker Hub)
RepositoryOne image's name (ghcr.io/you/greeter-api)
TagA label for one version (sha-abc123def456, latest)
DigestThe 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 :latest only 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_TOKEN is minted per run; permissions: zeroes every scope you do not list.
  • Forgetting push: true produces 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.

Next: 17 — Jobs that depend on jobs.