Releases are cut in two phases, both automated, with a pull request between them. Nothing is tagged or published until a human has merged that PR.
develop is the integration branch and the release branch — there is no separate
stable channel.
.github/workflows/release.yml, run manually from the Actions tab.
Inputs:
| Input | Meaning |
|---|---|
release_type |
patch (default), minor, or major. Bumps VERSION. |
version |
Explicit x.y.z. Overrides release_type. |
dry_run |
Compute the version and changelog, open no pull request. |
What it does:
- Computes the next version and fails if that tag already exists. The bump is
applied to whichever is higher, the root
VERSIONfile or the newestvX.Y.Ztag. They are normally the same; they diverge when a publish failed part-way and spent a version without committing it, and bumping fromVERSIONalone would then reissue a number that already has images on Docker Hub. - Writes the new
VERSION. - Builds the
CHANGELOG.mdentry from the pull requests merged intodevelopsince the previous tag, and prepends it. - Commits to
release/vX.Y.Zand opens a PR titledchore(release): vX.Y.Zagainstdevelop.
It never pushes to develop, tags, or publishes. That keeps develop PR-only.
Re-running for the same version updates the existing release PR rather than opening a second one.
The release PR is an ordinary pull request, so the Tests workflow runs against it. Read the changelog entry, confirm the tests are green, then merge.
Phase 2 independently re-checks Tests against the merge commit and refuses to publish a red one, so this review is about the changelog and the timing — not about being the last thing standing between a broken build and Docker Hub.
.github/workflows/tag-release.yml, triggered by a push to develop that touches
VERSION — i.e. the release PR merging.
- Waits for the Tests workflow on the merge commit and fails if it is not green. Tests runs on the same push, so this normally waits a minute or two.
- Builds and pushes
teem/tops,teem/tops-backupandteem/tops-installerto Docker Hub forlinux/amd64andlinux/arm64, taggedvX.Y.Z(the app image also getssha-<commit>). - Creates the annotated tag
vX.Y.Z. Tags are not subject to branch protection, so this works withdeveloplocked down. - Cuts the GitHub release — which provides the downloadable source zip that
install.shpulls — using theCHANGELOG.mdentry as the release notes. - Moves
latestontovX.Y.Z, by re-pointing the manifest that step 2 already pushed. No rebuild, and the digest matches the version tag exactly.
Images are pushed before the tag exists, deliberately: tagging first would mean a failed image build leaves a dangling tag, the next release would skip past that version, and it would be burned with nothing published under it.
latest is the exception, and moves last. It is the tag users can land on
without asking for it, so it must never point at a build with no tag and no
release behind it — which is what a run that died before tagging used to leave.
If VERSION already has a matching tag, the workflow is a no-op. workflow_dispatch
is available for re-running a failed publish; it reads VERSION the same way, so it
cannot cut a release no PR prepared.
Phase 2 can die after pushing images but before tagging. Docker Hub then holds a
vX.Y.Z that git and GitHub know nothing about.
Re-run Phase 2 with workflow_dispatch — it reads VERSION, sees no matching tag,
and finishes the job. That is the normal fix, and it republishes the same version.
If the commit is no longer the one you want to ship, do not just bump past the
spent version and leave the orphan behind. Either tag the commit those images were
built from (the app image carries sha-<commit>, so it is always identifiable) or
delete the orphaned Docker Hub tags. Phase 1 will refuse to reissue the number once
a tag exists for it.
docker/app/Dockerfile builds FROM teem/tops-base:<tag> — nginx, supervisor and
four compiled PHP extensions, defined in docker/base/Dockerfile. None of that
changes release to release, so a release pulls it instead of rebuilding it for two
architectures with arm64 under QEMU.
This is not part of a release. It happens on its own, rarely, when the PHP version, the package list or the extension list changes.
- Edit
docker/base/Dockerfileon a branch, and bump the pin indocker/app/Dockerfileto the tag you are about to publish. Both changes go in the same PR — a base tag with no app pointing at it, or a pin pointing at a tag that does not exist, is a broken build for everyone who builds from source. - Run
.github/workflows/publish-base.ymlfrom the Actions tab against that branch, with the new tag as its input. It builds and pusheslinux/amd64andlinux/arm64. - Merge the PR once the image is on Docker Hub, not before. Until then, the pin references a tag nobody can pull.
Tags are immutable and the workflow refuses to publish over an existing one. That is what makes the pin worth having: republishing a base could otherwise change what the next app build produces without a diff to review. To correct a base, publish the next revision.
The tag encodes the PHP version and a revision — php8.3-1, php8.3-2, then
php8.4-1 after a PHP bump. There is no latest, on purpose: the app image must
always name the exact base it was built on.
| Secret | Used by | Required |
|---|---|---|
DOCKERHUB_USERNAME / DOCKERHUB_TOKEN |
Phase 2, and the base image workflow | Yes. A scoped Docker Hub access token, never an account password. |
RELEASE_TOKEN |
Phase 1 | Optional PAT with repo scope. Pull requests opened with the default GITHUB_TOKEN do not trigger other workflows, so without it the release PR arrives with no Tests run attached. Convenience only — Phase 2 re-checks Tests on the merge commit either way. |
install.sh writes TOPS_IMAGE_TAG=vX.Y.Z into .env at install time, so a later
docker compose up cannot silently jump onto a newer latest. Moving forward is an
explicit docker compose pull after editing that value.