Back to Journal
2026-07-17
Research Entry

Release Engineering: From Tag to Traceable Image

Release EngineeringGHCRDockerSemverCI/CDRobotics

A release in robotics is not a git push — it is a contract. When an image lands on a robot, someone will eventually ask: which code is running here? If the answer is "whatever was on main last Tuesday," you don't have a release process. You have an opinion. This post documents how I turned a tag push into a fully traceable image on GHCR — and the painful lesson that taught me why a published tag is a seal, not a draft.

This is Part 6 of a 7-part series on building a production-grade CI/CD pipeline for a ROS 2 robot. Parts 1–5 covered the why, the architecture, the GitHub Actions setup, the test layers, and the bugs the SIL platform caught. Here we close the loop: from a green main to a versioned, traceable image that can be deployed, audited, and reproduced.

The Release Workflow

The release workflow is the smallest workflow in the repo, and the most consequential. It lives in release.yml, triggers on a tag push matching v*.*.*, and does exactly three things: build the runner stage of the Dockerfile, push the image to GHCR, and stamp it with semver-derived tags. No tests run here — tests already ran on the PR and on main. The release workflow trusts the merge queue and refuses to duplicate its work.

flowchart LR
    A[git tag v0.1.2 pushed] --> B[release.yml triggered]
    B --> C[docker/login-action<br/>GHCR via GITHUB_TOKEN]
    C --> D[docker/metadata-action<br/>derives tags from semver]
    D --> E[docker/build-push-action<br/>target: runner]
    E --> F{Build + push}
    F -->|cache: type=gha| G[(GHCR:<br/>robotics_cicd:0.1.2<br/>:0.1, :latest,<br/>sha-2d1f5a6)]
    F --> H[GitHub Release<br/>auto-generated notes]

    style A fill:#e8f5e9,stroke:#2e7d32
    style G fill:#e3f2fd,stroke:#1565c0

The three actions are deliberately standard:

  • docker/login-action authenticates to ghcr.io using ${{ secrets.GITHUB_TOKEN }} — no extra PAT, no rotation headaches.
  • docker/metadata-action derives the tag set from the git tag and the commit SHA (more on the mapping below).
  • docker/build-push-action builds with target: runner, push: true, and cache-from: type=gha, cache-to: type=gha. The cache is what keeps a release build under two minutes when the builder stage hasn't changed.

The workflow uses ${{ github.repository }} everywhere it names the image. That single choice paid off later in a way I didn't expect: when I moved the repo from my personal account to an org, the release workflow adapted with zero code changes. The image namespace followed the repo. More on that below — it's the one part of the release story that went better than I planned.

Docker Tag Mapping

docker/metadata-action is the part that turns one git tag into four image tags. Every image published to GHCR carries all of them, so a deploy can pin to a specific patch, track a minor, or follow latest — all from the same push.

Tag pattern Source Example for v0.1.2 on commit 2d1f5a6
{{version}} semver tag, stripped of v 0.1.2
{{major}}.{{minor}} major.minor rolling track 0.1
latest always, on the newest tag push latest
sha-{{sha}} short commit SHA sha-2d1f5a6

The sha-{{sha}} tag is the one I reach for in incident reviews. A semver tag tells you the release; a SHA tag tells you the commit. When a robot misbehaves, I want the commit, not the marketing version. Both are on the image, and both point at the same digest.

The Traceable Chain

This is the whole point of the post. When someone asks "which code is running on the robot?", the answer is not a version number — it's a chain of references, each one verifiable from the one before it:

digest (sha256:d7ec1f34...)
  → image tag (:0.1.2)
  → git tag (v0.1.2)
  → commit (2d1f5a6)
  → PR (#12)
  → issue (#8 — corridor exit drift)

Every link is a lookup, not a guess. docker inspect gives me the digest and the tags. git rev-parse v0.1.2 gives me the commit. gh pr view gives me the PR. The PR body references the issue it closed. In a certification environment — medical robotics, automotive, aerospace — this is not a nice-to-have. It is the audit trail. An auditor doesn't want to hear "we think it was this commit." They want a chain they can re-walk themselves, in order, with signatures at each hop.

flowchart LR
    D[digest<br/>sha256:d7ec1f34...] -->|docker inspect| T[image tag<br/>:0.1.2]
    T -->|git tag lookup| G[git tag<br/>v0.1.2]
    G -->|git rev-parse| C[commit<br/>2d1f5a6]
    C -->|gh pr view --merged| P[PR #12<br/>fix: corridor exit drift]
    P -->|closes| I[Issue #8<br/>robot drifts on exit]

    style D fill:#fff3e0,stroke:#ef6c00
    style T fill:#e3f2fd,stroke:#1565c0
    style G fill:#e3f2fd,stroke:#1565c0
    style C fill:#f3e5f5,stroke:#6a1b9a
    style P fill:#e8f5e9,stroke:#2e7d32
    style I fill:#ffebee,stroke:#c62828

The chain runs in reverse just as cleanly: from the issue, I can find the PR, the commit, the tag, and the exact digest on the robot. Bidirectionality is what makes it an audit trail, not a breadcrumb.

The v0.1.0 → v0.1.1 Lesson

The first release I cut failed. I pushed v0.1.0, the release workflow ran, the Docker build broke at the COPY step: the Dockerfile's COPY list didn't include otonav_nav, so rosdep couldn't resolve the package's dependencies, and the image never landed on GHCR. The tag was public, the build was red, and there was no image behind it.

My first instinct was to delete the tag and re-push it. GitHub rejected that, and it was right to. A tag that's been pushed to a remote is, for all practical purposes, published history. Re-pointing it rewrites history that anyone may have already fetched, forked, or built against. The error message was clear, and once I read it, the correct move was obvious too: don't break the seal, stamp a new one.

I cut v0.1.1 as a clean patch release — the first image that actually landed on GHCR, in the personal namespace ghcr.io/ozkanceylon/robotics_cicd:0.1.1. The v0.1.0 tag stayed in git history, empty, and the CHANGELOG documented it honestly:

## [0.1.0] — 2026-07-02
### Known Issues
- Release build failed: Dockerfile COPY list missing `otonav_nav`.
  No image published. See [release run #42](#).
  Fixed in [0.1.1].

## [0.1.1] — 2026-07-02
### Fixed
- Dockerfile `COPY` list now includes `otonav_nav` (#12).
- First published image: `ghcr.io/ozkanceylan/robotics_cicd:0.1.1`.

A tag is a seal. You don't break the seal — you stamp a new one. That sentence now lives in the repo's RELEASING.md, and it's the one line every new contributor reads before they cut their first release.

Org Transfer and the GHCR Namespace

Here's the part that surprised me. When I moved the repo from my personal account (ozkanceylan) to the org (ozkanceylan-dev), I expected the release workflow to break. It didn't — because every reference to the image name used ${{ github.repository }}, which expands to <owner>/<repo> at run time. The workflow re-pointed itself.

What did break was the GHCR package namespace. GitHub Container Registry packages do not transfer with a repo. The v0.1.1 image stayed at ghcr.io/ozkanceylan/robotics_cicd:0.1.1; the next release, v0.1.2, published to ghcr.io/ozkanceylan-dev/robotics_cicd:0.1.2. Same workflow, same release.yml, two different namespaces.

Version Namespace Notes
v0.1.0 Tag pushed, build failed, no image
v0.1.1 ghcr.io/ozkanceylan/robotics_cicd First published image (personal)
v0.1.2 ghcr.io/ozkanceylan-dev/robotics_cicd First image in org namespace

I documented this in the CHANGELOG so no one goes looking for v0.1.1 in the org namespace and concludes it was deleted. It wasn't deleted — it was inherited from the pre-transfer world. The digest sha256:d7ec1f34... still resolves, just under the old owner.

Test What You Ship

The release image is the test image, modulo the stage. The Dockerfile is multi-stage: a builder stage runs colcon test and compiles against ros:humble-ros-base + MuJoCo 3.3.7, and a runner stage ships the install layout on ros:humble-ros-core. The CI container job runs its tests on ros:humble-ros-base, the same base the builder uses. The differences are intentional:

  • CI needs ros-base because it builds from source on every run.
  • Release builder needs ros-base for the same reason.
  • Release runner needs only ros-core — the install layout is already built, so we ship a smaller image.

The MuJoCo version is pinned at 3.3.7 in both stages of the release Dockerfile and in the CI container image. If the test environment and the ship environment drift, the test results are fiction. Pinning the simulator version across CI and release is what makes the green checkmark on a PR mean something when the same code shows up on a robot.

CHANGELOG Discipline

The CHANGELOG is not a git log. It's a curated, human-written document in keep-a-changelog format, and every release entry references the PRs that went in. The v0.1.0 entry is short, honest, and links to the failed run. The v0.1.1 entry explains what was fixed and which PR fixed it. The v0.1.2 entry notes the namespace move.

The discipline is simple: if a PR isn't in the CHANGELOG, it didn't ship. Reviewers check the CHANGELOG diff as part of the PR review. A release without a CHANGELOG entry blocks the tag push — there's a tiny pre-tag check in release.yml that grep's the CHANGELOG for the new version string. It's five lines of YAML and it has saved me from myself twice.

What's Next

Release engineering closes the loop from code to deployable artifact, but it doesn't close the loop from artifact to confidence. The pipeline now produces a traceable image on every tag — but the process of getting there taught me more than the artifact itself. Part 7 is the lessons-learned post: the integration pain that nearly killed the project, the decisions I'd make differently, and the one workflow I'd delete on day one if I started over.

End of Protocol — part-6-release-engineering-traceable-image.md