mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
[codex] converge release workflows and stable dry-runs (#4390)
* fix(tools-pack): use junctions for Windows standalone peer deps * fix(desktop): expose IPC during startup * fix(tools-pack): preserve Windows inspect diagnostics * fix(tools-pack): report Windows inspect status errors * fix(packaged): use Electron net fetch for app protocol * fix(packaged): load Windows renderer from web sidecar * fix(desktop): show Windows packaged window during startup * fix(packaged): disable Windows GPU startup * fix(tools-pack): keep Windows core smoke observable * fix(packaged): remove Windows startup probes * fix(tools-pack): trace Windows desktop IPC status * fix(tools-pack): add Windows IPC diagnose loop * fix(release): default beta-s Windows updater feed * chore: clean merged test eof * refactor(release): unify prerelease channel model * chore(release): close prerelease doc escape hatches * refactor(release): converge release channel workflows * fix(release): install toolchain in metadata jobs * fix(release): build release package before contracts * chore(release): bump development version to 0.10.1 * fix(e2e): seed windows packaged smoke runtime config * fix(release): install toolchain for metadata publish * fix(release): materialize betas metadata checkout * chore(release): bump development version to 0.10.2 * fix(release): allow betas metadata cold start from s3 * fix(e2e): support betas packaged update scenarios * fix(release): pass betas channel into packaged smoke * fix(release): set betas channel during self-hosted builds * fix(release): verify counted channel reservations * fix(release): use pnpm cmd for betas windows publish * fix(release): add betas manifest artifact fallback * fix(release): skip beta-s public metadata fetch * fix(release): read beta-s manifests from storage * fix(release): cache beta windows tools-pack builds * fix(release): inline beta mac tools-pack builds * fix(pack): deep sign unsigned mac bundles * docs(pack): document payload-first beta updater validation * fix(release): align preview tools-pack cache flow * fix(release): align prerelease tools-pack cache flow * fix(release): pass github token to prerelease metadata * fix(release): setup pnpm before feishu notify * fix(release): add stable dry-run prepublish flow * fix(release): accept completed prerelease metadata gate * fix(release): require stable release branches * fix(release): converge r2 access checks * fix(updater): use release channel parser for defaults * fix(updater): harden windows payload relaunch * fix(release): converge updater smoke fixture contract * test(e2e): require silent updater fixture output * fix(release): align stable windows smoke build path * fix(ci): include release workspace in validation * fix(ci): repair release validation lanes Generated-By: looper 0.9.10+codex.autoclean (runner=fixer, agent=codex) * fix(ci): restore zero-install Feishu notification Generated-By: looper 0.9.10+codex.autoclean (runner=fixer, agent=codex) --------- Co-authored-by: Looper <looper@noreply.github.com>
This commit is contained in:
+5
-1
@@ -109,6 +109,9 @@ const residualAllowedExactPaths = new Set([
|
||||
"tools/dev/esbuild.config.mjs",
|
||||
"tools/pack/bin/tools-pack.mjs",
|
||||
"tools/pack/esbuild.config.mjs",
|
||||
// Checked-in bin shim so pnpm can link `tools-release` before dist output exists.
|
||||
"tools/release/bin/tools-release.mjs",
|
||||
"tools/release/esbuild.config.mjs",
|
||||
"tools/serve/bin/tools-serve.mjs",
|
||||
"tools/serve/esbuild.config.mjs",
|
||||
"tools/pack/resources/mac/notarize.cjs",
|
||||
@@ -923,6 +926,7 @@ const toolsRootAllowlist = new Map<string, "directory" | "file">([
|
||||
["AGENTS.md", "file"],
|
||||
["dev", "directory"],
|
||||
["pack", "directory"],
|
||||
["release", "directory"],
|
||||
["serve", "directory"],
|
||||
]);
|
||||
|
||||
@@ -937,7 +941,7 @@ async function checkToolsLayout(): Promise<boolean> {
|
||||
const repositoryPath = `tools/${entry.name}${entry.isDirectory() ? "/" : ""}`;
|
||||
|
||||
if (expected == null) {
|
||||
violations.push(`${repositoryPath} -> tools/ top-level entries are allowlisted; expected only AGENTS.md, dev/, pack/, and serve/`);
|
||||
violations.push(`${repositoryPath} -> tools/ top-level entries are allowlisted; expected only AGENTS.md, dev/, pack/, release/, and serve/`);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = resolve(scriptDir, "..");
|
||||
|
||||
const buildTargets = [
|
||||
"packages/release",
|
||||
"packages/contracts",
|
||||
"packages/components",
|
||||
"packages/platform",
|
||||
@@ -23,6 +24,7 @@ const buildTargets = [
|
||||
"apps/daemon",
|
||||
"tools/dev",
|
||||
"tools/pack",
|
||||
"tools/release",
|
||||
"tools/serve",
|
||||
];
|
||||
|
||||
|
||||
@@ -72,11 +72,14 @@ function workspaceDependencyNames(manifest: unknown, includeDevDependencies = fa
|
||||
}
|
||||
|
||||
function postinstallBuildTargets(): Set<string> {
|
||||
return new Set(postinstallBuildTargetList());
|
||||
}
|
||||
|
||||
function postinstallBuildTargetList(): string[] {
|
||||
const source = readFileSync(join(repoRoot, "scripts/postinstall.mjs"), "utf8");
|
||||
const targets = [...source.matchAll(/"([^"]+)"/g)]
|
||||
return [...source.matchAll(/"([^"]+)"/g)]
|
||||
.map((match) => match[1])
|
||||
.filter((value): value is string => value != null && /^(?:apps|packages|tools)\//.test(value));
|
||||
return new Set(targets);
|
||||
}
|
||||
|
||||
function workspacePackageDirectories(): string[] {
|
||||
@@ -160,6 +163,14 @@ test("postinstall builds workspace packages whose linkable bins delegate to dist
|
||||
assert.deepEqual(missingBuildTargets, []);
|
||||
});
|
||||
|
||||
test("postinstall builds release before contracts", () => {
|
||||
const targets = postinstallBuildTargetList();
|
||||
|
||||
assert.ok(targets.indexOf("packages/release") >= 0);
|
||||
assert.ok(targets.indexOf("packages/contracts") >= 0);
|
||||
assert.ok(targets.indexOf("packages/release") < targets.indexOf("packages/contracts"));
|
||||
});
|
||||
|
||||
test("every postinstall build target has a checked-in tsconfig.json", () => {
|
||||
// postinstall.mjs skips targets without a tsconfig.json so partial install
|
||||
// contexts (deploy/Dockerfile) survive; this invariant keeps that skip a
|
||||
|
||||
@@ -1,379 +0,0 @@
|
||||
import { execFile as execFileCallback } from "node:child_process";
|
||||
import { appendFileSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { get as httpsGet } from "node:https";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFile = promisify(execFileCallback);
|
||||
|
||||
const stableVersionPattern = /^(\d+)\.(\d+)\.(\d+)$/;
|
||||
const stableTagPattern = /^open-design-v(\d+\.\d+\.\d+)$/;
|
||||
const betaVersionPattern = /^(\d+\.\d+\.\d+)-beta\.(\d+)$/;
|
||||
|
||||
type ParsedStableVersion = {
|
||||
parsed: [number, number, number];
|
||||
value: string;
|
||||
};
|
||||
|
||||
type ParsedBetaVersion = {
|
||||
baseVersion: string;
|
||||
betaNumber: number;
|
||||
betaVersion: string;
|
||||
};
|
||||
|
||||
type ParsedBetaMetadata = ParsedBetaVersion & {
|
||||
source: "metadata-json";
|
||||
};
|
||||
|
||||
function fail(message: string): never {
|
||||
console.error(`[release-beta] ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function parseStableVersion(value: string): [number, number, number] | null {
|
||||
const match = stableVersionPattern.exec(value);
|
||||
if (match == null) return null;
|
||||
|
||||
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
||||
}
|
||||
|
||||
function compareVersions(left: [number, number, number], right: [number, number, number]): number {
|
||||
const [leftMajor, leftMinor, leftPatch] = left;
|
||||
const [rightMajor, rightMinor, rightPatch] = right;
|
||||
const pairs = [
|
||||
[leftMajor, rightMajor],
|
||||
[leftMinor, rightMinor],
|
||||
[leftPatch, rightPatch],
|
||||
] as const;
|
||||
|
||||
for (const [leftPart, rightPart] of pairs) {
|
||||
if (leftPart > rightPart) return 1;
|
||||
if (leftPart < rightPart) return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function extractStableVersionFromTag(tag: string): ParsedStableVersion | null {
|
||||
const match = stableTagPattern.exec(tag);
|
||||
if (match?.[1] == null) return null;
|
||||
|
||||
const parsed = parseStableVersion(match[1]);
|
||||
return parsed == null ? null : { parsed, value: match[1] };
|
||||
}
|
||||
|
||||
function parseBetaParts(baseVersion: string, betaNumber: string): ParsedBetaVersion {
|
||||
const parsedBetaNumber = Number(betaNumber);
|
||||
if (!Number.isSafeInteger(parsedBetaNumber) || parsedBetaNumber < 1) {
|
||||
fail(`invalid beta number in latest beta metadata: ${betaNumber}`);
|
||||
}
|
||||
|
||||
return {
|
||||
baseVersion,
|
||||
betaNumber: parsedBetaNumber,
|
||||
betaVersion: `${baseVersion}-beta.${betaNumber}`,
|
||||
};
|
||||
}
|
||||
|
||||
function readStringField(record: Record<string, unknown>, field: string): string | null {
|
||||
const value = record[field];
|
||||
return typeof value === "string" && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
function readNumberField(record: Record<string, unknown>, field: string): number | null {
|
||||
const value = record[field];
|
||||
return typeof value === "number" && Number.isSafeInteger(value) ? value : null;
|
||||
}
|
||||
|
||||
function parseBetaVersion(value: string, sourceName: string): ParsedBetaVersion {
|
||||
const match = betaVersionPattern.exec(value);
|
||||
if (match?.[1] == null || match[2] == null) {
|
||||
fail(`${sourceName} betaVersion must be x.y.z-beta.N; got ${value}`);
|
||||
}
|
||||
return parseBetaParts(match[1], match[2]);
|
||||
}
|
||||
|
||||
function parseBetaMetadataJson(value: string): ParsedBetaMetadata {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(value.replace(/^\uFEFF/u, ""));
|
||||
} catch (error) {
|
||||
fail(`beta metadata.json is invalid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
|
||||
if (typeof parsed !== "object" || parsed == null || Array.isArray(parsed)) {
|
||||
fail("beta metadata.json must be a JSON object");
|
||||
}
|
||||
|
||||
const record = parsed as Record<string, unknown>;
|
||||
// The unified release publisher and the in-flight tools-release rewrite stamp
|
||||
// beta metadata.json with generic releaseVersion/releaseNumber fields, while
|
||||
// the legacy publisher used betaVersion/betaNumber. Accept either spelling so
|
||||
// the daily beta reader survives whichever publisher last wrote the feed.
|
||||
const betaVersion = readStringField(record, "betaVersion") ?? readStringField(record, "releaseVersion");
|
||||
const betaNumber = readNumberField(record, "betaNumber") ?? readNumberField(record, "releaseNumber");
|
||||
const baseVersion = readStringField(record, "baseVersion");
|
||||
|
||||
if (betaVersion != null) {
|
||||
const beta = parseBetaVersion(betaVersion, "beta metadata.json");
|
||||
if (baseVersion != null && baseVersion !== beta.baseVersion) {
|
||||
fail(`beta metadata.json baseVersion ${baseVersion} does not match betaVersion ${beta.betaVersion}`);
|
||||
}
|
||||
if (betaNumber != null && betaNumber !== beta.betaNumber) {
|
||||
fail(`beta metadata.json betaNumber ${betaNumber} does not match betaVersion ${beta.betaVersion}`);
|
||||
}
|
||||
return { ...beta, source: "metadata-json" };
|
||||
}
|
||||
|
||||
if (baseVersion == null || betaNumber == null) {
|
||||
fail("beta metadata.json must include betaVersion/releaseVersion or baseVersion+betaNumber/releaseNumber");
|
||||
}
|
||||
|
||||
const parsedBase = parseStableVersion(baseVersion);
|
||||
if (parsedBase == null) {
|
||||
fail(`beta metadata.json baseVersion must be x.y.z; got ${baseVersion}`);
|
||||
}
|
||||
|
||||
return { ...parseBetaParts(baseVersion, String(betaNumber)), source: "metadata-json" };
|
||||
}
|
||||
|
||||
function parseStableMetadataJson(value: string): ParsedStableVersion {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(value.replace(/^\uFEFF/u, ""));
|
||||
} catch (error) {
|
||||
fail(`stable metadata.json is invalid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
|
||||
if (typeof parsed !== "object" || parsed == null || Array.isArray(parsed)) {
|
||||
fail("stable metadata.json must be a JSON object");
|
||||
}
|
||||
|
||||
const record = parsed as Record<string, unknown>;
|
||||
const stableVersion = readStringField(record, "stableVersion") ?? readStringField(record, "releaseVersion");
|
||||
if (stableVersion == null) {
|
||||
fail("stable metadata.json must include stableVersion or releaseVersion");
|
||||
}
|
||||
|
||||
const parsedStable = parseStableVersion(stableVersion);
|
||||
if (parsedStable == null) {
|
||||
fail(`stable metadata.json stableVersion must be x.y.z; got ${stableVersion}`);
|
||||
}
|
||||
return { parsed: parsedStable, value: stableVersion };
|
||||
}
|
||||
|
||||
async function readPackagedVersion(): Promise<string> {
|
||||
const packageJsonPath = join(process.cwd(), "apps", "packaged", "package.json");
|
||||
const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8")) as { version?: unknown };
|
||||
|
||||
if (typeof packageJson.version !== "string") {
|
||||
fail(`missing version in ${packageJsonPath}`);
|
||||
}
|
||||
|
||||
if (!stableVersionPattern.test(packageJson.version)) {
|
||||
fail(`apps/packaged/package.json version must be a stable x.y.z base version; got ${packageJson.version}`);
|
||||
}
|
||||
|
||||
return packageJson.version;
|
||||
}
|
||||
|
||||
async function fetchGitTags(pattern: string): Promise<string[]> {
|
||||
const { stdout } = await execFile("git", ["tag", "--list", pattern]);
|
||||
return stdout
|
||||
.split("\n")
|
||||
.map((tag) => tag.trim())
|
||||
.filter((tag) => tag.length > 0);
|
||||
}
|
||||
|
||||
function fetchOptionalHttpsTextOnce(url: string, redirectCount = 0): Promise<string | null> {
|
||||
return new Promise((resolvePromise, reject) => {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol !== "https:") {
|
||||
reject(new Error(`expected HTTPS URL for beta feed lookup: ${parsed.protocol}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const request = httpsGet(
|
||||
parsed,
|
||||
{
|
||||
headers: {
|
||||
"Cache-Control": "no-cache",
|
||||
},
|
||||
},
|
||||
(response) => {
|
||||
const statusCode = response.statusCode ?? 0;
|
||||
if (statusCode === 404) {
|
||||
response.resume();
|
||||
resolvePromise(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const location = response.headers.location;
|
||||
if (statusCode >= 300 && statusCode < 400 && typeof location === "string") {
|
||||
response.resume();
|
||||
if (redirectCount >= 3) {
|
||||
reject(new Error("too many redirects while reading beta feed"));
|
||||
return;
|
||||
}
|
||||
const nextUrl = new URL(location, parsed).toString();
|
||||
fetchOptionalHttpsTextOnce(nextUrl, redirectCount + 1).then(resolvePromise, reject);
|
||||
return;
|
||||
}
|
||||
|
||||
if (statusCode < 200 || statusCode >= 300) {
|
||||
response.resume();
|
||||
reject(new Error(`beta feed request failed with HTTP ${statusCode}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
response.on("data", (chunk: Buffer) => {
|
||||
chunks.push(chunk);
|
||||
});
|
||||
response.on("end", () => {
|
||||
resolvePromise(Buffer.concat(chunks).toString("utf8"));
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
request.setTimeout(10_000, () => {
|
||||
request.destroy(new Error("timed out while reading beta feed"));
|
||||
});
|
||||
request.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchOptionalHttpsText(url: string): Promise<string | null> {
|
||||
const maxAttempts = 3;
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||
try {
|
||||
return await fetchOptionalHttpsTextOnce(url);
|
||||
} catch (error) {
|
||||
if (attempt === maxAttempts) {
|
||||
throw error;
|
||||
}
|
||||
const delayMs = 1_000 * attempt;
|
||||
console.warn(
|
||||
`[release-beta] metadata request failed (attempt ${attempt}/${maxAttempts}); retrying in ${delayMs}ms: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateHttpsUrl(value: string, name: string): void {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(value);
|
||||
} catch {
|
||||
fail(`${name} must be an HTTPS URL; got ${value}`);
|
||||
}
|
||||
|
||||
if (parsed.protocol !== "https:") {
|
||||
fail(`${name} must be an HTTPS URL; got ${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
function setOutput(name: string, value: string): void {
|
||||
const outputPath = process.env.GITHUB_OUTPUT;
|
||||
if (outputPath == null || outputPath.length === 0) return;
|
||||
appendFileSync(outputPath, `${name}=${value}\n`);
|
||||
}
|
||||
|
||||
const packagedVersion = await readPackagedVersion();
|
||||
const packagedParsed = parseStableVersion(packagedVersion) ?? fail(`invalid packaged version: ${packagedVersion}`);
|
||||
|
||||
let latestStable: ParsedStableVersion | null = null;
|
||||
const stableMetadataUrl = process.env.OPEN_DESIGN_STABLE_METADATA_URL;
|
||||
if (stableMetadataUrl != null && stableMetadataUrl.length > 0) {
|
||||
validateHttpsUrl(stableMetadataUrl, "OPEN_DESIGN_STABLE_METADATA_URL");
|
||||
const stableMetadataJson = await fetchOptionalHttpsText(stableMetadataUrl);
|
||||
if (stableMetadataJson == null) {
|
||||
fail(`stable metadata.json was not found: ${stableMetadataUrl}`);
|
||||
}
|
||||
latestStable = parseStableMetadataJson(stableMetadataJson);
|
||||
console.log(`[release-beta] stable metadata.json version: ${latestStable.value}`);
|
||||
} else {
|
||||
const tags = await fetchGitTags("open-design-v*");
|
||||
for (const tag of tags) {
|
||||
const stableVersion = extractStableVersionFromTag(tag);
|
||||
if (stableVersion == null) continue;
|
||||
|
||||
if (latestStable == null || compareVersions(stableVersion.parsed, latestStable.parsed) > 0) {
|
||||
latestStable = stableVersion;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (latestStable != null && compareVersions(packagedParsed, latestStable.parsed) <= 0) {
|
||||
fail(`packaged base version ${packagedVersion} must be strictly greater than latest stable ${latestStable.value}`);
|
||||
}
|
||||
|
||||
const metadataUrl = process.env.OPEN_DESIGN_BETA_METADATA_URL;
|
||||
if (metadataUrl == null || metadataUrl.length === 0) {
|
||||
fail("OPEN_DESIGN_BETA_METADATA_URL is required");
|
||||
}
|
||||
validateHttpsUrl(metadataUrl, "OPEN_DESIGN_BETA_METADATA_URL");
|
||||
|
||||
let betaNumber = 1;
|
||||
let latestBeta: ParsedBetaVersion | null = null;
|
||||
let stateSource = "beta metadata.json";
|
||||
const latestMetadataJson = await fetchOptionalHttpsText(metadataUrl);
|
||||
if (latestMetadataJson == null) {
|
||||
// Only HTTP 404 reaches this branch; other fetch failures throw above. This
|
||||
// is an intentional cold-start/reset behavior for a missing beta metadata
|
||||
// object, not a fallback to any updater feed or GitHub release state.
|
||||
latestBeta = {
|
||||
baseVersion: packagedVersion,
|
||||
betaNumber: 0,
|
||||
betaVersion: `${packagedVersion}-beta.0`,
|
||||
};
|
||||
stateSource = "missing beta metadata.json fallback beta.0";
|
||||
console.log("[release-beta] beta metadata.json: not found; using beta.0 fallback");
|
||||
} else {
|
||||
latestBeta = parseBetaMetadataJson(latestMetadataJson);
|
||||
console.log(`[release-beta] beta metadata.json version: ${latestBeta.betaVersion}`);
|
||||
}
|
||||
|
||||
if (latestBeta != null) {
|
||||
const beta = latestBeta;
|
||||
const existingBase = parseStableVersion(beta.baseVersion);
|
||||
if (existingBase == null) {
|
||||
fail(`invalid beta base version in ${stateSource}: ${beta.baseVersion}`);
|
||||
}
|
||||
|
||||
const ordering = compareVersions(packagedParsed, existingBase);
|
||||
if (ordering < 0) {
|
||||
fail(`packaged base version ${packagedVersion} regressed below current beta base version ${beta.baseVersion}`);
|
||||
}
|
||||
|
||||
if (ordering === 0) {
|
||||
betaNumber = beta.betaNumber + 1;
|
||||
}
|
||||
}
|
||||
|
||||
const betaVersion = `${packagedVersion}-beta.${betaNumber}`;
|
||||
const branch = process.env.GITHUB_REF_NAME ?? "";
|
||||
const commit = process.env.GITHUB_SHA ?? "";
|
||||
const releaseName = `Open Design Beta ${betaVersion}`;
|
||||
|
||||
console.log(`[release-beta] channel: beta`);
|
||||
console.log(`[release-beta] base version: ${packagedVersion}`);
|
||||
console.log(`[release-beta] beta version: ${betaVersion}`);
|
||||
console.log(`[release-beta] beta state source: ${stateSource}`);
|
||||
if (latestStable != null) console.log(`[release-beta] latest stable: ${latestStable.value}`);
|
||||
if (latestBeta != null) console.log(`[release-beta] latest beta: ${latestBeta.betaVersion}`);
|
||||
|
||||
setOutput("asset_version_suffix", "");
|
||||
setOutput("base_version", packagedVersion);
|
||||
setOutput("beta_number", String(betaNumber));
|
||||
setOutput("beta_version", betaVersion);
|
||||
setOutput("branch", branch);
|
||||
setOutput("commit", commit);
|
||||
setOutput("latest_stable", latestStable?.value ?? "");
|
||||
setOutput("release_name", releaseName);
|
||||
setOutput("state_source", stateSource);
|
||||
@@ -1,327 +0,0 @@
|
||||
import { execFile as execFileCallback } from "node:child_process";
|
||||
import { appendFileSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { get as httpsGet } from "node:https";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFile = promisify(execFileCallback);
|
||||
|
||||
const stableVersionPattern = /^(\d+)\.(\d+)\.(\d+)$/;
|
||||
const stableTagPattern = /^open-design-v(\d+\.\d+\.\d+)$/;
|
||||
const previewReleaseBranchPattern = /^preview\/v(\d+\.\d+\.\d+)$/;
|
||||
const previewVersionPattern = /^(\d+\.\d+\.\d+)-preview\.(\d+)$/;
|
||||
|
||||
type ParsedStableVersion = {
|
||||
parsed: [number, number, number];
|
||||
value: string;
|
||||
};
|
||||
|
||||
type ParsedPreviewVersion = {
|
||||
baseVersion: string;
|
||||
previewNumber: number;
|
||||
previewVersion: string;
|
||||
};
|
||||
|
||||
type ParsedPreviewMetadata = ParsedPreviewVersion & {
|
||||
source: "metadata-json";
|
||||
};
|
||||
|
||||
function fail(message: string): never {
|
||||
console.error(`[release-preview] ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function parseStableVersion(value: string): [number, number, number] | null {
|
||||
const match = stableVersionPattern.exec(value);
|
||||
if (match == null) return null;
|
||||
|
||||
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
||||
}
|
||||
|
||||
function compareVersions(left: [number, number, number], right: [number, number, number]): number {
|
||||
const [leftMajor, leftMinor, leftPatch] = left;
|
||||
const [rightMajor, rightMinor, rightPatch] = right;
|
||||
const pairs = [
|
||||
[leftMajor, rightMajor],
|
||||
[leftMinor, rightMinor],
|
||||
[leftPatch, rightPatch],
|
||||
] as const;
|
||||
|
||||
for (const [leftPart, rightPart] of pairs) {
|
||||
if (leftPart > rightPart) return 1;
|
||||
if (leftPart < rightPart) return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function extractStableVersionFromTag(tag: string): ParsedStableVersion | null {
|
||||
const match = stableTagPattern.exec(tag);
|
||||
if (match?.[1] == null) return null;
|
||||
|
||||
const parsed = parseStableVersion(match[1]);
|
||||
return parsed == null ? null : { parsed, value: match[1] };
|
||||
}
|
||||
|
||||
function parsePreviewParts(baseVersion: string, previewNumber: string): ParsedPreviewVersion {
|
||||
const parsedPreviewNumber = Number(previewNumber);
|
||||
if (!Number.isSafeInteger(parsedPreviewNumber) || parsedPreviewNumber < 1) {
|
||||
fail(`invalid preview number in latest preview metadata: ${previewNumber}`);
|
||||
}
|
||||
|
||||
return {
|
||||
baseVersion,
|
||||
previewNumber: parsedPreviewNumber,
|
||||
previewVersion: `${baseVersion}-preview.${previewNumber}`,
|
||||
};
|
||||
}
|
||||
|
||||
function readStringField(record: Record<string, unknown>, field: string): string | null {
|
||||
const value = record[field];
|
||||
return typeof value === "string" && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
function readNumberField(record: Record<string, unknown>, field: string): number | null {
|
||||
const value = record[field];
|
||||
return typeof value === "number" && Number.isSafeInteger(value) ? value : null;
|
||||
}
|
||||
|
||||
function parsePreviewVersion(value: string, sourceName: string): ParsedPreviewVersion {
|
||||
const match = previewVersionPattern.exec(value);
|
||||
if (match?.[1] == null || match[2] == null) {
|
||||
fail(`${sourceName} previewVersion must be x.y.z-preview.N; got ${value}`);
|
||||
}
|
||||
return parsePreviewParts(match[1], match[2]);
|
||||
}
|
||||
|
||||
function parsePreviewMetadataJson(value: string): ParsedPreviewMetadata {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(value);
|
||||
} catch (error) {
|
||||
fail(`R2 preview metadata.json is invalid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
|
||||
if (typeof parsed !== "object" || parsed == null || Array.isArray(parsed)) {
|
||||
fail("R2 preview metadata.json must be a JSON object");
|
||||
}
|
||||
|
||||
const record = parsed as Record<string, unknown>;
|
||||
const previewVersion = readStringField(record, "previewVersion") ?? readStringField(record, "releaseVersion");
|
||||
const previewNumber = readNumberField(record, "previewNumber");
|
||||
const baseVersion = readStringField(record, "baseVersion");
|
||||
|
||||
if (previewVersion != null) {
|
||||
const preview = parsePreviewVersion(previewVersion, "R2 preview metadata.json");
|
||||
if (baseVersion != null && baseVersion !== preview.baseVersion) {
|
||||
fail(`R2 preview metadata.json baseVersion ${baseVersion} does not match previewVersion ${preview.previewVersion}`);
|
||||
}
|
||||
if (previewNumber != null && previewNumber !== preview.previewNumber) {
|
||||
fail(`R2 preview metadata.json previewNumber ${previewNumber} does not match previewVersion ${preview.previewVersion}`);
|
||||
}
|
||||
return { ...preview, source: "metadata-json" };
|
||||
}
|
||||
|
||||
if (baseVersion == null || previewNumber == null) {
|
||||
fail("R2 preview metadata.json must include previewVersion or baseVersion+previewNumber");
|
||||
}
|
||||
|
||||
const parsedBase = parseStableVersion(baseVersion);
|
||||
if (parsedBase == null) {
|
||||
fail(`R2 preview metadata.json baseVersion must be x.y.z; got ${baseVersion}`);
|
||||
}
|
||||
|
||||
return { ...parsePreviewParts(baseVersion, String(previewNumber)), source: "metadata-json" };
|
||||
}
|
||||
|
||||
async function readPackagedVersion(): Promise<string> {
|
||||
const packageJsonPath = join(process.cwd(), "apps", "packaged", "package.json");
|
||||
const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8")) as { version?: unknown };
|
||||
|
||||
if (typeof packageJson.version !== "string") {
|
||||
fail(`missing version in ${packageJsonPath}`);
|
||||
}
|
||||
|
||||
if (!stableVersionPattern.test(packageJson.version)) {
|
||||
fail(`apps/packaged/package.json version must be a stable x.y.z base version; got ${packageJson.version}`);
|
||||
}
|
||||
|
||||
return packageJson.version;
|
||||
}
|
||||
|
||||
async function fetchGitTags(pattern: string): Promise<string[]> {
|
||||
const { stdout } = await execFile("git", ["tag", "--list", pattern]);
|
||||
return stdout
|
||||
.split("\n")
|
||||
.map((tag) => tag.trim())
|
||||
.filter((tag) => tag.length > 0);
|
||||
}
|
||||
|
||||
function fetchOptionalHttpsText(url: string, redirectCount = 0): Promise<string | null> {
|
||||
return new Promise((resolvePromise, reject) => {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol !== "https:") {
|
||||
reject(new Error(`expected HTTPS URL for preview feed lookup: ${parsed.protocol}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const request = httpsGet(
|
||||
parsed,
|
||||
{
|
||||
headers: {
|
||||
"Cache-Control": "no-cache",
|
||||
},
|
||||
},
|
||||
(response) => {
|
||||
const statusCode = response.statusCode ?? 0;
|
||||
if (statusCode === 404) {
|
||||
response.resume();
|
||||
resolvePromise(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const location = response.headers.location;
|
||||
if (statusCode >= 300 && statusCode < 400 && typeof location === "string") {
|
||||
response.resume();
|
||||
if (redirectCount >= 3) {
|
||||
reject(new Error("too many redirects while reading preview feed"));
|
||||
return;
|
||||
}
|
||||
const nextUrl = new URL(location, parsed).toString();
|
||||
fetchOptionalHttpsText(nextUrl, redirectCount + 1).then(resolvePromise, reject);
|
||||
return;
|
||||
}
|
||||
|
||||
if (statusCode < 200 || statusCode >= 300) {
|
||||
response.resume();
|
||||
reject(new Error(`preview feed request failed with HTTP ${statusCode}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
response.on("data", (chunk: Buffer) => {
|
||||
chunks.push(chunk);
|
||||
});
|
||||
response.on("end", () => {
|
||||
resolvePromise(Buffer.concat(chunks).toString("utf8"));
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
request.setTimeout(10_000, () => {
|
||||
request.destroy(new Error("timed out while reading preview feed"));
|
||||
});
|
||||
request.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function validateHttpsUrl(value: string, name: string): void {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(value);
|
||||
} catch {
|
||||
fail(`${name} must be an HTTPS URL; got ${value}`);
|
||||
}
|
||||
|
||||
if (parsed.protocol !== "https:") {
|
||||
fail(`${name} must be an HTTPS URL; got ${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
function setOutput(name: string, value: string): void {
|
||||
const outputPath = process.env.GITHUB_OUTPUT;
|
||||
if (outputPath == null || outputPath.length === 0) return;
|
||||
appendFileSync(outputPath, `${name}=${value}\n`);
|
||||
}
|
||||
|
||||
const packagedVersion = await readPackagedVersion();
|
||||
const packagedParsed = parseStableVersion(packagedVersion) ?? fail(`invalid packaged version: ${packagedVersion}`);
|
||||
const branch = process.env.GITHUB_REF_NAME ?? "";
|
||||
const branchMatch = previewReleaseBranchPattern.exec(branch);
|
||||
if (branchMatch?.[1] == null) {
|
||||
fail(`release-preview can only run from preview/vX.Y.Z branches; got ${branch || "(empty)"}`);
|
||||
}
|
||||
const branchVersion = branchMatch[1];
|
||||
if (branchVersion !== packagedVersion) {
|
||||
fail(`preview branch version ${branchVersion} must match apps/packaged/package.json version ${packagedVersion}`);
|
||||
}
|
||||
|
||||
const tags = await fetchGitTags("open-design-v*");
|
||||
let latestStable: ParsedStableVersion | null = null;
|
||||
for (const tag of tags) {
|
||||
const stableVersion = extractStableVersionFromTag(tag);
|
||||
if (stableVersion == null) continue;
|
||||
|
||||
if (latestStable == null || compareVersions(stableVersion.parsed, latestStable.parsed) > 0) {
|
||||
latestStable = stableVersion;
|
||||
}
|
||||
}
|
||||
|
||||
if (latestStable != null && compareVersions(packagedParsed, latestStable.parsed) <= 0) {
|
||||
fail(`packaged base version ${packagedVersion} must be strictly greater than latest stable ${latestStable.value}`);
|
||||
}
|
||||
|
||||
const metadataUrl = process.env.OPEN_DESIGN_PREVIEW_METADATA_URL;
|
||||
if (metadataUrl == null || metadataUrl.length === 0) {
|
||||
fail("OPEN_DESIGN_PREVIEW_METADATA_URL is required");
|
||||
}
|
||||
validateHttpsUrl(metadataUrl, "OPEN_DESIGN_PREVIEW_METADATA_URL");
|
||||
|
||||
let previewNumber = 1;
|
||||
let latestPreview: ParsedPreviewVersion | null = null;
|
||||
let stateSource = "R2 metadata.json";
|
||||
const latestMetadataJson = await fetchOptionalHttpsText(metadataUrl);
|
||||
if (latestMetadataJson == null) {
|
||||
latestPreview = {
|
||||
baseVersion: packagedVersion,
|
||||
previewNumber: 0,
|
||||
previewVersion: `${packagedVersion}-preview.0`,
|
||||
};
|
||||
stateSource = "missing R2 metadata.json fallback preview.0";
|
||||
console.log("[release-preview] R2 preview metadata.json: not found; using preview.0 fallback");
|
||||
} else {
|
||||
latestPreview = parsePreviewMetadataJson(latestMetadataJson);
|
||||
console.log(`[release-preview] R2 preview metadata.json version: ${latestPreview.previewVersion}`);
|
||||
}
|
||||
|
||||
if (latestPreview != null) {
|
||||
const preview = latestPreview;
|
||||
const existingBase = parseStableVersion(preview.baseVersion);
|
||||
if (existingBase == null) {
|
||||
fail(`invalid preview base version in ${stateSource}: ${preview.baseVersion}`);
|
||||
}
|
||||
|
||||
const ordering = compareVersions(packagedParsed, existingBase);
|
||||
if (ordering < 0) {
|
||||
fail(`packaged base version ${packagedVersion} regressed below current preview base version ${preview.baseVersion}`);
|
||||
}
|
||||
|
||||
if (ordering === 0) {
|
||||
previewNumber = preview.previewNumber + 1;
|
||||
}
|
||||
}
|
||||
|
||||
const previewVersion = `${packagedVersion}-preview.${previewNumber}`;
|
||||
const commit = process.env.GITHUB_SHA ?? "";
|
||||
const releaseName = `Open Design Preview ${previewVersion}`;
|
||||
|
||||
console.log("[release-preview] channel: preview");
|
||||
console.log(`[release-preview] base version: ${packagedVersion}`);
|
||||
console.log(`[release-preview] preview version: ${previewVersion}`);
|
||||
console.log(`[release-preview] preview state source: ${stateSource}`);
|
||||
if (latestStable != null) console.log(`[release-preview] latest stable: ${latestStable.value}`);
|
||||
if (latestPreview != null) console.log(`[release-preview] latest preview: ${latestPreview.previewVersion}`);
|
||||
|
||||
setOutput("asset_version_suffix", "");
|
||||
setOutput("base_version", packagedVersion);
|
||||
setOutput("branch", branch);
|
||||
setOutput("channel", "preview");
|
||||
setOutput("commit", commit);
|
||||
setOutput("github_release_enabled", "false");
|
||||
setOutput("latest_stable", latestStable?.value ?? "");
|
||||
setOutput("preview_number", String(previewNumber));
|
||||
setOutput("preview_version", previewVersion);
|
||||
setOutput("release_name", releaseName);
|
||||
setOutput("release_version", previewVersion);
|
||||
setOutput("state_source", stateSource);
|
||||
@@ -1,633 +0,0 @@
|
||||
import { execFile as execFileCallback } from "node:child_process";
|
||||
import { appendFileSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { get as httpsGet } from "node:https";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFile = promisify(execFileCallback);
|
||||
|
||||
const stableVersionPattern = /^(\d+)\.(\d+)\.(\d+)$/;
|
||||
const stableReleaseBranchPattern = /^release\/v(\d+\.\d+\.\d+)$/;
|
||||
const stableTagPattern = /^open-design-v(\d+\.\d+\.\d+)$/;
|
||||
const nightlyVersionPattern = /^(\d+\.\d+\.\d+)\.nightly\.(\d+)$/;
|
||||
|
||||
type ReleaseChannel = "nightly" | "stable";
|
||||
|
||||
type GitHubRelease = {
|
||||
draft?: boolean;
|
||||
name?: string | null;
|
||||
prerelease?: boolean;
|
||||
tag_name?: string;
|
||||
};
|
||||
|
||||
type ParsedStableVersion = {
|
||||
parsed: [number, number, number];
|
||||
source?: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
type ParsedNightlyVersion = {
|
||||
baseVersion: string;
|
||||
nightlyNumber: number;
|
||||
nightlyVersion: string;
|
||||
};
|
||||
|
||||
type ParsedNightlyMetadata = ParsedNightlyVersion & {
|
||||
source: "metadata-json";
|
||||
};
|
||||
|
||||
type StableNightlyValidation = {
|
||||
metadataUrl: string;
|
||||
nightlyVersion: string;
|
||||
};
|
||||
|
||||
type ReleaseNamespaces = {
|
||||
linux: string;
|
||||
mac: string;
|
||||
macIntel: string;
|
||||
win: string;
|
||||
};
|
||||
|
||||
function fail(message: string): never {
|
||||
console.error(`[release-stable] ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function parseChannel(value: string | undefined): ReleaseChannel {
|
||||
if (value == null || value.length === 0 || value === "stable") return "stable";
|
||||
if (value === "nightly") return "nightly";
|
||||
fail(`OPEN_DESIGN_RELEASE_CHANNEL must be stable or nightly; got ${value}`);
|
||||
}
|
||||
|
||||
function parseBooleanInput(value: string | undefined, name: string): boolean {
|
||||
if (value == null || value.length === 0 || value === "false") return false;
|
||||
if (value === "true") return true;
|
||||
fail(`${name} must be true or false; got ${value}`);
|
||||
}
|
||||
|
||||
function parseStableVersion(value: string): [number, number, number] | null {
|
||||
const match = stableVersionPattern.exec(value);
|
||||
if (match == null) return null;
|
||||
|
||||
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
||||
}
|
||||
|
||||
function parseStableVersionInput(value: string | undefined, sourceName: string): ParsedStableVersion | null {
|
||||
const trimmed = value?.trim() ?? "";
|
||||
if (trimmed.length === 0) return null;
|
||||
|
||||
const parsed = parseStableVersion(trimmed);
|
||||
if (parsed == null) {
|
||||
fail(`${sourceName} must be a stable x.y.z version; got ${trimmed}`);
|
||||
}
|
||||
|
||||
return { parsed, source: sourceName, value: trimmed };
|
||||
}
|
||||
|
||||
function resolveStableBaseVersion(branch: string, inputValue: string | undefined): ParsedStableVersion {
|
||||
const branchMatch = stableReleaseBranchPattern.exec(branch);
|
||||
const branchVersion =
|
||||
branchMatch?.[1] == null
|
||||
? null
|
||||
: ({
|
||||
parsed: parseStableVersion(branchMatch[1]) ?? fail(`invalid release branch version: ${branchMatch[1]}`),
|
||||
source: "GITHUB_REF_NAME",
|
||||
value: branchMatch[1],
|
||||
} satisfies ParsedStableVersion);
|
||||
const inputVersion = parseStableVersionInput(inputValue, "OPEN_DESIGN_STABLE_VERSION");
|
||||
|
||||
if (branchVersion != null) {
|
||||
if (inputVersion != null && inputVersion.value !== branchVersion.value) {
|
||||
fail(
|
||||
`OPEN_DESIGN_STABLE_VERSION ${inputVersion.value} must match release branch version ${branchVersion.value} when both are provided`,
|
||||
);
|
||||
}
|
||||
return branchVersion;
|
||||
}
|
||||
|
||||
if (inputVersion != null) return inputVersion;
|
||||
|
||||
fail("release-stable requires either a release/vX.Y.Z branch or OPEN_DESIGN_STABLE_VERSION");
|
||||
}
|
||||
|
||||
function compareVersions(left: [number, number, number], right: [number, number, number]): number {
|
||||
const [leftMajor, leftMinor, leftPatch] = left;
|
||||
const [rightMajor, rightMinor, rightPatch] = right;
|
||||
const pairs = [
|
||||
[leftMajor, rightMajor],
|
||||
[leftMinor, rightMinor],
|
||||
[leftPatch, rightPatch],
|
||||
] as const;
|
||||
|
||||
for (const [leftPart, rightPart] of pairs) {
|
||||
if (leftPart > rightPart) return 1;
|
||||
if (leftPart < rightPart) return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function releaseNamespaces(channel: ReleaseChannel): ReleaseNamespaces {
|
||||
const mac = channel === "nightly" ? "release-nightly" : "release-stable";
|
||||
|
||||
return {
|
||||
linux: `${mac}-linux`,
|
||||
mac,
|
||||
macIntel: `${mac}-intel`,
|
||||
win: `${mac}-win`,
|
||||
};
|
||||
}
|
||||
|
||||
function extractStableVersion(release: GitHubRelease): ParsedStableVersion | null {
|
||||
const candidates = [release.tag_name, release.name].filter((value): value is string => typeof value === "string");
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const tagMatch = stableTagPattern.exec(candidate);
|
||||
const value = tagMatch?.[1] ?? candidate.match(/\b(\d+\.\d+\.\d+)\b/)?.[1];
|
||||
if (value == null) continue;
|
||||
|
||||
const parsed = parseStableVersion(value);
|
||||
if (parsed != null) return { parsed, value };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseNightlyParts(baseVersion: string, nightlyNumber: string): ParsedNightlyVersion {
|
||||
const parsedNightlyNumber = Number(nightlyNumber);
|
||||
if (!Number.isSafeInteger(parsedNightlyNumber) || parsedNightlyNumber < 1) {
|
||||
fail(`invalid nightly number in latest nightly metadata: ${nightlyNumber}`);
|
||||
}
|
||||
|
||||
return {
|
||||
baseVersion,
|
||||
nightlyNumber: parsedNightlyNumber,
|
||||
nightlyVersion: `${baseVersion}.nightly.${nightlyNumber}`,
|
||||
};
|
||||
}
|
||||
|
||||
function readStringField(record: Record<string, unknown>, field: string): string | null {
|
||||
const value = record[field];
|
||||
return typeof value === "string" && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
function readNumberField(record: Record<string, unknown>, field: string): number | null {
|
||||
const value = record[field];
|
||||
return typeof value === "number" && Number.isSafeInteger(value) ? value : null;
|
||||
}
|
||||
|
||||
function readBooleanField(record: Record<string, unknown>, field: string): boolean | null {
|
||||
const value = record[field];
|
||||
return typeof value === "boolean" ? value : null;
|
||||
}
|
||||
|
||||
function readObjectField(record: Record<string, unknown>, field: string): Record<string, unknown> | null {
|
||||
const value = record[field];
|
||||
return typeof value === "object" && value != null && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
|
||||
}
|
||||
|
||||
function parseJsonRecord(value: string, sourceName: string): Record<string, unknown> {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(value);
|
||||
} catch (error) {
|
||||
fail(`${sourceName} is invalid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
|
||||
if (typeof parsed !== "object" || parsed == null || Array.isArray(parsed)) {
|
||||
fail(`${sourceName} must be a JSON object`);
|
||||
}
|
||||
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function parseNightlyVersion(value: string, sourceName: string): ParsedNightlyVersion {
|
||||
const match = nightlyVersionPattern.exec(value);
|
||||
if (match?.[1] == null || match[2] == null) {
|
||||
fail(`${sourceName} nightlyVersion must be x.y.z.nightly.N; got ${value}`);
|
||||
}
|
||||
return parseNightlyParts(match[1], match[2]);
|
||||
}
|
||||
|
||||
function parseNightlyMetadataJson(value: string): ParsedNightlyMetadata {
|
||||
const record = parseJsonRecord(value, "R2 nightly metadata.json");
|
||||
const nightlyVersion = readStringField(record, "nightlyVersion");
|
||||
const nightlyNumber = readNumberField(record, "nightlyNumber");
|
||||
const baseVersion = readStringField(record, "baseVersion");
|
||||
|
||||
if (nightlyVersion != null) {
|
||||
const nightly = parseNightlyVersion(nightlyVersion, "R2 nightly metadata.json");
|
||||
if (baseVersion != null && baseVersion !== nightly.baseVersion) {
|
||||
fail(`R2 nightly metadata.json baseVersion ${baseVersion} does not match nightlyVersion ${nightly.nightlyVersion}`);
|
||||
}
|
||||
if (nightlyNumber != null && nightlyNumber !== nightly.nightlyNumber) {
|
||||
fail(`R2 nightly metadata.json nightlyNumber ${nightlyNumber} does not match nightlyVersion ${nightly.nightlyVersion}`);
|
||||
}
|
||||
return { ...nightly, source: "metadata-json" };
|
||||
}
|
||||
|
||||
if (baseVersion == null || nightlyNumber == null) {
|
||||
fail("R2 nightly metadata.json must include nightlyVersion or baseVersion+nightlyNumber");
|
||||
}
|
||||
|
||||
const parsedBase = parseStableVersion(baseVersion);
|
||||
if (parsedBase == null) {
|
||||
fail(`R2 nightly metadata.json baseVersion must be x.y.z; got ${baseVersion}`);
|
||||
}
|
||||
|
||||
return { ...parseNightlyParts(baseVersion, String(nightlyNumber)), source: "metadata-json" };
|
||||
}
|
||||
|
||||
function requireObjectField(record: Record<string, unknown>, field: string, sourceName: string): Record<string, unknown> {
|
||||
const value = readObjectField(record, field);
|
||||
if (value == null) {
|
||||
fail(`${sourceName}.${field} must be a JSON object`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireStringField(record: Record<string, unknown>, field: string, sourceName: string): string {
|
||||
const value = readStringField(record, field);
|
||||
if (value == null) {
|
||||
fail(`${sourceName}.${field} is required`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function expectStringField(record: Record<string, unknown>, field: string, expected: string, sourceName: string): void {
|
||||
const value = readStringField(record, field);
|
||||
if (value !== expected) {
|
||||
fail(`${sourceName}.${field} must be ${expected}; got ${value ?? "(missing)"}`);
|
||||
}
|
||||
}
|
||||
|
||||
function expectBooleanField(record: Record<string, unknown>, field: string, expected: boolean, sourceName: string): void {
|
||||
const value = readBooleanField(record, field);
|
||||
if (value !== expected) {
|
||||
fail(`${sourceName}.${field} must be ${String(expected)}; got ${value == null ? "(missing)" : String(value)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function requireVersionedUrlField(
|
||||
record: Record<string, unknown>,
|
||||
field: string,
|
||||
expectedVersionUrl: string,
|
||||
sourceName: string,
|
||||
): void {
|
||||
const value = requireStringField(record, field, sourceName);
|
||||
validateHttpsUrl(value, `${sourceName}.${field}`);
|
||||
if (!value.startsWith(`${expectedVersionUrl}/`)) {
|
||||
fail(`${sourceName}.${field} must point under ${expectedVersionUrl}/; got ${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
function trimTrailingSlash(value: string): string {
|
||||
return value.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
async function validateStableNightlyMetadata(options: {
|
||||
branch: string;
|
||||
commit: string;
|
||||
nightlyVersionInput: string | undefined;
|
||||
packagedVersion: string;
|
||||
publicOrigin: string | undefined;
|
||||
repository: string;
|
||||
}): Promise<StableNightlyValidation> {
|
||||
if (options.commit.length === 0) {
|
||||
fail("GITHUB_SHA is required to validate the stable channel nightly gate");
|
||||
}
|
||||
|
||||
const nightlyVersionInput = options.nightlyVersionInput?.trim() ?? "";
|
||||
if (nightlyVersionInput.length === 0) {
|
||||
fail("OPEN_DESIGN_STABLE_NIGHTLY_VERSION is required when channel=stable; pass the exact validated nightly version");
|
||||
}
|
||||
|
||||
const nightly = parseNightlyVersion(nightlyVersionInput, "OPEN_DESIGN_STABLE_NIGHTLY_VERSION");
|
||||
if (nightly.baseVersion !== options.packagedVersion) {
|
||||
fail(
|
||||
`stable channel nightly gate requires base version ${options.packagedVersion}; got ${nightly.nightlyVersion}`,
|
||||
);
|
||||
}
|
||||
|
||||
const publicOrigin = trimTrailingSlash(
|
||||
options.publicOrigin ?? fail("OPEN_DESIGN_RELEASES_PUBLIC_ORIGIN is required when channel=stable"),
|
||||
);
|
||||
validateHttpsUrl(publicOrigin, "OPEN_DESIGN_RELEASES_PUBLIC_ORIGIN");
|
||||
|
||||
const expectedVersionPrefix = `nightly/versions/${nightly.nightlyVersion}`;
|
||||
const expectedVersionUrl = `${publicOrigin}/${expectedVersionPrefix}`;
|
||||
const metadataUrl = `${expectedVersionUrl}/metadata.json`;
|
||||
const metadataJson = await fetchOptionalHttpsText(metadataUrl);
|
||||
if (metadataJson == null) {
|
||||
fail(`required nightly metadata was not found: ${metadataUrl}`);
|
||||
}
|
||||
|
||||
const sourceName = "R2 stable-channel nightly metadata";
|
||||
const metadata = parseJsonRecord(metadataJson, sourceName);
|
||||
const parsedNightly = parseNightlyMetadataJson(metadataJson);
|
||||
if (parsedNightly.nightlyVersion !== nightly.nightlyVersion) {
|
||||
fail(`${sourceName}.nightlyVersion must be ${nightly.nightlyVersion}; got ${parsedNightly.nightlyVersion}`);
|
||||
}
|
||||
|
||||
expectStringField(metadata, "channel", "nightly", sourceName);
|
||||
expectStringField(metadata, "releaseVersion", nightly.nightlyVersion, sourceName);
|
||||
expectStringField(metadata, "nightlyVersion", nightly.nightlyVersion, sourceName);
|
||||
expectStringField(metadata, "baseVersion", options.packagedVersion, sourceName);
|
||||
expectStringField(metadata, "stableVersion", options.packagedVersion, sourceName);
|
||||
expectBooleanField(metadata, "signed", true, sourceName);
|
||||
|
||||
const github = requireObjectField(metadata, "github", sourceName);
|
||||
expectStringField(github, "branch", options.branch, `${sourceName}.github`);
|
||||
expectStringField(github, "commit", options.commit, `${sourceName}.github`);
|
||||
expectStringField(github, "repository", options.repository, `${sourceName}.github`);
|
||||
expectStringField(github, "workflow", "release-stable", `${sourceName}.github`);
|
||||
|
||||
const r2 = requireObjectField(metadata, "r2", sourceName);
|
||||
expectStringField(r2, "versionPrefix", expectedVersionPrefix, `${sourceName}.r2`);
|
||||
expectStringField(r2, "versionMetadataUrl", metadataUrl, `${sourceName}.r2`);
|
||||
expectStringField(r2, "reportZipUrl", `${expectedVersionUrl}/report.zip`, `${sourceName}.r2`);
|
||||
const report = requireObjectField(r2, "report", `${sourceName}.r2`);
|
||||
expectStringField(report, "type", "zip", `${sourceName}.r2.report`);
|
||||
expectStringField(report, "url", `${expectedVersionUrl}/report.zip`, `${sourceName}.r2.report`);
|
||||
|
||||
const platforms = requireObjectField(metadata, "platforms", sourceName);
|
||||
const mac = requireObjectField(platforms, "mac", `${sourceName}.platforms`);
|
||||
expectBooleanField(mac, "enabled", true, `${sourceName}.platforms.mac`);
|
||||
expectStringField(mac, "arch", "arm64", `${sourceName}.platforms.mac`);
|
||||
expectBooleanField(mac, "signed", true, `${sourceName}.platforms.mac`);
|
||||
const macArtifacts = requireObjectField(mac, "artifacts", `${sourceName}.platforms.mac`);
|
||||
const macDmg = requireObjectField(macArtifacts, "dmg", `${sourceName}.platforms.mac.artifacts`);
|
||||
requireVersionedUrlField(macDmg, "url", expectedVersionUrl, `${sourceName}.platforms.mac.artifacts.dmg`);
|
||||
requireVersionedUrlField(macDmg, "sha256Url", expectedVersionUrl, `${sourceName}.platforms.mac.artifacts.dmg`);
|
||||
const macZip = requireObjectField(macArtifacts, "zip", `${sourceName}.platforms.mac.artifacts`);
|
||||
requireVersionedUrlField(macZip, "url", expectedVersionUrl, `${sourceName}.platforms.mac.artifacts.zip`);
|
||||
requireVersionedUrlField(macZip, "sha256Url", expectedVersionUrl, `${sourceName}.platforms.mac.artifacts.zip`);
|
||||
|
||||
const macIntel = requireObjectField(platforms, "macIntel", `${sourceName}.platforms`);
|
||||
expectBooleanField(macIntel, "enabled", true, `${sourceName}.platforms.macIntel`);
|
||||
expectStringField(macIntel, "arch", "x64", `${sourceName}.platforms.macIntel`);
|
||||
expectBooleanField(macIntel, "signed", true, `${sourceName}.platforms.macIntel`);
|
||||
const macIntelArtifacts = requireObjectField(macIntel, "artifacts", `${sourceName}.platforms.macIntel`);
|
||||
const macIntelDmg = requireObjectField(macIntelArtifacts, "dmg", `${sourceName}.platforms.macIntel.artifacts`);
|
||||
requireVersionedUrlField(macIntelDmg, "url", expectedVersionUrl, `${sourceName}.platforms.macIntel.artifacts.dmg`);
|
||||
requireVersionedUrlField(macIntelDmg, "sha256Url", expectedVersionUrl, `${sourceName}.platforms.macIntel.artifacts.dmg`);
|
||||
const macIntelZip = requireObjectField(macIntelArtifacts, "zip", `${sourceName}.platforms.macIntel.artifacts`);
|
||||
requireVersionedUrlField(macIntelZip, "url", expectedVersionUrl, `${sourceName}.platforms.macIntel.artifacts.zip`);
|
||||
requireVersionedUrlField(macIntelZip, "sha256Url", expectedVersionUrl, `${sourceName}.platforms.macIntel.artifacts.zip`);
|
||||
|
||||
const win = requireObjectField(platforms, "win", `${sourceName}.platforms`);
|
||||
expectBooleanField(win, "enabled", true, `${sourceName}.platforms.win`);
|
||||
expectStringField(win, "arch", "x64", `${sourceName}.platforms.win`);
|
||||
const winArtifacts = requireObjectField(win, "artifacts", `${sourceName}.platforms.win`);
|
||||
const winInstaller = requireObjectField(winArtifacts, "installer", `${sourceName}.platforms.win.artifacts`);
|
||||
requireVersionedUrlField(winInstaller, "url", expectedVersionUrl, `${sourceName}.platforms.win.artifacts.installer`);
|
||||
requireVersionedUrlField(winInstaller, "sha256Url", expectedVersionUrl, `${sourceName}.platforms.win.artifacts.installer`);
|
||||
|
||||
return {
|
||||
metadataUrl,
|
||||
nightlyVersion: nightly.nightlyVersion,
|
||||
};
|
||||
}
|
||||
|
||||
async function readPackagedVersion(): Promise<string> {
|
||||
const packageJsonPath = join(process.cwd(), "apps", "packaged", "package.json");
|
||||
const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8")) as { version?: unknown };
|
||||
|
||||
if (typeof packageJson.version !== "string") {
|
||||
fail(`missing version in ${packageJsonPath}`);
|
||||
}
|
||||
|
||||
if (!stableVersionPattern.test(packageJson.version)) {
|
||||
fail(`apps/packaged/package.json version must be a stable x.y.z base version; got ${packageJson.version}`);
|
||||
}
|
||||
|
||||
return packageJson.version;
|
||||
}
|
||||
|
||||
async function fetchReleases(repository: string): Promise<GitHubRelease[]> {
|
||||
const releases: GitHubRelease[] = [];
|
||||
for (let page = 1; ; page += 1) {
|
||||
const { stdout } = await execFile("gh", ["api", `repos/${repository}/releases?per_page=100&page=${page}`]);
|
||||
const batch = JSON.parse(stdout) as GitHubRelease[];
|
||||
if (batch.length === 0) break;
|
||||
releases.push(...batch);
|
||||
}
|
||||
return releases;
|
||||
}
|
||||
|
||||
function fetchOptionalHttpsText(url: string, redirectCount = 0): Promise<string | null> {
|
||||
return new Promise((resolvePromise, reject) => {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol !== "https:") {
|
||||
reject(new Error(`expected HTTPS URL for nightly feed lookup: ${parsed.protocol}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const request = httpsGet(
|
||||
parsed,
|
||||
{
|
||||
headers: {
|
||||
"Cache-Control": "no-cache",
|
||||
},
|
||||
},
|
||||
(response) => {
|
||||
const statusCode = response.statusCode ?? 0;
|
||||
if (statusCode === 404) {
|
||||
response.resume();
|
||||
resolvePromise(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const location = response.headers.location;
|
||||
if (statusCode >= 300 && statusCode < 400 && typeof location === "string") {
|
||||
response.resume();
|
||||
if (redirectCount >= 3) {
|
||||
reject(new Error("too many redirects while reading nightly feed"));
|
||||
return;
|
||||
}
|
||||
const nextUrl = new URL(location, parsed).toString();
|
||||
fetchOptionalHttpsText(nextUrl, redirectCount + 1).then(resolvePromise, reject);
|
||||
return;
|
||||
}
|
||||
|
||||
if (statusCode < 200 || statusCode >= 300) {
|
||||
response.resume();
|
||||
reject(new Error(`nightly feed request failed with HTTP ${statusCode}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
response.on("data", (chunk: Buffer) => {
|
||||
chunks.push(chunk);
|
||||
});
|
||||
response.on("end", () => {
|
||||
resolvePromise(Buffer.concat(chunks).toString("utf8"));
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
request.setTimeout(10_000, () => {
|
||||
request.destroy(new Error("timed out while reading nightly feed"));
|
||||
});
|
||||
request.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function validateHttpsUrl(value: string, name: string): void {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(value);
|
||||
} catch {
|
||||
fail(`${name} must be an HTTPS URL; got ${value}`);
|
||||
}
|
||||
|
||||
if (parsed.protocol !== "https:") {
|
||||
fail(`${name} must be an HTTPS URL; got ${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
function setOutput(name: string, value: string): void {
|
||||
const outputPath = process.env.GITHUB_OUTPUT;
|
||||
if (outputPath == null || outputPath.length === 0) return;
|
||||
appendFileSync(outputPath, `${name}=${value}\n`);
|
||||
}
|
||||
|
||||
const repository = process.env.GITHUB_REPOSITORY ?? fail("GITHUB_REPOSITORY is required");
|
||||
const channel = parseChannel(process.env.OPEN_DESIGN_RELEASE_CHANNEL);
|
||||
const dryRun = parseBooleanInput(process.env.OPEN_DESIGN_RELEASE_DRY_RUN, "OPEN_DESIGN_RELEASE_DRY_RUN");
|
||||
const namespaces = releaseNamespaces(channel);
|
||||
const packagedVersion = await readPackagedVersion();
|
||||
const commit = process.env.GITHUB_SHA ?? "";
|
||||
const branch = process.env.GITHUB_REF_NAME ?? "";
|
||||
const stableBaseVersion = resolveStableBaseVersion(branch, process.env.OPEN_DESIGN_STABLE_VERSION);
|
||||
const packagedParsed = stableBaseVersion.parsed;
|
||||
if (stableBaseVersion.value !== packagedVersion) {
|
||||
fail(
|
||||
`${stableBaseVersion.source ?? "release base"} version ${stableBaseVersion.value} must match apps/packaged/package.json version ${packagedVersion}`,
|
||||
);
|
||||
}
|
||||
|
||||
const releases = await fetchReleases(repository);
|
||||
const versionTag = `open-design-v${packagedVersion}`;
|
||||
|
||||
let latestStable: ParsedStableVersion | null = null;
|
||||
for (const release of releases) {
|
||||
if (release.draft === true || release.prerelease === true) continue;
|
||||
|
||||
const parsedRelease = extractStableVersion(release);
|
||||
if (parsedRelease == null) continue;
|
||||
|
||||
if (release.tag_name === versionTag) {
|
||||
fail(`stable release ${versionTag} already exists; bump apps/packaged/package.json before publishing`);
|
||||
}
|
||||
|
||||
if (latestStable == null || compareVersions(parsedRelease.parsed, latestStable.parsed) > 0) {
|
||||
latestStable = parsedRelease;
|
||||
}
|
||||
}
|
||||
|
||||
if (latestStable != null && compareVersions(packagedParsed, latestStable.parsed) <= 0) {
|
||||
fail(`packaged stable version ${packagedVersion} must be strictly greater than latest stable ${latestStable.value}`);
|
||||
}
|
||||
|
||||
let releaseVersion = packagedVersion;
|
||||
let releaseName = `Open Design ${packagedVersion}`;
|
||||
let nightlyNumber = "";
|
||||
let stateSource = channel === "nightly" ? "R2 metadata.json" : "GitHub Releases";
|
||||
|
||||
if (channel === "nightly") {
|
||||
const metadataUrl = process.env.OPEN_DESIGN_NIGHTLY_METADATA_URL;
|
||||
if (metadataUrl == null || metadataUrl.length === 0) {
|
||||
fail("OPEN_DESIGN_NIGHTLY_METADATA_URL is required for nightly channel");
|
||||
}
|
||||
validateHttpsUrl(metadataUrl, "OPEN_DESIGN_NIGHTLY_METADATA_URL");
|
||||
|
||||
let nextNightlyNumber = 1;
|
||||
let latestNightly: ParsedNightlyVersion | null = null;
|
||||
const latestMetadataJson = await fetchOptionalHttpsText(metadataUrl);
|
||||
if (latestMetadataJson == null) {
|
||||
latestNightly = {
|
||||
baseVersion: packagedVersion,
|
||||
nightlyNumber: 0,
|
||||
nightlyVersion: `${packagedVersion}.nightly.0`,
|
||||
};
|
||||
stateSource = "missing R2 metadata.json fallback nightly.0";
|
||||
console.log("[release-stable] R2 nightly metadata.json: not found; using nightly.0 fallback");
|
||||
} else {
|
||||
latestNightly = parseNightlyMetadataJson(latestMetadataJson);
|
||||
console.log(`[release-stable] R2 nightly metadata.json version: ${latestNightly.nightlyVersion}`);
|
||||
}
|
||||
|
||||
const existingBase = parseStableVersion(latestNightly.baseVersion);
|
||||
if (existingBase == null) {
|
||||
fail(`invalid nightly base version in ${stateSource}: ${latestNightly.baseVersion}`);
|
||||
}
|
||||
|
||||
const ordering = compareVersions(packagedParsed, existingBase);
|
||||
if (ordering < 0) {
|
||||
fail(`packaged base version ${packagedVersion} regressed below current nightly base version ${latestNightly.baseVersion}`);
|
||||
}
|
||||
if (ordering === 0) {
|
||||
nextNightlyNumber = latestNightly.nightlyNumber + 1;
|
||||
}
|
||||
|
||||
nightlyNumber = String(nextNightlyNumber);
|
||||
releaseVersion = `${packagedVersion}.nightly.${nightlyNumber}`;
|
||||
releaseName = `Open Design Nightly ${releaseVersion}`;
|
||||
console.log(`[release-stable] latest nightly: ${latestNightly.nightlyVersion}`);
|
||||
} else if (process.env.OPEN_DESIGN_SKIP_STABLE_NIGHTLY_GATE === "true") {
|
||||
// Escape hatch (skip_nightly_gate input). The stable-promotion gate validates
|
||||
// a long list of fields on the candidate nightly's metadata; when a publisher
|
||||
// gap leaves one missing (e.g. r2.reportZipUrl is hard-coded null), the gate
|
||||
// blocks an otherwise-good release. This bypasses that validation for the run.
|
||||
// The stable build still produces freshly built + signed artifacts and still
|
||||
// runs publish-metadata.ts's own per-platform manifest consistency checks.
|
||||
const skippedNightly = (process.env.OPEN_DESIGN_STABLE_NIGHTLY_VERSION ?? "").trim();
|
||||
stateSource = skippedNightly.length > 0
|
||||
? `R2 nightly metadata ${skippedNightly} (gate skipped)`
|
||||
: "stable build (nightly gate skipped)";
|
||||
console.warn(
|
||||
`[release-stable] WARNING: stable nightly gate skipped via skip_nightly_gate; not validating nightly ${skippedNightly || "(none)"}`,
|
||||
);
|
||||
} else {
|
||||
const stableReleaseBranch = `release/v${stableBaseVersion.value}`;
|
||||
const stableNightly = await validateStableNightlyMetadata({
|
||||
branch: stableReleaseBranch,
|
||||
commit,
|
||||
nightlyVersionInput: process.env.OPEN_DESIGN_STABLE_NIGHTLY_VERSION,
|
||||
packagedVersion,
|
||||
publicOrigin: process.env.OPEN_DESIGN_RELEASES_PUBLIC_ORIGIN,
|
||||
repository,
|
||||
});
|
||||
stateSource = `R2 nightly metadata ${stableNightly.nightlyVersion}`;
|
||||
console.log(`[release-stable] validated nightly: ${stableNightly.nightlyVersion}`);
|
||||
console.log(`[release-stable] validated nightly metadata: ${stableNightly.metadataUrl}`);
|
||||
}
|
||||
|
||||
console.log(`[release-stable] channel: ${channel}`);
|
||||
console.log(`[release-stable] base version: ${packagedVersion}`);
|
||||
console.log(`[release-stable] release version: ${releaseVersion}`);
|
||||
console.log(`[release-stable] namespace: ${namespaces.mac}`);
|
||||
console.log(`[release-stable] dry run: ${String(dryRun)}`);
|
||||
if (channel === "stable") console.log(`[release-stable] version tag: ${versionTag}`);
|
||||
console.log(`[release-stable] state source: ${stateSource}`);
|
||||
if (latestStable != null) console.log(`[release-stable] previous stable: ${latestStable.value}`);
|
||||
|
||||
setOutput("base_version", packagedVersion);
|
||||
setOutput("branch", branch);
|
||||
setOutput("channel", channel);
|
||||
setOutput("commit", commit);
|
||||
setOutput("dry_run", dryRun ? "true" : "false");
|
||||
setOutput("github_release_enabled", channel === "stable" ? "true" : "false");
|
||||
setOutput("linux_namespace", namespaces.linux);
|
||||
setOutput("mac_intel_namespace", namespaces.macIntel);
|
||||
setOutput("namespace", namespaces.mac);
|
||||
setOutput("nightly_number", nightlyNumber);
|
||||
setOutput("previous_stable", latestStable?.value ?? "");
|
||||
setOutput("release_name", releaseName);
|
||||
setOutput("release_version", releaseVersion);
|
||||
setOutput("stable_version", packagedVersion);
|
||||
setOutput("state_source", stateSource);
|
||||
setOutput("version_tag", channel === "stable" ? versionTag : "");
|
||||
setOutput("win_namespace", namespaces.win);
|
||||
+24
-16
@@ -152,35 +152,39 @@ function changedPullRequestFiles(): string[] {
|
||||
throw new Error("pull_request event payload did not include pull_request.number");
|
||||
}
|
||||
|
||||
const stdout = execFileSync(
|
||||
"gh",
|
||||
["api", "--paginate", `repos/${repository}/pulls/${prNumber}/files`, "--jq", ".[].filename"],
|
||||
{ encoding: "utf8", stdio: ["ignore", "pipe", "inherit"] },
|
||||
);
|
||||
const stdout = runGh(["api", "--paginate", `repos/${repository}/pulls/${prNumber}/files`, "--jq", ".[].filename"]);
|
||||
return stdout.split(/\r?\n/).filter(Boolean);
|
||||
}
|
||||
|
||||
function changedManualFiles(): string[] {
|
||||
const repository = requiredEnv("GITHUB_REPOSITORY");
|
||||
const sha = requiredEnv("GITHUB_SHA");
|
||||
const stdout = execFileSync(
|
||||
"gh",
|
||||
[
|
||||
"api",
|
||||
"--paginate",
|
||||
`repos/${repository}/compare/main...${sha}`,
|
||||
"--jq",
|
||||
'(.files // [])[] | select(.status != "removed") | .filename',
|
||||
],
|
||||
{ encoding: "utf8", stdio: ["ignore", "pipe", "inherit"] },
|
||||
);
|
||||
const stdout = runGh([
|
||||
"api",
|
||||
"--paginate",
|
||||
`repos/${repository}/compare/main...${sha}`,
|
||||
"--jq",
|
||||
'(.files // [])[] | select(.status != "removed") | .filename',
|
||||
]);
|
||||
return stdout.split(/\r?\n/).filter(Boolean);
|
||||
}
|
||||
|
||||
function runGh(args: string[]): string {
|
||||
const nodeScript = process.env.OPEN_DESIGN_GH_NODE_SCRIPT;
|
||||
if (nodeScript != null && nodeScript.length > 0) {
|
||||
return execFileSync(process.execPath, [nodeScript, ...args], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "inherit"],
|
||||
});
|
||||
}
|
||||
return execFileSync("gh", args, { encoding: "utf8", stdio: ["ignore", "pipe", "inherit"] });
|
||||
}
|
||||
|
||||
function applyChangedFile(file: string, target: ScopeOutputs): void {
|
||||
if (
|
||||
startsWithAny(file, [
|
||||
"apps/daemon/",
|
||||
"packages/release/",
|
||||
"packages/contracts/",
|
||||
"packages/platform/",
|
||||
"packages/sidecar/",
|
||||
@@ -193,6 +197,7 @@ function applyChangedFile(file: string, target: ScopeOutputs): void {
|
||||
if (
|
||||
startsWithAny(file, [
|
||||
"apps/web/",
|
||||
"packages/release/",
|
||||
"packages/components/",
|
||||
"packages/contracts/",
|
||||
"packages/host/",
|
||||
@@ -218,6 +223,7 @@ function applyChangedFile(file: string, target: ScopeOutputs): void {
|
||||
"tools/pack/",
|
||||
"apps/packaged/",
|
||||
"apps/desktop/",
|
||||
"packages/release/",
|
||||
"packages/components/",
|
||||
"packages/host/",
|
||||
"packages/platform/",
|
||||
@@ -274,6 +280,7 @@ function isUiP0RelevantFile(file: string): boolean {
|
||||
startsWithAny(file, [
|
||||
"apps/web/",
|
||||
"apps/daemon/",
|
||||
"packages/release/",
|
||||
"packages/components/",
|
||||
"packages/contracts/",
|
||||
"packages/host/",
|
||||
@@ -344,6 +351,7 @@ function isNixRelevantFile(file: string): boolean {
|
||||
return (
|
||||
startsWithAny(file, [
|
||||
"nix/",
|
||||
"packages/release/",
|
||||
]) ||
|
||||
[
|
||||
"package.json",
|
||||
|
||||
Reference in New Issue
Block a user