newspaper_wedding/.aidlc/tools/aidlc-channel.ts
Andrew Ridgway bec1eaac87
Some checks failed
Test / test (push) Has been cancelled
first pass at the newspaper builder
2026-09-14 11:57:22 +10:00

146 lines
5.7 KiB
TypeScript

// Release channels and the closed version-id grammar shared by the native
// lifecycle, the release scripts, and the tests. The preview marker is spelled
// here exactly once; the shell installers and the launcher shims carry their own
// literal next to a comment that names PREVIEW_CHANNEL.
//
// Stable ids are exactly `x.y.z`. Preview ids are
// `<x.y.z>-preview.<YYYYMMDD>.<N>`: the source tree's AIDLC_VERSION, the UTC
// build date, and a positive build counter for that date. Nothing else is a
// version anywhere in the install tree, so a version is always a safe directory
// name by construction.
import { AIDLC_VERSION } from "./aidlc-version.ts";
export const STABLE_CHANNEL = "stable";
export const PREVIEW_CHANNEL = "preview";
export type ReleaseChannel = typeof STABLE_CHANNEL | typeof PREVIEW_CHANNEL;
export const RELEASE_CHANNELS: readonly ReleaseChannel[] = [STABLE_CHANNEL, PREVIEW_CHANNEL];
// Environment variable that stamps a release build with a preview id. The
// packager renders every projected aidlc-version.ts copy and projection stamp
// from it; the source tree itself is never modified.
export const BUILD_VERSION_ENV = "AIDLC_BUILD_VERSION";
const NUMBER = "(?:0|[1-9]\\d*)";
const PREVIEW_SUFFIX = `-${PREVIEW_CHANNEL}\\.\\d{8}\\.[1-9]\\d*`;
// Unanchored, capture-free sources for callers that embed the grammar in a
// larger expression (reservation file names, PowerShell shim checks).
export const STABLE_VERSION_PATTERN = `${NUMBER}\\.${NUMBER}\\.${NUMBER}`;
export const PREVIEW_VERSION_PATTERN = `${STABLE_VERSION_PATTERN}${PREVIEW_SUFFIX}`;
export const VERSION_ID_PATTERN = `${STABLE_VERSION_PATTERN}(?:${PREVIEW_SUFFIX})?`;
export const STABLE_VERSION = new RegExp(`^${STABLE_VERSION_PATTERN}$`);
export const PREVIEW_VERSION = new RegExp(`^${PREVIEW_VERSION_PATTERN}$`);
export const VERSION_ID = new RegExp(`^${VERSION_ID_PATTERN}$`);
const PARSED_VERSION = new RegExp(
`^(${NUMBER})\\.(${NUMBER})\\.(${NUMBER})(?:-${PREVIEW_CHANNEL}\\.(\\d{8})\\.([1-9]\\d*))?$`,
);
export type ParsedVersion = {
base: string;
major: number;
minor: number;
patch: number;
channel: ReleaseChannel;
date?: string;
build?: number;
};
export function isReleaseChannel(value: unknown): value is ReleaseChannel {
return value === STABLE_CHANNEL || value === PREVIEW_CHANNEL;
}
export function requireReleaseChannel(value: string): ReleaseChannel {
if (!isReleaseChannel(value)) {
throw new Error(
`invalid release channel "${value}"; expected ${RELEASE_CHANNELS.join(" or ")}`,
);
}
return value;
}
export function requireVersion(value: string): string {
if (!VERSION_ID.test(value)) {
throw new Error(
`invalid version "${value}"; expected x.y.z or x.y.z-${PREVIEW_CHANNEL}.YYYYMMDD.N (for example 2.5.0)`,
);
}
return value;
}
export function parseVersion(value: string): ParsedVersion {
const match = PARSED_VERSION.exec(requireVersion(value));
if (!match) throw new Error(`invalid version "${value}"`);
const [, major, minor, patch, date, build] = match;
return {
base: `${major}.${minor}.${patch}`,
major: Number(major),
minor: Number(minor),
patch: Number(patch),
channel: date === undefined ? STABLE_CHANNEL : PREVIEW_CHANNEL,
...(date === undefined ? {} : { date, build: Number(build) }),
};
}
export function versionChannel(value: string): ReleaseChannel {
return parseVersion(value).channel;
}
// Total order over version ids. Numeric on x.y.z; at an equal base the stable
// release sorts after every preview built from that base (a preview is built
// from main before the same version is blessed); previews order by build date
// then build counter.
export function compareVersions(left: string, right: string): number {
const a = parseVersion(left);
const b = parseVersion(right);
for (const key of ["major", "minor", "patch"] as const) {
if (a[key] !== b[key]) return a[key] < b[key] ? -1 : 1;
}
if (a.channel !== b.channel) return a.channel === STABLE_CHANNEL ? 1 : -1;
if (a.channel === STABLE_CHANNEL) return 0;
if (a.date !== b.date) return (a.date as string) < (b.date as string) ? -1 : 1;
const aBuild = a.build as number;
const bBuild = b.build as number;
return aBuild === bBuild ? 0 : aBuild < bBuild ? -1 : 1;
}
export function utcBuildDate(now: Date = new Date()): string {
return now.toISOString().slice(0, 10).replaceAll("-", "");
}
export function previewVersion(base: string, date: string, build: number): string {
if (!STABLE_VERSION.test(base)) {
throw new Error(`preview base must be a stable x.y.z version, got "${base}"`);
}
if (!/^\d{8}$/.test(date)) {
throw new Error(`preview build date must be YYYYMMDD, got "${date}"`);
}
if (!Number.isSafeInteger(build) || build < 1) {
throw new Error(`preview build counter must be a positive integer, got ${String(build)}`);
}
return requireVersion(`${base}-${PREVIEW_CHANNEL}.${date}.${build}`);
}
// The version a release build stamps into its artifacts. Unset or equal to the
// source version means an ordinary stable build; a preview id must be built from
// exactly the source tree's AIDLC_VERSION so `x.y.z` in the id is never a lie.
export function releaseBuildVersion(env: NodeJS.ProcessEnv = process.env): string {
const configured = env[BUILD_VERSION_ENV]?.trim();
if (!configured || configured === AIDLC_VERSION) return AIDLC_VERSION;
const parsed = parseVersion(configured);
if (parsed.channel !== PREVIEW_CHANNEL) {
throw new Error(
`${BUILD_VERSION_ENV} must be unset, ${AIDLC_VERSION}, or a ${PREVIEW_CHANNEL} id built from it; got "${configured}"`,
);
}
if (parsed.base !== AIDLC_VERSION) {
throw new Error(
`${BUILD_VERSION_ENV} "${configured}" is not built from source version ${AIDLC_VERSION}`,
);
}
return configured;
}