diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 66b4b33c..1df40709 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,20 @@ jobs: needs: test if: github.event_name == 'push' runs-on: ubuntu-latest + # Bloc 97: the image this job builds, verifies and pushes, named once. + # `main` publishes the stable channel (:latest), every other branch the + # rolling one (:dev) — the split README.md documents for ML_HELPER_IMAGE. + # + # It deliberately carries compose's own variable name: the Docker Compose + # check below never set it, so compose fell back to its built-in :dev + # default. On dev that happened to be the tag this job had just built; on + # main, where the job builds :latest, compose looked for an image that had + # never existed on the runner and the step died in under a second with + # "No such image: ghcr.io/magicgg91/ml-helper:dev" — before any container + # started, so nothing about it was a health problem. One name, read by + # every step here and by compose itself, cannot drift like that again. + env: + ML_HELPER_IMAGE: "ghcr.io/magicgg91/ml-helper:${{ github.ref_name == 'main' && 'latest' || 'dev' }}" steps: - uses: actions/checkout@v4 - uses: docker/setup-buildx-action@v3 @@ -44,7 +58,7 @@ jobs: with: context: . load: true - tags: ghcr.io/magicgg91/ml-helper:${{ github.ref_name == 'main' && 'latest' || 'dev' }} + tags: ${{ env.ML_HELPER_IMAGE }} - name: Verify container startup run: | mkdir -p "$RUNNER_TEMP/ml-helper-data" @@ -53,7 +67,7 @@ jobs: -e DATABASE_URL=file:/app/data/ml-helper.db \ -e NEXTAUTH_URL=http://localhost:3000 \ -e NEXTAUTH_SECRET=container-startup-test-secret \ - ghcr.io/magicgg91/ml-helper:${{ github.ref_name == 'main' && 'latest' || 'dev' }} \ + "$ML_HELPER_IMAGE" \ node -e "console.log('Container startup verified')" - name: Verify Docker Compose health run: | @@ -92,7 +106,7 @@ jobs: --header "Accept: application/vnd.github+json" \ --header "X-GitHub-Api-Version: 2022-11-28" \ "${{ github.api_url }}/repos/${{ github.repository }}/statuses/${{ github.sha }}" \ - --data '{"state":"failure","context":"docker-compose/health","description":"Docker Compose app container did not become healthy"}' + --data '{"state":"failure","context":"docker-compose/health","description":"Docker Compose app container did not start or did not become healthy"}' # Bloc 84: a plain `docker push` of the image loaded in the step above # started failing deterministically with "unknown blob" (2 runs in a # row, immediately on the first layer, no prior occurrence in 225+ @@ -108,5 +122,5 @@ jobs: with: context: . push: true - tags: ghcr.io/magicgg91/ml-helper:${{ github.ref_name == 'main' && 'latest' || 'dev' }} + tags: ${{ env.ML_HELPER_IMAGE }} diff --git a/src/foundation.test.ts b/src/foundation.test.ts index d6ad456f..33a90f63 100644 --- a/src/foundation.test.ts +++ b/src/foundation.test.ts @@ -17,3 +17,33 @@ describe("Docker healthcheck", () => { expect(healthcheck).toContain('path: "/api/health"'); }); }); + +// Bloc 97: the "Verify Docker Compose health" job failed on the first push to +// main since that step was written (CI run 34835198084). It never set +// ML_HELPER_IMAGE, so compose used its own :dev default while the job had +// built and loaded :latest — "No such image: ghcr.io/magicgg91/ml-helper:dev", +// in under a second, before any container existed. Two files each spelled the +// tag out on their own, and only the main branch made them disagree. +describe("CI image tag", () => { + const workflow = readFileSync(".github/workflows/ci.yml", "utf8"); + const compose = readFileSync("docker-compose.yml", "utf8"); + + it("hands Docker Compose the very image the job just built", () => { + // The name is the link between the two files: compose reads this variable, + // so the workflow has to be what sets it. + expect(compose).toContain("${ML_HELPER_IMAGE:-"); + expect(workflow).toMatch(/^\s+ML_HELPER_IMAGE: /m); + }); + + it("spells the registry tag exactly once, so no step can drift", () => { + // Every other reference goes through ML_HELPER_IMAGE. A second literal is + // how the branch-dependent tag came apart in the first place. Comment + // lines don't count — the one above quotes the tag on purpose. + const effective = workflow + .split("\n") + .filter((line) => !line.trimStart().startsWith("#")) + .join("\n"); + const literals = effective.match(/ghcr\.io\/magicgg91\/ml-helper:/g) ?? []; + expect(literals).toHaveLength(1); + }); +});