From b0427185a0a6c620c1d68bd270ae5c2a995a97e0 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Sun, 13 Sep 2026 19:19:27 +0800 Subject: [PATCH] ci: stop pushing :latest from main; the tag owns it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `think-watch-server:latest` currently points at a `main` build rather than v1.0.2. Both workflows push `:latest` and neither declares a concurrency group, so cutting the release raced the `main` push that carried it — same tag, two runs, last writer wins. CI's server manifest finished after the release's, so it won. `think-watch-web:latest` is correct only because CI's web job happened to be cancelled in that run, which is the same race landing the other way. None of this is visible from the Release page: every job in the release run reports success, the version tags are right, and the chart is attached. Only comparing digests shows it. `:latest` asserts "the newest stable release". A branch push cannot know whether its commit is that, so it must not set the tag that claims it. Both `:latest` pushes are removed from `ci.yml`; the `:` tags stay, which is how you deploy an unreleased `main` deliberately and can never collide with a release. A shared concurrency group was the other option and is worse: it makes the two runs queue, so which one writes `:latest` last is still decided by ordering rather than by meaning. `docs/operations/release.md` gains a table of which workflow owns which tag. The runbook already said `:latest` was for stable releases only — that was true of the intent and false of the implementation, which is the pairing that keeps a defect alive. Repointing the live `:latest` is a separate step, done after this lands so the merge's own CI run can't overwrite it again. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 17 +++++++++++++++-- docs/operations/release.md | 20 ++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e8afa2f8..5edaa872 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -204,12 +204,24 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + # **Tagged by commit SHA only. `:latest` belongs to release.yml.** + # + # Both workflows used to push `:latest`, with no concurrency group + # between them, so a release raced the `main` push that carried it. + # v1.0.2 lost that race: `think-watch-server:latest` ended up + # pointing at a main-branch build rather than the tagged release, + # while `think-watch-web:latest` stayed correct only because this + # workflow's web job happened to be cancelled mid-run. + # + # `:latest` means "the newest stable release", which is a fact only + # a tag knows. A branch push cannot know it, so it must not claim + # it. The SHA tag stays — that is how you deploy an unreleased + # `main` on purpose, and it can never collide with a release tag. - name: Create and push multi-arch manifest working-directory: ${{ runner.temp }}/digests run: | IMAGE="${IMAGE_PREFIX}/think-watch-server" docker buildx imagetools create \ - -t "${IMAGE}:latest" \ -t "${IMAGE}:${{ github.sha }}" \ $(printf "${IMAGE}@sha256:%s " *) @@ -250,8 +262,9 @@ jobs: file: deploy/docker/Dockerfile.web push: true platforms: linux/amd64,linux/arm64 + # SHA only — see the note on the server manifest job above. + # `:latest` is release.yml's to set. tags: | - ${{ env.IMAGE_PREFIX }}/think-watch-web:latest ${{ env.IMAGE_PREFIX }}/think-watch-web:${{ github.sha }} cache-from: type=gha,scope=web cache-to: type=gha,scope=web,mode=max diff --git a/docs/operations/release.md b/docs/operations/release.md index d3f4ce42..140aa2ed 100644 --- a/docs/operations/release.md +++ b/docs/operations/release.md @@ -159,6 +159,26 @@ page. Total wall-clock: ~13 min for a typical release. +### Who owns which image tag + +| Tag | Set by | Means | +|---|---|---| +| `:X.Y.Z` | `release.yml` (tag push) | That release, exactly | +| `:latest` | `release.yml`, stable releases only | The newest stable release | +| `:` | `ci.yml` (push to `main`) | That commit on `main`, released or not | + +**`ci.yml` must never push `:latest`.** Both workflows used to, with no +concurrency group between them, so cutting a release raced the `main` +push that carried it — two runs, same tag, last writer wins. v1.0.2 lost +that race: `think-watch-server:latest` pointed at a `main` build instead +of the tagged release, and `think-watch-web:latest` was correct only +because CI's web job happened to be cancelled that run. The mismatch is +invisible from the Release page, which looks entirely healthy. + +A branch push cannot know which commit is the newest stable release, so +it must not claim the tag that asserts it. To deploy an unreleased +`main`, pull it by commit SHA on purpose. + ## Pre-release tags For `1.0.0-rc.1`, `1.1.0-beta.2`, `2.0.0-alpha.5`: