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 { 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 { 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(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; }; 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("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 { 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("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("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`); }