From d49bbdaec3a1b391a819653f15985b3b48fdd5d2 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Fri, 29 May 2026 10:19:38 -0400 Subject: [PATCH 1/4] hotfix: repair Docker release publishing Refs #1400 --- .github/workflows/docker-release.yml | 34 ++++++++++++++++- scripts/docker-dashboard-sunset-guard.js | 8 ++-- tests/docker/dashboard-sunset-guard.test.sh | 5 ++- .../docker-release-workflow-context.test.ts | 38 +++++++++++++++++++ .../docker-dashboard-sunset-guard.test.ts | 22 ++++++----- 5 files changed, 92 insertions(+), 15 deletions(-) create mode 100644 tests/unit/docker/docker-release-workflow-context.test.ts diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index c69cf972..214bacda 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -260,7 +260,7 @@ jobs: id: build uses: docker/build-push-action@v6 with: - context: . + context: docker file: docker/Dockerfile.integrated platforms: linux/amd64,linux/arm64 push: true @@ -332,6 +332,17 @@ jobs: - name: Pull image run: docker pull "${{ steps.image.outputs.ref }}" + - name: Verify anonymous pull access + run: | + CLEAN_DOCKER_CONFIG="$(mktemp -d)" + trap 'rm -rf "${CLEAN_DOCKER_CONFIG}"' EXIT + if ! DOCKER_CONFIG="${CLEAN_DOCKER_CONFIG}" docker pull "${{ steps.image.outputs.ref }}"; then + echo "[X] ghcr.io/kaitranntt/ccs is not anonymously pullable." >&2 + echo "[i] Make the GHCR package public, then rerun the Docker publish workflow." >&2 + exit 1 + fi + echo "[OK] Anonymous pull succeeded for ${{ steps.image.outputs.ref }}" + - name: Assert image size budget (amd64) run: | chmod +x tests/docker/image-size.sh @@ -471,3 +482,24 @@ jobs: "${IMAGE_REF}" echo "[OK] Promoted: :latest :${MINOR} :${MAJOR} → ${IMAGE_REF}" + + - name: Verify promoted tags are anonymously pullable + env: + VERSION: ${{ needs.publish-integrated.outputs.version }} + run: | + OWNER_LOWER=$(echo "${GITHUB_REPOSITORY_OWNER}" | tr '[:upper:]' '[:lower:]') + IMAGE="ghcr.io/${OWNER_LOWER}/ccs" + MINOR="${VERSION%.*}" + MAJOR="${VERSION%%.*}" + CLEAN_DOCKER_CONFIG="$(mktemp -d)" + trap 'rm -rf "${CLEAN_DOCKER_CONFIG}"' EXIT + + for TAG in latest "${MINOR}" "${MAJOR}"; do + REF="${IMAGE}:${TAG}" + if ! DOCKER_CONFIG="${CLEAN_DOCKER_CONFIG}" docker pull "${REF}"; then + echo "[X] ${REF} is not anonymously pullable." >&2 + echo "[i] Confirm the GHCR package is public, then rerun promotion." >&2 + exit 1 + fi + echo "[OK] Anonymous pull succeeded for ${REF}" + done diff --git a/scripts/docker-dashboard-sunset-guard.js b/scripts/docker-dashboard-sunset-guard.js index 0561bd81..e9d4fccd 100755 --- a/scripts/docker-dashboard-sunset-guard.js +++ b/scripts/docker-dashboard-sunset-guard.js @@ -97,9 +97,11 @@ function evaluateDashboardSunset({ targetTag, baselineVersion, releaseWindow, st const versions = parseStableTags(stableTags); const hasBaseline = versions.some((version) => compareVersions(version, baseline) === 0); if (compareVersions(target, baseline) > 0 && !hasBaseline) { - throw new Error( - `Cannot count dashboard sunset releases: baseline tag ${baseline.raw} is missing from git tags`, - ); + return { + publish: false, + elapsed: releaseWindow, + reason: `legacy dashboard sunset baseline ${baseline.raw} is missing from git tags; skipping deprecated image publish`, + }; } if (!versions.some((version) => compareVersions(version, target) === 0)) { diff --git a/tests/docker/dashboard-sunset-guard.test.sh b/tests/docker/dashboard-sunset-guard.test.sh index c466cee6..5aea7a5a 100755 --- a/tests/docker/dashboard-sunset-guard.test.sh +++ b/tests/docker/dashboard-sunset-guard.test.sh @@ -106,8 +106,9 @@ assert_case "skips at the larger configured window boundary" 0 false 3 \ assert_failure "rejects prerelease target tags" \ --target "v7.80.0-rc.1" --baseline "7.80.0" --window "2" -assert_failure "fails loudly when baseline tag is unavailable after baseline" \ - --target "v7.81.2" --baseline "7.81.0" --window "2" +assert_case "skips deprecated publish when baseline tag is unavailable after baseline" 0 false 2 \ + "v7.81.2" "7.81.0" "2" \ + $'v7.80.0' echo "" echo "Dashboard sunset guard tests complete: ${PASS} passed, ${FAIL} failed." diff --git a/tests/unit/docker/docker-release-workflow-context.test.ts b/tests/unit/docker/docker-release-workflow-context.test.ts new file mode 100644 index 00000000..4ddb9099 --- /dev/null +++ b/tests/unit/docker/docker-release-workflow-context.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from 'bun:test'; +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const repoRoot = join(import.meta.dir, '../../..'); + +describe('docker release workflow context', () => { + test('builds the integrated Dockerfile with the context its COPY paths expect', () => { + const workflow = readFileSync(join(repoRoot, '.github/workflows/docker-release.yml'), 'utf8'); + const dockerfile = readFileSync(join(repoRoot, 'docker/Dockerfile.integrated'), 'utf8'); + + expect(workflow).toMatch( + /Build and push integrated image[\s\S]*context: docker[\s\S]*file: docker\/Dockerfile\.integrated/, + ); + expect(dockerfile).toContain('COPY supervisord.conf /etc/supervisord.conf'); + expect(dockerfile).toContain('COPY entrypoint-integrated.sh /entrypoint-integrated.sh'); + expect(existsSync(join(repoRoot, 'docker/supervisord.conf'))).toBe(true); + expect(existsSync(join(repoRoot, 'docker/entrypoint-integrated.sh'))).toBe(true); + }); + + test('keeps integrated smoke tests independent from legacy dashboard publish', () => { + const workflow = readFileSync(join(repoRoot, '.github/workflows/docker-release.yml'), 'utf8'); + + expect(workflow).not.toMatch(/publish-integrated:[\s\S]*?needs:\s*\[?publish-dashboard/); + expect(workflow).toMatch(/smoke-test:[\s\S]*?needs:\s*\[publish-integrated\]/); + }); + + test('verifies immutable and promoted tags without registry credentials', () => { + const workflow = readFileSync(join(repoRoot, '.github/workflows/docker-release.yml'), 'utf8'); + + expect(workflow).toMatch( + /Verify anonymous pull access[\s\S]*DOCKER_CONFIG="\$\{CLEAN_DOCKER_CONFIG\}" docker pull/ + ); + expect(workflow).toMatch( + /Verify promoted tags are anonymously pullable[\s\S]*DOCKER_CONFIG="\$\{CLEAN_DOCKER_CONFIG\}" docker pull/ + ); + }); +}); diff --git a/tests/unit/scripts/docker-dashboard-sunset-guard.test.ts b/tests/unit/scripts/docker-dashboard-sunset-guard.test.ts index b35ffa5f..153169d4 100644 --- a/tests/unit/scripts/docker-dashboard-sunset-guard.test.ts +++ b/tests/unit/scripts/docker-dashboard-sunset-guard.test.ts @@ -45,14 +45,18 @@ describe('docker dashboard sunset guard', () => { }); }); - test('fails loudly if the baseline tag is missing after the baseline', () => { - expect(() => - evaluateDashboardSunset({ - targetTag: 'v7.81.2', - baselineVersion: '7.81.0', - releaseWindow: 2, - stableTags: ['v7.80.0'], - }), - ).toThrow('baseline tag v7.81.0 is missing'); + test('skips deprecated publish if the baseline tag is missing after the baseline', () => { + const result = evaluateDashboardSunset({ + targetTag: 'v7.81.2', + baselineVersion: '7.81.0', + releaseWindow: 2, + stableTags: ['v7.80.0'], + }); + + expect(result).toMatchObject({ + elapsed: 2, + publish: false, + }); + expect(result.reason).toContain('baseline v7.81.0 is missing'); }); }); From 23c819cabc954e41aceab67b6e0d96a505b7e899 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Fri, 29 May 2026 14:23:28 +0000 Subject: [PATCH 2/4] chore(release): 8.1.1 [skip ci] ## [8.1.1](https://github.com/kaitranntt/ccs/compare/v8.1.0...v8.1.1) (2026-05-29) ### Hotfixes * repair Docker release publishing ([d49bbda](https://github.com/kaitranntt/ccs/commit/d49bbdaec3a1b391a819653f15985b3b48fdd5d2)), closes [#1400](https://github.com/kaitranntt/ccs/issues/1400) --- CHANGELOG.md | 6 ++++++ package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b31c04ef..c70996ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## [8.1.1](https://github.com/kaitranntt/ccs/compare/v8.1.0...v8.1.1) (2026-05-29) + +### Hotfixes + +* repair Docker release publishing ([d49bbda](https://github.com/kaitranntt/ccs/commit/d49bbdaec3a1b391a819653f15985b3b48fdd5d2)), closes [#1400](https://github.com/kaitranntt/ccs/issues/1400) + ## [8.1.0](https://github.com/kaitranntt/ccs/compare/v8.0.0...v8.1.0) (2026-05-23) ### Features diff --git a/package.json b/package.json index d8a507f0..67739318 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "8.1.0", + "version": "8.1.1", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From 731d43ec9a331fa34b77c9abc18d4608dde27252 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Fri, 29 May 2026 10:39:28 -0400 Subject: [PATCH 3/4] hotfix: repair Docker image size inspection Refs #1400 --- tests/docker/image-size-logic.test.sh | 52 +++++++++++++++++ tests/docker/image-size.sh | 84 +++++++++++++++++++++++---- 2 files changed, 124 insertions(+), 12 deletions(-) diff --git a/tests/docker/image-size-logic.test.sh b/tests/docker/image-size-logic.test.sh index 5b5255c1..4e8a535e 100755 --- a/tests/docker/image-size-logic.test.sh +++ b/tests/docker/image-size-logic.test.sh @@ -159,6 +159,54 @@ MOCK_EOF chmod +x "${MOCK_DIR}/docker" } +make_mock_docker_platform_raw_index() { + # First inspect returns a multi-arch index; digest inspect returns the + # platform manifest with real layer sizes. This mirrors GHCR OCI output. + cat > "${MOCK_DIR}/docker" <<'MOCK_EOF' +#!/usr/bin/env bash +if [[ "$1" == "buildx" && "$2" == "imagetools" && "$3" == "inspect" ]]; then + ref="$4" + if [[ "$ref" == "mock-image:tag" ]]; then + cat <<'JSON' +{ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.index.v1+json", + "manifests": [ + { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:amd64digest", + "platform": { "os": "linux", "architecture": "amd64" } + }, + { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:arm64digest", + "platform": { "os": "linux", "architecture": "arm64" } + } + ] +} +JSON + exit 0 + fi + if [[ "$ref" == "mock-image:tag@sha256:amd64digest" ]]; then + cat <<'JSON' +{ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "layers": [ + { "size": 100000000 }, + { "size": 57671680 } + ] +} +JSON + exit 0 + fi + exit 1 +fi +exit 0 +MOCK_EOF + chmod +x "${MOCK_DIR}/docker" +} + run_platform_test() { local name="$1" local expected_exit="$2" @@ -190,6 +238,10 @@ run_platform_test "--platform: pass when platform-scoped size < budget" 0 "20971 make_mock_docker_platform "150000000 112000000" run_platform_test "--platform: fail when platform-scoped size > budget" 1 "209715200" +# --platform raw OCI index: resolve the platform digest then sum manifest layers +make_mock_docker_platform_raw_index +run_platform_test "--platform: resolves raw OCI index before summing layers" 0 "209715200" + # --platform inspect failure → must exit 1 (REV5 regression guard) make_mock_docker_platform_fail run_platform_test "--platform: fail loudly when imagetools inspect fails (REV5 guard)" 1 "209715200" diff --git a/tests/docker/image-size.sh b/tests/docker/image-size.sh index f1449bec..e53fee33 100755 --- a/tests/docker/image-size.sh +++ b/tests/docker/image-size.sh @@ -50,27 +50,87 @@ fi MAX_MB=$(( MAX_BYTES / 1048576 )) +sum_manifest_layer_sizes() { + local sum + sum="$(jq -sr ' + .[0] as $manifest + | if ($manifest | type) == "object" and ($manifest.layers | type) == "array" then + ([$manifest.layers[]?.size | numbers] | add) // 0 + else + 0 + end + ' 2>/dev/null)" || { + echo "0" + return + } + echo "${sum:-0}" +} + +select_platform_digest() { + local os="$1" + local arch="$2" + local variant="$3" + + jq -r \ + --arg os "$os" \ + --arg arch "$arch" \ + --arg variant "$variant" \ + ' + .manifests[]? + | select((.platform.os // "") == $os) + | select((.platform.architecture // "") == $arch) + | select($variant == "" or (.platform.variant // "") == $variant) + | .digest + ' 2>/dev/null | head -1 || true +} + if [[ -n "$PLATFORM" ]]; then # Multi-arch path: sum compressed layer sizes from the registry manifest. - # `docker buildx imagetools inspect --format` returns the manifest JSON for - # the given platform. We sum the `size` field of each layer entry. + # Read raw OCI/Docker manifests directly. Multi-arch tags first resolve the + # requested platform digest from the index, then sum that manifest's layer + # sizes. A single-platform tag can be summed immediately from its raw manifest. + if ! command -v jq >/dev/null 2>&1; then + echo "[X] jq is required for platform-scoped image size inspection" >&2 + exit 1 + fi + + if [[ "$PLATFORM" != */* ]]; then + echo "[X] --platform must use os/arch format, got: ${PLATFORM}" >&2 + exit 1 + fi + + PLATFORM_OS="${PLATFORM%%/*}" + PLATFORM_REST="${PLATFORM#*/}" + PLATFORM_ARCH="${PLATFORM_REST%%/*}" + PLATFORM_VARIANT="" + if [[ "$PLATFORM_REST" == */* ]]; then + PLATFORM_VARIANT="${PLATFORM_REST#*/}" + fi + echo "[i] Inspecting ${IMAGE} for platform ${PLATFORM} via registry manifest..." >&2 - ACTUAL_BYTES=$( - docker buildx imagetools inspect "${IMAGE}" \ - --format "{{ range .Manifest.Layers }}{{ .Size }} {{ end }}" \ - --raw 2>/dev/null \ - | tr ' ' '\n' \ - | awk 'NF && /^[0-9]+$/ { sum += $1 } END { print sum+0 }' \ - 2>/dev/null || echo "" - ) + RAW_MANIFEST="$(docker buildx imagetools inspect "${IMAGE}" --raw 2>/dev/null || true)" + ACTUAL_BYTES="$(printf '%s' "$RAW_MANIFEST" | sum_manifest_layer_sizes)" if [[ -z "$ACTUAL_BYTES" || "$ACTUAL_BYTES" == "0" ]]; then - # Fallback: try the platform-specific sub-manifest + PLATFORM_DIGEST="$(printf '%s' "$RAW_MANIFEST" | select_platform_digest "$PLATFORM_OS" "$PLATFORM_ARCH" "$PLATFORM_VARIANT")" + if [[ -n "$PLATFORM_DIGEST" && "$PLATFORM_DIGEST" != "null" ]]; then + # Fallback: inspect the selected platform sub-manifest. + echo "[i] Falling back to platform-scoped manifest ${PLATFORM_DIGEST}..." >&2 + RAW_PLATFORM_MANIFEST="$(docker buildx imagetools inspect "${IMAGE}@${PLATFORM_DIGEST}" --raw 2>/dev/null || true)" + ACTUAL_BYTES="$(printf '%s' "$RAW_PLATFORM_MANIFEST" | sum_manifest_layer_sizes)" + else + ACTUAL_BYTES="0" + fi + fi + + if [[ -z "$ACTUAL_BYTES" || "$ACTUAL_BYTES" == "0" ]]; then + # Last resort: support older buildx versions that expose layer sizes only + # through the templated manifest object. echo "[i] Falling back to platform-scoped imagetools inspect..." >&2 ACTUAL_BYTES=$( docker buildx imagetools inspect "${IMAGE}@$( docker buildx imagetools inspect "${IMAGE}" \ - --format "{{ range .Manifest.Manifests }}{{ if eq .Platform.OS \"$(echo "${PLATFORM}" | cut -d/ -f1)\" }}{{ if eq .Platform.Architecture \"$(echo "${PLATFORM}" | cut -d/ -f2)\" }}{{ .Digest }}{{ end }}{{ end }}{{ end }}" \ + --format "{{ range .Manifest.Manifests }}{{ if eq .Platform.OS \"${PLATFORM_OS}\" }}{{ if eq .Platform.Architecture \"${PLATFORM_ARCH}\" }}{{ .Digest }}{{ end }}{{ end }}{{ end }}" \ 2>/dev/null | head -1 )" --format "{{ range .Manifest.Layers }}{{ .Size }} {{ end }}" 2>/dev/null \ | tr ' ' '\n' \ From 2a5c99f62417860bbab97c653c26dd339a4abf30 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Fri, 29 May 2026 14:43:01 +0000 Subject: [PATCH 4/4] chore(release): 8.1.2 [skip ci] ## [8.1.2](https://github.com/kaitranntt/ccs/compare/v8.1.1...v8.1.2) (2026-05-29) ### Hotfixes * repair Docker image size inspection ([731d43e](https://github.com/kaitranntt/ccs/commit/731d43ec9a331fa34b77c9abc18d4608dde27252)), closes [#1400](https://github.com/kaitranntt/ccs/issues/1400) --- CHANGELOG.md | 6 ++++++ package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c70996ce..3b5b12c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## [8.1.2](https://github.com/kaitranntt/ccs/compare/v8.1.1...v8.1.2) (2026-05-29) + +### Hotfixes + +* repair Docker image size inspection ([731d43e](https://github.com/kaitranntt/ccs/commit/731d43ec9a331fa34b77c9abc18d4608dde27252)), closes [#1400](https://github.com/kaitranntt/ccs/issues/1400) + ## [8.1.1](https://github.com/kaitranntt/ccs/compare/v8.1.0...v8.1.1) (2026-05-29) ### Hotfixes diff --git a/package.json b/package.json index 67739318..e96a205b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "8.1.1", + "version": "8.1.2", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli",