Phase 2 · CONTAINERS & CI/CD
Project 1, Day 5: ship, tag & write the README
By the end of today
- Cut v1.0.0 with an annotated git tag and a GitHub release
- Trigger CI on the version tag and verify the image on GHCR
- Write a README with a CI badge and a keep-a-changelog CHANGELOG
Cutting v1.0.0: annotated tags, releases, and a README that earns trust
Four days ago linkstash was an empty folder. Now it’s a FastAPI service with a Postgres companion, a multi-stage image that runs as a non-root user, a compose stack that boots the whole thing with one command, and a CI pipeline that lints, tests, and pushes to GHCR on every commit. Today you cut the first release — v1.0.0 — and write the two files that turn a pile of code into a project a stranger can actually use: a README.md and a CHANGELOG.md.
A release is a named, frozen point in history. The name follows semantic versioning — MAJOR.MINOR.PATCH — so the number itself is a contract: 1.0.0 says “first stable public API; a breaking change bumps the 1.” You mark that point in git with a tag, and here is the one design decision that matters today: use an annotated tag (git tag -a), not a lightweight one.
A lightweight tag is a movable sticky note — a name pointing at a commit, nothing more. An annotated tag is a real git object: it records who cut the release, when, and a message, and it can be signed. GitHub Releases, gh release create, and tag-triggered CI all expect that object. Back on Day 4 you wired ci.yml to fire not only on pushes to main but on version tags matching v*.*.*; pushing the v1.0.0 tag is what makes that pipeline stamp and publish ghcr.io/pushkar/linkstash:v1.0.0 — a permanent, versioned image beside the moving latest and the per-commit SHA tags.
Real world: A version tag is a book’s edition. The “first edition” is a fixed, citable printing — quote page 200 and every copy agrees. Reprints that fix typos bump the number; a rewrite is a new edition. Cutting v1.0.0 freezes an edition of linkstash that anyone can pull, run, and cite months from now and get byte-identical behaviour.
The two documents are the project’s front door. The README answers, in order: what linkstash is and why, how to run it in one command (docker compose up), a CI badge that shows the pipeline’s live status, and the docker pull ghcr.io/pushkar/linkstash:v1.0.0 line so a stranger can grab the exact release. The CHANGELOG is the release history — and rather than invent a format, follow Keep a Changelog: group each version’s entries under Added, Changed, Fixed, newest version on top, so a reader skims “what changed, do I care?” in ten seconds.
Tag, release, README, CHANGELOG, verified image — that is the capstone of Project 1. Not more app code, but the shipping ritual that makes the code trustworthy and reusable. You do it by hand below.
Hands-On Lab
Budget about 30 minutes. You’re in the linkstash repo built across Days 1–4 (WSL2 Ubuntu 24.04, Docker 27, gh authenticated), sitting on a green main. You’ll write the README and CHANGELOG, cut an annotated v1.0.0 tag, create the release, watch the tag trigger CI, then verify the exact :v1.0.0 image in GHCR. SHAs, run IDs, digests and timestamps are unique per run — yours will differ.
# 1. Stand on a clean main and confirm the last CI run is green before releasing.
cd ~/linkstash && git switch main && git status -s && git log --oneline -1
gh run list --limit 1
# Output (empty status line = clean tree; SHAs, IDs and ages are yours-will-differ):
# 4c1e9a7 test: cover /shorten, redirect and /healthz
# STATUS TITLE WORKFLOW BRANCH EVENT ID ELAPSED AGE
# completed test: cover /shorten, redirect … ci main push 17398441027 1m2s 6m
# 2. Write the README — what/why, one-command quickstart, CI badge, and the GHCR pull line.
cat > README.md <<'EOF'
# linkstash
A minimal URL shortener. `POST /shorten` a long URL to get a short code;
`GET /{code}` 307-redirects to the original; `GET /healthz` returns 200 for
health checks. Deliberately small — FastAPI + PostgreSQL — so the focus is the
DevOps pipeline that containerises, composes, tests and ships it.

## Quickstart
Requires Docker 27+ with the compose plugin.
1. `git clone https://github.com/pushkar/linkstash.git && cd linkstash`
2. `docker compose up -d` — starts the `web` and `db` services; web waits on the db healthcheck.
3. Shorten a URL: `curl -X POST localhost:8000/shorten -H 'content-type: application/json' -d '{"url":"https://example.com"}'` returns `{"code":"aB3xZ"}`.
4. Follow it: `curl -i localhost:8000/aB3xZ` returns a 307 redirect to the original URL.
## Run a released image
`docker pull ghcr.io/pushkar/linkstash:v1.0.0` — the exact image CI built and published for the v1.0.0 tag. `DATABASE_URL` wires the container to Postgres (see `compose.yaml`).
## Tech
Python 3.12 · FastAPI · Uvicorn · PostgreSQL 16 · Docker · Docker Compose · GitHub Actions. See `CHANGELOG.md` for release history.
EOF
head -3 README.md
# Output:
# # linkstash
#
# A minimal URL shortener. `POST /shorten` a long URL to get a short code;
# 3. Write the CHANGELOG in Keep a Changelog format — v1.0.0, grouped under Added.
cat > CHANGELOG.md <<'EOF'
# Changelog
All notable changes to this project are documented here. The format is based on
Keep a Changelog (keepachangelog.com), and this project adheres to Semantic
Versioning (semver.org).
## [1.0.0] - 2026-07-11
### Added
- `POST /shorten` — accepts JSON `{ "url" }` and returns a short `{ "code" }`.
- `GET /{code}` — 307 redirect to the original URL.
- `GET /healthz` — liveness endpoint returning 200.
- Multi-stage Dockerfile on `python:3.12-slim`, running as a non-root user.
- `compose.yaml` — web + Postgres 16 with a db healthcheck and a named `pgdata` volume.
- GitHub Actions CI — ruff lint, pytest, and build-push to GHCR on every commit and version tag.
[1.0.0]: https://github.com/pushkar/linkstash/releases/tag/v1.0.0
EOF
cat CHANGELOG.md | head -8
# Output:
# # Changelog
#
# All notable changes to this project are documented here. The format is based on
# Keep a Changelog (keepachangelog.com), and this project adheres to Semantic
# Versioning (semver.org).
#
# ## [1.0.0] - 2026-07-11
#
# 4. Commit the docs so the release tag points at a commit that already includes them.
git add README.md CHANGELOG.md
git commit -m "docs: add README and CHANGELOG for v1.0.0"
git push
# Output (your hashes will differ):
# [main 7d2f0a9] docs: add README and CHANGELOG for v1.0.0
# 2 files changed, 41 insertions(+)
# To https://github.com/pushkar/linkstash.git
# 4c1e9a7..7d2f0a9 main -> main
# 5. Cut an ANNOTATED tag (-a) with a message, then push the tag itself — plain git push won't.
git tag -a v1.0.0 -m "linkstash v1.0.0 — first stable release"
git push origin v1.0.0
# Output:
# * [new tag] v1.0.0 -> v1.0.0
# 6. Prove it's a real annotated tag object, not a lightweight pointer.
git cat-file -t v1.0.0
# Output (an annotated tag reports 'tag'; a lightweight one would report 'commit'):
# tag
# 7. Create the GitHub Release from the tag, using the CHANGELOG as the release notes.
gh release create v1.0.0 --title "v1.0.0" --notes-file CHANGELOG.md
# Output (the release URL is yours-will-differ):
# https://github.com/pushkar/linkstash/releases/tag/v1.0.0
# 8. The tag push triggers ci.yml on its v*.*.* path. Watch that run finish green.
gh run watch "$(gh run list -L1 --json databaseId -q '.[0].databaseId')"
# Output (run/job IDs and timing are yours-will-differ):
# ✓ ci · 17399002518
# Triggered via push about 1 minute ago
#
# JOBS
# ✓ test in 24s
# ✓ build-push in 47s
#
# ✓ Run ci (17399002518) completed with 'success'
# 9. Confirm the versioned image now sits in GHCR beside latest and the SHA tags.
gh api /user/packages/container/linkstash/versions --jq '.[0].metadata.container.tags'
# Output (the v1.0.0 tag push produced the semver + latest tags):
# ["v1.0.0","1.0","latest"]
# 10. Pull the release by its version tag and read back what CI actually published.
docker pull ghcr.io/pushkar/linkstash:v1.0.0
docker image inspect ghcr.io/pushkar/linkstash:v1.0.0 \
--format 'user={{.Config.User}} cmd={{.Config.Cmd}} digest={{index .RepoDigests 0}}'
# Output (the digest is content-addressed — yours will differ):
# Status: Downloaded newer image for ghcr.io/pushkar/linkstash:v1.0.0
# user=app cmd=[uvicorn app.main:app --host 0.0.0.0 --port 8000] digest=ghcr.io/pushkar/linkstash@sha256:9c1e…
# 11. Capstone check: the tag, the release, and the versioned image all name v1.0.0.
git tag -l 'v*' && gh release view v1.0.0 --json tagName -q '.tagName'
# Output:
# v1.0.0
# v1.0.0
Read the last steps back: you wrote the two files that make linkstash usable, cut an annotated tag, created a release from it, and that tag fired the Day-4 pipeline to publish an immutable ghcr.io/pushkar/linkstash:v1.0.0 — which you then pulled and confirmed runs as a non-root uvicorn process. That’s Project 1 shipped: a small app, containerised, composed, tested in CI, and released with a version anyone can pull and trust.
Common Errors & Fixes
These three catch people the first time they cut a tagged release. Read the text slowly — parsing it is the actual skill.
Common error: Committing and running
git push, then expecting the versioned image to appear — butdocker pull ghcr.io/pushkar/linkstash:v1.0.0fails:Error response from daemon: manifest unknownWhy:
git pushsends commits on the current branch, but not tags — tags travel only when pushed explicitly. So thev1.0.0tag never reached GitHub,ci.yml’s tag trigger never fired, and no:v1.0.0image was ever built. The registry is telling the truth: that manifest doesn’t exist.Fix: Push the tag itself with
git push origin v1.0.0(orgit push --follow-tagsto send annotated tags alongside commits). Thengh run listshows the tag-triggered run; once it’s green, the pull succeeds.How you’d spot it in prod: A deploy that asks for a freshly-cut version and gets
manifest unknownalmost always means the tag build never ran — check that the tag exists on the remote (git ls-remote --tags origin) before blaming the registry.
Common error: Cutting a tag named
1.0.0(nov) and pushing it — the push succeeds, but no workflow run ever appears ingh run list.# git push origin 1.0.0 -> * [new tag] 1.0.0 -> 1.0.0 # ...but gh run list shows no new run for the tag.Why: The Day-4 workflow triggers on
on.push.tags: ['v*.*.*']. The tag1.0.0doesn’t match that glob (it has no leadingv), so GitHub Actions never starts a run. Nothing errors — the trigger simply doesn’t fire, which is harder to notice than a red failure.Fix: Name release tags to match the trigger —
v1.0.0. Delete the stray tag (git tag -d 1.0.0 && git push origin :refs/tags/1.0.0), re-cut it asv1.0.0, and push. If you truly want bare-number tags, widen the glob to['[0-9]*.*.*']instead.How you’d spot it in prod: A “released” version with no corresponding pipeline run is the tell — if
gh run listhas nothing for the tag you just pushed, the tag name doesn’t match the workflow’stags:filter.
Common error: Re-running
gh release create v1.0.0after the release already exists (for example, to fix the notes) — it fails:HTTP 422: Validation Failed (https://api.github.com/repos/pushkar/linkstash/releases) Release.tag_name already existsWhy:
gh release createcreates a new release and refuses to clobber one that already points atv1.0.0. A release is a one-per-tag object, so the second create is a conflict, not an update.Fix: Edit the existing release instead of recreating it:
gh release edit v1.0.0 --notes-file CHANGELOG.md. If you genuinely need to start over,gh release delete v1.0.0first (that leaves the git tag intact), then create again.How you’d spot it in prod: A release-automation step that’s green on the first run and red with
already existson re-runs isn’t idempotent — switch the “create” to an “edit-or-create” so replays of the same version don’t fail the job.
Release Engineering Interview Questions
These four are what a screening round asks once “can you build an image in CI?” turns into “can you cut a release someone else can depend on?” — cover each answer, say your own version out loud first, then compare, because recalling before revealing is what makes it stick. The four questions and answers render right after this note.
Go Deeper
Optional extras if you have ~30 more minutes today:
- 5 min — Read the tag object itself:
git show v1.0.0for the tagger, date and message, thengit cat-file -p v1.0.0to see the raw annotated-tag record pointing at your release commit. - 10 min — Skim Keep a Changelog and the Semantic Versioning spec, and note which kind of change bumps MAJOR, MINOR and PATCH.
- 15 min — Read the shipping-and-release section of the DevOps projects guide to see how tagging, releases and a good README fit the wider build-and-ship workflow you just completed.
What's the difference between an annotated and a lightweight git tag, and which do you use for a release? Both
A lightweight tag is just a movable name pointing at a commit — no extra data. An annotated tag is a full git object: it stores who cut it, when, a message, and it can be GPG-signed. For releases you always use annotated (git tag -a), because GitHub Releases, gh release create, git describe, and tag-triggered CI all read that metadata. I cut v1.0.0 as annotated with a one-line message summarising the release. You can tell them apart with git cat-file -t v1.0.0: an annotated tag reports 'tag', a lightweight one reports 'commit'. Using a lightweight tag for a release loses the tagger, date and message you'd want for an audit trail.
What does the version 1.0.0 communicate under semantic versioning, and when do you bump the major? Both
Semantic versioning is MAJOR.MINOR.PATCH. 1.0.0 signals the first stable, public release — the API is now a promise, not a moving target. From there the rules are mechanical: bump PATCH (1.0.1) for backward-compatible bug fixes, MINOR (1.1.0) for backward-compatible new features, and MAJOR (2.0.0) only for a breaking change that forces consumers to update their code. So the number itself is a compatibility contract a reader can trust at a glance. Before 1.0.0 (the 0.x range) anything can change; cutting 1.0.0 is the moment you commit to that contract, which is exactly what a capstone release should do.
How do you make a GitHub Actions workflow build and publish an image only when you cut a release? Product
Trigger the workflow on tag pushes, not just branch pushes: on.push.tags with a glob like ['v*.*.*'] so only semver tags fire it. Then derive the image tag from the ref — docker/metadata-action with a semver pattern turns the git tag v1.0.0 into image tags v1.0.0, 1.0 and latest — and pass those to docker/build-push-action. The one gotcha is that git push does not send tags by default: you push the tag explicitly with git push origin v1.0.0 (or git push --follow-tags). That keeps everyday commits building the SHA image while a deliberate tag is what stamps a versioned, releasable image in the registry.
Why keep a CHANGELOG, and what does the Keep a Changelog format give you? Both
A CHANGELOG is the human-readable history of a project — what changed in each release and whether a consumer needs to care — which raw git log can't give you because commit messages are for authors, not users. Keep a Changelog is the widely-adopted convention: one section per version, newest on top, entries grouped under headings like Added, Changed, Fixed, Removed and Deprecated, with the version and date in the heading. A reader skims it in ten seconds to decide whether to upgrade. It also feeds release notes directly — I pass CHANGELOG.md to gh release create so the GitHub Release and the file never drift apart.
Mark Day 45 complete
Project 1 is shipped — tomorrow Phase 3 opens with cloud fundamentals: regions, availability zones, and the shared-responsibility model.
Stuck on today’s lab? Ask in Mission 90 Q&A