diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index 22834fd5..c69cf972 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -40,6 +40,10 @@ jobs: contents: read packages: write + env: + DEPRECATION_BASELINE_VERSION: "7.80.0" + STABLE_RELEASE_WINDOW: "2" + steps: - name: Resolve target tag id: target @@ -69,18 +73,26 @@ jobs: uses: actions/checkout@v4 with: ref: ${{ steps.target.outputs.tag }} + fetch-depth: 0 persist-credentials: false - - name: Set up QEMU + - name: Check legacy dashboard sunset if: steps.tag.outputs.publish == 'true' + id: sunset + env: + TARGET_TAG: ${{ steps.target.outputs.tag }} + run: node scripts/docker-dashboard-sunset-guard.js + + - name: Set up QEMU + if: steps.tag.outputs.publish == 'true' && steps.sunset.outputs.publish == 'true' uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx - if: steps.tag.outputs.publish == 'true' + if: steps.tag.outputs.publish == 'true' && steps.sunset.outputs.publish == 'true' uses: docker/setup-buildx-action@v3 - name: Derive image metadata - if: steps.tag.outputs.publish == 'true' + if: steps.tag.outputs.publish == 'true' && steps.sunset.outputs.publish == 'true' id: meta run: | VERSION="${{ steps.target.outputs.tag }}" @@ -111,7 +123,7 @@ jobs: } >> "$GITHUB_OUTPUT" - name: Log in to GitHub Container Registry - if: steps.tag.outputs.publish == 'true' + if: steps.tag.outputs.publish == 'true' && steps.sunset.outputs.publish == 'true' uses: docker/login-action@v3 with: registry: ghcr.io @@ -119,7 +131,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push legacy dashboard image - if: steps.tag.outputs.publish == 'true' + if: steps.tag.outputs.publish == 'true' && steps.sunset.outputs.publish == 'true' uses: docker/build-push-action@v6 with: context: . diff --git a/scripts/docker-dashboard-sunset-guard.js b/scripts/docker-dashboard-sunset-guard.js new file mode 100755 index 00000000..0561bd81 --- /dev/null +++ b/scripts/docker-dashboard-sunset-guard.js @@ -0,0 +1,171 @@ +#!/usr/bin/env node +'use strict'; + +const fs = require('node:fs'); +const { execFileSync } = require('node:child_process'); + +function parseStableVersion(value) { + const match = String(value || '').match(/^v?([0-9]+)\.([0-9]+)\.([0-9]+)$/); + if (!match) { + return null; + } + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + raw: `v${Number(match[1])}.${Number(match[2])}.${Number(match[3])}`, + }; +} + +function compareVersions(left, right) { + for (const key of ['major', 'minor', 'patch']) { + if (left[key] !== right[key]) { + return left[key] - right[key]; + } + } + return 0; +} + +function stableVersionKey(version) { + return `${version.major}.${version.minor}.${version.patch}`; +} + +function parseArgs(argv) { + const args = {}; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (!arg.startsWith('--')) { + throw new Error(`Unexpected argument: ${arg}`); + } + const key = arg.slice(2); + if (key === 'tags-stdin') { + args.tagsStdin = true; + continue; + } + const value = argv[i + 1]; + if (!value || value.startsWith('--')) { + throw new Error(`Missing value for --${key}`); + } + args[key] = value; + i += 1; + } + return args; +} + +function readStableTagsFromGit() { + const output = execFileSync('git', ['tag', '--list', 'v*'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + return output.split(/\r?\n/).filter(Boolean); +} + +function parseStableTags(tags) { + const byKey = new Map(); + for (const tag of tags) { + const version = parseStableVersion(tag.trim()); + if (version) { + byKey.set(stableVersionKey(version), version); + } + } + return Array.from(byKey.values()).sort(compareVersions); +} + +function evaluateDashboardSunset({ targetTag, baselineVersion, releaseWindow, stableTags }) { + const target = parseStableVersion(targetTag); + if (!target) { + throw new Error(`Target tag must be stable semver like v1.2.3, got: ${targetTag}`); + } + + const baseline = parseStableVersion(baselineVersion); + if (!baseline) { + throw new Error(`DEPRECATION_BASELINE_VERSION must be stable semver, got: ${baselineVersion}`); + } + + if (!Number.isInteger(releaseWindow) || releaseWindow < 1) { + throw new Error(`STABLE_RELEASE_WINDOW must be a positive integer, got: ${releaseWindow}`); + } + + if (compareVersions(target, baseline) < 0) { + return { + publish: true, + elapsed: 0, + reason: `${target.raw} is before dashboard deprecation baseline ${baseline.raw}`, + }; + } + + 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`, + ); + } + + if (!versions.some((version) => compareVersions(version, target) === 0)) { + versions.push(target); + versions.sort(compareVersions); + } + + const elapsed = versions.filter( + (version) => compareVersions(version, baseline) > 0 && compareVersions(version, target) <= 0, + ).length; + const publish = elapsed < releaseWindow; + const reason = publish + ? `legacy dashboard publish is still inside sunset window (${elapsed}/${releaseWindow} stable releases elapsed since ${baseline.raw})` + : `legacy dashboard sunset reached (${elapsed}/${releaseWindow} stable releases elapsed since ${baseline.raw})`; + + return { publish, elapsed, reason }; +} + +function appendGithubOutput(values) { + if (!process.env.GITHUB_OUTPUT) { + return; + } + const lines = Object.entries(values).map(([key, value]) => `${key}=${String(value)}`); + fs.appendFileSync(process.env.GITHUB_OUTPUT, `${lines.join('\n')}\n`); +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + const targetTag = args.target || process.env.TARGET_TAG; + const baselineVersion = args.baseline || process.env.DEPRECATION_BASELINE_VERSION; + const windowValue = args.window || process.env.STABLE_RELEASE_WINDOW || '2'; + const releaseWindow = Number(windowValue); + const stableTags = args.tagsStdin + ? fs.readFileSync(0, 'utf8').split(/\r?\n/).filter(Boolean) + : readStableTagsFromGit(); + + const result = evaluateDashboardSunset({ + targetTag, + baselineVersion, + releaseWindow, + stableTags, + }); + + appendGithubOutput({ + publish: result.publish ? 'true' : 'false', + elapsed: result.elapsed, + reason: result.reason, + }); + + const status = result.publish ? '[OK]' : '[i]'; + console.log(`${status} ${result.reason}`); + if (!result.publish) { + console.log('[i] Skipping ghcr.io/kaitranntt/ccs-dashboard publish; use ghcr.io/kaitranntt/ccs.'); + } +} + +if (require.main === module) { + try { + main(); + } catch (error) { + console.error(`[X] ${error.message}`); + process.exit(1); + } +} + +module.exports = { + evaluateDashboardSunset, + parseStableVersion, +}; diff --git a/tests/docker/dashboard-sunset-guard.test.sh b/tests/docker/dashboard-sunset-guard.test.sh new file mode 100755 index 00000000..c466cee6 --- /dev/null +++ b/tests/docker/dashboard-sunset-guard.test.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +# Unit tests for scripts/docker-dashboard-sunset-guard.js. +# Run: bash tests/docker/dashboard-sunset-guard.test.sh +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +SCRIPT="${ROOT_DIR}/scripts/docker-dashboard-sunset-guard.js" + +PASS=0 +FAIL=0 + +run_guard() { + local output_file="$1" + local target="$2" + local baseline="$3" + local window="$4" + local tags="$5" + + GITHUB_OUTPUT="$output_file" node "$SCRIPT" \ + --target "$target" \ + --baseline "$baseline" \ + --window "$window" \ + --tags-stdin <<< "$tags" >/dev/null 2>&1 +} + +assert_case() { + local name="$1" + local expected_exit="$2" + local expected_publish="$3" + local expected_elapsed="$4" + local target="$5" + local baseline="$6" + local window="$7" + local tags="$8" + + local output_file + output_file="$(mktemp)" + local actual_exit=0 + run_guard "$output_file" "$target" "$baseline" "$window" "$tags" || actual_exit=$? + + local actual_publish="" + local actual_elapsed="" + if [[ -s "$output_file" ]]; then + actual_publish="$(grep '^publish=' "$output_file" | tail -1 | cut -d= -f2- || true)" + actual_elapsed="$(grep '^elapsed=' "$output_file" | tail -1 | cut -d= -f2- || true)" + fi + rm -f "$output_file" + + if [[ "$actual_exit" -eq "$expected_exit" && + "$actual_publish" == "$expected_publish" && + "$actual_elapsed" == "$expected_elapsed" ]]; then + echo "[OK] ${name}" + (( PASS++ )) || true + else + echo "[X] ${name}: exit=${actual_exit}/${expected_exit} publish=${actual_publish}/${expected_publish} elapsed=${actual_elapsed}/${expected_elapsed}" + (( FAIL++ )) || true + fi +} + +assert_failure() { + local name="$1" + shift + local output_file + output_file="$(mktemp)" + local actual_exit=0 + GITHUB_OUTPUT="$output_file" node "$SCRIPT" "$@" --tags-stdin <<< "v7.80.0" >/dev/null 2>&1 || actual_exit=$? + rm -f "$output_file" + + if [[ "$actual_exit" -ne 0 ]]; then + echo "[OK] ${name}" + (( PASS++ )) || true + else + echo "[X] ${name}: expected non-zero exit" + (( FAIL++ )) || true + fi +} + +echo "" +echo "Running dashboard sunset guard tests..." +echo "" + +assert_case "allows the baseline release" 0 true 0 \ + "v7.80.0" "7.80.0" "2" \ + $'v7.79.1\nv7.80.0' + +assert_case "allows first stable release after baseline" 0 true 1 \ + "v7.80.1" "7.80.0" "2" \ + $'v7.79.1\nv7.80.0\nv7.80.1' + +assert_case "skips once the two-release sunset is reached" 0 false 2 \ + "v7.80.2" "7.80.0" "2" \ + $'v7.79.1\nv7.80.0\nv7.80.1\nv7.80.2' + +assert_case "allows releases before the baseline" 0 true 0 \ + "v7.79.1" "7.80.0" "2" \ + $'v7.79.1' + +assert_case "honors a larger configured window" 0 true 2 \ + "v7.80.2" "7.80.0" "3" \ + $'v7.80.0\nv7.80.1\nv7.80.2' + +assert_case "skips at the larger configured window boundary" 0 false 3 \ + "v7.80.3" "7.80.0" "3" \ + $'v7.80.0\nv7.80.1\nv7.80.2\nv7.80.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" + +echo "" +echo "Dashboard sunset guard tests complete: ${PASS} passed, ${FAIL} failed." +echo "" + +if [[ "$FAIL" -ne 0 ]]; then + exit 1 +fi diff --git a/tests/unit/scripts/docker-dashboard-sunset-guard.test.ts b/tests/unit/scripts/docker-dashboard-sunset-guard.test.ts new file mode 100644 index 00000000..b35ffa5f --- /dev/null +++ b/tests/unit/scripts/docker-dashboard-sunset-guard.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from 'bun:test'; + +const { evaluateDashboardSunset } = require('../../../scripts/docker-dashboard-sunset-guard.js'); + +describe('docker dashboard sunset guard', () => { + test('allows the baseline stable release', () => { + const result = evaluateDashboardSunset({ + targetTag: 'v7.80.0', + baselineVersion: '7.80.0', + releaseWindow: 2, + stableTags: ['v7.79.1', 'v7.80.0'], + }); + + expect(result).toMatchObject({ + elapsed: 0, + publish: true, + }); + }); + + test('allows the first stable release after the baseline', () => { + const result = evaluateDashboardSunset({ + targetTag: 'v7.80.1', + baselineVersion: '7.80.0', + releaseWindow: 2, + stableTags: ['v7.79.1', 'v7.80.0', 'v7.80.1'], + }); + + expect(result).toMatchObject({ + elapsed: 1, + publish: true, + }); + }); + + test('turns the legacy publish into a no-op at the configured sunset boundary', () => { + const result = evaluateDashboardSunset({ + targetTag: 'v7.80.2', + baselineVersion: '7.80.0', + releaseWindow: 2, + stableTags: ['v7.79.1', 'v7.80.0', 'v7.80.1', 'v7.80.2'], + }); + + expect(result).toMatchObject({ + elapsed: 2, + publish: false, + }); + }); + + 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'); + }); +});