From c8d102179d2c8f058cbb20bbcd8fc11cba3384d6 Mon Sep 17 00:00:00 2001 From: lorenzozane Date: Thu, 17 Sep 2026 00:36:18 +0800 Subject: [PATCH] fix(docker): smoke-test images before promoting latest (#13761) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merged. The ordering is what makes this fix real: the version manifests are published first, the smoke test runs against the published image, and only then are the floating `latest` tags promoted — so a broken build can no longer become `latest`. Both Dockerfiles carry a `HEALTHCHECK`, and the 30×2s window covers the 15s `start-period`. Validated as a combined board first (this PR merged with the 21 siblings of the same wave on the release tip): eslint with the frozen suppressions, typecheck:core, check:open-sse-typecheck, complexity, cognitive-complexity, changelog-integrity, i18n new-key coverage, docs-counts, docs-sync, migration-numbering, provider-consistency and a duplicate-identifier audit all green, plus 176 passing / 0 failing focused node:test cases across the 25 test files the wave touches and the dashboard test under Vitest (2/0). Then re-validated alone on the fresh tip before this merge: conflicts re-resolved, file sizes rebaselined for this PR's own growth, eslint and this PR's focused tests re-run. Thanks for closing #11835 properly instead of just adding a check after the fact. --- .github/workflows/docker-publish.yml | 69 +++++++++++++++---- ...docker-published-image-smoke-11835.test.ts | 46 +++++++++++++ 2 files changed, 103 insertions(+), 12 deletions(-) create mode 100644 tests/unit/build/docker-published-image-smoke-11835.test.ts diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 77e722b318..200dca613f 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -411,16 +411,12 @@ jobs: path: /tmp/digests/bun-web merge-multiple: true - - name: Create Docker Hub manifest + - name: Create Docker Hub version manifests run: | set -euo pipefail create_manifest() { local image="$1" suffix="$2" dir="$3" optional="${4:-}" - local tags=(-t "${image}:${VERSION}${suffix}") - if [ "$PROMOTE_LATEST" = "true" ]; then - tags+=(-t "${image}:latest${suffix}") - fi local refs=() while IFS= read -r digest_file; do refs+=("${image}@sha256:$(basename "$digest_file")") @@ -433,7 +429,7 @@ jobs: echo "No image digests in $dir" >&2 exit 1 fi - docker buildx imagetools create "${tags[@]}" "${refs[@]}" + docker buildx imagetools create -t "${image}:${VERSION}${suffix}" "${refs[@]}" } create_manifest "${IMAGE_NAME}" "" /tmp/digests/base @@ -441,16 +437,12 @@ jobs: create_manifest "${IMAGE_NAME}" "-bun" /tmp/digests/bun-base optional create_manifest "${IMAGE_NAME}" "-web-bun" /tmp/digests/bun-web optional - - name: Create GHCR manifest + - name: Create GHCR version manifests run: | set -euo pipefail create_manifest() { local image="$1" suffix="$2" dir="$3" optional="${4:-}" - local tags=(-t "${image}:${VERSION}${suffix}") - if [ "$PROMOTE_LATEST" = "true" ]; then - tags+=(-t "${image}:latest${suffix}") - fi local refs=() while IFS= read -r digest_file; do refs+=("${image}@sha256:$(basename "$digest_file")") @@ -463,7 +455,7 @@ jobs: echo "No image digests in $dir" >&2 exit 1 fi - docker buildx imagetools create "${tags[@]}" "${refs[@]}" + docker buildx imagetools create -t "${image}:${VERSION}${suffix}" "${refs[@]}" } create_manifest "${GHCR_IMAGE_NAME}" "" /tmp/digests/base @@ -471,6 +463,59 @@ jobs: create_manifest "${GHCR_IMAGE_NAME}" "-bun" /tmp/digests/bun-base optional create_manifest "${GHCR_IMAGE_NAME}" "-web-bun" /tmp/digests/bun-web optional + - name: Smoke-test published Docker image + if: needs.prepare.outputs.version != 'main' + run: | + set -euo pipefail + container="omniroute-smoke-${VERSION//[^a-zA-Z0-9_.-]/-}" + trap 'docker rm -f "$container" >/dev/null 2>&1 || true' EXIT + docker run --detach --name "$container" "${IMAGE_NAME}:${VERSION}" + for attempt in $(seq 1 30); do + status="$(docker inspect --format '{{.State.Health.Status}}' "$container")" + if [ "$status" = "healthy" ]; then + exit 0 + fi + if [ "$status" = "unhealthy" ] || [ "$(docker inspect --format '{{.State.Status}}' "$container")" = "exited" ]; then + docker logs "$container" + exit 1 + fi + sleep 2 + done + docker logs "$container" + exit 1 + + - name: Promote Docker Hub latest tags + if: needs.prepare.outputs.promote_latest == 'true' + run: | + set -euo pipefail + promote_tag() { + local suffix="$1" + docker buildx imagetools create -t "${IMAGE_NAME}:latest${suffix}" "${IMAGE_NAME}:${VERSION}${suffix}" + } + promote_tag "" + promote_tag "-web" + for suffix in -bun -web-bun; do + if docker buildx imagetools inspect "${IMAGE_NAME}:${VERSION}${suffix}" >/dev/null 2>&1; then + promote_tag "$suffix" + fi + done + + - name: Promote GHCR latest tags + if: needs.prepare.outputs.promote_latest == 'true' + run: | + set -euo pipefail + promote_tag() { + local suffix="$1" + docker buildx imagetools create -t "${GHCR_IMAGE_NAME}:latest${suffix}" "${GHCR_IMAGE_NAME}:${VERSION}${suffix}" + } + promote_tag "" + promote_tag "-web" + for suffix in -bun -web-bun; do + if docker buildx imagetools inspect "${GHCR_IMAGE_NAME}:${VERSION}${suffix}" >/dev/null 2>&1; then + promote_tag "$suffix" + fi + done + - name: Inspect image if: needs.prepare.outputs.version != 'main' run: | diff --git a/tests/unit/build/docker-published-image-smoke-11835.test.ts b/tests/unit/build/docker-published-image-smoke-11835.test.ts new file mode 100644 index 0000000000..06cfb77edd --- /dev/null +++ b/tests/unit/build/docker-published-image-smoke-11835.test.ts @@ -0,0 +1,46 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(here, "../../.."); +const workflow = readFileSync( + path.join(root, ".github/workflows/docker-publish.yml"), + "utf8", +); + +test("published version is smoke-tested before latest promotion", () => { + const versionManifest = workflow.indexOf("Create Docker Hub version manifests"); + const smoke = workflow.indexOf("Smoke-test published Docker image"); + const latestPromotion = workflow.indexOf("Promote Docker Hub latest tags"); + + assert.ok(versionManifest >= 0, "version manifest step must exist"); + assert.ok(smoke > versionManifest, "smoke test must follow version publication"); + assert.ok(latestPromotion > smoke, "latest promotion must follow the smoke test"); +}); + +test("smoke test verifies the container health status", () => { + const smoke = workflow.match( + /- name: Smoke-test published Docker image[\s\S]*?(?=\n - name:|\n - uses:|\n\s*$)/, + ); + + assert.ok(smoke, "published image smoke-test step must exist"); + assert.match(smoke[0], /docker run/); + assert.match(smoke[0], /docker inspect/); + assert.match(smoke[0], /healthy/); +}); + +test("latest promotion preserves every published image flavor", () => { + const promotion = workflow.match( + /- name: Promote Docker Hub latest tags[\s\S]*?(?=\n - name: Inspect image)/, + ); + + assert.ok(promotion, "Docker Hub latest promotion must exist"); + assert.match(promotion[0], /promote_tag ""/); + assert.match(promotion[0], /promote_tag "-web"/); + assert.match(promotion[0], /for suffix in -bun -web-bun/); + assert.match(promotion[0], /imagetools inspect/); + assert.match(workflow, /Promote GHCR latest tags/); +});