mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 14:16:43 +00:00
feat(ci): sunset legacy dashboard image publishing
This commit is contained in:
@@ -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: .
|
||||
|
||||
Executable
+171
@@ -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,
|
||||
};
|
||||
Executable
+118
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user