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`: