mirror of
https://github.com/t8y2/dbx.git
synced 2026-11-15 08:27:46 +08:00
feat(update): add beta and stable release channels
This commit is contained in:
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Executable
+80
@@ -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
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
Reference in New Issue
Block a user