Releasing¶
A tag publishes five things, and every one of them is cut from the same commit.
| Artifact | Where it goes | Built by |
|---|---|---|
cellcast-hub image, linux/amd64 and linux/arm64 |
ghcr.io/ethan-kane-ops/cellcast-hub |
Dockerfile |
cellcast-agent image, both architectures |
ghcr.io/ethan-kane-ops/cellcast-agent |
Dockerfile |
cellcast client image, both architectures |
ghcr.io/ethan-kane-ops/cellcast |
Dockerfile |
cellcast and cellcast-agent charts |
oci://ghcr.io/ethan-kane-ops/charts |
helm package |
cellcast client archives and checksums |
the GitHub release | goreleaser |
The client is on that list twice. A person unpacks an archive; a pipeline step that has no package manager, such as an Argo CD PreSync hook, runs the image instead. The hub and the agent are the other way round and ship only as images: they hold trust configuration and mint credentials, and a tarball of the hub would look like a supported way to run the broker outside every control the chart applies.
One number¶
The git tag, both chart appVersion fields and every image tag are the same string.
The chart version fields are that string without the leading v, because Helm
requires bare semver there.
Helm lets a chart version and an appVersion move independently, and for a chart packaged separately from the thing it installs that indirection earns its keep. It does not here. One tag cuts the images and both charts from one commit, so a chart version that did not match would name a release nothing else in the project has heard of.
just release-version v0.1.0 writes all four numbers and regenerates the changelog.
A contract test holds the relationship, so a hand edit that breaks it fails just
check rather than reaching a registry.
Cutting a release¶
Three commands, and a merge in the middle.
just release-preview # what the next version would be, and its notes
just release-prepare # opens the release pull request
# ... review and merge it, then:
git switch main && git pull
just release-tag # tags the merged commit and pushes the tag
just release-preview writes nothing. The version comes from the commits rather than
from memory: cliff.toml sets features_always_bump_minor and
breaking_always_bump_major, so the conventional-commit prefixes the commit hook already
enforces are what decide it. Pass patch, minor, major or an explicit vX.Y.Z to
override.
just release-prepare cuts a branch, sets both charts, regenerates the chart READMEs and
the changelog, runs just check, and opens the pull request. It stops there because
main requires one, and because the tag has to name the commit that reached main
rather than the branch it arrived on.
just release-tag reads the version back out of Chart.yaml, so it cannot tag one
number while the charts declare another. Pushing that tag is the last thing done from a
laptop.
What the tag starts¶
The tag triggers .github/workflows/release.yml, which runs just release <tag>. That
is not a preference. A keyless signature carries the identity of whoever authenticated,
and the identity every published cosign verify command names is that workflow's, so a
release cut by hand signs as a person and then fails its own just verify.
just release refuses to start unless the working tree is clean, the tag exists, the
tag points at HEAD, and both charts already declare it. Then it runs just check-all
and just verify-e2e before publishing anything, because a broken credential broker
cannot be fixed forward: by the time it is noticed, the bad image has been pulled.
just release-check runs every step of it locally and publishes nothing. Run it after
any change to the Dockerfile, either Chart.yaml, or .goreleaser.yaml.
Publishing order is images, then charts, then the GitHub release. The release is the artifact a person reads, and it should not appear before the things it describes exist.
When a publish step fails halfway¶
Nothing about pushing three images, two charts and a GitHub release is atomic, so decide in advance what a half-finished release means.
Before anyone has been told the release exists, re-run just release v0.1.0. Every step
is safe to repeat: the images and charts overwrite the same tag, and goreleaser is
configured with mode: replace so it rewrites the GitHub release rather than failing on
one that already exists.
Once the tag has been announced, or anything has pulled it, stop overwriting and cut a patch tag instead. A tag whose contents changed after somebody pulled it costs more than a skipped version number, and the answer to "what am I running" rests on a digest naming the same bytes tomorrow.
Rehearsing it¶
just release-check runs every build the real thing runs and publishes none of it.
It is the local stand-in for a release workflow, and it belongs before the tag rather
than after.
Individual pieces, when something in there fails:
just image cellcast-hub # one image, this machine's architecture, loaded locally
just images-check # every image, every architecture, output discarded
just chart-package # both charts into dist/charts
just release-notes v0.1.0 # what the GitHub release will say
just image-bases # current digests for the base images the Dockerfile pins
Reproducible builds¶
Two builds of one commit produce identical bytes, which is checkable rather than claimed:
Three things make that true. -trimpath keeps the absolute path of whoever built it
out of the binary. The base images are pinned by digest, not by tag, so rebuilding an
old tag uses the compiler that built it the first time. And the build stamps the
commit's own timestamp rather than the wall clock, which is the usual reason a
"reproducible" release turns out not to be.
The version a binary reports carries a -dirty suffix when the tree was not exactly a
commit. That suffix means its bytes are not reproducible by anybody else.
What the release signs, and what that proves¶
Every image and both charts are signed with cosign keyless signing, and goreleaser signs
checksums.txt, which covers every archive and every archive SBOM. One signature, whole
release.
Keyless means there is no key. The signer authenticates over OIDC, Fulcio issues a certificate valid for a few minutes, and the signature plus that certificate go into Rekor, a public transparency log. Nothing to rotate, nothing to steal.
The consequence is that "it is signed" stops being a useful statement, because anyone can sign anything with a valid identity. Verification has to name the expected identity:
cosign verify ghcr.io/ethan-kane-ops/cellcast-hub:v0.1.0 \
--certificate-identity https://github.com/ethan-kane-ops/cellcast/.github/workflows/release.yml@refs/tags/v0.1.0 \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
Those two values live in the justfile as sign_workflow and sign_issuer. just verify <tag>
runs the command above against every published artifact, just release runs just verify as its
last step, and a contract test holds the documented command to the same pair. A verify command
that has drifted from the signer teaches people that a verification failure is normal, which is
worse than shipping no signature.
SBOM and provenance¶
Each image carries two attestations, produced by BuildKit during the build:
| Predicate | What it is |
|---|---|
https://spdx.dev/Document |
the SBOM, the packages actually compiled in |
https://slsa.dev/provenance/v1 |
build provenance: source, build arguments, base images |
Generating them during the build rather than by scanning the finished image matters here: these images hold one stripped static binary and nothing else, so a scanner has almost nothing to read, while the builder knows every module it linked.
just verify-attestations cellcast-hub # build one and read the predicates back
docker buildx imagetools inspect ghcr.io/ethan-kane-ops/cellcast-hub:v0.1.0 --format '{{ json .SBOM }}'
The archives get their own SBOM from syft, one per archive, published beside it.
Verifying what you install¶
Pin by digest in production. A tag can be moved; a digest names the same bytes tomorrow. Both
charts accept image.digest, which wins over image.tag:
What the workflow does, and what it does not¶
.github/workflows/release.yml installs the toolchain, logs in to GHCR and runs
just release <tag>. It holds no release logic of its own. The pipeline is the recipes,
which is why it could be rehearsed with just release-check long before there was
anywhere to run it.
One part cannot be done from a laptop, and it fails rather than degrades. The identity in
sign_workflow is that workflow's, so a release cut by hand signs as whoever ran it and
then fails its own just verify. That is the correct outcome: signing with a personal
identity would produce artifacts that verify only against a command nobody published, and
the first release must not be the one that teaches people to ignore a verification failure.
The workflow's path is part of the contract. Renaming or moving that file changes the
signing identity and invalidates every cosign verify command in this repository, with
nothing to catch it.