feat(update): add beta and stable release channels

This commit is contained in:
二丫讲梵
2026-11-01 09:33:20 +08:00
committed by GitHub
parent a3add88da1
commit 4d67b2587d
55 changed files with 2347 additions and 309 deletions
@@ -0,0 +1,85 @@
import assert from "node:assert/strict";
import { chmodSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { spawnSync } from "node:child_process";
import test from "node:test";
const workflow = readFileSync(new URL("../workflows/daily-beta-release.yml", import.meta.url), "utf8");
const script = workflow.match(/ run: \|\n((?: [^\n]*\n|\n)*)/)[1].replace(/^ {10}/gm, "");
function runDaily(options = {}) {
const root = mkdtempSync(join(tmpdir(), "dbx-daily-beta-test-"));
const statePath = join(root, "state.json");
const gh = join(root, "gh");
writeFileSync(statePath, JSON.stringify({ calls: [] }));
writeFileSync(gh, `#!/usr/bin/env node
const fs = require("node:fs");
const args = process.argv.slice(2);
const state = JSON.parse(fs.readFileSync(process.env.MOCK_GH_STATE, "utf8"));
state.calls.push(args);
const save = () => fs.writeFileSync(process.env.MOCK_GH_STATE, JSON.stringify(state));
save();
if (args[0] === "release" && args[1] === "view") {
if (args[2] === "--repo") { console.log("v0.6.59"); process.exit(0); }
process.exit(1);
}
if (args[0] === "api" && args[1]?.includes("/git/ref/tags/")) {
process.exit(process.env.MOCK_ORPHAN_TAG && args[1].endsWith(".1") ? 0 : 1);
}
if (args[0] === "api" && args[1] === "--method" && args[2] === "POST") {
state.tag = args.find(arg => arg.startsWith("ref=refs/tags/"))?.slice("ref=refs/tags/".length);
state.sha = args.find(arg => arg.startsWith("sha="))?.slice(4);
save(); process.exit(state.tag && state.sha === process.env.GITHUB_SHA ? 0 : 1);
}
if (args[0] === "release" && args[1] === "create") {
if (!state.tag || args[2] !== state.tag || !args.includes("--verify-tag")) process.exit(1);
state.release = args[2]; save(); process.exit(0);
}
if (args[0] === "workflow" && args[1] === "run") {
const ref = args[args.indexOf("--ref") + 1];
if (!state.release || ref !== state.tag || !args.includes("release_tag=" + ref)) process.exit(1);
if (process.env.GH_TOKEN !== "test-dispatch-token") process.exit(1);
state.dispatched = ref; save(); process.exit(0);
}
process.exit(1);
`);
chmodSync(gh, 0o755);
const result = spawnSync("bash", ["-c", script], {
encoding: "utf8",
env: {
...process.env,
PATH: `${root}:${process.env.PATH}`,
MOCK_GH_STATE: statePath,
GITHUB_REPOSITORY: "example/dbx",
GITHUB_SHA: "a".repeat(40),
REQUESTED_BASE_VERSION: "0.6.60",
FORCE_BUILD: "",
GH_TOKEN: "test-release-token",
RELEASE_WORKFLOW_TOKEN: "test-dispatch-token",
...options,
},
});
return { ...result, state: JSON.parse(readFileSync(statePath, "utf8")) };
}
test("daily beta creates a real tag before creating the draft and dispatching its build", () => {
const result = runDaily();
assert.equal(result.status, 0, result.stderr);
assert.match(result.state.dispatched, /^v0\.6\.60-beta\.\d{8}\.1$/);
assert.equal(result.state.sha, "a".repeat(40));
});
test("daily beta skips a tag left behind without a release", () => {
const result = runDaily({ MOCK_ORPHAN_TAG: "1" });
assert.equal(result.status, 0, result.stderr);
assert.match(result.state.dispatched, /^v0\.6\.60-beta\.\d{8}\.2$/);
});
test("daily beta refuses to create refs when the workflow dispatch token is absent", () => {
const result = runDaily({ RELEASE_WORKFLOW_TOKEN: "" });
assert.notEqual(result.status, 0);
assert.match(result.stdout, /MCP_RELEASE_TOKEN is required/);
assert.equal(result.state.tag, undefined);
assert.equal(result.state.release, undefined);
});
@@ -0,0 +1,37 @@
#!/usr/bin/env node
import { readFileSync, writeFileSync } from "node:fs";
const [input, output, channel = "stable", tag = "", promoted = "false", publishedAt = ""] = process.argv.slice(2);
const tagMatch = /^v(\d+\.\d+\.\d+)(-beta\.\d{8}\.\d+)?$/.exec(tag);
if (
!input ||
!output ||
!["stable", "beta"].includes(channel) ||
!tagMatch ||
!["true", "false"].includes(promoted)
) {
console.error("Usage: prepare-channel-manifest.mjs <input> <output> <stable|beta> <vX.Y.Z[ -beta.YYYYMMDD.N]> [true|false] [published_at]");
process.exit(1);
}
if (channel === "beta" && !tagMatch[2]) {
throw new Error(`Beta manifests require a beta tag: ${tag}`);
}
const manifest = JSON.parse(readFileSync(input, "utf8"));
if (!manifest || typeof manifest !== "object" || !manifest.version || !manifest.platforms || typeof manifest.platforms !== "object") {
throw new Error("Updater manifest must contain version and platforms.");
}
const baseVersion = tagMatch[1];
const tagVersion = tag.slice(1);
if (manifest.version !== baseVersion && manifest.version !== tagVersion) {
throw new Error(`Manifest version ${manifest.version} does not match tag version ${tagVersion} or base version ${baseVersion}.`);
}
manifest.version = channel === "beta" ? tagVersion : baseVersion;
manifest.channel = channel;
manifest.release_tag = tag;
manifest.is_promoted = promoted === "true";
manifest.published_at ||= publishedAt || new Date().toISOString();
writeFileSync(output, `${JSON.stringify(manifest, null, 2)}\n`);
@@ -0,0 +1,91 @@
import assert from "node:assert/strict";
import { mkdtemp, readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { spawn } from "node:child_process";
import test from "node:test";
const script = fileURLToPath(new URL("./prepare-channel-manifest.mjs", import.meta.url));
async function run(args) {
const child = spawn(process.execPath, [script, ...args], { stdio: ["ignore", "pipe", "pipe"] });
let stderr = "";
child.stderr.on("data", (chunk) => {
stderr += chunk;
});
const code = await new Promise((resolve) => child.on("close", resolve));
return { code, stderr };
}
async function fixture() {
const root = await mkdtemp(join(tmpdir(), "dbx-channel-manifest-"));
const input = join(root, "input.json");
const output = join(root, "output.json");
await writeFile(input, JSON.stringify({ version: "0.6.60", platforms: { "darwin-aarch64": {} } }));
return { input, output };
}
test("writes channel and promotion metadata", async () => {
const { input, output } = await fixture();
const result = await run([input, output, "beta", "v0.6.60-beta.20261028.1", "false", "2026-10-28T00:00:00.000Z"]);
assert.equal(result.code, 0, result.stderr);
assert.deepEqual(JSON.parse(await readFile(output, "utf8")), {
version: "0.6.60-beta.20261028.1",
platforms: { "darwin-aarch64": {} },
channel: "beta",
release_tag: "v0.6.60-beta.20261028.1",
is_promoted: false,
published_at: "2026-10-28T00:00:00.000Z",
});
});
test("promotes a beta manifest back to the stable base version", async () => {
const { input, output } = await fixture();
await writeFile(input, JSON.stringify({ version: "0.6.60-beta.20261028.1", platforms: {} }));
const result = await run([input, output, "stable", "v0.6.60-beta.20261028.1", "true", "2026-10-28T00:00:00.000Z"]);
assert.equal(result.code, 0, result.stderr);
assert.equal(JSON.parse(await readFile(output, "utf8")).version, "0.6.60");
});
test("preserves an existing published_at", async () => {
const { input, output } = await fixture();
await writeFile(input, JSON.stringify({ version: "0.6.60", platforms: {}, published_at: "2026-10-27T00:00:00.000Z" }));
const result = await run([input, output, "stable", "v0.6.60", "true", "2026-10-28T00:00:00.000Z"]);
assert.equal(result.code, 0, result.stderr);
assert.equal(JSON.parse(await readFile(output, "utf8")).published_at, "2026-10-27T00:00:00.000Z");
});
test("fixed published_at makes repeated promotion deterministic", async () => {
const first = await fixture();
const second = await fixture();
const args = ["stable", "v0.6.60", "true", "2026-10-28T00:00:00.000Z"];
assert.equal((await run([first.input, first.output, ...args])).code, 0);
assert.equal((await run([second.input, second.output, ...args])).code, 0);
assert.equal(await readFile(first.output, "utf8"), await readFile(second.output, "utf8"));
});
test("rejects invalid channel, tag, and promotion flag", async () => {
const { input, output } = await fixture();
for (const args of [
["canary", "v0.6.60", "false"],
["stable", "0.6.60", "true"],
["stable", "v0.6.60", "yes"],
["beta", "v0.6.60", "false"],
]) {
const result = await run([input, output, ...args]);
assert.notEqual(result.code, 0);
}
});
test("rejects a tag whose base version differs from the built manifest", async () => {
const { input, output } = await fixture();
const result = await run([input, output, "beta", "v0.6.61-beta.20261028.1", "false"]);
assert.notEqual(result.code, 0);
});
test("rejects a beta manifest with a stable tag", async () => {
const { input, output } = await fixture();
const result = await run([input, output, "beta", "v0.6.60", "false"]);
assert.notEqual(result.code, 0);
});
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env node
// WiX/MSI only accepts numeric `major.minor.patch.build` versions, and the Tauri
// bundler rejects a pre-release such as `-beta.20261029.1`. Beta builds keep the
// full semver for the app and updater; only the MSI gets this numeric version.
// Windows Installer ignores the fourth field when comparing versions, so it is
// informational: day-of-year * 100 + the daily build sequence (max 36699).
import { writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
const betaTag = /^v(\d+)\.(\d+)\.(\d+)-beta\.(\d{4})(\d{2})(\d{2})\.(\d+)$/;
const stableTag = /^v\d+\.\d+\.\d+$/;
export function msiConfigForTag(tag) {
if (stableTag.test(tag)) return {};
const match = betaTag.exec(tag);
if (!match) throw new Error(`Unsupported release tag for MSI version: ${tag}`);
const [major, minor, patch, year, month, day, sequence] = match.slice(1).map(Number);
const date = new Date(Date.UTC(year, month - 1, day));
if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) {
throw new Error(`Invalid beta build date in ${tag}.`);
}
if (major > 255 || minor > 255 || patch > 65535) {
throw new Error(`MSI version limits (255.255.65535) exceeded by ${tag}.`);
}
if (sequence < 1 || sequence > 99) {
throw new Error(`Beta build sequence must be 1-99 to fit the MSI build field: ${tag}.`);
}
const dayOfYear = Math.round((date - Date.UTC(year, 0, 1)) / 86_400_000) + 1;
const build = dayOfYear * 100 + sequence;
return { bundle: { windows: { wix: { version: `${major}.${minor}.${patch}.${build}` } } } };
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const [tag, output] = process.argv.slice(2);
if (!tag || !output) {
console.error("Usage: prepare-msi-config.mjs <release_tag> <output.json>");
process.exit(1);
}
try {
const config = msiConfigForTag(tag);
writeFileSync(output, `${JSON.stringify(config, null, 2)}\n`);
console.log(`Wrote ${output}: ${JSON.stringify(config)}`);
} catch (error) {
console.error(error.message);
process.exit(1);
}
}
@@ -0,0 +1,47 @@
import assert from "node:assert/strict";
import { mkdtemp, readFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
import test from "node:test";
import { msiConfigForTag } from "./prepare-msi-config.mjs";
const script = fileURLToPath(new URL("./prepare-msi-config.mjs", import.meta.url));
test("stable tags leave the MSI version to the app version", () => {
assert.deepEqual(msiConfigForTag("v0.6.60"), {});
});
test("beta tags get a numeric MSI version derived from the build date and sequence", () => {
const wixVersion = (tag) => msiConfigForTag(tag).bundle.windows.wix.version;
assert.equal(wixVersion("v0.6.60-beta.20261029.1"), "0.6.60.30201");
assert.equal(wixVersion("v0.6.60-beta.20260101.1"), "0.6.60.101");
assert.equal(wixVersion("v0.6.60-beta.20261231.99"), "0.6.60.36599");
assert.equal(wixVersion("v0.6.60-beta.20240229.2"), "0.6.60.6002");
});
test("every beta MSI build number fits the 65535 field limit", () => {
const { version } = msiConfigForTag("v255.255.65535-beta.20261231.99").bundle.windows.wix;
assert.ok(Number(version.split(".")[3]) <= 65535);
});
test("rejects tags the MSI version cannot represent", () => {
assert.throws(() => msiConfigForTag("v0.6.60-beta.20261029.100"), /1-99/);
assert.throws(() => msiConfigForTag("v0.6.60-beta.20261029.0"), /1-99/);
assert.throws(() => msiConfigForTag("v0.6.60-beta.20261329.1"), /Invalid beta build date/);
assert.throws(() => msiConfigForTag("v256.0.0-beta.20261029.1"), /limits/);
assert.throws(() => msiConfigForTag("v0.6.60-rc.1"), /Unsupported release tag/);
});
test("writes the config file for the Tauri CLI", async () => {
const dir = await mkdtemp(join(tmpdir(), "dbx-msi-config-"));
const output = join(dir, "tauri.msi.conf.json");
const result = spawnSync(process.execPath, [script, "v0.6.60-beta.20261029.1", output], { encoding: "utf8" });
assert.equal(result.status, 0, result.stderr);
assert.deepEqual(JSON.parse(await readFile(output, "utf8")), {
bundle: { windows: { wix: { version: "0.6.60.30201" } } },
});
const failed = spawnSync(process.execPath, [script, "main", output], { encoding: "utf8" });
assert.equal(failed.status, 1);
});
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env bash
# Keep only the newest five versioned release directories of one R2 channel.
#
# Usage: prune-r2-releases.sh <stable|beta> <current_tag>
#
# stable: published non-prerelease releases (a promoted beta tag lives under
# releases/stable/ too) and also removes legacy releases/<tag>/ dirs.
# beta: published releases whose tag is a beta tag, whatever their prerelease
# flag, because a promoted beta keeps its releases/beta/<tag>/ directory.
#
# Nothing is deleted unless five releases are listed and <current_tag> is one
# of them, so incomplete release metadata can never remove the wrong versions.
# Needs GH_TOKEN, GITHUB_REPOSITORY, R2_ACCOUNT_ID, R2_BUCKET_NAME and AWS credentials.
set -euo pipefail
family="${1:?family (stable|beta) required}"
current_tag="${2:?current tag required}"
keep_count=5
beta_pattern='^v[0-9]+[.][0-9]+[.][0-9]+-beta[.][0-9]{8}[.][0-9]+$'
version_pattern='^v[0-9]+[.][0-9]+[.][0-9]+([.-][0-9A-Za-z.-]+)?$'
case "$family" in
stable)
filter='.isDraft == false and .isPrerelease == false and (.tagName | test($version))'
;;
beta)
filter='.isDraft == false and (.tagName | test($beta))'
;;
*)
echo "::error::Unknown R2 channel: $family" >&2
exit 2
;;
esac
mapfile -t keep_tags < <(
gh release list --repo "${GITHUB_REPOSITORY}" --limit 100 --json tagName,isDraft,isPrerelease,publishedAt \
| jq -r --arg version "$version_pattern" --arg beta "$beta_pattern" --argjson keep "$keep_count" \
"[.[] | select($filter)] | sort_by(.publishedAt) | reverse | .[:\$keep] | .[].tagName"
)
if [ "${#keep_tags[@]}" -lt "$keep_count" ]; then
echo "::warning::Fewer than ${keep_count} published ${family} releases; skipping ${family} R2 cleanup."
exit 0
fi
is_kept() {
printf '%s\n' "${keep_tags[@]}" | grep -Fxq -- "$1"
}
if ! is_kept "$current_tag"; then
echo "::warning::Current release ${current_tag} is not among the latest ${keep_count} ${family} releases; skipping ${family} R2 cleanup."
exit 0
fi
echo "Keeping R2 ${family} releases: ${keep_tags[*]}"
endpoint="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
list_tags() {
local prefix="$1"
aws s3api list-objects-v2 --bucket "${R2_BUCKET_NAME}" --prefix "${prefix}" --delimiter "/" \
--endpoint-url "${endpoint}" --query 'CommonPrefixes[].Prefix' --output text \
| tr '\t' '\n' \
| sed -nE "s#^${prefix}(v[0-9]+\\.[0-9]+\\.[0-9]+([.-][0-9A-Za-z.-]+)?)/\$#\\1#p"
}
mapfile -t stored_tags < <(list_tags "releases/${family}/")
for tag in "${stored_tags[@]}"; do
if is_kept "$tag"; then continue; fi
echo "Removing old R2 release: ${family}/${tag}"
aws s3 rm "s3://${R2_BUCKET_NAME}/releases/${family}/${tag}/" --recursive --endpoint-url "${endpoint}"
done
if [ "$family" = stable ]; then
mapfile -t legacy_tags < <(list_tags "releases/")
for tag in "${legacy_tags[@]}"; do
echo "Removing legacy R2 release directory: ${tag}"
aws s3 rm "s3://${R2_BUCKET_NAME}/releases/${tag}/" --recursive --endpoint-url "${endpoint}"
done
fi
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env node
import { readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
const tag = process.argv[2] || "";
const root = resolve(process.argv[3] || fileURLToPath(new URL("../..", import.meta.url)));
const match = /^v(\d+\.\d+\.\d+)(-beta\.\d{8}\.\d+)?$/.exec(tag);
if (!match) {
console.error("Usage: set-release-version.mjs <vX.Y.Z[ -beta.YYYYMMDD.N]> [repository_root]");
process.exit(1);
}
const version = `${match[1]}${match[2] || ""}`;
const jsonFiles = ["package.json", "src-tauri/tauri.conf.json"];
const cargoFiles = ["src-tauri/Cargo.toml", "crates/dbx-web/Cargo.toml"];
for (const relativePath of jsonFiles) {
const path = resolve(root, relativePath);
const data = JSON.parse(readFileSync(path, "utf8"));
data.version = version;
writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`);
}
for (const relativePath of cargoFiles) {
const path = resolve(root, relativePath);
const source = readFileSync(path, "utf8");
const pattern = /^(version\s*=\s*")[^"]+(")/m;
if (!pattern.test(source)) throw new Error(`Package version declaration not found in ${relativePath}.`);
const updated = source.replace(pattern, `$1${version}$2`);
writeFileSync(path, updated);
}
const lockPath = resolve(root, "Cargo.lock");
const lockSource = readFileSync(lockPath, "utf8");
let lockUpdated = lockSource;
for (const packageName of ["dbx", "dbx-web"]) {
const pattern = new RegExp(`(\\[\\[package\\]\\]\\nname = "${packageName}"\\nversion = ")[^\\n"]+(")`);
if (!pattern.test(lockUpdated)) throw new Error(`${packageName} package version was not found in Cargo.lock.`);
lockUpdated = lockUpdated.replace(pattern, `$1${version}$2`);
}
writeFileSync(lockPath, lockUpdated);
for (const relativePath of [...jsonFiles, ...cargoFiles, "Cargo.lock"]) {
if (!readFileSync(resolve(root, relativePath), "utf8").includes(version)) {
throw new Error(`Release version ${version} was not written to ${relativePath}.`);
}
}
console.log(`Prepared release version ${version}.`);
@@ -0,0 +1,71 @@
import assert from "node:assert/strict";
import { mkdtemp, readFile, mkdir, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { spawn } from "node:child_process";
import test from "node:test";
const script = fileURLToPath(new URL("./set-release-version.mjs", import.meta.url));
async function run(tag, root) {
const child = spawn(process.execPath, [script, tag, root], { stdio: ["ignore", "pipe", "pipe"] });
let stderr = "";
child.stderr.on("data", (chunk) => {
stderr += chunk;
});
const code = await new Promise((resolve) => child.on("close", resolve));
return { code, stderr };
}
async function fixture() {
const root = await mkdtemp(join(tmpdir(), "dbx-release-version-"));
await mkdir(join(root, "src-tauri"), { recursive: true });
await mkdir(join(root, "crates/dbx-web"), { recursive: true });
await writeFile(join(root, "package.json"), JSON.stringify({ name: "dbx", version: "0.6.59" }) + "\n");
await writeFile(join(root, "src-tauri/tauri.conf.json"), JSON.stringify({ version: "0.6.59" }) + "\n");
await writeFile(join(root, "src-tauri/Cargo.toml"), 'version = "0.6.59"\n');
await writeFile(join(root, "crates/dbx-web/Cargo.toml"), 'version = "0.6.59"\n');
await writeFile(
join(root, "Cargo.lock"),
'[[package]]\nname = "dbx"\nversion = "0.6.59"\n\n[[package]]\nname = "dbx-web"\nversion = "0.6.59"\n',
);
return root;
}
test("sets the complete beta version across build metadata", async () => {
const root = await fixture();
const result = await run("v0.6.60-beta.20261029.1", root);
assert.equal(result.code, 0, result.stderr);
for (const relativePath of ["package.json", "src-tauri/tauri.conf.json", "src-tauri/Cargo.toml", "crates/dbx-web/Cargo.toml", "Cargo.lock"]) {
assert.match(await readFile(join(root, relativePath), "utf8"), /0\.6\.60-beta\.20261029\.1/);
}
});
test("sets the base version for a stable build", async () => {
const root = await fixture();
const result = await run("v0.6.60", root);
assert.equal(result.code, 0, result.stderr);
assert.match(await readFile(join(root, "src-tauri/Cargo.toml"), "utf8"), /version = "0\.6\.60"/);
});
test("accepts an already prepared stable or beta checkout without changing its metadata", async () => {
for (const tag of ["v0.6.59", "v0.6.60-beta.20261029.1"]) {
const root = await fixture();
const first = await run(tag, root);
assert.equal(first.code, 0, first.stderr);
const files = ["package.json", "src-tauri/tauri.conf.json", "src-tauri/Cargo.toml", "crates/dbx-web/Cargo.toml", "Cargo.lock"];
const before = await Promise.all(files.map((path) => readFile(join(root, path), "utf8")));
const second = await run(tag, root);
assert.equal(second.code, 0, second.stderr);
assert.deepEqual(await Promise.all(files.map((path) => readFile(join(root, path), "utf8"))), before);
}
});
test("rejects a lockfile missing one of the application packages", async () => {
const root = await fixture();
await writeFile(join(root, "Cargo.lock"), '[[package]]\nname = "dbx"\nversion = "0.6.59"\n');
const result = await run("v0.6.60", root);
assert.notEqual(result.code, 0);
assert.match(result.stderr, /dbx-web package version was not found/);
});
+28
View File
@@ -0,0 +1,28 @@
import { execFileSync } from "node:child_process";
import { pathToFileURL } from "node:url";
export function validateReleaseCi(pages, sha) {
const runs = pages.flatMap((page) => page.workflow_runs ?? [])
.filter((run) => run.head_sha === sha && ["push", "pull_request"].includes(run.event))
.sort((a, b) => b.run_number - a.run_number || b.run_attempt - a.run_attempt);
const latest = runs[0];
if (!latest || latest.status !== "completed" || latest.conclusion !== "success") {
throw new Error(`Latest CI run for ${sha} must complete successfully before promotion (${latest ? `${latest.status}/${latest.conclusion}` : "no CI run"}).`);
}
return latest;
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const [repository, sha] = process.argv.slice(2);
if (!/^[\w.-]+\/[\w.-]+$/.test(repository ?? "") || !/^[a-f0-9]{40}$/i.test(sha ?? "")) {
throw new Error("Usage: validate-release-ci.mjs <owner/repo> <commit-sha>");
}
// Query the CI workflow alone: the promotion workflow runs on the same
// commit and must never fail its own gate merely because it is in progress.
const pages = JSON.parse(execFileSync("gh", [
"api", "--paginate", "--slurp",
`repos/${repository}/actions/workflows/ci.yml/runs?head_sha=${sha}&per_page=100`,
], { encoding: "utf8" }));
const run = validateReleaseCi(pages, sha);
console.log(`Validated CI run ${run.id} for ${sha}.`);
}
@@ -0,0 +1,39 @@
import assert from "node:assert/strict";
import test from "node:test";
import { validateReleaseCi } from "./validate-release-ci.mjs";
const sha = "a".repeat(40);
const success = { id: 1, head_sha: sha, event: "push", run_number: 1, run_attempt: 1, status: "completed", conclusion: "success" };
test("accepts the successful CI run for the release commit", () => {
assert.equal(validateReleaseCi([{ workflow_runs: [success] }], sha), success);
});
test("rejects missing CI and success belonging to a different commit", () => {
assert.throws(() => validateReleaseCi([{ workflow_runs: [] }], sha), /no CI run/);
assert.throws(() => validateReleaseCi([{ workflow_runs: [success] }], "b".repeat(40)), /no CI run/);
});
test("rejects failed, cancelled, skipped, and running CI", () => {
for (const conclusion of ["failure", "cancelled", "skipped", "neutral", null]) {
const run = { ...success, conclusion, status: conclusion ? "completed" : "in_progress" };
assert.throws(() => validateReleaseCi([{ workflow_runs: [run] }], sha), /must complete successfully/);
}
});
test("does not accept an earlier success when the latest run fails", () => {
const failed = { ...success, run_number: 2, conclusion: "failure" };
assert.throws(() => validateReleaseCi([{ workflow_runs: [success, failed] }], sha), /completed\/failure/);
});
test("reads all result pages and respects rerun attempts", () => {
const failed = { ...success, conclusion: "failure" };
const rerun = { ...success, run_attempt: 2 };
assert.equal(validateReleaseCi([{ workflow_runs: [failed] }, { workflow_runs: [rerun] }], sha), rerun);
});
test("unrelated dispatches cannot satisfy or block the source CI gate", () => {
const dispatch = { ...success, event: "workflow_dispatch", run_number: 10, status: "in_progress", conclusion: null };
assert.equal(validateReleaseCi([{ workflow_runs: [dispatch, success] }], sha), success);
assert.throws(() => validateReleaseCi([{ workflow_runs: [{ ...dispatch, status: "completed", conclusion: "success" }] }], sha), /no CI run/);
});