Your pipeline now proves the code works. Next it should package it — because last week's promise is due: in the Container Registries chapter you pushed to Docker Hub by hand and read that GitHub Actions would log in to GHCR with a built-in token, no setup at all. Today is that day.
Pushing to a registry needs authentication — on your laptop that was docker login with your account. On the runner, GitHub mints a fresh GITHUB_TOKEN for every single run: it belongs to your repository, it can do only what you allow, and it expires when the run ends. You never generate it, never store it, never rotate it.
You only allow it things. By default it may read your code; pushing a package needs one extra permission, declared on the job:
permissions:
contents: read
packages: write
Add a second job to ci.yaml. The commands inside are deliberately the same three you typed by hand last week — log in, build with tags, push:
build-and-push:
runs-on: ubuntu-latest
needs: test
if: github.ref == 'refs/heads/main'
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v7
- name: Log in to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build image
run: >
docker build
-t ghcr.io/<your-username>/<your-repo>:latest
-t ghcr.io/<your-username>/<your-repo>:${{ github.sha }}
.
- name: Push image
run: docker push --all-tags ghcr.io/<your-username>/<your-repo>
The new pieces, one by one:
needs: test — this job waits for the test job and runs only if it succeeded. There is the gate again: code that fails its tests never becomes an image.if: github.ref == 'refs/heads/main' — pull requests only test; merged code on main also ships. Every merge to main produces a deployable image — which is Continuous Delivery, by definition.docker/login-action@v4 Allows us to authenticate to ghcr in order to push image on behalf of our Github user.latest for “the newest from main”, and the commit SHA as the frozen, unique, per-build tag the registries chapter promised the CI pipeline would generate. When something needs investigating — or undoing — the SHA tag tells you exactly which commit an image contains.run: docker … just works — no setup step needed.<aside> ⚠️
GHCR image names must be lowercase. If your GitHub username or repository has capital letters, write the image name in lowercase yourself (ghcr.io/hackyourfuture/shop-api, not ghcr.io/HackYourFuture/Shop-API) — otherwise the push fails with a confusing invalid reference format error.
</aside>
<aside> ❗
The rule from last week did not change: the image must contain no secrets. Configuration arrives as environment variables at runtime — the pipeline builds the same secret-free image you built by hand.
</aside>
Merge the change to main and let the pipeline run. On your repository's front page, look for Packages in the right sidebar — your image is there, with both tags. This is GHCR: the image lives next to the code that produced it.
<aside> ⌨️
Hands on: open the package page and make the package public (Package settings → Change visibility). Your first push creates it as private; the next chapter's deployment needs to pull it without credentials. Same reasoning as last week: fine for a learning project that contains no secrets.
</aside>
💬 Question: the workflow logs in to a registry and pushes an image — yet nowhere in the repository is any password stored. Where does the credential come from, and why is this safer than the access token you used on your laptop?
Many real pipelines use Docker's published actions instead of raw commands — docker/login-action, docker/build-push-action — which add conveniences like build caching and multi-platform images. They do exactly what your three commands do; you will recognise every part when you meet them.
GITHUB_TOKEN login.The HackYourFuture curriculum is licensed under CC BY-NC-SA 4.0 *https://hackyourfuture.net/*

Built with ❤️ by the HackYourFuture community · Thank you, contributors
Found a mistake or have a suggestion? Let us know in the feedback form.