247 lines · 7.2 KB · TypeScript
RawDownload
import { appendFileSync, existsSync, readFileSync } from "node:fs";import { dirname, join } from "node:path";import { fileURLToPath } from "node:url";import { $ } from "execa"; /** * Publish context. Resolved once per workflow run by the `context-output` CI * subcommand and passed through every subsequent step via GitHub Actions job * outputs + per-step flags. */export type Trigger = "branch" | "release"; export interface PublishContext {	trigger: Trigger;	/** Resolved version string, never null. */	version: string;	/** npm dist-tag. */	npmTag: string;	/** Short commit sha (7 chars). */	sha: string;	/** Only meaningful when trigger === "release". */	latest: boolean;	/** Branch name. Only set when trigger === "branch". */	branch?: string;	repoRoot: string;} /** Override set accepted by the local release cutter. */export interface ResolveOverrides {	trigger?: Trigger;	version?: string;	latest?: boolean;	branch?: string;	sha?: string;} function findRepoRoot(): string {	if (process.env.GITHUB_WORKSPACE && existsSync(process.env.GITHUB_WORKSPACE)) {		return process.env.GITHUB_WORKSPACE;	}	let dir = dirname(fileURLToPath(import.meta.url));	for (let i = 0; i < 10; i++) {		if (existsSync(join(dir, "pnpm-workspace.yaml"))) return dir;		dir = dirname(dir);	}	throw new Error("Could not locate repo root (no pnpm-workspace.yaml)");} /** * Base for all preview pre-release strings. Hardcoded to `0.0.0` so preview * versions like `0.0.0-my-branch.abc1234` never look like real releases and * always sort below any published `X.Y.Z`. Using a committed `package.json` * version as the base would just embed whatever stale number happened to be * committed there. It has no semantic relationship to the branch being * previewed. */const PREVIEW_BASE_VERSION = "0.0.0"; async function readShortSha(repoRoot: string): Promise<string> {	const envSha = process.env.GITHUB_SHA;	if (envSha) return envSha.slice(0, 7);	const { stdout } = await $({ cwd: repoRoot })`git rev-parse HEAD`;	return stdout.trim().slice(0, 7);} async function readBranchName(repoRoot: string): Promise<string> {	const envRef = process.env.GITHUB_REF_NAME;	if (envRef) return envRef;	const { stdout } = await $({		cwd: repoRoot,	})`git rev-parse --abbrev-ref HEAD`;	return stdout.trim();} /** * Sanitize a branch name into something safe to use as both an npm dist-tag * and a semver prerelease identifier. Lowercases, replaces any * non-alphanumeric character (other than hyphens) with a hyphen, collapses * runs of hyphens, and trims leading/trailing hyphens. */function sanitizeBranch(branch: string): string {	const cleaned = branch		.toLowerCase()		.replace(/[^a-z0-9-]+/g, "-")		.replace(/-+/g, "-")		.replace(/^-+|-+$/g, "");	if (cleaned.length === 0) {		throw new Error(`branch name "${branch}" sanitized to empty string`);	}	return cleaned;} function readInputFromEvent<T = unknown>(name: string): T | undefined {	const path = process.env.GITHUB_EVENT_PATH;	if (!path || !existsSync(path)) return undefined;	try {		const event = JSON.parse(readFileSync(path, "utf-8")) as {			inputs?: Record<string, unknown>;		};		const v = event.inputs?.[name];		return v as T | undefined;	} catch {		return undefined;	}} function parseBoolInput(v: unknown, fallback: boolean): boolean {	if (typeof v === "boolean") return v;	if (typeof v === "string") {		if (v === "true") return true;		if (v === "false") return false;	}	return fallback;} function deriveTrigger(overrides: ResolveOverrides | undefined): Trigger {	if (overrides?.trigger) return overrides.trigger;	const eventName = process.env.GITHUB_EVENT_NAME;	if (eventName === "workflow_dispatch") {		const version = readInputFromEvent<string>("version");		if (typeof version === "string" && version.length > 0) return "release";		return "branch";	}	// Default for local invocation without overrides (unusual): assume release	// so missing fields are caught loudly.	return "release";} function computeNpmTag(	trigger: Trigger,	version: string,	latest: boolean,	branch?: string,): string {	if (trigger === "branch") {		if (!branch) {			throw new Error("branch trigger requires branch to compute npm tag");		}		return sanitizeBranch(branch);	}	// release	if (version.includes("-rc.")) return "rc";	return latest ? "latest" : "next";} function computeVersion(	trigger: Trigger,	base: string,	sha: string,	branch: string | undefined,	overrideVersion: string | undefined,): string {	if (overrideVersion) return overrideVersion;	if (trigger === "branch") {		if (!branch) {			throw new Error("branch trigger requires branch to compute version");		}		return `${base}-${sanitizeBranch(branch)}.${sha}`;	}	throw new Error("release trigger requires an explicit version override");} /** * Resolve the publish context. Pure function of environment + overrides. * Not memoized: each subcommand process re-reads env, and the `context-output` * subcommand exists specifically so downstream steps receive stable values via * `$GITHUB_OUTPUT` / flags instead of re-resolving. */export async function resolveContext(	overrides: ResolveOverrides = {},): Promise<PublishContext> {	const repoRoot = findRepoRoot();	const trigger = deriveTrigger(overrides); 	const sha = overrides.sha ?? (await readShortSha(repoRoot)); 	let branch = overrides.branch;	if (trigger === "branch" && !branch) {		branch = await readBranchName(repoRoot);	} 	// Release version: override > workflow_dispatch input > error.	let version = overrides.version;	if (!version && trigger === "release") {		const input = readInputFromEvent<string>("version");		if (typeof input === "string" && input.length > 0) version = input;	} 	if (trigger !== "release") {		version = computeVersion(			trigger,			PREVIEW_BASE_VERSION,			sha,			branch,			version,		);	} else if (!version) {		throw new Error(			"release trigger requires version (pass --version or workflow_dispatch input)",		);	} 	// Latest: override > workflow_dispatch input > false.	let latest = overrides.latest;	if (latest === undefined) {		const input = readInputFromEvent<unknown>("latest");		latest = parseBoolInput(input, false);	}	if (trigger !== "release") latest = false; 	const npmTag = computeNpmTag(trigger, version, latest, branch); 	return {		trigger,		version,		npmTag,		sha,		latest,		branch,		repoRoot,	};} /** Write every context field to `$GITHUB_OUTPUT` so downstream steps read via needs.*. */export function writeContextToGithubOutput(ctx: PublishContext): void {	const path = process.env.GITHUB_OUTPUT;	if (!path) {		// When invoked locally for debugging, print to stdout in the same format.		console.log(`trigger=${ctx.trigger}`);		console.log(`version=${ctx.version}`);		console.log(`npm_tag=${ctx.npmTag}`);		console.log(`sha=${ctx.sha}`);		console.log(`latest=${ctx.latest}`);		if (ctx.branch !== undefined) console.log(`branch=${ctx.branch}`);		return;	}	const lines = [		`trigger=${ctx.trigger}`,		`version=${ctx.version}`,		`npm_tag=${ctx.npmTag}`,		`sha=${ctx.sha}`,		`latest=${ctx.latest}`,	];	if (ctx.branch !== undefined) lines.push(`branch=${ctx.branch}`);	// Append (do not overwrite) in case other steps also wrote to GITHUB_OUTPUT.	appendFileSync(path, `${lines.join("\n")}\n`);}