feat(ci): sunset legacy dashboard image publishing

This commit is contained in:
Tam Nhu Tran
2026-05-22 14:16:42 -04:00
parent 1cfaedf4b2
commit 9f6e64fd39
4 changed files with 364 additions and 5 deletions
+17 -5
View File
@@ -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: .
+171
View File
@@ -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,
};
+118
View File
@@ -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
@@ -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');
});
});