From 2954e72a7d14b2f1a28599a833a5ce5ce1b5cd8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sun, 23 Aug 2026 19:48:31 +0000 Subject: [PATCH 01/11] refactor(release): centralize publishable package roster --- .github/workflows/publish.yml | 62 +---------- scripts/publish-packages.test.ts | 118 +++++++++++++++++++++ scripts/publish-packages.ts | 135 ++++++++++++++++++++++++ scripts/release-packages.test.ts | 140 +++++++++++++++++++++++++ scripts/release-packages.ts | 174 +++++++++++++++++++++++++++++++ scripts/set-version.ts | 29 ++---- 6 files changed, 577 insertions(+), 81 deletions(-) create mode 100644 scripts/publish-packages.test.ts create mode 100644 scripts/publish-packages.ts create mode 100644 scripts/release-packages.test.ts create mode 100644 scripts/release-packages.ts diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 657c33e924..0335e28451 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -127,67 +127,7 @@ jobs: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} VERSION: ${{ steps.version.outputs.version }} DIST_TAG: ${{ steps.version.outputs.dist_tag }} - run: | - FAILED=0 - - publish_pkg() { - local filter="$1" - local name="$2" - - # Check if this version is already published - if npm view "${name}@${VERSION}" version >/dev/null 2>&1; then - echo "⏭️ ${name}@${VERSION} already published — skipping" - return 0 - fi - - echo "📦 Publishing ${name}@${VERSION}..." - if pnpm --filter "$filter" publish --access public --no-git-checks --tag "$DIST_TAG"; then - echo "✅ ${name}@${VERSION} published" - else - echo "❌ ${name}@${VERSION} failed to publish" - FAILED=1 - fi - } - - publish_pkg "@hyperframes/parsers" "@hyperframes/parsers" - publish_pkg "@hyperframes/lint" "@hyperframes/lint" - publish_pkg "@hyperframes/studio-server" "@hyperframes/studio-server" - publish_pkg "@hyperframes/core" "@hyperframes/core" - publish_pkg "@hyperframes/sdk" "@hyperframes/sdk" - publish_pkg "@hyperframes/engine" "@hyperframes/engine" - publish_pkg "@hyperframes/player" "@hyperframes/player" - publish_pkg "@hyperframes/producer" "@hyperframes/producer" - publish_pkg "@hyperframes/shader-transitions" "@hyperframes/shader-transitions" - publish_pkg "@hyperframes/studio" "@hyperframes/studio" - publish_pkg "@hyperframes/aws-lambda" "@hyperframes/aws-lambda" - publish_pkg "@hyperframes/gcp-cloud-run" "@hyperframes/gcp-cloud-run" - - # CLI is @hyperframes/cli in the monorepo but published as unscoped "hyperframes" on npm. - # Rewrite the name in package.json before publishing, then use npm publish directly - # since pnpm --filter won't match the rewritten name. - if npm view "hyperframes@${VERSION}" version >/dev/null 2>&1; then - echo "⏭️ hyperframes@${VERSION} already published — skipping" - else - node -e " - const fs = require('fs'); - const p = 'packages/cli/package.json'; - const pkg = JSON.parse(fs.readFileSync(p, 'utf8')); - pkg.name = 'hyperframes'; - fs.writeFileSync(p, JSON.stringify(pkg, null, 2) + '\n'); - " - echo "📦 Publishing hyperframes@${VERSION}..." - if (cd packages/cli && npm publish --access public --tag "$DIST_TAG"); then - echo "✅ hyperframes@${VERSION} published" - else - echo "❌ hyperframes@${VERSION} failed to publish" - FAILED=1 - fi - fi - - if [ "$FAILED" -ne 0 ]; then - echo "::error::One or more packages failed to publish" - exit 1 - fi + run: node --import tsx scripts/publish-packages.ts - name: Create GitHub Release env: diff --git a/scripts/publish-packages.test.ts b/scripts/publish-packages.test.ts new file mode 100644 index 0000000000..734c676208 --- /dev/null +++ b/scripts/publish-packages.test.ts @@ -0,0 +1,118 @@ +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, it } from "node:test"; +import { runPublishPackages, type PublishCommand } from "./publish-packages.ts"; +import { type PublishablePackage } from "./release-packages.ts"; + +const normal: PublishablePackage = { + workspacePath: "packages/core", + workspaceName: "@hyperframes/core", + npmName: "@hyperframes/core", + publishMode: "workspace", +}; +const cli: PublishablePackage = { + workspacePath: "packages/cli", + workspaceName: "@hyperframes/cli", + npmName: "hyperframes", + publishMode: "manifest-name-override", +}; + +function fixture(): string { + const root = mkdtempSync(join(tmpdir(), "hf-publish-packages-")); + writeFileSync(join(root, "package.json"), JSON.stringify({ workspaces: ["packages/*"] })); + for (const entry of [normal, cli]) { + mkdirSync(join(root, entry.workspacePath), { recursive: true }); + writeFileSync( + join(root, entry.workspacePath, "package.json"), + `${JSON.stringify({ name: entry.workspaceName, version: "1.2.3" }, null, 2)}\n`, + ); + } + return root; +} + +// fallow-ignore-next-line unit-size +describe("shared package publisher", () => { + it("skips existing versions and publishes missing workspaces in roster order", async () => { + const root = fixture(); + const calls: string[] = []; + const command: PublishCommand = async (executable, args, cwd) => { + calls.push(`${executable} ${args.join(" ")} @ ${cwd}`); + }; + try { + const result = await runPublishPackages( + { root, version: "1.2.3", distTag: "latest", roster: [normal, cli] }, + { + packageExists: async (name) => name === normal.npmName, + command, + log: () => undefined, + }, + ); + assert.deepEqual(result, { + published: ["hyperframes"], + skipped: ["@hyperframes/core"], + failed: [], + }); + assert.deepEqual(calls, [ + `npm publish --access public --tag latest @ ${join(root, "packages/cli")}`, + ]); + assert.equal( + JSON.parse(readFileSync(join(root, "packages/cli/package.json"), "utf8")).name, + "@hyperframes/cli", + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("aggregates failures, continues, and restores CLI manifest bytes", async () => { + const root = fixture(); + const cliManifest = join(root, "packages/cli/package.json"); + const original = readFileSync(cliManifest, "utf8"); + const calls: string[] = []; + try { + await assert.rejects( + runPublishPackages( + { root, version: "1.2.3", distTag: "latest", roster: [normal, cli] }, + { + packageExists: async () => false, + command: async (executable, args) => { + calls.push(`${executable} ${args.join(" ")}`); + throw new Error("publish failed"); + }, + log: () => undefined, + }, + ), + /@hyperframes\/core, hyperframes/, + ); + assert.equal(calls.length, 2); + assert.equal(readFileSync(cliManifest, "utf8"), original); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("fails reconciliation before any npm boundary when a public workspace is omitted", async () => { + const root = fixture(); + let calls = 0; + try { + await assert.rejects( + runPublishPackages( + { root, version: "1.2.3", distTag: "latest", roster: [normal] }, + { + packageExists: async () => false, + command: async () => { + calls += 1; + }, + log: () => undefined, + }, + ), + /public workspace.*@hyperframes\/cli.*missing/i, + ); + assert.equal(calls, 0); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/scripts/publish-packages.ts b/scripts/publish-packages.ts new file mode 100644 index 0000000000..e57071d2ed --- /dev/null +++ b/scripts/publish-packages.ts @@ -0,0 +1,135 @@ +#!/usr/bin/env tsx +import { execFile } from "node:child_process"; +import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { pathToFileURL } from "node:url"; +import { + PUBLISHABLE_PACKAGES, + validatePublishablePackages, + type PublishablePackage, +} from "./release-packages.ts"; + +const execFileAsync = promisify(execFile); + +export type PublishCommand = ( + executable: string, + args: readonly string[], + cwd: string, +) => Promise; +type PublishDependencies = { + packageExists: (npmName: string, version: string) => Promise; + command: PublishCommand; + log: (message: string) => void; +}; +type PublishInput = { + root: string; + version: string; + distTag: string; + roster?: readonly PublishablePackage[]; +}; +type PublishResult = { published: string[]; skipped: string[]; failed: string[] }; + +// The two allowed publish modes converge here so CLI restoration cannot be bypassed. +// fallow-ignore-next-line complexity +async function publishEntry( + root: string, + entry: PublishablePackage, + distTag: string, + command: PublishCommand, +): Promise { + if (entry.publishMode === "workspace") { + await command( + "pnpm", + [ + "--filter", + entry.workspaceName, + "publish", + "--access", + "public", + "--no-git-checks", + "--tag", + distTag, + ], + root, + ); + return; + } + const workspace = join(root, entry.workspacePath); + const manifestPath = join(workspace, "package.json"); + const original = readFileSync(manifestPath, "utf8"); + const manifest: unknown = JSON.parse(original); + if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) + throw new Error(`Invalid package manifest at ${manifestPath}.`); + try { + writeFileSync( + manifestPath, + `${JSON.stringify({ ...manifest, name: entry.npmName }, null, 2)}\n`, + ); + await command("npm", ["publish", "--access", "public", "--tag", distTag], workspace); + } finally { + writeFileSync(manifestPath, original); + } +} + +// Continue-and-aggregate is deliberate: every missing package gets one attempt per rerun. +// fallow-ignore-next-line complexity +export async function runPublishPackages( + input: PublishInput, + dependencies: PublishDependencies, +): Promise { + const roster = validatePublishablePackages(input.root, input.roster ?? PUBLISHABLE_PACKAGES); + const result: PublishResult = { published: [], skipped: [], failed: [] }; + for (const entry of roster) { + if (await dependencies.packageExists(entry.npmName, input.version)) { + dependencies.log(`⏭️ ${entry.npmName}@${input.version} already published — skipping`); + result.skipped.push(entry.npmName); + continue; + } + dependencies.log(`📦 Publishing ${entry.npmName}@${input.version}...`); + try { + await publishEntry(input.root, entry, input.distTag, dependencies.command); + dependencies.log(`✅ ${entry.npmName}@${input.version} published`); + result.published.push(entry.npmName); + } catch { + dependencies.log(`❌ ${entry.npmName}@${input.version} failed to publish`); + result.failed.push(entry.npmName); + } + } + if (result.failed.length > 0) + throw new Error(`Packages failed to publish: ${result.failed.join(", ")}`); + return result; +} + +const defaultDependencies: PublishDependencies = { + packageExists: async (npmName, version) => { + try { + await execFileAsync("npm", ["view", `${npmName}@${version}`, "version"]); + return true; + } catch { + return false; + } + }, + command: async (executable, args, cwd) => { + await execFileAsync(executable, [...args], { cwd }); + }, + log: console.log, +}; + +// fallow-ignore-next-line complexity +async function main(): Promise { + const version = process.env.VERSION ?? ""; + const distTag = process.env.DIST_TAG ?? ""; + if (!version || !distTag) throw new Error("VERSION and DIST_TAG are required."); + await runPublishPackages( + { root: join(import.meta.dirname, ".."), version, distTag }, + defaultDependencies, + ); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error: unknown) => { + console.error(`::error::${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + }); +} diff --git a/scripts/release-packages.test.ts b/scripts/release-packages.test.ts new file mode 100644 index 0000000000..a824cfb53e --- /dev/null +++ b/scripts/release-packages.test.ts @@ -0,0 +1,140 @@ +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, it } from "node:test"; +import { + PUBLISHABLE_PACKAGES, + discoverWorkspacePackages, + validatePublishablePackages, + type PublishablePackage, +} from "./release-packages.ts"; + +function writeManifest(root: string, path: string, manifest: Record): void { + mkdirSync(join(root, path), { recursive: true }); + writeFileSync(join(root, path, "package.json"), `${JSON.stringify(manifest, null, 2)}\n`); +} + +function fixture(): string { + const root = mkdtempSync(join(tmpdir(), "hf-release-packages-")); + writeFileSync( + join(root, "package.json"), + `${JSON.stringify({ private: true, workspaces: ["packages/*"] }, null, 2)}\n`, + ); + for (const entry of PUBLISHABLE_PACKAGES) { + writeManifest(root, entry.workspacePath, { name: entry.workspaceName, version: "1.0.0" }); + } + writeManifest(root, "packages/private-tool", { + name: "@hyperframes/private-tool", + version: "1.0.0", + private: true, + }); + return root; +} + +// One fixture-backed matrix documents the complete reconciliation contract. +// fallow-ignore-next-line unit-size +describe("publishable package roster", () => { + it("covers every public workspace exactly once and excludes private workspaces", () => { + const root = fixture(); + try { + const discovered = discoverWorkspacePackages(root); + assert.equal(discovered.filter((entry) => !entry.private).length, 13); + assert.deepEqual(validatePublishablePackages(root), PUBLISHABLE_PACKAGES); + assert.equal( + PUBLISHABLE_PACKAGES.find((entry) => entry.workspaceName === "@hyperframes/cli")?.npmName, + "hyperframes", + ); + assert.equal( + PUBLISHABLE_PACKAGES.find((entry) => entry.workspaceName === "@hyperframes/cli") + ?.publishMode, + "manifest-name-override", + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("mutation-pins a public workspace omitted from the roster", () => { + const root = fixture(); + try { + const withoutSdk = PUBLISHABLE_PACKAGES.filter( + (entry) => entry.workspaceName !== "@hyperframes/sdk", + ); + assert.throws( + () => validatePublishablePackages(root, withoutSdk), + /public workspace.*@hyperframes\/sdk.*missing/i, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("fails closed for duplicate paths, workspace names, and npm names", () => { + const root = fixture(); + const first = PUBLISHABLE_PACKAGES[0]!; + try { + for (const duplicate of [ + { ...PUBLISHABLE_PACKAGES[1]!, workspacePath: first.workspacePath }, + { ...PUBLISHABLE_PACKAGES[1]!, workspaceName: first.workspaceName }, + { ...PUBLISHABLE_PACKAGES[1]!, npmName: first.npmName }, + ]) { + const roster: PublishablePackage[] = [first, duplicate]; + assert.throws(() => validatePublishablePackages(root, roster), /duplicate/i); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("rejects missing manifests, name mismatches, private entries, and invalid mappings", () => { + const root = fixture(); + const first = PUBLISHABLE_PACKAGES[0]!; + try { + assert.throws( + () => + validatePublishablePackages(root, [ + { ...first, workspacePath: "packages/does-not-exist" }, + ]), + /manifest.*does not exist/i, + ); + + const manifestPath = join(root, first.workspacePath, "package.json"); + const original = readFileSync(manifestPath, "utf8"); + writeFileSync(manifestPath, original.replace(first.workspaceName, "@hyperframes/wrong")); + assert.throws(() => validatePublishablePackages(root), /workspace name mismatch/i); + writeFileSync(manifestPath, original); + + writeManifest(root, first.workspacePath, { + name: first.workspaceName, + version: "1.0.0", + private: true, + }); + assert.throws(() => validatePublishablePackages(root), /private.*roster/i); + writeFileSync(manifestPath, original); + + assert.throws( + () => + validatePublishablePackages(root, [{ ...first, npmName: "renamed-without-override" }]), + /mapping.*manifest-name-override/i, + ); + const invalidOverride: PublishablePackage = { + ...first, + npmName: "renamed-core", + publishMode: "manifest-name-override", + }; + assert.throws( + () => + validatePublishablePackages( + root, + PUBLISHABLE_PACKAGES.map((entry) => + entry.workspaceName === first.workspaceName ? invalidOverride : entry, + ), + ), + /only.*@hyperframes\/cli.*hyperframes/i, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/scripts/release-packages.ts b/scripts/release-packages.ts new file mode 100644 index 0000000000..8dd8f11a8b --- /dev/null +++ b/scripts/release-packages.ts @@ -0,0 +1,174 @@ +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; + +export type PublishMode = "workspace" | "manifest-name-override"; + +export type PublishablePackage = { + workspacePath: string; + workspaceName: string; + npmName: string; + publishMode: PublishMode; +}; + +export type DiscoveredWorkspace = { + workspacePath: string; + workspaceName: string; + private: boolean; +}; + +function packageEntry( + workspacePath: string, + workspaceName: string, + npmName: string, + publishMode: PublishMode = "workspace", +): PublishablePackage { + return { workspacePath, workspaceName, npmName, publishMode }; +} + +/** Ordered release roster shared by versioning and npm publication. */ +export const PUBLISHABLE_PACKAGES: readonly PublishablePackage[] = [ + packageEntry("packages/parsers", "@hyperframes/parsers", "@hyperframes/parsers"), + packageEntry("packages/lint", "@hyperframes/lint", "@hyperframes/lint"), + packageEntry( + "packages/studio-server", + "@hyperframes/studio-server", + "@hyperframes/studio-server", + ), + packageEntry("packages/core", "@hyperframes/core", "@hyperframes/core"), + packageEntry("packages/sdk", "@hyperframes/sdk", "@hyperframes/sdk"), + packageEntry("packages/engine", "@hyperframes/engine", "@hyperframes/engine"), + packageEntry("packages/player", "@hyperframes/player", "@hyperframes/player"), + packageEntry("packages/producer", "@hyperframes/producer", "@hyperframes/producer"), + packageEntry( + "packages/shader-transitions", + "@hyperframes/shader-transitions", + "@hyperframes/shader-transitions", + ), + packageEntry("packages/studio", "@hyperframes/studio", "@hyperframes/studio"), + packageEntry("packages/aws-lambda", "@hyperframes/aws-lambda", "@hyperframes/aws-lambda"), + packageEntry( + "packages/gcp-cloud-run", + "@hyperframes/gcp-cloud-run", + "@hyperframes/gcp-cloud-run", + ), + packageEntry("packages/cli", "@hyperframes/cli", "hyperframes", "manifest-name-override"), +]; + +type WorkspaceManifest = { name?: unknown; private?: unknown; workspaces?: unknown }; + +function readManifest(path: string): WorkspaceManifest { + const value: unknown = JSON.parse(readFileSync(path, "utf8")); + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`Package manifest ${path} must contain a JSON object.`); + } + return value; +} + +// Fail-closed workspace-pattern validation is intentionally kept at discovery's single boundary. +// fallow-ignore-next-line complexity +function configuredWorkspaceDirectories(root: string): string[] { + const workspaces = readManifest(join(root, "package.json")).workspaces; + if (!Array.isArray(workspaces) || !workspaces.every((value) => typeof value === "string")) { + throw new Error("Root package.json must define an array of workspaces."); + } + const directories: string[] = []; + for (const pattern of workspaces) { + if (!pattern.endsWith("/*") || pattern.slice(0, -2).includes("*")) { + throw new Error(`Unsupported workspace pattern ${JSON.stringify(pattern)}.`); + } + const parent = pattern.slice(0, -2); + for (const entry of readdirSync(join(root, parent), { withFileTypes: true })) { + if (entry.isDirectory()) directories.push(join(parent, entry.name)); + } + } + return directories.sort(); +} + +export function discoverWorkspacePackages(root: string): DiscoveredWorkspace[] { + return configuredWorkspaceDirectories(root).map((workspacePath) => { + const manifestPath = join(root, workspacePath, "package.json"); + if (!existsSync(manifestPath)) + throw new Error(`Workspace manifest does not exist: ${manifestPath}`); + const manifest = readManifest(manifestPath); + if (typeof manifest.name !== "string" || manifest.name.length === 0) { + throw new Error(`Workspace manifest ${manifestPath} has no package name.`); + } + return { workspacePath, workspaceName: manifest.name, private: manifest.private === true }; + }); +} + +function rejectDuplicates(roster: readonly PublishablePackage[], key: keyof PublishablePackage) { + const seen = new Set(); + for (const entry of roster) { + const value = entry[key]; + if (seen.has(value)) throw new Error(`Duplicate ${key} in publish roster: ${value}`); + seen.add(value); + } +} + +// One reconciliation pass owns every roster invariant so consumers cannot validate subsets. +// fallow-ignore-next-line complexity +export function validatePublishablePackages( + root: string, + roster: readonly PublishablePackage[] = PUBLISHABLE_PACKAGES, +): readonly PublishablePackage[] { + rejectDuplicates(roster, "workspacePath"); + rejectDuplicates(roster, "workspaceName"); + rejectDuplicates(roster, "npmName"); + const discovered = discoverWorkspacePackages(root); + const byPath = new Map(discovered.map((workspace) => [workspace.workspacePath, workspace])); + + for (const entry of roster) { + const manifestPath = join(root, entry.workspacePath, "package.json"); + if (!existsSync(manifestPath)) + throw new Error(`Package manifest does not exist: ${manifestPath}`); + const workspace = byPath.get(entry.workspacePath); + if (!workspace) + throw new Error(`Roster path is not a configured workspace: ${entry.workspacePath}`); + if (workspace.workspaceName !== entry.workspaceName) { + throw new Error( + `Workspace name mismatch at ${entry.workspacePath}: expected ${entry.workspaceName}, found ${workspace.workspaceName}.`, + ); + } + if (workspace.private) + throw new Error(`Private workspace cannot be a publish roster entry: ${entry.workspaceName}`); + if (entry.publishMode === "workspace" && entry.npmName !== entry.workspaceName) { + throw new Error( + `Package name mapping for ${entry.workspaceName} requires manifest-name-override mode.`, + ); + } + if (entry.publishMode === "manifest-name-override" && entry.npmName === entry.workspaceName) { + throw new Error( + `manifest-name-override requires a distinct npm name for ${entry.workspaceName}.`, + ); + } + if ( + entry.publishMode === "manifest-name-override" && + (entry.workspacePath !== "packages/cli" || + entry.workspaceName !== "@hyperframes/cli" || + entry.npmName !== "hyperframes") + ) { + throw new Error( + "Only @hyperframes/cli may use manifest-name-override to publish as hyperframes.", + ); + } + if (entry.publishMode !== "workspace" && entry.publishMode !== "manifest-name-override") { + throw new Error(`Invalid publish mode for ${entry.workspaceName}.`); + } + } + + const rosterNames = new Set(roster.map((entry) => entry.workspaceName)); + for (const workspace of discovered) { + if (!workspace.private && !rosterNames.has(workspace.workspaceName)) { + throw new Error( + `Public workspace ${workspace.workspaceName} is missing from the publish roster.`, + ); + } + if (workspace.private && rosterNames.has(workspace.workspaceName)) { + throw new Error( + `Private workspace ${workspace.workspaceName} cannot be in the publish roster.`, + ); + } + } + return roster; +} diff --git a/scripts/set-version.ts b/scripts/set-version.ts index 150d3e1db7..c3183591bb 100644 --- a/scripts/set-version.ts +++ b/scripts/set-version.ts @@ -19,22 +19,7 @@ import { join } from "path"; import { execFileSync } from "child_process"; import { pathToFileURL } from "url"; import { CLI_SEMVER_PATTERN } from "./cli-options.ts"; - -const PACKAGES = [ - "packages/parsers", - "packages/lint", - "packages/studio-server", - "packages/core", - "packages/engine", - "packages/player", - "packages/producer", - "packages/shader-transitions", - "packages/studio", - "packages/cli", - "packages/aws-lambda", - "packages/gcp-cloud-run", - "packages/sdk", -]; +import { validatePublishablePackages } from "./release-packages.ts"; const PLUGINS = [".claude-plugin", ".codex-plugin", ".cursor-plugin"]; @@ -67,7 +52,7 @@ function main() { updatePluginVersions(options.version); console.log( - `\nSet ${PACKAGES.length} packages and ${PLUGINS.length} plugin manifests to v${options.version}`, + `\nSet ${publishablePackages().length} packages and ${PLUGINS.length} plugin manifests to v${options.version}`, ); if (options.skipTag) { @@ -102,8 +87,8 @@ export function parseReleaseOptions(args: string[]): ReleaseOptions { } function updatePackageVersions(version: string) { - for (const pkg of PACKAGES) { - const pkgPath = join(ROOT, pkg, "package.json"); + for (const pkg of publishablePackages()) { + const pkgPath = join(ROOT, pkg.workspacePath, "package.json"); const content = JSON.parse(readFileSync(pkgPath, "utf-8")); const oldVersion = content.version; content.version = version; @@ -310,13 +295,17 @@ export function docsChangelogEntryHasGeneratedTodo(content: string, marker: stri export function releaseAllowedPaths(version: string) { return [ - ...PACKAGES.map((pkg) => join(pkg, "package.json")), + ...publishablePackages().map((pkg) => join(pkg.workspacePath, "package.json")), ...PLUGINS.map((plugin) => join(plugin, "plugin.json")), "docs/changelog.mdx", join("releases", `v${version}.md`), ]; } +function publishablePackages() { + return validatePublishablePackages(ROOT); +} + // Collect every uncommitted path (modified-tracked + untracked) as clean, // repo-relative paths. We deliberately use `diff --name-only` and `ls-files` // with `-z` rather than parsing `git status --porcelain`: the porcelain From ecb34c62f82e0d7646db479a3d075b66dd135598 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sun, 23 Aug 2026 19:56:03 +0000 Subject: [PATCH 02/11] fix(release): gate stable publish on reviewed green merge SHA --- .github/workflows/publish.yml | 10 + package.json | 2 +- scripts/publish-workflow.test.mjs | 21 ++ scripts/stable-release-guard.mjs | 463 ++++++++++++++++++++++++++ scripts/stable-release-guard.test.mjs | 462 +++++++++++++++++++++++++ 5 files changed, 957 insertions(+), 1 deletion(-) create mode 100644 scripts/stable-release-guard.mjs create mode 100644 scripts/stable-release-guard.test.mjs diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 0335e28451..11ef68ceb8 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -19,8 +19,11 @@ jobs: timeout-minutes: 10 environment: npm-publish permissions: + actions: read + checks: read contents: write id-token: write + pull-requests: read env: EXPECTED_RELEASE_SHA: >- ${{ github.event_name == 'pull_request' @@ -83,6 +86,13 @@ jobs: PR_HEAD_REF: ${{ github.event.pull_request.head.ref }} run: node scripts/validate-release-channel.mjs + - name: Guard stable release + if: github.event_name == 'pull_request' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ steps.version.outputs.version }} + run: node scripts/stable-release-guard.mjs + - name: Create release tag if: github.event_name == 'pull_request' env: diff --git a/package.json b/package.json index 504e5315c3..8388832bdf 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "player:perf": "bun run --filter @hyperframes/player perf", "format:check": "oxfmt --check .", "knip": "knip", - "test:scripts": "node --import tsx --test scripts/animejs-v4-guidance.test.mjs scripts/check-tracked-artifacts.test.mjs scripts/check-no-main-deletions.test.mjs scripts/check-docs-snippet-motion.test.mjs scripts/registry-target-paths.test.mjs scripts/check-workspace-contracts.test.mjs scripts/check-package-cycles.test.mjs scripts/check-cli-process-ownership.test.mjs scripts/package-subpaths.test.mjs scripts/validate-release-channel.test.mjs scripts/publish-workflow.test.mjs scripts/install-workspace-dependencies.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/catalog-payload-assets.test.ts scripts/catalog-preview-temp.test.ts scripts/player-cdn-pin.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs scripts/lint-skills.test.mjs packages/gcp-cloud-run/check-dockerfile-workspaces.test.mjs && vitest run scripts/catalog/", + "test:scripts": "node --import tsx --test scripts/release-packages.test.ts scripts/publish-packages.test.ts scripts/stable-release-guard.test.mjs scripts/animejs-v4-guidance.test.mjs scripts/check-tracked-artifacts.test.mjs scripts/check-no-main-deletions.test.mjs scripts/check-docs-snippet-motion.test.mjs scripts/registry-target-paths.test.mjs scripts/check-workspace-contracts.test.mjs scripts/check-package-cycles.test.mjs scripts/check-cli-process-ownership.test.mjs scripts/package-subpaths.test.mjs scripts/validate-release-channel.test.mjs scripts/publish-workflow.test.mjs scripts/install-workspace-dependencies.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/catalog-payload-assets.test.ts scripts/catalog-preview-temp.test.ts scripts/player-cdn-pin.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs scripts/lint-skills.test.mjs packages/gcp-cloud-run/check-dockerfile-workspaces.test.mjs && vitest run scripts/catalog/", "typecheck:scripts": "tsc --noEmit -p scripts/tsconfig.json", "test:skills": "node --test 'skills/**/*.test.mjs'", "generate:previews": "tsx scripts/generate-template-previews.ts", diff --git a/scripts/publish-workflow.test.mjs b/scripts/publish-workflow.test.mjs index 051543daff..28b9b301df 100644 --- a/scripts/publish-workflow.test.mjs +++ b/scripts/publish-workflow.test.mjs @@ -14,6 +14,8 @@ const checkoutGuard = publish.steps.find( (step) => step.name === "Verify immutable release checkout", ); const createReleaseTag = publish.steps.find((step) => step.name === "Create release tag"); +const stableGuard = publish.steps.find((step) => step.name === "Guard stable release"); +const publishPackages = publish.steps.find((step) => step.name === "Publish packages"); const normalizeExpression = (expression) => expression.replace(/\s+/g, " ").trim(); @@ -99,6 +101,25 @@ test("stable release tag recovery is idempotent and immutable", () => { ); }); +test("the stable guard precedes every irreversible release side effect", () => { + assert.ok(stableGuard); + assert.equal(stableGuard.if, "github.event_name == 'pull_request'"); + assert.equal(stableGuard["continue-on-error"], undefined); + assert.match(stableGuard.run, /stable-release-guard\.mjs/); + assert.ok(publish.steps.indexOf(stableGuard) < publish.steps.indexOf(createReleaseTag)); + assert.ok(publish.steps.indexOf(stableGuard) < publish.steps.indexOf(publishPackages)); + assert.equal(publish.permissions.actions, "read"); + assert.equal(publish.permissions.checks, "read"); + assert.equal(publish.permissions["pull-requests"], "read"); +}); + +test("the workflow invokes one shared publisher and owns no package roster", () => { + assert.ok(publishPackages); + assert.equal(publishPackages.run.trim(), "node --import tsx scripts/publish-packages.ts"); + assert.doesNotMatch(workflow, /@hyperframes\//); + assert.doesNotMatch(workflow, /publish_pkg|packages\/cli/); +}); + test("stable release tag creation survives retries and rejects a mismatched commit", () => { const root = mkdtempSync(join(tmpdir(), "hyperframes-release-tag-test-")); const origin = join(root, "origin.git"); diff --git a/scripts/stable-release-guard.mjs b/scripts/stable-release-guard.mjs new file mode 100644 index 0000000000..0bd36c2aa1 --- /dev/null +++ b/scripts/stable-release-guard.mjs @@ -0,0 +1,463 @@ +#!/usr/bin/env node +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; + +export const DEFAULT_TIMEOUT_MS = 8 * 60 * 1_000; +export const INITIAL_BACKOFF_MS = 5_000; +export const MAX_BACKOFF_MS = 30_000; + +const PASSING_CONCLUSIONS = new Set(["success", "neutral", "skipped"]); +const TERMINAL_FAILURE_CONCLUSIONS = new Set([ + "action_required", + "cancelled", + "failure", + "stale", + "startup_failure", + "timed_out", + "error", +]); +const DECISIVE_REVIEW_STATES = new Set(["APPROVED", "CHANGES_REQUESTED", "DISMISSED"]); +const REVIEW_STATES = new Set([...DECISIVE_REVIEW_STATES, "COMMENTED"]); +const NON_CHECK_RULES = new Set([ + "creation", + "update", + "deletion", + "required_linear_history", + "required_signatures", + "non_fast_forward", + "commit_message_pattern", + "commit_author_email_pattern", + "committer_email_pattern", + "branch_name_pattern", + "tag_name_pattern", +]); + +function requiredString(value, label) { + if (typeof value !== "string" || value.length === 0) throw new Error(`Missing ${label}.`); + return value; +} + +function requiredNumber(value, label) { + if (!Number.isInteger(value)) throw new Error(`Missing ${label}.`); + return value; +} + +// fallow-ignore-next-line complexity +function pullIdentity(pull, label) { + if (!pull || typeof pull !== "object") throw new Error(`Missing ${label}.`); + return { + number: requiredNumber(pull.number, `${label} number`), + merged: pull.merged === true, + baseRef: requiredString(pull.base?.ref, `${label} base ref`), + headRef: requiredString(pull.head?.ref, `${label} head ref`), + headSha: requiredString(pull.head?.sha, `${label} head SHA`), + mergeSha: requiredString(pull.merge_commit_sha, `${label} merge SHA`), + authorLogin: requiredString(pull.user?.login, `${label} author`), + }; +} + +// All identity comparisons stay together so no caller can omit one. +// fallow-ignore-next-line complexity +export function assertImmutableRelease({ + event, + apiPull, + expectedSha, + githubSha, + checkoutSha, + version, +}) { + if (event?.action !== "closed") + throw new Error("Stable release event must be pull_request closed."); + const fromEvent = pullIdentity(event.pull_request, "event pull request"); + const fromApi = pullIdentity(apiPull, "API pull request"); + if (!fromEvent.merged || !fromApi.merged) throw new Error("Stable release PR must be merged."); + if (fromEvent.baseRef !== "main" || fromApi.baseRef !== "main") + throw new Error("Stable release PR must target main."); + const expectedBranch = `release/v${version}`; + if (fromEvent.headRef !== expectedBranch || fromApi.headRef !== expectedBranch) { + throw new Error(`Stable release branch mismatch: expected ${expectedBranch}.`); + } + const identities = [ + ["PR number", fromEvent.number, fromApi.number], + ["head SHA", fromEvent.headSha, fromApi.headSha], + ["merge SHA", fromEvent.mergeSha, fromApi.mergeSha], + ["author", fromEvent.authorLogin, fromApi.authorLogin], + ["EXPECTED_RELEASE_SHA", fromEvent.mergeSha, expectedSha], + ["GITHUB_SHA", fromEvent.mergeSha, githubSha], + ["checked-out HEAD", fromEvent.mergeSha, checkoutSha], + ]; + for (const [label, expected, actual] of identities) { + if (expected !== actual) + throw new Error(`${label} mismatch: expected ${expected}, got ${actual}.`); + } + return fromEvent; +} + +function reviewOrder(review) { + return [Date.parse(review.submitted_at), review.id]; +} + +// Every decisive review field is validated together so malformed records cannot be partially used. +// fallow-ignore-next-line complexity +function validateReview(review) { + const state = typeof review?.state === "string" ? review.state.toUpperCase() : ""; + if ( + !Number.isInteger(review?.id) || + typeof review?.user?.login !== "string" || + review.user.login.length === 0 || + !REVIEW_STATES.has(state) || + typeof review?.submitted_at !== "string" || + !Number.isFinite(Date.parse(review.submitted_at)) || + typeof review?.commit_id !== "string" || + review.commit_id.length === 0 + ) { + throw new Error( + `Malformed review record: reviewer=${String(review?.user?.login)} state=${String(review?.state)} head=${String(review?.commit_id)}.`, + ); + } + return { ...review, state }; +} + +// The decisive-review reducer mirrors GitHub's per-reviewer state semantics. +// fallow-ignore-next-line complexity +export function collectEffectiveApprovals({ reviews, authorLogin, headSha, requiredCount }) { + if (!Array.isArray(reviews)) throw new Error("Review API response is malformed."); + const latestByReviewer = new Map(); + for (const candidate of reviews) { + const review = validateReview(candidate); + const { state } = review; + const login = review.user.login; + if (!DECISIVE_REVIEW_STATES.has(state)) continue; + const existing = latestByReviewer.get(login.toLowerCase()); + const [time, id] = reviewOrder(review); + const [existingTime, existingId] = existing ? reviewOrder(existing) : [-1, -1]; + if (!existing || time > existingTime || (time === existingTime && id > existingId)) { + latestByReviewer.set(login.toLowerCase(), review); + } + } + const approvals = [...latestByReviewer.values()] + .filter( + (review) => + review.state.toUpperCase() === "APPROVED" && + review.commit_id === headSha && + review.user.login.toLowerCase() !== authorLogin.toLowerCase(), + ) + .map((review) => review.user.login) + .sort(); + const minimum = Math.max(1, Number.isInteger(requiredCount) ? requiredCount : 0); + if (approvals.length < minimum) { + throw new Error( + `Final release head requires ${minimum} valid non-author approval(s); found ${approvals.length}.`, + ); + } + return approvals; +} + +// Unknown effective gates must fail closed rather than disappear during parsing. +// fallow-ignore-next-line complexity +export function extractEffectiveRules(rules) { + if (!Array.isArray(rules)) throw new Error("Repository rules API response is malformed."); + const requiredChecks = []; + let requiredApprovals = 0; + let requireLastPushApproval = false; + let requireExtraApprovalForUnattributedChanges = false; + let requireSignedCommits = false; + for (const rule of rules) { + if (rule?.type === "required_status_checks") { + const checks = rule.parameters?.required_status_checks; + if (!Array.isArray(checks)) throw new Error("Required status checks rule is malformed."); + for (const check of checks) { + if (typeof check?.context !== "string" || !Number.isInteger(check.integration_id)) { + throw new Error("Required status check identity is malformed."); + } + requiredChecks.push({ context: check.context, integrationId: check.integration_id }); + } + continue; + } + if (rule?.type === "pull_request") { + const count = rule.parameters?.required_approving_review_count; + if (!Number.isInteger(count)) throw new Error("Pull request approval rule is malformed."); + requiredApprovals = Math.max(requiredApprovals, count); + requireLastPushApproval ||= rule.parameters.require_last_push_approval === true; + requireExtraApprovalForUnattributedChanges ||= + rule.parameters.require_extra_approval_for_unattributed_changes === true; + continue; + } + if (rule?.type === "required_signatures") { + requireSignedCommits = true; + continue; + } + if (!NON_CHECK_RULES.has(rule?.type)) { + throw new Error(`Unsupported effective repository rule: ${String(rule?.type)}.`); + } + } + if (requiredChecks.length === 0) throw new Error("No required status checks found for main."); + const unique = new Map( + requiredChecks.map((check) => [`${check.context}:${check.integrationId}`, check]), + ); + return { + requiredApprovals, + requiredChecks: [...unique.values()], + requireLastPushApproval, + requireExtraApprovalForUnattributedChanges, + requireSignedCommits, + }; +} + +// The exact update identity and outcome form one indivisible authoritative gate. +// fallow-ignore-next-line complexity +function assertRuleSuitePass(suite, mergeSha) { + if ( + suite?.afterSha !== mergeSha || + suite?.ref !== "refs/heads/main" || + suite?.result !== "pass" + ) { + throw new Error( + `Rule suite for ${mergeSha} must be pass, got after=${String(suite?.afterSha)} ref=${String(suite?.ref)} result=${String(suite?.result)}.`, + ); + } +} + +// fallow-ignore-next-line complexity +function checkOrder(check) { + return [ + Date.parse(check.started_at ?? "") || 0, + Date.parse(check.completed_at ?? "") || 0, + Number(check.id) || 0, + ]; +} + +function isNewer(candidate, existing) { + const left = checkOrder(candidate); + const right = checkOrder(existing); + for (let index = 0; index < left.length; index += 1) { + if (left[index] !== right[index]) return left[index] > right[index]; + } + return false; +} + +function belongsToRun(check, runId) { + return Boolean(runId) && String(check.details_url ?? "").includes(`/actions/runs/${runId}/`); +} + +// Required-context classification remains one exhaustive outcome reducer. +// fallow-ignore-next-line complexity +export function evaluateRequiredChecks({ requiredChecks, checkRuns, currentRunId }) { + if (!Array.isArray(checkRuns)) + return { kind: "api-failure", summary: "Check-runs response is malformed." }; + const details = []; + for (const requirement of requiredChecks) { + const matching = checkRuns.filter( + (check) => + check?.name === requirement.context && check?.app?.id === requirement.integrationId, + ); + const latest = matching.reduce( + (selected, candidate) => (!selected || isNewer(candidate, selected) ? candidate : selected), + null, + ); + if (!latest) { + details.push(`${requirement.context}: missing`); + continue; + } + if (belongsToRun(latest, currentRunId)) { + return { + kind: "self-reference", + summary: `${requirement.context} resolves to current publish run ${currentRunId}.`, + }; + } + if (latest.status !== "completed") { + details.push(`${requirement.context}: ${latest.status}`); + continue; + } + if (PASSING_CONCLUSIONS.has(latest.conclusion)) continue; + if (TERMINAL_FAILURE_CONCLUSIONS.has(latest.conclusion) || latest.conclusion == null) { + return { + kind: "terminal-failure", + summary: `${requirement.context}: ${String(latest.conclusion)}`, + }; + } + return { + kind: "terminal-failure", + summary: `${requirement.context}: unsupported conclusion ${latest.conclusion}`, + }; + } + return details.length > 0 + ? { kind: "pending", summary: details.join(", ") } + : { kind: "passing", summary: "All required checks are terminal green." }; +} + +// fallow-ignore-next-line complexity +export async function runStableReleaseGuard({ + event, + expectedSha, + githubSha, + checkoutSha, + version, + currentRunId, + client, + now, + sleep, + timeoutMs, + initialBackoffMs, + maxBackoffMs, + log, +}) { + const deadline = now() + timeoutMs; + const remainingBudget = (phase) => { + const remaining = deadline - now(); + if (remaining <= 0) throw new Error(`Timed out during stable release guard (${phase}).`); + return remaining; + }; + const apiPull = await client.getPull(event.pull_request.number, remainingBudget("pull request")); + const identity = assertImmutableRelease({ + event, + apiPull, + expectedSha, + githubSha, + checkoutSha, + version, + }); + const ruleSuite = await client.getRuleSuite(identity.mergeSha, remainingBudget("rule suite")); + assertRuleSuitePass(ruleSuite, identity.mergeSha); + const rules = await client.getEffectiveRules("main", remainingBudget("effective rules")); + const reviews = await client.listReviews(identity.number, remainingBudget("reviews")); + const approvals = collectEffectiveApprovals({ + reviews, + authorLogin: identity.authorLogin, + headSha: identity.headSha, + requiredCount: rules.requiredApprovals, + }); + log( + `Stable release identity verified: PR #${identity.number} head=${identity.headSha} merge=${identity.mergeSha}.`, + ); + log(`Valid final-head approvals: ${approvals.join(", ")}.`); + log( + `Effective PR rules: last-push=${rules.requireLastPushApproval === true} unattributed=${rules.requireExtraApprovalForUnattributedChanges === true} signatures=${rules.requireSignedCommits === true}; exact update rule suite passed.`, + ); + + let backoff = initialBackoffMs; + while (true) { + const checkRuns = await client.listCheckRuns( + identity.mergeSha, + remainingBudget("required checks"), + ); + const outcome = evaluateRequiredChecks({ + requiredChecks: rules.requiredChecks, + checkRuns, + currentRunId, + }); + log(`Required checks: ${outcome.summary}`); + if (outcome.kind === "passing") return; + if (outcome.kind !== "pending") + throw new Error(`Stable release checks failed: ${outcome.summary}`); + const remaining = deadline - now(); + if (remaining <= 0) + throw new Error( + `Timed out waiting for required checks on ${identity.mergeSha}: ${outcome.summary}`, + ); + await sleep(Math.min(backoff, remaining)); + backoff = Math.min(maxBackoffMs, backoff * 2); + } +} + +function createGitHubClient({ repository, token }) { + const request = async (path, requestBudgetMs) => { + const response = await fetch(`https://api.github.com${path}`, { + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "X-GitHub-Api-Version": "2022-11-28", + }, + signal: AbortSignal.timeout(Math.max(1, requestBudgetMs)), + }); + if (!response.ok) throw new Error(`GitHub API ${response.status} for ${path}.`); + return response.json(); + }; + // fallow-ignore-next-line complexity + const paginate = async (path, itemKey, requestBudgetMs) => { + const items = []; + const deadline = Date.now() + requestBudgetMs; + for (let page = 1; ; page += 1) { + const separator = path.includes("?") ? "&" : "?"; + const remaining = deadline - Date.now(); + if (remaining <= 0) throw new Error(`GitHub API pagination timed out for ${path}.`); + const response = await request(`${path}${separator}per_page=100&page=${page}`, remaining); + const pageItems = itemKey ? response?.[itemKey] : response; + if (!Array.isArray(pageItems)) + throw new Error(`GitHub API pagination response is malformed for ${path}.`); + items.push(...pageItems); + if (pageItems.length < 100) return items; + } + }; + return { + getPull: (number, requestBudgetMs) => + request(`/repos/${repository}/pulls/${number}`, requestBudgetMs), + listReviews: (number, requestBudgetMs) => + paginate(`/repos/${repository}/pulls/${number}/reviews`, null, requestBudgetMs), + getRuleSuite: async (sha, requestBudgetMs) => { + const suites = await paginate( + `/repos/${repository}/rulesets/rule-suites?ref=${encodeURIComponent("refs/heads/main")}`, + null, + requestBudgetMs, + ); + const matching = suites.filter((suite) => suite?.after_sha === sha); + if (matching.length !== 1) { + throw new Error( + `Expected one rule suite for main update ${sha}, found ${matching.length}.`, + ); + } + return { + afterSha: matching[0].after_sha, + ref: matching[0].ref, + result: matching[0].result, + }; + }, + getEffectiveRules: async (branch, requestBudgetMs) => + extractEffectiveRules( + await request( + `/repos/${repository}/rules/branches/${encodeURIComponent(branch)}`, + requestBudgetMs, + ), + ), + listCheckRuns: (sha, requestBudgetMs) => + paginate( + `/repos/${repository}/commits/${sha}/check-runs?filter=all`, + "check_runs", + requestBudgetMs, + ), + }; +} + +async function main() { + const eventPath = requiredString(process.env.GITHUB_EVENT_PATH, "GITHUB_EVENT_PATH"); + const repository = requiredString(process.env.GITHUB_REPOSITORY, "GITHUB_REPOSITORY"); + const token = requiredString(process.env.GH_TOKEN, "GH_TOKEN"); + const expectedSha = requiredString(process.env.EXPECTED_RELEASE_SHA, "EXPECTED_RELEASE_SHA"); + const githubSha = requiredString(process.env.GITHUB_SHA, "GITHUB_SHA"); + const version = requiredString(process.env.VERSION, "VERSION"); + const currentRunId = requiredString(process.env.GITHUB_RUN_ID, "GITHUB_RUN_ID"); + const event = JSON.parse(readFileSync(eventPath, "utf8")); + const checkoutSha = execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).trim(); + await runStableReleaseGuard({ + event, + expectedSha, + githubSha, + checkoutSha, + version, + currentRunId, + client: createGitHubClient({ repository, token }), + now: Date.now, + sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)), + timeoutMs: DEFAULT_TIMEOUT_MS, + initialBackoffMs: INITIAL_BACKOFF_MS, + maxBackoffMs: MAX_BACKOFF_MS, + log: console.log, + }); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + main().catch((error) => { + console.error(`::error::${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + }); +} diff --git a/scripts/stable-release-guard.test.mjs b/scripts/stable-release-guard.test.mjs new file mode 100644 index 0000000000..317aa5a08a --- /dev/null +++ b/scripts/stable-release-guard.test.mjs @@ -0,0 +1,462 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + assertImmutableRelease, + collectEffectiveApprovals, + evaluateRequiredChecks, + extractEffectiveRules, + runStableReleaseGuard, +} from "./stable-release-guard.mjs"; + +const headSha = "1".repeat(40); +const mergeSha = "2".repeat(40); + +function releaseEvent(overrides = {}) { + return { + action: "closed", + pull_request: { + number: 42, + merged: true, + base: { ref: "main" }, + head: { ref: "release/v1.2.3", sha: headSha }, + merge_commit_sha: mergeSha, + user: { login: "release-author" }, + }, + ...overrides, + }; +} + +function pull(overrides = {}) { + return { + number: 42, + merged: true, + base: { ref: "main" }, + head: { ref: "release/v1.2.3", sha: headSha }, + merge_commit_sha: mergeSha, + user: { login: "release-author" }, + ...overrides, + }; +} + +function review(overrides = {}) { + return { + id: 1, + user: { login: "reviewer" }, + state: "APPROVED", + commit_id: headSha, + submitted_at: "2026-08-23T18:00:00Z", + ...overrides, + }; +} + +const requiredChecks = [ + { context: "Build", integrationId: 15368 }, + { context: "Test", integrationId: 15368 }, +]; + +function check(name, overrides = {}) { + return { + id: name === "Build" ? 100 : 200, + name, + app: { id: 15368 }, + status: "completed", + conclusion: "success", + started_at: "2026-08-23T18:00:00Z", + completed_at: "2026-08-23T18:01:00Z", + details_url: "https://github.com/heygen-com/hyperframes/actions/runs/99/job/1", + ...overrides, + }; +} + +describe("immutable stable release identity", () => { + it("accepts one exact merged release PR identity", () => { + assert.doesNotThrow(() => + assertImmutableRelease({ + event: releaseEvent(), + apiPull: pull(), + expectedSha: mergeSha, + githubSha: mergeSha, + checkoutSha: mergeSha, + version: "1.2.3", + }), + ); + }); + + it("rejects non-merged, wrong-base, wrong-branch, and any SHA/version mismatch", () => { + const base = { + event: releaseEvent(), + apiPull: pull(), + expectedSha: mergeSha, + githubSha: mergeSha, + checkoutSha: mergeSha, + version: "1.2.3", + }; + for (const mutation of [ + { event: releaseEvent({ action: "opened" }) }, + { apiPull: pull({ merged: false }) }, + { apiPull: pull({ base: { ref: "next" } }) }, + { apiPull: pull({ head: { ref: "feature/not-release", sha: headSha } }) }, + { expectedSha: "3".repeat(40) }, + { githubSha: "3".repeat(40) }, + { checkoutSha: "3".repeat(40) }, + { version: "1.2.4" }, + ]) { + assert.throws( + () => assertImmutableRelease({ ...base, ...mutation }), + /release|mismatch|merged|main/i, + ); + } + }); +}); + +describe("effective final-head approvals", () => { + it("requires the ruleset count of effective non-author final-head approvals", () => { + assert.deepEqual( + collectEffectiveApprovals({ + reviews: [review(), review({ id: 2, user: { login: "second" } })], + authorLogin: "release-author", + headSha, + requiredCount: 2, + }), + ["reviewer", "second"], + ); + }); + + it("fails zero, author-only, old-head, dismissed, and later change-requested reviews", () => { + for (const reviews of [ + [], + [review({ user: { login: "release-author" } })], + [review({ commit_id: "0".repeat(40) })], + [review(), review({ id: 2, state: "DISMISSED", submitted_at: "2026-08-23T18:02:00Z" })], + [ + review(), + review({ id: 2, state: "CHANGES_REQUESTED", submitted_at: "2026-08-23T18:02:00Z" }), + ], + ]) { + assert.throws( + () => + collectEffectiveApprovals({ + reviews, + authorLogin: "release-author", + headSha, + requiredCount: 1, + }), + /approval/i, + ); + } + }); + + it("fails closed on malformed decisive review records", () => { + for (const malformed of [ + review({ id: "not-a-number" }), + review({ submitted_at: "not-a-date" }), + review({ user: {} }), + review({ commit_id: null }), + review({ state: "UNKNOWN" }), + ]) { + assert.throws( + () => + collectEffectiveApprovals({ + reviews: [malformed, review({ id: 99, user: { login: "valid" } })], + authorLogin: "release-author", + headSha, + requiredCount: 1, + }), + /malformed review/i, + ); + } + }); +}); + +// fallow-ignore-next-line unit-size +describe("latest required checks", () => { + it("accepts success, neutral, and skipped while ignoring irrelevant checks", () => { + const outcome = evaluateRequiredChecks({ + requiredChecks, + checkRuns: [ + check("Build"), + check("Test", { conclusion: "neutral" }), + check("Docs", { conclusion: "failure" }), + ], + currentRunId: "777", + }); + assert.equal(outcome.kind, "passing"); + }); + + it("uses only the newest attempt for duplicate contexts", () => { + for (const [newestStatus, newestConclusion, expectedKind] of [ + ["in_progress", null, "pending"], + ["completed", "failure", "terminal-failure"], + ["completed", "success", "passing"], + ]) { + const outcome = evaluateRequiredChecks({ + requiredChecks: [requiredChecks[0]], + checkRuns: [ + check("Build", { id: 1, started_at: "2026-08-23T18:00:00Z" }), + check("Build", { + id: 2, + status: newestStatus, + conclusion: newestConclusion, + started_at: "2026-08-23T18:02:00Z", + completed_at: newestStatus === "completed" ? "2026-08-23T18:03:00Z" : null, + }), + ], + currentRunId: "777", + }); + assert.equal(outcome.kind, expectedKind); + } + assert.equal( + evaluateRequiredChecks({ + requiredChecks: [requiredChecks[0]], + checkRuns: [ + check("Build", { id: 999, started_at: "2026-08-23T17:00:00Z" }), + check("Build", { id: 2, started_at: "2026-08-23T18:00:00Z" }), + ], + currentRunId: "777", + }).kind, + "passing", + ); + }); + + it("fails terminal conclusions and identifies missing, pending, and self-referential checks", () => { + for (const conclusion of [ + "failure", + "cancelled", + "timed_out", + "action_required", + "stale", + "startup_failure", + ]) { + assert.equal( + evaluateRequiredChecks({ + requiredChecks: [requiredChecks[0]], + checkRuns: [check("Build", { conclusion })], + currentRunId: "777", + }).kind, + "terminal-failure", + ); + } + assert.equal( + evaluateRequiredChecks({ requiredChecks, checkRuns: [], currentRunId: "777" }).kind, + "pending", + ); + assert.equal( + evaluateRequiredChecks({ + requiredChecks: [requiredChecks[0]], + checkRuns: [check("Build", { status: "queued", conclusion: null })], + currentRunId: "777", + }).kind, + "pending", + ); + assert.equal( + evaluateRequiredChecks({ + requiredChecks: [requiredChecks[0]], + checkRuns: [ + check("Build", { + details_url: "https://github.com/heygen-com/hyperframes/actions/runs/777/job/1", + }), + ], + currentRunId: "777", + }).kind, + "self-reference", + ); + }); +}); + +describe("effective repository rules", () => { + it("extracts required contexts with integration identity and the approval count", () => { + assert.deepEqual( + extractEffectiveRules([ + { + type: "required_status_checks", + parameters: { + required_status_checks: [ + { context: "Build", integration_id: 15368 }, + { context: "Test", integration_id: 15368 }, + ], + }, + }, + { + type: "pull_request", + parameters: { + required_approving_review_count: 2, + require_last_push_approval: true, + require_extra_approval_for_unattributed_changes: true, + }, + }, + { type: "required_signatures" }, + ]), + { + requiredApprovals: 2, + requiredChecks, + requireLastPushApproval: true, + requireExtraApprovalForUnattributedChanges: true, + requireSignedCommits: true, + }, + ); + }); + + it("fails closed for malformed or unsupported gate rules", () => { + assert.throws(() => extractEffectiveRules([]), /required status checks/i); + assert.throws( + () => extractEffectiveRules([{ type: "required_status_checks", parameters: {} }]), + /malformed/i, + ); + assert.throws(() => extractEffectiveRules([{ type: "required_deployments" }]), /unsupported/i); + }); +}); + +function fakeClient(checkResponses, overrides = {}) { + let index = 0; + return { + getPull: async () => pull(), + getRuleSuite: async () => ({ afterSha: mergeSha, ref: "refs/heads/main", result: "pass" }), + listReviews: async () => [review()], + getEffectiveRules: async () => ({ requiredApprovals: 1, requiredChecks }), + listCheckRuns: async () => checkResponses[Math.min(index++, checkResponses.length - 1)], + ...overrides, + }; +} + +// fallow-ignore-next-line unit-size +describe("stable release polling guard", () => { + it("passes all-green and pending-then-green cases", async () => { + for (const responses of [ + [[check("Build"), check("Test")]], + [ + [check("Build", { status: "queued", conclusion: null }), check("Test")], + [check("Build"), check("Test")], + ], + ]) { + let now = 0; + await assert.doesNotReject(() => + runStableReleaseGuard({ + event: releaseEvent(), + expectedSha: mergeSha, + githubSha: mergeSha, + checkoutSha: mergeSha, + version: "1.2.3", + currentRunId: "777", + client: fakeClient(responses), + now: () => now, + sleep: async (ms) => { + now += ms; + }, + timeoutMs: 100, + initialBackoffMs: 10, + maxBackoffMs: 20, + log: () => undefined, + }), + ); + } + }); + + it("fails closed for timeout, terminal failure, API error, approval failure, and immutable mismatch", async () => { + const pending = [check("Build", { status: "queued", conclusion: null }), check("Test")]; + const base = { + event: releaseEvent(), + expectedSha: mergeSha, + githubSha: mergeSha, + checkoutSha: mergeSha, + version: "1.2.3", + currentRunId: "777", + now: () => nowValue, + sleep: async (ms) => { + nowValue += ms; + }, + timeoutMs: 50, + initialBackoffMs: 10, + maxBackoffMs: 20, + log: () => undefined, + }; + let nowValue = 0; + await assert.rejects( + runStableReleaseGuard({ ...base, client: fakeClient([pending]) }), + /timed out/i, + ); + await assert.rejects( + runStableReleaseGuard({ + ...base, + client: fakeClient([[check("Build", { conclusion: "failure" }), check("Test")]]), + }), + /failed/i, + ); + await assert.rejects( + runStableReleaseGuard({ + ...base, + client: fakeClient([], { + getEffectiveRules: async () => { + throw new Error("API down"); + }, + }), + }), + /API down/, + ); + await assert.rejects( + runStableReleaseGuard({ + ...base, + client: fakeClient([], { listReviews: async () => [] }), + }), + /approval/i, + ); + await assert.rejects( + runStableReleaseGuard({ + ...base, + githubSha: "3".repeat(40), + client: fakeClient([[check("Build"), check("Test")]]), + }), + /mismatch/i, + ); + await assert.rejects( + runStableReleaseGuard({ + ...base, + client: fakeClient([[check("Build"), check("Test")]], { + getRuleSuite: async () => ({ + afterSha: mergeSha, + ref: "refs/heads/main", + result: "bypass", + }), + }), + }), + /rule suite.*bypass/i, + ); + }); + + it("clamps every wait and API request to the hard deadline", async () => { + let nowValue = 0; + const sleeps = []; + const requestBudgets = []; + const client = fakeClient( + [[check("Build", { status: "queued", conclusion: null }), check("Test")]], + { + listCheckRuns: async (_sha, budget) => { + requestBudgets.push(budget); + return [check("Build", { status: "queued", conclusion: null }), check("Test")]; + }, + }, + ); + await assert.rejects( + runStableReleaseGuard({ + event: releaseEvent(), + expectedSha: mergeSha, + githubSha: mergeSha, + checkoutSha: mergeSha, + version: "1.2.3", + currentRunId: "777", + client, + now: () => nowValue, + sleep: async (ms) => { + sleeps.push(ms); + nowValue += ms; + }, + timeoutMs: 25, + initialBackoffMs: 20, + maxBackoffMs: 30, + log: () => undefined, + }), + /timed out/i, + ); + assert.deepEqual(sleeps, [20, 5]); + assert.ok(requestBudgets.every((budget) => budget > 0 && budget <= 25)); + }); +}); From 4c6e53132e39135c582dcdd36def605145de6bc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sun, 23 Aug 2026 20:28:05 +0000 Subject: [PATCH 03/11] fix(release): make stable guard timeout configurable --- .github/workflows/publish.yml | 4 +++- docs/contributing/release-channels.mdx | 11 ++++++++++ scripts/publish-workflow.test.mjs | 17 ++++++++++++++++ scripts/stable-release-guard.mjs | 28 ++++++++++++++++++++++++-- scripts/stable-release-guard.test.mjs | 15 ++++++++++++++ 5 files changed, 72 insertions(+), 3 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 11ef68ceb8..721219c288 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -16,7 +16,7 @@ jobs: publish: name: Publish runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 60 environment: npm-publish permissions: actions: read @@ -90,6 +90,8 @@ jobs: if: github.event_name == 'pull_request' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + STABLE_RELEASE_GUARD_TIMEOUT_MINUTES: >- + ${{ vars.STABLE_RELEASE_GUARD_TIMEOUT_MINUTES || '25' }} VERSION: ${{ steps.version.outputs.version }} run: node scripts/stable-release-guard.mjs diff --git a/docs/contributing/release-channels.mdx b/docs/contributing/release-channels.mdx index 09239099b0..7ec1f79cfd 100644 --- a/docs/contributing/release-channels.mdx +++ b/docs/contributing/release-channels.mdx @@ -69,3 +69,14 @@ The publish workflow validates release channel boundaries before publishing: - Every publish job verifies that its checkout matches the immutable event SHA. This prevents an alpha-only feature from being included in a stable hotfix by accident. + +### Stable guard maintenance + +The stable guard intentionally fails closed when GitHub adds a rule type it does not recognize. +If a release reports `Unsupported effective repository rule`, inspect the effective rules for +`main` and decide whether the new rule needs an explicit check or is already enforced by the +exact update rule-suite result. Add a rule to `NON_CHECK_RULES` only when a `pass` rule suite +fully covers it; never soften the unknown-rule failure into a warning. + +After updating and reviewing the guard, rerun the original merged-PR workflow for recovery. +Do not push a stable tag, select a different SHA, or introduce a manual publish path. diff --git a/scripts/publish-workflow.test.mjs b/scripts/publish-workflow.test.mjs index 28b9b301df..ce130efd5c 100644 --- a/scripts/publish-workflow.test.mjs +++ b/scripts/publish-workflow.test.mjs @@ -7,6 +7,11 @@ import test from "node:test"; import { parse } from "yaml"; const workflow = readFileSync(new URL("../.github/workflows/publish.yml", import.meta.url), "utf8"); +const guardSource = readFileSync(new URL("./stable-release-guard.mjs", import.meta.url), "utf8"); +const releaseRunbook = readFileSync( + new URL("../docs/contributing/release-channels.mdx", import.meta.url), + "utf8", +); const config = parse(workflow); const publish = config.jobs.publish; const checkout = publish.steps.find((step) => step.uses?.startsWith("actions/checkout@")); @@ -111,6 +116,18 @@ test("the stable guard precedes every irreversible release side effect", () => { assert.equal(publish.permissions.actions, "read"); assert.equal(publish.permissions.checks, "read"); assert.equal(publish.permissions["pull-requests"], "read"); + assert.equal(publish["timeout-minutes"], 60); +}); + +test("effective non-check rule enforcement and maintenance are explicit", () => { + assert.match( + guardSource, + /last-push.*unattributed.*signed.*enforced indirectly.*exact.*rule-suite.*pass/is, + ); + assert.match(releaseRunbook, /Unsupported effective repository rule/); + assert.match(releaseRunbook, /NON_CHECK_RULES/); + assert.match(releaseRunbook, /GitHub adds.*rule type/i); + assert.match(releaseRunbook, /rerun the original merged-PR workflow/i); }); test("the workflow invokes one shared publisher and owns no package roster", () => { diff --git a/scripts/stable-release-guard.mjs b/scripts/stable-release-guard.mjs index 0bd36c2aa1..ead3723a92 100644 --- a/scripts/stable-release-guard.mjs +++ b/scripts/stable-release-guard.mjs @@ -2,7 +2,10 @@ import { execFileSync } from "node:child_process"; import { readFileSync } from "node:fs"; -export const DEFAULT_TIMEOUT_MS = 8 * 60 * 1_000; +const MIN_TIMEOUT_MINUTES = 10; +const MAX_TIMEOUT_MINUTES = 40; +const DEFAULT_TIMEOUT_MINUTES = 25; +export const DEFAULT_TIMEOUT_MS = DEFAULT_TIMEOUT_MINUTES * 60 * 1_000; export const INITIAL_BACKOFF_MS = 5_000; export const MAX_BACKOFF_MS = 30_000; @@ -32,6 +35,24 @@ const NON_CHECK_RULES = new Set([ "tag_name_pattern", ]); +// One parser owns syntax and both policy bounds so configuration cannot bypass either limit. +// fallow-ignore-next-line complexity +export function parseGuardTimeoutMs(value) { + if (value === undefined) return DEFAULT_TIMEOUT_MS; + if (!/^\d+$/.test(value)) { + throw new Error( + `Stable release guard timeout must be an integer from ${MIN_TIMEOUT_MINUTES} to ${MAX_TIMEOUT_MINUTES} minutes.`, + ); + } + const minutes = Number(value); + if (minutes < MIN_TIMEOUT_MINUTES || minutes > MAX_TIMEOUT_MINUTES) { + throw new Error( + `Stable release guard timeout must be an integer from ${MIN_TIMEOUT_MINUTES} to ${MAX_TIMEOUT_MINUTES} minutes.`, + ); + } + return minutes * 60 * 1_000; +} + function requiredString(value, label) { if (typeof value !== "string" || value.length === 0) throw new Error(`Missing ${label}.`); return value; @@ -331,6 +352,9 @@ export async function runStableReleaseGuard({ `Stable release identity verified: PR #${identity.number} head=${identity.headSha} merge=${identity.mergeSha}.`, ); log(`Valid final-head approvals: ${approvals.join(", ")}.`); + // Last-push approval, extra unattributed-change approval, and signed commits are enforced indirectly + // by requiring the exact main-update rule-suite result to be pass; check contexts and the final-head + // non-author review count are additionally revalidated explicitly here. log( `Effective PR rules: last-push=${rules.requireLastPushApproval === true} unattributed=${rules.requireExtraApprovalForUnattributedChanges === true} signatures=${rules.requireSignedCommits === true}; exact update rule suite passed.`, ); @@ -448,7 +472,7 @@ async function main() { client: createGitHubClient({ repository, token }), now: Date.now, sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)), - timeoutMs: DEFAULT_TIMEOUT_MS, + timeoutMs: parseGuardTimeoutMs(process.env.STABLE_RELEASE_GUARD_TIMEOUT_MINUTES), initialBackoffMs: INITIAL_BACKOFF_MS, maxBackoffMs: MAX_BACKOFF_MS, log: console.log, diff --git a/scripts/stable-release-guard.test.mjs b/scripts/stable-release-guard.test.mjs index 317aa5a08a..f251006232 100644 --- a/scripts/stable-release-guard.test.mjs +++ b/scripts/stable-release-guard.test.mjs @@ -5,6 +5,7 @@ import { collectEffectiveApprovals, evaluateRequiredChecks, extractEffectiveRules, + parseGuardTimeoutMs, runStableReleaseGuard, } from "./stable-release-guard.mjs"; @@ -460,3 +461,17 @@ describe("stable release polling guard", () => { assert.ok(requestBudgets.every((budget) => budget > 0 && budget <= 25)); }); }); + +describe("stable release guard timeout configuration", () => { + it("uses a safe 25-minute default and accepts a bounded minute override", () => { + assert.equal(parseGuardTimeoutMs(undefined), 25 * 60 * 1_000); + assert.equal(parseGuardTimeoutMs("20"), 20 * 60 * 1_000); + assert.equal(parseGuardTimeoutMs("40"), 40 * 60 * 1_000); + }); + + it("rejects malformed, fractional, lower, and upper out-of-bound values", () => { + for (const value of ["", "abc", "20.5", "9", "41"]) { + assert.throws(() => parseGuardTimeoutMs(value), /10.*40.*minutes/i); + } + }); +}); From dcd23fcfca54b315ba27d80d1e07e8c6666ec278 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sun, 23 Aug 2026 20:40:48 +0000 Subject: [PATCH 04/11] fix(release): preserve recovery and publish diagnostics --- docs/contributing/release-channels.mdx | 5 +++ scripts/publish-packages.test.ts | 37 +++++++++++++++++- scripts/publish-packages.ts | 44 ++++++++++++++++++--- scripts/publish-workflow.test.mjs | 2 + scripts/release-packages.test.ts | 5 +++ scripts/release-packages.ts | 7 ++++ scripts/stable-release-guard.mjs | 6 +-- scripts/stable-release-guard.test.mjs | 54 ++++++++++++++++++++++++++ 8 files changed, 151 insertions(+), 9 deletions(-) diff --git a/docs/contributing/release-channels.mdx b/docs/contributing/release-channels.mdx index 7ec1f79cfd..6ad65063dd 100644 --- a/docs/contributing/release-channels.mdx +++ b/docs/contributing/release-channels.mdx @@ -80,3 +80,8 @@ fully covers it; never soften the unknown-rule failure into a warning. After updating and reviewing the guard, rerun the original merged-PR workflow for recovery. Do not push a stable tag, select a different SHA, or introduce a manual publish path. + +Bypass rejection is intentional: a ruleset result of `bypass` permanently blocks that merge SHA +from publishing. Release PRs must merge normally with their required approval and checks. If a +release PR was bypass-merged, prepare a fresh release PR and merge it normally; rerunning the +bypassed event cannot turn its historical rule-suite result into `pass`. diff --git a/scripts/publish-packages.test.ts b/scripts/publish-packages.test.ts index 734c676208..9eb91b2d64 100644 --- a/scripts/publish-packages.test.ts +++ b/scripts/publish-packages.test.ts @@ -3,7 +3,11 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "nod import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, it } from "node:test"; -import { runPublishPackages, type PublishCommand } from "./publish-packages.ts"; +import { + runPublishPackages, + sanitizePublishError, + type PublishCommand, +} from "./publish-packages.ts"; import { type PublishablePackage } from "./release-packages.ts"; const normal: PublishablePackage = { @@ -93,6 +97,37 @@ describe("shared package publisher", () => { } }); + it("preserves useful publish output while redacting secrets and user paths", async () => { + const root = fixture(); + const logs: string[] = []; + const error = Object.assign(new Error("npm publish failed for /home/private-user/project"), { + stdout: "uploaded package metadata\n", + stderr: + "npm ERR! code E403\n//registry.npmjs.org/:_authToken=super-secret\naccess_token=also-secret\n", + }); + try { + assert.match(sanitizePublishError(error), /E403/); + assert.doesNotMatch(sanitizePublishError(error), /super-secret|also-secret|private-user/); + await assert.rejects( + runPublishPackages( + { root, version: "1.2.3", distTag: "latest", roster: [normal, cli] }, + { + packageExists: async () => false, + command: async () => { + throw error; + }, + log: (message) => logs.push(message), + }, + ), + /E403/, + ); + assert.match(logs.join("\n"), /uploaded package metadata|E403/); + assert.doesNotMatch(logs.join("\n"), /super-secret|also-secret|private-user/); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + it("fails reconciliation before any npm boundary when a public workspace is omitted", async () => { const root = fixture(); let calls = 0; diff --git a/scripts/publish-packages.ts b/scripts/publish-packages.ts index e57071d2ed..3cddb95fb3 100644 --- a/scripts/publish-packages.ts +++ b/scripts/publish-packages.ts @@ -30,6 +30,32 @@ type PublishInput = { }; type PublishResult = { published: string[]; skipped: string[]; failed: string[] }; +function outputText(value: unknown): string { + if (typeof value === "string") return value; + if (Buffer.isBuffer(value)) return value.toString("utf8"); + return ""; +} + +// Keep all credential/path redaction in one boundary before diagnostics reach logs or errors. +// fallow-ignore-next-line complexity +export function sanitizePublishError(error: unknown): string { + const record = error && typeof error === "object" ? error : {}; + const parts = [ + error instanceof Error ? error.message : String(error), + outputText("stdout" in record ? record.stdout : undefined), + outputText("stderr" in record ? record.stderr : undefined), + ]; + return parts + .filter(Boolean) + .join("\n") + .replace(/(authorization:\s*bearer\s+)\S+/gi, "$1[redacted]") + .replace(/((?:_authToken|access_token|token|password|secret)\s*[=:]\s*)\S+/gi, "$1[redacted]") + .replace(/\/home\/[^/\s]+/g, "$HOME") + .replace(/[A-Za-z]:\\Users\\[^\\\s]+/g, "$HOME") + .trim() + .slice(0, 8_000); +} + // The two allowed publish modes converge here so CLI restoration cannot be bypassed. // fallow-ignore-next-line complexity async function publishEntry( @@ -80,6 +106,7 @@ export async function runPublishPackages( ): Promise { const roster = validatePublishablePackages(input.root, input.roster ?? PUBLISHABLE_PACKAGES); const result: PublishResult = { published: [], skipped: [], failed: [] }; + const failedDetails: string[] = []; for (const entry of roster) { if (await dependencies.packageExists(entry.npmName, input.version)) { dependencies.log(`⏭️ ${entry.npmName}@${input.version} already published — skipping`); @@ -91,13 +118,20 @@ export async function runPublishPackages( await publishEntry(input.root, entry, input.distTag, dependencies.command); dependencies.log(`✅ ${entry.npmName}@${input.version} published`); result.published.push(entry.npmName); - } catch { - dependencies.log(`❌ ${entry.npmName}@${input.version} failed to publish`); + } catch (error) { + const diagnostic = sanitizePublishError(error); + dependencies.log( + `❌ ${entry.npmName}@${input.version} failed to publish${diagnostic ? `:\n${diagnostic}` : ""}`, + ); result.failed.push(entry.npmName); + failedDetails.push(`${entry.npmName}: ${diagnostic || "unknown publish failure"}`); } } - if (result.failed.length > 0) - throw new Error(`Packages failed to publish: ${result.failed.join(", ")}`); + if (result.failed.length > 0) { + throw new Error( + `Packages failed to publish: ${result.failed.join(", ")}\n${failedDetails.join("\n")}`, + ); + } return result; } @@ -111,7 +145,7 @@ const defaultDependencies: PublishDependencies = { } }, command: async (executable, args, cwd) => { - await execFileAsync(executable, [...args], { cwd }); + await execFileAsync(executable, [...args], { cwd, maxBuffer: 16 * 1024 * 1024 }); }, log: console.log, }; diff --git a/scripts/publish-workflow.test.mjs b/scripts/publish-workflow.test.mjs index ce130efd5c..76b3c42451 100644 --- a/scripts/publish-workflow.test.mjs +++ b/scripts/publish-workflow.test.mjs @@ -128,6 +128,8 @@ test("effective non-check rule enforcement and maintenance are explicit", () => assert.match(releaseRunbook, /NON_CHECK_RULES/); assert.match(releaseRunbook, /GitHub adds.*rule type/i); assert.match(releaseRunbook, /rerun the original merged-PR workflow/i); + assert.match(releaseRunbook, /bypass rejection is intentional/i); + assert.match(releaseRunbook, /merge.*normally/i); }); test("the workflow invokes one shared publisher and owns no package roster", () => { diff --git a/scripts/release-packages.test.ts b/scripts/release-packages.test.ts index a824cfb53e..b4dc7cc6d2 100644 --- a/scripts/release-packages.test.ts +++ b/scripts/release-packages.test.ts @@ -6,6 +6,7 @@ import { describe, it } from "node:test"; import { PUBLISHABLE_PACKAGES, discoverWorkspacePackages, + validateRepositoryPublishablePackages, validatePublishablePackages, type PublishablePackage, } from "./release-packages.ts"; @@ -35,6 +36,10 @@ function fixture(): string { // One fixture-backed matrix documents the complete reconciliation contract. // fallow-ignore-next-line unit-size describe("publishable package roster", () => { + it("directly reconciles the actual repository packages tree", () => { + assert.deepEqual(validateRepositoryPublishablePackages(), PUBLISHABLE_PACKAGES); + }); + it("covers every public workspace exactly once and excludes private workspaces", () => { const root = fixture(); try { diff --git a/scripts/release-packages.ts b/scripts/release-packages.ts index 8dd8f11a8b..1c0b2a61b5 100644 --- a/scripts/release-packages.ts +++ b/scripts/release-packages.ts @@ -1,6 +1,8 @@ import { existsSync, readFileSync, readdirSync } from "node:fs"; import { join } from "node:path"; +const REPOSITORY_ROOT = join(import.meta.dirname, ".."); + export type PublishMode = "workspace" | "manifest-name-override"; export type PublishablePackage = { @@ -172,3 +174,8 @@ export function validatePublishablePackages( } return roster; } + +/** CI-facing contract that directly reconciles the checked-out repository tree. */ +export function validateRepositoryPublishablePackages(): readonly PublishablePackage[] { + return validatePublishablePackages(REPOSITORY_ROOT); +} diff --git a/scripts/stable-release-guard.mjs b/scripts/stable-release-guard.mjs index ead3723a92..0241016afe 100644 --- a/scripts/stable-release-guard.mjs +++ b/scripts/stable-release-guard.mjs @@ -384,9 +384,9 @@ export async function runStableReleaseGuard({ } } -function createGitHubClient({ repository, token }) { +export function createGitHubClient({ repository, token, fetchImpl = fetch }) { const request = async (path, requestBudgetMs) => { - const response = await fetch(`https://api.github.com${path}`, { + const response = await fetchImpl(`https://api.github.com${path}`, { headers: { Accept: "application/vnd.github+json", Authorization: `Bearer ${token}`, @@ -420,7 +420,7 @@ function createGitHubClient({ repository, token }) { paginate(`/repos/${repository}/pulls/${number}/reviews`, null, requestBudgetMs), getRuleSuite: async (sha, requestBudgetMs) => { const suites = await paginate( - `/repos/${repository}/rulesets/rule-suites?ref=${encodeURIComponent("refs/heads/main")}`, + `/repos/${repository}/rulesets/rule-suites?ref=${encodeURIComponent("refs/heads/main")}&time_period=month`, null, requestBudgetMs, ); diff --git a/scripts/stable-release-guard.test.mjs b/scripts/stable-release-guard.test.mjs index f251006232..8e07e3a879 100644 --- a/scripts/stable-release-guard.test.mjs +++ b/scripts/stable-release-guard.test.mjs @@ -3,6 +3,7 @@ import { describe, it } from "node:test"; import { assertImmutableRelease, collectEffectiveApprovals, + createGitHubClient, evaluateRequiredChecks, extractEffectiveRules, parseGuardTimeoutMs, @@ -475,3 +476,56 @@ describe("stable release guard timeout configuration", () => { } }); }); + +describe("rule-suite recovery lookback", () => { + function clientForSuites(suites, requests) { + return createGitHubClient({ + repository: "heygen-com/hyperframes", + token: "test-token", + fetchImpl: async (url) => { + requests.push(String(url)); + return new Response(JSON.stringify(suites), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }, + }); + } + + it("finds an exact passing merge suite older than the default day within the month", async () => { + const requests = []; + const client = clientForSuites( + [ + { + id: 1, + after_sha: mergeSha, + ref: "refs/heads/main", + result: "pass", + pushed_at: "2026-08-16T00:00:00Z", + }, + ], + requests, + ); + assert.deepEqual(await client.getRuleSuite(mergeSha, 1_000), { + afterSha: mergeSha, + ref: "refs/heads/main", + result: "pass", + }); + assert.match(requests[0], /time_period=month/); + }); + + it("fails closed when the month response is absent or ambiguous", async () => { + for (const suites of [ + [], + [ + { after_sha: mergeSha, ref: "refs/heads/main", result: "pass" }, + { after_sha: mergeSha, ref: "refs/heads/main", result: "pass" }, + ], + ]) { + await assert.rejects( + clientForSuites(suites, []).getRuleSuite(mergeSha, 1_000), + /Expected one rule suite/, + ); + } + }); +}); From ddd2cedfa011d90888fe13a45cbda499023c474a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sun, 23 Aug 2026 21:28:16 +0000 Subject: [PATCH 05/11] fix(release): use a dedicated policy-read credential --- .github/workflows/publish.yml | 2 +- docs/contributing/release-channels.mdx | 27 ++++ scripts/publish-workflow.test.mjs | 20 +++ scripts/stable-release-guard.mjs | 139 ++++++++++++++++++-- scripts/stable-release-guard.test.mjs | 170 +++++++++++++++++++++++++ 5 files changed, 345 insertions(+), 13 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 721219c288..c16949d741 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -89,7 +89,7 @@ jobs: - name: Guard stable release if: github.event_name == 'pull_request' env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_GUARD_TOKEN: ${{ secrets.RELEASE_GUARD_TOKEN }} STABLE_RELEASE_GUARD_TIMEOUT_MINUTES: >- ${{ vars.STABLE_RELEASE_GUARD_TIMEOUT_MINUTES || '25' }} VERSION: ${{ steps.version.outputs.version }} diff --git a/docs/contributing/release-channels.mdx b/docs/contributing/release-channels.mdx index 6ad65063dd..5c76394ee1 100644 --- a/docs/contributing/release-channels.mdx +++ b/docs/contributing/release-channels.mdx @@ -72,6 +72,33 @@ This prevents an alpha-only feature from being included in a stable hotfix by ac ### Stable guard maintenance +Stable publishing requires the repository secret `RELEASE_GUARD_TOKEN`. Its named owner must +provision and rotate a dedicated fine-grained personal access token with an explicit expiry and only +these repository permissions: Administration (read-only), Pull requests (read-only), and Commit +statuses (read-only). Metadata read access is added automatically by GitHub. Scope the token to this +repository only; do not grant Contents, Packages, Workflows, or organization write access. The +workflow passes this credential only to the read-only stable guard; checkout, tag creation, npm +provenance, and GitHub Release writes continue to use their existing credentials. + +After provisioning or rotation, an administrator must run the read-only capability check from a +protected environment that injects the secret without putting its value on the command line: + +```bash +GITHUB_REPOSITORY=heygen-com/hyperframes \ + RELEASE_GUARD_PROBE_PR= \ + RELEASE_GUARD_PROBE_SHA= \ + node scripts/stable-release-guard.mjs --preflight +``` + +The protected environment must supply `RELEASE_GUARD_TOKEN`. The check reads effective `main` +rules, repository rule suites, reviews for the probe PR, and check runs plus commit statuses for the +probe SHA. Public check-run reads may not expose a separate fine-grained permission in GitHub's PAT +UI, so this same-client capability proof is authoritative. It reports only endpoint/status/request-ID +classification on failure and never prints response bodies, headers, or the credential. Record a +successful check before merge. If the secret is missing, expired, incorrectly scoped, or the +capability check does not pass, this PR must not merge and the stable workflow will fail before +creating a tag. + The stable guard intentionally fails closed when GitHub adds a rule type it does not recognize. If a release reports `Unsupported effective repository rule`, inspect the effective rules for `main` and decide whether the new rule needs an explicit check or is already enforced by the diff --git a/scripts/publish-workflow.test.mjs b/scripts/publish-workflow.test.mjs index 76b3c42451..3105f2773c 100644 --- a/scripts/publish-workflow.test.mjs +++ b/scripts/publish-workflow.test.mjs @@ -21,6 +21,7 @@ const checkoutGuard = publish.steps.find( const createReleaseTag = publish.steps.find((step) => step.name === "Create release tag"); const stableGuard = publish.steps.find((step) => step.name === "Guard stable release"); const publishPackages = publish.steps.find((step) => step.name === "Publish packages"); +const createGitHubRelease = publish.steps.find((step) => step.name === "Create GitHub Release"); const normalizeExpression = (expression) => expression.replace(/\s+/g, " ").trim(); @@ -119,6 +120,16 @@ test("the stable guard precedes every irreversible release side effect", () => { assert.equal(publish["timeout-minutes"], 60); }); +test("the stable guard alone receives the dedicated policy-read credential", () => { + assert.equal(stableGuard.env.RELEASE_GUARD_TOKEN, "${{ secrets.RELEASE_GUARD_TOKEN }}"); + assert.equal(stableGuard.env.GH_TOKEN, undefined); + assert.equal(createGitHubRelease.env.GH_TOKEN, "${{ secrets.GITHUB_TOKEN }}"); + for (const step of publish.steps.filter((candidate) => candidate !== stableGuard)) { + assert.equal(step.env?.RELEASE_GUARD_TOKEN, undefined); + } + assert.equal(workflow.match(/secrets\.RELEASE_GUARD_TOKEN/g)?.length, 1); +}); + test("effective non-check rule enforcement and maintenance are explicit", () => { assert.match( guardSource, @@ -130,6 +141,15 @@ test("effective non-check rule enforcement and maintenance are explicit", () => assert.match(releaseRunbook, /rerun the original merged-PR workflow/i); assert.match(releaseRunbook, /bypass rejection is intentional/i); assert.match(releaseRunbook, /merge.*normally/i); + assert.match(releaseRunbook, /fine-grained personal access token/i); + assert.match(releaseRunbook, /Administration.*read/i); + assert.match(releaseRunbook, /Pull requests.*read/i); + assert.match(releaseRunbook, /Commit\s+statuses\s*\(read-only\)/i); + assert.match(releaseRunbook, /Metadata.*automatically/is); + assert.doesNotMatch(releaseRunbook, /Checks\s*\(read/i); + assert.match(releaseRunbook, /owner.*rotat|rotat.*owner/is); + assert.match(releaseRunbook, /read-only capability check/i); + assert.match(releaseRunbook, /must not merge/i); }); test("the workflow invokes one shared publisher and owns no package roster", () => { diff --git a/scripts/stable-release-guard.mjs b/scripts/stable-release-guard.mjs index 0241016afe..46850ad1ad 100644 --- a/scripts/stable-release-guard.mjs +++ b/scripts/stable-release-guard.mjs @@ -21,6 +21,13 @@ const TERMINAL_FAILURE_CONCLUSIONS = new Set([ ]); const DECISIVE_REVIEW_STATES = new Set(["APPROVED", "CHANGES_REQUESTED", "DISMISSED"]); const REVIEW_STATES = new Set([...DECISIVE_REVIEW_STATES, "COMMENTED"]); +const HTTP_FAILURE_CLASSIFICATIONS = new Map([ + [401, "authentication failure"], + [403, "authorization or scope failure"], + [404, "endpoint unavailable"], + [429, "rate limit"], +]); +const HEADER_FAILURE_CLASSIFICATIONS = new Map([["403:0", "rate limit"]]); const NON_CHECK_RULES = new Set([ "creation", "update", @@ -35,6 +42,21 @@ const NON_CHECK_RULES = new Set([ "tag_name_pattern", ]); +function classifyHttpFailure(response) { + const headerKey = `${response.status}:${response.headers.get("x-ratelimit-remaining")}`; + return ( + HEADER_FAILURE_CLASSIFICATIONS.get(headerKey) ?? + HTTP_FAILURE_CLASSIFICATIONS.get(response.status) ?? + "HTTP failure" + ); +} + +function assertCapabilityArray(value, endpoint) { + if (!Array.isArray(value)) { + throw new Error(`GitHub policy-read capability ${endpoint}: malformed response.`); + } +} + // One parser owns syntax and both policy bounds so configuration cannot bypass either limit. // fallow-ignore-next-line complexity export function parseGuardTimeoutMs(value) { @@ -58,6 +80,15 @@ function requiredString(value, label) { return value; } +function requiredCredential(value) { + if (typeof value !== "string" || value.trim().length === 0) { + throw new Error( + "Missing RELEASE_GUARD_TOKEN. Provision the documented read-only policy credential before merging.", + ); + } + return value.trim(); +} + function requiredNumber(value, label) { if (!Number.isInteger(value)) throw new Error(`Missing ${label}.`); return value; @@ -329,6 +360,12 @@ export async function runStableReleaseGuard({ if (remaining <= 0) throw new Error(`Timed out during stable release guard (${phase}).`); return remaining; }; + await client.verifyPolicyReadCapabilities( + "main", + requiredNumber(event?.pull_request?.number, "event pull request number"), + requiredString(event?.pull_request?.merge_commit_sha, "event pull request merge SHA"), + remainingBudget("policy-read capability"), + ); const apiPull = await client.getPull(event.pull_request.number, remainingBudget("pull request")); const identity = assertImmutableRelease({ event, @@ -385,7 +422,13 @@ export async function runStableReleaseGuard({ } export function createGitHubClient({ repository, token, fetchImpl = fetch }) { - const request = async (path, requestBudgetMs) => { + const requestIdClassification = (response) => + response.headers.get("x-github-request-id") ? "present" : "absent"; + const capabilityFailure = (endpoint, classification, response) => + new Error( + `GitHub policy-read capability ${endpoint}: ${classification} (status ${response.status}, request-id ${requestIdClassification(response)}).`, + ); + const request = async (path, requestBudgetMs, endpoint) => { const response = await fetchImpl(`https://api.github.com${path}`, { headers: { Accept: "application/vnd.github+json", @@ -394,35 +437,89 @@ export function createGitHubClient({ repository, token, fetchImpl = fetch }) { }, signal: AbortSignal.timeout(Math.max(1, requestBudgetMs)), }); - if (!response.ok) throw new Error(`GitHub API ${response.status} for ${path}.`); - return response.json(); + if (!response.ok) { + throw capabilityFailure(endpoint, classifyHttpFailure(response), response); + } + try { + return await response.json(); + } catch { + throw capabilityFailure(endpoint, "malformed response", response); + } }; // fallow-ignore-next-line complexity - const paginate = async (path, itemKey, requestBudgetMs) => { + const paginate = async (path, itemKey, requestBudgetMs, endpoint) => { const items = []; const deadline = Date.now() + requestBudgetMs; for (let page = 1; ; page += 1) { const separator = path.includes("?") ? "&" : "?"; const remaining = deadline - Date.now(); if (remaining <= 0) throw new Error(`GitHub API pagination timed out for ${path}.`); - const response = await request(`${path}${separator}per_page=100&page=${page}`, remaining); + const response = await request( + `${path}${separator}per_page=100&page=${page}`, + remaining, + endpoint, + ); const pageItems = itemKey ? response?.[itemKey] : response; if (!Array.isArray(pageItems)) - throw new Error(`GitHub API pagination response is malformed for ${path}.`); + throw new Error(`GitHub policy-read capability ${endpoint}: malformed response.`); items.push(...pageItems); if (pageItems.length < 100) return items; } }; return { + verifyPolicyReadCapabilities: async (branch, pullNumber, sha, requestBudgetMs) => { + const deadline = Date.now() + requestBudgetMs; + const remaining = () => { + const budget = deadline - Date.now(); + if (budget <= 0) throw new Error("GitHub policy-read capability check timed out."); + return budget; + }; + const rules = await request( + `/repos/${repository}/rules/branches/${encodeURIComponent(branch)}`, + remaining(), + "effective branch rules", + ); + assertCapabilityArray(rules, "effective branch rules"); + const suites = await request( + `/repos/${repository}/rulesets/rule-suites?ref=${encodeURIComponent("refs/heads/main")}&time_period=month&per_page=1`, + remaining(), + "rule suites", + ); + assertCapabilityArray(suites, "rule suites"); + const reviews = await request( + `/repos/${repository}/pulls/${pullNumber}/reviews?per_page=1`, + remaining(), + "pull request reviews", + ); + assertCapabilityArray(reviews, "pull request reviews"); + const checkRuns = await request( + `/repos/${repository}/commits/${sha}/check-runs?filter=all&per_page=1`, + remaining(), + "check runs", + ); + assertCapabilityArray(checkRuns?.check_runs, "check runs"); + const statuses = await request( + `/repos/${repository}/commits/${sha}/status?per_page=1`, + remaining(), + "commit statuses", + ); + assertCapabilityArray(statuses?.statuses, "commit statuses"); + }, getPull: (number, requestBudgetMs) => - request(`/repos/${repository}/pulls/${number}`, requestBudgetMs), + request(`/repos/${repository}/pulls/${number}`, requestBudgetMs, "pull request"), listReviews: (number, requestBudgetMs) => - paginate(`/repos/${repository}/pulls/${number}/reviews`, null, requestBudgetMs), + paginate( + `/repos/${repository}/pulls/${number}/reviews`, + null, + requestBudgetMs, + "pull request reviews", + ), getRuleSuite: async (sha, requestBudgetMs) => { const suites = await paginate( `/repos/${repository}/rulesets/rule-suites?ref=${encodeURIComponent("refs/heads/main")}&time_period=month`, null, requestBudgetMs, + "rule suites", ); const matching = suites.filter((suite) => suite?.after_sha === sha); if (matching.length !== 1) { @@ -441,6 +538,7 @@ export function createGitHubClient({ repository, token, fetchImpl = fetch }) { await request( `/repos/${repository}/rules/branches/${encodeURIComponent(branch)}`, requestBudgetMs, + "effective branch rules", ), ), listCheckRuns: (sha, requestBudgetMs) => @@ -448,14 +546,31 @@ export function createGitHubClient({ repository, token, fetchImpl = fetch }) { `/repos/${repository}/commits/${sha}/check-runs?filter=all`, "check_runs", requestBudgetMs, + "check runs", ), }; } async function main() { - const eventPath = requiredString(process.env.GITHUB_EVENT_PATH, "GITHUB_EVENT_PATH"); + const token = requiredCredential(process.env.RELEASE_GUARD_TOKEN); const repository = requiredString(process.env.GITHUB_REPOSITORY, "GITHUB_REPOSITORY"); - const token = requiredString(process.env.GH_TOKEN, "GH_TOKEN"); + const timeoutMs = parseGuardTimeoutMs(process.env.STABLE_RELEASE_GUARD_TIMEOUT_MINUTES); + const client = createGitHubClient({ repository, token }); + if (process.argv.includes("--preflight")) { + const probePullNumber = Number( + requiredString(process.env.RELEASE_GUARD_PROBE_PR, "RELEASE_GUARD_PROBE_PR"), + ); + if (!Number.isInteger(probePullNumber) || probePullNumber <= 0) { + throw new Error("RELEASE_GUARD_PROBE_PR must be a positive integer."); + } + const probeSha = requiredString(process.env.RELEASE_GUARD_PROBE_SHA, "RELEASE_GUARD_PROBE_SHA"); + await client.verifyPolicyReadCapabilities("main", probePullNumber, probeSha, timeoutMs); + console.log( + "Policy-read capability verified: effective branch rules, rule suites, pull request reviews, check runs, and commit statuses.", + ); + return; + } + const eventPath = requiredString(process.env.GITHUB_EVENT_PATH, "GITHUB_EVENT_PATH"); const expectedSha = requiredString(process.env.EXPECTED_RELEASE_SHA, "EXPECTED_RELEASE_SHA"); const githubSha = requiredString(process.env.GITHUB_SHA, "GITHUB_SHA"); const version = requiredString(process.env.VERSION, "VERSION"); @@ -469,10 +584,10 @@ async function main() { checkoutSha, version, currentRunId, - client: createGitHubClient({ repository, token }), + client, now: Date.now, sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)), - timeoutMs: parseGuardTimeoutMs(process.env.STABLE_RELEASE_GUARD_TIMEOUT_MINUTES), + timeoutMs, initialBackoffMs: INITIAL_BACKOFF_MS, maxBackoffMs: MAX_BACKOFF_MS, log: console.log, diff --git a/scripts/stable-release-guard.test.mjs b/scripts/stable-release-guard.test.mjs index 8e07e3a879..19cc581b6e 100644 --- a/scripts/stable-release-guard.test.mjs +++ b/scripts/stable-release-guard.test.mjs @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; import { describe, it } from "node:test"; import { assertImmutableRelease, @@ -12,6 +13,7 @@ import { const headSha = "1".repeat(40); const mergeSha = "2".repeat(40); +const capabilityArgs = ["main", 42, mergeSha, 1_000]; function releaseEvent(overrides = {}) { return { @@ -311,6 +313,7 @@ describe("effective repository rules", () => { function fakeClient(checkResponses, overrides = {}) { let index = 0; return { + verifyPolicyReadCapabilities: async () => undefined, getPull: async () => pull(), getRuleSuite: async () => ({ afterSha: mergeSha, ref: "refs/heads/main", result: "pass" }), listReviews: async () => [review()], @@ -322,6 +325,36 @@ function fakeClient(checkResponses, overrides = {}) { // fallow-ignore-next-line unit-size describe("stable release polling guard", () => { + it("checks policy-read capability before immutable policy evaluation", async () => { + const order = []; + await runStableReleaseGuard({ + event: releaseEvent(), + expectedSha: mergeSha, + githubSha: mergeSha, + checkoutSha: mergeSha, + version: "1.2.3", + currentRunId: "777", + client: fakeClient([[check("Build"), check("Test")]], { + verifyPolicyReadCapabilities: async () => order.push("capability"), + getPull: async () => { + order.push("pull"); + return pull(); + }, + getRuleSuite: async () => { + order.push("rule-suite"); + return { afterSha: mergeSha, ref: "refs/heads/main", result: "pass" }; + }, + }), + now: () => 0, + sleep: async () => undefined, + timeoutMs: 100, + initialBackoffMs: 10, + maxBackoffMs: 20, + log: () => undefined, + }); + assert.deepEqual(order.slice(0, 3), ["capability", "pull", "rule-suite"]); + }); + it("passes all-green and pending-then-green cases", async () => { for (const responses of [ [[check("Build"), check("Test")]], @@ -529,3 +562,140 @@ describe("rule-suite recovery lookback", () => { } }); }); + +describe("policy-read credential boundary", () => { + it("fails closed on a missing or blank dedicated credential before API work", () => { + for (const token of [undefined, " "]) { + const env = { + ...process.env, + GITHUB_REPOSITORY: "heygen-com/hyperframes", + }; + delete env.GH_TOKEN; + if (token === undefined) delete env.RELEASE_GUARD_TOKEN; + else env.RELEASE_GUARD_TOKEN = token; + const result = spawnSync(process.execPath, ["scripts/stable-release-guard.mjs"], { + cwd: new URL("..", import.meta.url), + env, + encoding: "utf8", + }); + assert.equal(result.status, 1); + assert.match(result.stderr, /Missing RELEASE_GUARD_TOKEN/); + assert.doesNotMatch(result.stderr, /Authorization:|Bearer /i); + } + }); + + it("classifies capability failures without exposing bodies, credentials, or request IDs", async () => { + const cases = [ + [401, {}, "authentication failure"], + [403, {}, "authorization or scope failure"], + [404, {}, "endpoint unavailable"], + [429, {}, "rate limit"], + [403, { "x-ratelimit-remaining": "0" }, "rate limit"], + ]; + for (const [status, headers, expected] of cases) { + const client = createGitHubClient({ + repository: "heygen-com/hyperframes", + token: "never-print-this-token", + fetchImpl: async () => + new Response('{"secret":"never-print-this-body"}', { + status, + headers: { ...headers, "x-github-request-id": "never-print-this-request-id" }, + }), + }); + await assert.rejects(client.verifyPolicyReadCapabilities(...capabilityArgs), (error) => { + assert.match(error.message, new RegExp(expected, "i")); + assert.match(error.message, /request-id present/i); + assert.doesNotMatch(error.message, /never-print-this/); + return true; + }); + } + }); + + it("rejects malformed capability responses and accepts all read-only endpoints", async () => { + const malformed = createGitHubClient({ + repository: "heygen-com/hyperframes", + token: "never-print-this-token", + fetchImpl: async () => new Response("never-print-this-body", { status: 200 }), + }); + await assert.rejects(malformed.verifyPolicyReadCapabilities(...capabilityArgs), (error) => { + assert.match(error.message, /effective branch rules.*malformed/i); + assert.doesNotMatch(error.message, /never-print-this/); + return true; + }); + + let requestCount = 0; + const malformedSuites = createGitHubClient({ + repository: "heygen-com/hyperframes", + token: "never-print-this-token", + fetchImpl: async () => + new Response(requestCount++ === 0 ? "[]" : "never-print-this-body", { status: 200 }), + }); + await assert.rejects( + malformedSuites.verifyPolicyReadCapabilities(...capabilityArgs), + (error) => { + assert.match(error.message, /rule suites.*malformed/i); + assert.doesNotMatch(error.message, /never-print-this/); + return true; + }, + ); + + const endpoints = []; + const capable = createGitHubClient({ + repository: "heygen-com/hyperframes", + token: "never-print-this-token", + fetchImpl: async (url) => { + endpoints.push(String(url)); + const body = String(url).includes("/check-runs") + ? '{"check_runs":[]}' + : String(url).includes(`/commits/${mergeSha}/status`) + ? '{"state":"success","statuses":[]}' + : "[]"; + return new Response(body, { status: 200 }); + }, + }); + await assert.doesNotReject(() => capable.verifyPolicyReadCapabilities(...capabilityArgs)); + assert.equal(endpoints.length, 5); + assert.match(endpoints[0], /\/rules\/branches\/main/); + assert.match(endpoints[1], /\/rulesets\/rule-suites/); + assert.match(endpoints[2], /\/pulls\/42\/reviews/); + assert.match(endpoints[3], new RegExp(`/commits/${mergeSha}/check-runs`)); + assert.match(endpoints[4], new RegExp(`/commits/${mergeSha}/status`)); + }); + + it("sanitizes a denied capability at each authoritative read endpoint", async () => { + const endpointCases = [ + ["effective branch rules", "/rules/branches/main"], + ["rule suites", "/rulesets/rule-suites"], + ["pull request reviews", "/pulls/42/reviews"], + ["check runs", `/commits/${mergeSha}/check-runs`], + ["commit statuses", `/commits/${mergeSha}/status`], + ]; + for (const [label, target] of endpointCases) { + const client = createGitHubClient({ + repository: "heygen-com/hyperframes", + token: "never-print-this-token", + fetchImpl: async (url) => { + const value = String(url); + if (value.includes(target)) { + return new Response("never-print-this-body", { + status: 403, + headers: { "x-github-request-id": "never-print-this-request-id" }, + }); + } + const body = value.includes("/check-runs") + ? '{"check_runs":[]}' + : value.includes(`/commits/${mergeSha}/status`) + ? '{"state":"success","statuses":[]}' + : "[]"; + return new Response(body, { status: 200 }); + }, + }); + await assert.rejects(client.verifyPolicyReadCapabilities(...capabilityArgs), (error) => { + assert.match(error.message, new RegExp(label, "i")); + assert.match(error.message, /authorization or scope failure/i); + assert.doesNotMatch(error.message, /never-print-this/); + return true; + }); + } + }); +}); From d166e1da0d7d66e95cd87fcd071b914acf766803 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sun, 23 Aug 2026 21:54:23 +0000 Subject: [PATCH 06/11] fix(release): add credential health checks --- .github/workflows/publish.yml | 3 - .github/workflows/release-guard-health.yml | 24 ++++ docs/contributing/release-channels.mdx | 27 ++-- scripts/publish-workflow.test.mjs | 37 +++++- scripts/stable-release-guard.mjs | 107 ++++++++++++---- scripts/stable-release-guard.test.mjs | 136 ++++++++++++++++++++- 6 files changed, 291 insertions(+), 43 deletions(-) create mode 100644 .github/workflows/release-guard-health.yml diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c16949d741..77ab7c1bf2 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -19,11 +19,8 @@ jobs: timeout-minutes: 60 environment: npm-publish permissions: - actions: read - checks: read contents: write id-token: write - pull-requests: read env: EXPECTED_RELEASE_SHA: >- ${{ github.event_name == 'pull_request' diff --git a/.github/workflows/release-guard-health.yml b/.github/workflows/release-guard-health.yml new file mode 100644 index 0000000000..7e48161be6 --- /dev/null +++ b/.github/workflows/release-guard-health.yml @@ -0,0 +1,24 @@ +name: Release guard credential health + +permissions: {} + +on: + schedule: + - cron: "17 13 * * 1" + workflow_dispatch: {} + +jobs: + health: + name: Verify release guard credential health + runs-on: ubuntu-latest + timeout-minutes: 45 + permissions: + contents: read + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Verify release guard health + env: + GITHUB_REPOSITORY: ${{ github.repository }} + RELEASE_GUARD_TOKEN: ${{ secrets.RELEASE_GUARD_TOKEN }} + run: node scripts/stable-release-guard.mjs --health diff --git a/docs/contributing/release-channels.mdx b/docs/contributing/release-channels.mdx index 5c76394ee1..781f2100bf 100644 --- a/docs/contributing/release-channels.mdx +++ b/docs/contributing/release-channels.mdx @@ -74,11 +74,11 @@ This prevents an alpha-only feature from being included in a stable hotfix by ac Stable publishing requires the repository secret `RELEASE_GUARD_TOKEN`. Its named owner must provision and rotate a dedicated fine-grained personal access token with an explicit expiry and only -these repository permissions: Administration (read-only), Pull requests (read-only), and Commit -statuses (read-only). Metadata read access is added automatically by GitHub. Scope the token to this -repository only; do not grant Contents, Packages, Workflows, or organization write access. The -workflow passes this credential only to the read-only stable guard; checkout, tag creation, npm -provenance, and GitHub Release writes continue to use their existing credentials. +these repository permissions: Administration (read-only) and Pull requests (read-only). Metadata read +access is added automatically by GitHub. Scope the token to this repository only; do not grant +Contents, Packages, Workflows, or organization write access. The publish workflow passes this +credential only to the read-only stable guard; checkout, tag creation, npm provenance, and GitHub +Release writes continue to use their existing credentials. After provisioning or rotation, an administrator must run the read-only capability check from a protected environment that injects the secret without putting its value on the command line: @@ -91,14 +91,25 @@ GITHUB_REPOSITORY=heygen-com/hyperframes \ ``` The protected environment must supply `RELEASE_GUARD_TOKEN`. The check reads effective `main` -rules, repository rule suites, reviews for the probe PR, and check runs plus commit statuses for the -probe SHA. Public check-run reads may not expose a separate fine-grained permission in GitHub's PAT -UI, so this same-client capability proof is authoritative. It reports only endpoint/status/request-ID +rules, repository rule suites, reviews for the probe PR, and check runs for the probe SHA. Public +check-run reads may not expose a separate fine-grained permission in GitHub's PAT UI, so this +same-client capability proof is authoritative. It reports only endpoint/status/request-ID classification on failure and never prints response bodies, headers, or the credential. Record a successful check before merge. If the secret is missing, expired, incorrectly scoped, or the capability check does not pass, this PR must not merge and the stable workflow will fail before creating a tag. +The separate release-guard credential health workflow repeats these reads every Monday and supports +a safe manual run after provisioning or rotation. It discovers a recent merged `main` pull request +and verifies effective rules, its exact month-window rule suite, reviews, and check runs. It has only +`contents: read` on the built-in token, passes `RELEASE_GUARD_TOKEN` only to the health step, and has +no release, tag, package, or publish path. + +Required checks remain dynamically sourced from effective rules, but each must identify a concrete +GitHub App integration so the guard can reduce actual check runs. A new required context without a +positive integration ID is unsupported and fails closed; the guard never falls back to legacy commit +statuses. Configured stable-guard timeouts must be 15–40 minutes, with a 25-minute default. + The stable guard intentionally fails closed when GitHub adds a rule type it does not recognize. If a release reports `Unsupported effective repository rule`, inspect the effective rules for `main` and decide whether the new rule needs an explicit check or is already enforced by the diff --git a/scripts/publish-workflow.test.mjs b/scripts/publish-workflow.test.mjs index 3105f2773c..d2ce4537ef 100644 --- a/scripts/publish-workflow.test.mjs +++ b/scripts/publish-workflow.test.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { execFileSync, spawnSync } from "node:child_process"; -import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; @@ -12,6 +12,13 @@ const releaseRunbook = readFileSync( new URL("../docs/contributing/release-channels.mdx", import.meta.url), "utf8", ); +const healthWorkflowPath = new URL( + "../.github/workflows/release-guard-health.yml", + import.meta.url, +); +const healthWorkflowSource = existsSync(healthWorkflowPath) + ? readFileSync(healthWorkflowPath, "utf8") + : ""; const config = parse(workflow); const publish = config.jobs.publish; const checkout = publish.steps.find((step) => step.uses?.startsWith("actions/checkout@")); @@ -114,9 +121,7 @@ test("the stable guard precedes every irreversible release side effect", () => { assert.match(stableGuard.run, /stable-release-guard\.mjs/); assert.ok(publish.steps.indexOf(stableGuard) < publish.steps.indexOf(createReleaseTag)); assert.ok(publish.steps.indexOf(stableGuard) < publish.steps.indexOf(publishPackages)); - assert.equal(publish.permissions.actions, "read"); - assert.equal(publish.permissions.checks, "read"); - assert.equal(publish.permissions["pull-requests"], "read"); + assert.deepEqual(publish.permissions, { contents: "write", "id-token": "write" }); assert.equal(publish["timeout-minutes"], 60); }); @@ -144,12 +149,34 @@ test("effective non-check rule enforcement and maintenance are explicit", () => assert.match(releaseRunbook, /fine-grained personal access token/i); assert.match(releaseRunbook, /Administration.*read/i); assert.match(releaseRunbook, /Pull requests.*read/i); - assert.match(releaseRunbook, /Commit\s+statuses\s*\(read-only\)/i); + assert.doesNotMatch(releaseRunbook, /Commit\s+statuses\s*\(read-only\)/i); assert.match(releaseRunbook, /Metadata.*automatically/is); assert.doesNotMatch(releaseRunbook, /Checks\s*\(read/i); assert.match(releaseRunbook, /owner.*rotat|rotat.*owner/is); assert.match(releaseRunbook, /read-only capability check/i); assert.match(releaseRunbook, /must not merge/i); + assert.doesNotMatch(guardSource, /\/commits\/\$\{sha\}\/status/); +}); + +test("credential health is weekly, manually runnable, read-only, and incapable of publishing", () => { + assert.notEqual(healthWorkflowSource, "", "release-guard-health.yml must exist"); + const healthConfig = parse(healthWorkflowSource); + assert.deepEqual(Object.keys(healthConfig.on).sort(), ["schedule", "workflow_dispatch"]); + assert.equal(healthConfig.on.schedule.length, 1); + assert.match(healthConfig.on.schedule[0].cron, /^\d+ \d+ \* \* \d$/); + const healthJob = healthConfig.jobs.health; + assert.deepEqual(healthJob.permissions, { contents: "read" }); + const healthStep = healthJob.steps.find((step) => step.name === "Verify release guard health"); + assert.ok(healthStep); + assert.equal(healthStep.env.RELEASE_GUARD_TOKEN, "${{ secrets.RELEASE_GUARD_TOKEN }}"); + assert.equal(healthStep.run.trim(), "node scripts/stable-release-guard.mjs --health"); + for (const step of healthJob.steps.filter((candidate) => candidate !== healthStep)) { + assert.equal(step.env?.RELEASE_GUARD_TOKEN, undefined); + } + assert.equal(healthWorkflowSource.match(/secrets\.RELEASE_GUARD_TOKEN/g)?.length, 1); + const commands = healthJob.steps.map((step) => step.run ?? "").join("\n"); + assert.doesNotMatch(commands, /npm\s+publish|git\s+tag|gh\s+release|publish-packages/i); + assert.doesNotMatch(healthWorkflowSource, /id-token:\s*write|contents:\s*write/); }); test("the workflow invokes one shared publisher and owns no package roster", () => { diff --git a/scripts/stable-release-guard.mjs b/scripts/stable-release-guard.mjs index 46850ad1ad..621b3838c7 100644 --- a/scripts/stable-release-guard.mjs +++ b/scripts/stable-release-guard.mjs @@ -2,7 +2,7 @@ import { execFileSync } from "node:child_process"; import { readFileSync } from "node:fs"; -const MIN_TIMEOUT_MINUTES = 10; +const MIN_TIMEOUT_MINUTES = 15; const MAX_TIMEOUT_MINUTES = 40; const DEFAULT_TIMEOUT_MINUTES = 25; export const DEFAULT_TIMEOUT_MS = DEFAULT_TIMEOUT_MINUTES * 60 * 1_000; @@ -57,6 +57,28 @@ function assertCapabilityArray(value, endpoint) { } } +function assertVisibleRuleSuite(suite, sha) { + const record = Object(suite); + const visible = [ + record.afterSha === sha, + record.ref === "refs/heads/main", + typeof record.result === "string", + String(record.result).length > 0, + ].every(Boolean); + if (!visible) throw new Error(`Rule suite result is not visible for merged main SHA ${sha}.`); +} + +function isEligibleMergedMainPull(candidate) { + const record = Object(candidate); + return [ + Number.isInteger(record.number), + typeof record.merged_at === "string", + String(record.merged_at).length > 0, + typeof record.merge_commit_sha === "string", + String(record.merge_commit_sha).length > 0, + ].every(Boolean); +} + // One parser owns syntax and both policy bounds so configuration cannot bypass either limit. // fallow-ignore-next-line complexity export function parseGuardTimeoutMs(value) { @@ -219,7 +241,11 @@ export function extractEffectiveRules(rules) { const checks = rule.parameters?.required_status_checks; if (!Array.isArray(checks)) throw new Error("Required status checks rule is malformed."); for (const check of checks) { - if (typeof check?.context !== "string" || !Number.isInteger(check.integration_id)) { + if ( + typeof check?.context !== "string" || + !Number.isInteger(check.integration_id) || + check.integration_id <= 0 + ) { throw new Error("Required status check identity is malformed."); } requiredChecks.push({ context: check.context, integrationId: check.integration_id }); @@ -421,6 +447,29 @@ export async function runStableReleaseGuard({ } } +export async function runCredentialHealth({ client, now, timeoutMs, log }) { + const deadline = now() + timeoutMs; + const remainingBudget = (phase) => { + const remaining = deadline - now(); + if (remaining <= 0) throw new Error(`Timed out during credential health (${phase}).`); + return remaining; + }; + const pull = await client.findRecentMergedMainPull( + remainingBudget("merged main pull request discovery"), + ); + if (!pull) throw new Error("No eligible merged main pull request found for credential health."); + const rules = await client.getEffectiveRules("main", remainingBudget("effective rules")); + const suite = await client.getRuleSuite(pull.mergeSha, remainingBudget("rule suite")); + assertVisibleRuleSuite(suite, pull.mergeSha); + const reviews = await client.listReviews(pull.number, remainingBudget("reviews")); + const checkRuns = await client.listCheckRuns(pull.mergeSha, remainingBudget("check runs")); + log( + `Release guard credential health verified for PR #${pull.number} merge=${pull.mergeSha}: ` + + `${rules.requiredChecks.length} required check contract(s), rule-suite result visible, ` + + `${reviews.length} review record(s), ${checkRuns.length} check run(s).`, + ); +} + export function createGitHubClient({ repository, token, fetchImpl = fetch }) { const requestIdClassification = (response) => response.headers.get("x-github-request-id") ? "present" : "absent"; @@ -498,12 +547,16 @@ export function createGitHubClient({ repository, token, fetchImpl = fetch }) { "check runs", ); assertCapabilityArray(checkRuns?.check_runs, "check runs"); - const statuses = await request( - `/repos/${repository}/commits/${sha}/status?per_page=1`, - remaining(), - "commit statuses", + }, + findRecentMergedMainPull: async (requestBudgetMs) => { + const pulls = await request( + `/repos/${repository}/pulls?state=closed&base=main&sort=updated&direction=desc&per_page=100`, + requestBudgetMs, + "merged main pull request discovery", ); - assertCapabilityArray(statuses?.statuses, "commit statuses"); + assertCapabilityArray(pulls, "merged main pull request discovery"); + const pull = pulls.find(isEligibleMergedMainPull); + return pull ? { number: pull.number, mergeSha: pull.merge_commit_sha } : null; }, getPull: (number, requestBudgetMs) => request(`/repos/${repository}/pulls/${number}`, requestBudgetMs, "pull request"), @@ -551,25 +604,37 @@ export function createGitHubClient({ repository, token, fetchImpl = fetch }) { }; } +async function runPreflight({ client, timeoutMs }) { + const probePullNumber = Number( + requiredString(process.env.RELEASE_GUARD_PROBE_PR, "RELEASE_GUARD_PROBE_PR"), + ); + if (!Number.isInteger(probePullNumber) || probePullNumber <= 0) { + throw new Error("RELEASE_GUARD_PROBE_PR must be a positive integer."); + } + const probeSha = requiredString(process.env.RELEASE_GUARD_PROBE_SHA, "RELEASE_GUARD_PROBE_SHA"); + await client.verifyPolicyReadCapabilities("main", probePullNumber, probeSha, timeoutMs); + console.log( + "Policy-read capability verified: effective branch rules, rule suites, pull request reviews, and check runs.", + ); +} + +async function runUtilityMode({ client, timeoutMs }) { + const handlers = new Map([ + ["--health", () => runCredentialHealth({ client, now: Date.now, timeoutMs, log: console.log })], + ["--preflight", () => runPreflight({ client, timeoutMs })], + ]); + const selected = [...handlers.keys()].find((flag) => process.argv.includes(flag)); + if (!selected) return false; + await handlers.get(selected)(); + return true; +} + async function main() { const token = requiredCredential(process.env.RELEASE_GUARD_TOKEN); const repository = requiredString(process.env.GITHUB_REPOSITORY, "GITHUB_REPOSITORY"); const timeoutMs = parseGuardTimeoutMs(process.env.STABLE_RELEASE_GUARD_TIMEOUT_MINUTES); const client = createGitHubClient({ repository, token }); - if (process.argv.includes("--preflight")) { - const probePullNumber = Number( - requiredString(process.env.RELEASE_GUARD_PROBE_PR, "RELEASE_GUARD_PROBE_PR"), - ); - if (!Number.isInteger(probePullNumber) || probePullNumber <= 0) { - throw new Error("RELEASE_GUARD_PROBE_PR must be a positive integer."); - } - const probeSha = requiredString(process.env.RELEASE_GUARD_PROBE_SHA, "RELEASE_GUARD_PROBE_SHA"); - await client.verifyPolicyReadCapabilities("main", probePullNumber, probeSha, timeoutMs); - console.log( - "Policy-read capability verified: effective branch rules, rule suites, pull request reviews, check runs, and commit statuses.", - ); - return; - } + if (await runUtilityMode({ client, timeoutMs })) return; const eventPath = requiredString(process.env.GITHUB_EVENT_PATH, "GITHUB_EVENT_PATH"); const expectedSha = requiredString(process.env.EXPECTED_RELEASE_SHA, "EXPECTED_RELEASE_SHA"); const githubSha = requiredString(process.env.GITHUB_SHA, "GITHUB_SHA"); diff --git a/scripts/stable-release-guard.test.mjs b/scripts/stable-release-guard.test.mjs index 19cc581b6e..abbdcf04f1 100644 --- a/scripts/stable-release-guard.test.mjs +++ b/scripts/stable-release-guard.test.mjs @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import { describe, it } from "node:test"; +import * as guardModule from "./stable-release-guard.mjs"; import { assertImmutableRelease, collectEffectiveApprovals, @@ -307,6 +308,18 @@ describe("effective repository rules", () => { /malformed/i, ); assert.throws(() => extractEffectiveRules([{ type: "required_deployments" }]), /unsupported/i); + for (const integration_id of [undefined, null, 0, -1]) { + assert.throws( + () => + extractEffectiveRules([ + { + type: "required_status_checks", + parameters: { required_status_checks: [{ context: "Build", integration_id }] }, + }, + ]), + /integration|identity.*malformed/i, + ); + } }); }); @@ -499,13 +512,125 @@ describe("stable release polling guard", () => { describe("stable release guard timeout configuration", () => { it("uses a safe 25-minute default and accepts a bounded minute override", () => { assert.equal(parseGuardTimeoutMs(undefined), 25 * 60 * 1_000); - assert.equal(parseGuardTimeoutMs("20"), 20 * 60 * 1_000); + assert.equal(parseGuardTimeoutMs("15"), 15 * 60 * 1_000); assert.equal(parseGuardTimeoutMs("40"), 40 * 60 * 1_000); }); it("rejects malformed, fractional, lower, and upper out-of-bound values", () => { - for (const value of ["", "abc", "20.5", "9", "41"]) { - assert.throws(() => parseGuardTimeoutMs(value), /10.*40.*minutes/i); + for (const value of ["", "abc", "20.5", "10", "14", "41"]) { + assert.throws(() => parseGuardTimeoutMs(value), /15.*40.*minutes/i); + } + }); +}); + +describe("read-only credential health", () => { + it("discovers a merged main PR and checks every supported policy-read surface", async () => { + assert.equal(typeof guardModule.runCredentialHealth, "function"); + const calls = []; + await guardModule.runCredentialHealth({ + client: { + findRecentMergedMainPull: async () => { + calls.push("discover"); + return { number: 42, mergeSha }; + }, + getEffectiveRules: async () => { + calls.push("rules"); + return { requiredChecks }; + }, + getRuleSuite: async () => { + calls.push("rule-suite"); + return { afterSha: mergeSha, ref: "refs/heads/main", result: "pass" }; + }, + listReviews: async () => { + calls.push("reviews"); + return [review()]; + }, + listCheckRuns: async () => { + calls.push("check-runs"); + return [check("Build")]; + }, + }, + now: () => 0, + timeoutMs: 100, + log: () => undefined, + }); + assert.deepEqual(calls, ["discover", "rules", "rule-suite", "reviews", "check-runs"]); + }); + + it("fails closed when discovery or rule-suite result visibility is missing", async () => { + const base = { + getEffectiveRules: async () => ({ requiredChecks }), + listReviews: async () => [], + listCheckRuns: async () => [], + }; + await assert.rejects( + guardModule.runCredentialHealth({ + client: { ...base, findRecentMergedMainPull: async () => null }, + now: () => 0, + timeoutMs: 100, + log: () => undefined, + }), + /eligible merged main/i, + ); + await assert.rejects( + guardModule.runCredentialHealth({ + client: { + ...base, + findRecentMergedMainPull: async () => ({ number: 42, mergeSha }), + getRuleSuite: async () => ({ afterSha: mergeSha, ref: "refs/heads/main" }), + }, + now: () => 0, + timeoutMs: 100, + log: () => undefined, + }), + /rule suite.*result/i, + ); + }); + + it("discovers the newest eligible merged-main pull request through the read client", async () => { + const requests = []; + const client = createGitHubClient({ + repository: "heygen-com/hyperframes", + token: "never-print-this-token", + fetchImpl: async (url) => { + requests.push(String(url)); + return new Response( + JSON.stringify([ + { number: 44, merged_at: null, merge_commit_sha: "4".repeat(40) }, + { number: 43, merged_at: "2026-08-23T20:00:00Z", merge_commit_sha: mergeSha }, + ]), + { status: 200 }, + ); + }, + }); + assert.deepEqual(await client.findRecentMergedMainPull(1_000), { + number: 43, + mergeSha, + }); + assert.match( + requests[0], + /pulls\?state=closed&base=main&sort=updated&direction=desc&per_page=100/, + ); + }); + + it("sanitizes denied or malformed merged-main discovery", async () => { + for (const response of [ + new Response("never-print-this-body", { + status: 403, + headers: { "x-github-request-id": "never-print-this-request-id" }, + }), + new Response('{"not":"an array"}', { status: 200 }), + ]) { + const client = createGitHubClient({ + repository: "heygen-com/hyperframes", + token: "never-print-this-token", + fetchImpl: async () => response, + }); + await assert.rejects(client.findRecentMergedMainPull(1_000), (error) => { + assert.match(error.message, /merged main pull request discovery/i); + assert.doesNotMatch(error.message, /never-print-this/); + return true; + }); } }); }); @@ -654,12 +779,12 @@ describe("policy-read credential boundary", () => { }, }); await assert.doesNotReject(() => capable.verifyPolicyReadCapabilities(...capabilityArgs)); - assert.equal(endpoints.length, 5); + assert.equal(endpoints.length, 4); assert.match(endpoints[0], /\/rules\/branches\/main/); assert.match(endpoints[1], /\/rulesets\/rule-suites/); assert.match(endpoints[2], /\/pulls\/42\/reviews/); assert.match(endpoints[3], new RegExp(`/commits/${mergeSha}/check-runs`)); - assert.match(endpoints[4], new RegExp(`/commits/${mergeSha}/status`)); + assert.doesNotMatch(endpoints.join("\n"), new RegExp(`/commits/${mergeSha}/status`)); }); it("sanitizes a denied capability at each authoritative read endpoint", async () => { @@ -668,7 +793,6 @@ describe("policy-read credential boundary", () => { ["rule suites", "/rulesets/rule-suites"], ["pull request reviews", "/pulls/42/reviews"], ["check runs", `/commits/${mergeSha}/check-runs`], - ["commit statuses", `/commits/${mergeSha}/status`], ]; for (const [label, target] of endpointCases) { const client = createGitHubClient({ From c8a312ece3422df138853beb88f06e12a92f2def Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 25 Aug 2026 17:08:01 +0000 Subject: [PATCH 07/11] fix(release): make health proof reachable before merge --- .github/workflows/release-guard-health.yml | 27 ++++++++++++++++++ docs/contributing/release-channels.mdx | 13 +++++---- scripts/publish-workflow.test.mjs | 32 ++++++++++++++++++++-- 3 files changed, 65 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release-guard-health.yml b/.github/workflows/release-guard-health.yml index 7e48161be6..d7f8e132a4 100644 --- a/.github/workflows/release-guard-health.yml +++ b/.github/workflows/release-guard-health.yml @@ -3,6 +3,12 @@ name: Release guard credential health permissions: {} on: + pull_request: + types: [opened, synchronize, reopened] + branches: [main] + paths: + - ".github/workflows/release-guard-health.yml" + - "scripts/stable-release-guard.mjs" schedule: - cron: "17 13 * * 1" workflow_dispatch: {} @@ -10,12 +16,33 @@ on: jobs: health: name: Verify release guard credential health + if: >- + (github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name == github.repository) || + (github.event_name != 'pull_request' && + github.ref == format('refs/heads/{0}', github.event.repository.default_branch)) runs-on: ubuntu-latest timeout-minutes: 45 permissions: contents: read + env: + EXPECTED_HEALTH_SHA: >- + ${{ github.event_name == 'pull_request' + && github.event.pull_request.head.sha + || github.sha }} steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + ref: ${{ env.EXPECTED_HEALTH_SHA }} + + - name: Verify immutable health checkout + run: | + ACTUAL_SHA="$(git rev-parse HEAD)" + EXPECTED_COMMIT_SHA="$(git rev-parse "${EXPECTED_HEALTH_SHA}^{commit}")" + if [ "$ACTUAL_SHA" != "$EXPECTED_COMMIT_SHA" ]; then + echo "::error::Expected health source $EXPECTED_COMMIT_SHA, checked out $ACTUAL_SHA" + exit 1 + fi - name: Verify release guard health env: diff --git a/docs/contributing/release-channels.mdx b/docs/contributing/release-channels.mdx index 781f2100bf..23f9fab9ce 100644 --- a/docs/contributing/release-channels.mdx +++ b/docs/contributing/release-channels.mdx @@ -99,11 +99,14 @@ successful check before merge. If the secret is missing, expired, incorrectly sc capability check does not pass, this PR must not merge and the stable workflow will fail before creating a tag. -The separate release-guard credential health workflow repeats these reads every Monday and supports -a safe manual run after provisioning or rotation. It discovers a recent merged `main` pull request -and verifies effective rules, its exact month-window rule suite, reviews, and check runs. It has only -`contents: read` on the built-in token, passes `RELEASE_GUARD_TOKEN` only to the health step, and has -no release, tag, package, or publish path. +The separate release-guard credential health workflow repeats these reads on same-repository pull +requests, every Monday, and through a safe manual run after provisioning or rotation. The pull-request +trigger is the bootstrap path before the workflow exists on the default branch (GitHub does not +register `workflow_dispatch` there until after merge): each PR run pins checkout to the exact head SHA +and refuses fork pull requests. Scheduled and manual runs are restricted to the repository's default +branch. The check discovers a recent merged `main` pull request and verifies effective rules, its exact +month-window rule suite, reviews, and check runs. It has only `contents: read` on the built-in token, +passes `RELEASE_GUARD_TOKEN` only to the health step, and has no release, tag, package, or publish path. Required checks remain dynamically sourced from effective rules, but each must identify a concrete GitHub App integration so the guard can reduce actual check runs. A new required context without a diff --git a/scripts/publish-workflow.test.mjs b/scripts/publish-workflow.test.mjs index d2ce4537ef..24577340ad 100644 --- a/scripts/publish-workflow.test.mjs +++ b/scripts/publish-workflow.test.mjs @@ -158,16 +158,44 @@ test("effective non-check rule enforcement and maintenance are explicit", () => assert.doesNotMatch(guardSource, /\/commits\/\$\{sha\}\/status/); }); -test("credential health is weekly, manually runnable, read-only, and incapable of publishing", () => { +test("credential health is pre-merge reachable, immutable, read-only, and incapable of publishing", () => { assert.notEqual(healthWorkflowSource, "", "release-guard-health.yml must exist"); const healthConfig = parse(healthWorkflowSource); - assert.deepEqual(Object.keys(healthConfig.on).sort(), ["schedule", "workflow_dispatch"]); + assert.deepEqual(Object.keys(healthConfig.on).sort(), [ + "pull_request", + "schedule", + "workflow_dispatch", + ]); + assert.deepEqual(healthConfig.on.pull_request, { + types: ["opened", "synchronize", "reopened"], + branches: ["main"], + paths: [".github/workflows/release-guard-health.yml", "scripts/stable-release-guard.mjs"], + }); assert.equal(healthConfig.on.schedule.length, 1); assert.match(healthConfig.on.schedule[0].cron, /^\d+ \d+ \* \* \d$/); const healthJob = healthConfig.jobs.health; + assert.equal( + normalizeExpression(healthJob.if), + "(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) || (github.event_name != 'pull_request' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch))", + ); assert.deepEqual(healthJob.permissions, { contents: "read" }); + assert.equal( + normalizeExpression(healthJob.env.EXPECTED_HEALTH_SHA), + "${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}", + ); + const healthCheckout = healthJob.steps.find((step) => step.uses?.startsWith("actions/checkout@")); + assert.equal(healthCheckout.with.ref, "${{ env.EXPECTED_HEALTH_SHA }}"); + const healthCheckoutGuard = healthJob.steps.find( + (step) => step.name === "Verify immutable health checkout", + ); + assert.ok(healthCheckoutGuard); + assert.equal(healthCheckoutGuard.if, undefined); + assert.equal(healthCheckoutGuard["continue-on-error"], undefined); + assert.match(healthCheckoutGuard.run, /git rev-parse HEAD/); + assert.match(healthCheckoutGuard.run, /EXPECTED_HEALTH_SHA/); const healthStep = healthJob.steps.find((step) => step.name === "Verify release guard health"); assert.ok(healthStep); + assert.ok(healthJob.steps.indexOf(healthCheckoutGuard) < healthJob.steps.indexOf(healthStep)); assert.equal(healthStep.env.RELEASE_GUARD_TOKEN, "${{ secrets.RELEASE_GUARD_TOKEN }}"); assert.equal(healthStep.run.trim(), "node scripts/stable-release-guard.mjs --health"); for (const step of healthJob.steps.filter((candidate) => candidate !== healthStep)) { From b935f482e0b76c71e7fc2af46c625897a41c7b49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 25 Aug 2026 17:20:25 +0000 Subject: [PATCH 08/11] fix(release): protect health credential behind review --- .github/workflows/release-guard-health.yml | 3 +++ docs/contributing/release-channels.mdx | 17 ++++++++++++----- scripts/publish-workflow.test.mjs | 16 ++++++++++++++++ 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release-guard-health.yml b/.github/workflows/release-guard-health.yml index d7f8e132a4..a3d4592b9b 100644 --- a/.github/workflows/release-guard-health.yml +++ b/.github/workflows/release-guard-health.yml @@ -23,6 +23,9 @@ jobs: github.ref == format('refs/heads/{0}', github.event.repository.default_branch)) runs-on: ubuntu-latest timeout-minutes: 45 + environment: + name: release-guard-health + deployment: false permissions: contents: read env: diff --git a/docs/contributing/release-channels.mdx b/docs/contributing/release-channels.mdx index 23f9fab9ce..cb58658025 100644 --- a/docs/contributing/release-channels.mdx +++ b/docs/contributing/release-channels.mdx @@ -72,13 +72,15 @@ This prevents an alpha-only feature from being included in a stable hotfix by ac ### Stable guard maintenance -Stable publishing requires the repository secret `RELEASE_GUARD_TOKEN`. Its named owner must +Stable publishing requires the environment-scoped secret `RELEASE_GUARD_TOKEN`. Its named owner must provision and rotate a dedicated fine-grained personal access token with an explicit expiry and only these repository permissions: Administration (read-only) and Pull requests (read-only). Metadata read access is added automatically by GitHub. Scope the token to this repository only; do not grant -Contents, Packages, Workflows, or organization write access. The publish workflow passes this -credential only to the read-only stable guard; checkout, tag creation, npm provenance, and GitHub -Release writes continue to use their existing credentials. +Contents, Packages, Workflows, or organization write access. Store the credential under the same name +in both the `release-guard-health` and `npm-publish` environments; never create it as a repository or +organization secret. The publish workflow passes this credential only to the read-only stable guard; +checkout, tag creation, npm provenance, and GitHub Release writes continue to use their existing +credentials. After provisioning or rotation, an administrator must run the read-only capability check from a protected environment that injects the secret without putting its value on the command line: @@ -103,7 +105,12 @@ The separate release-guard credential health workflow repeats these reads on sam requests, every Monday, and through a safe manual run after provisioning or rotation. The pull-request trigger is the bootstrap path before the workflow exists on the default branch (GitHub does not register `workflow_dispatch` there until after merge): each PR run pins checkout to the exact head SHA -and refuses fork pull requests. Scheduled and manual runs are restricted to the repository's default +and refuses fork pull requests. Because that source is still mutable PR code, the credential-bearing +job references the `release-guard-health` environment and cannot start until a required reviewer +approves that exact run. Configure the environment with required reviewers (at least one), prevent +self-review enabled, and administrator bypass disabled **before** uploading its environment secret. +An unconfigured environment or missing environment secret is a fail-closed blocker, not a reason to +fall back to a repository secret. Scheduled and manual runs are restricted to the repository's default branch. The check discovers a recent merged `main` pull request and verifies effective rules, its exact month-window rule suite, reviews, and check runs. It has only `contents: read` on the built-in token, passes `RELEASE_GUARD_TOKEN` only to the health step, and has no release, tag, package, or publish path. diff --git a/scripts/publish-workflow.test.mjs b/scripts/publish-workflow.test.mjs index 24577340ad..f9731e82f1 100644 --- a/scripts/publish-workflow.test.mjs +++ b/scripts/publish-workflow.test.mjs @@ -179,6 +179,17 @@ test("credential health is pre-merge reachable, immutable, read-only, and incapa "(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) || (github.event_name != 'pull_request' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch))", ); assert.deepEqual(healthJob.permissions, { contents: "read" }); + const credentialBearingPullRequestJobs = Object.values(healthConfig.jobs).filter( + (job) => + healthConfig.on.pull_request && JSON.stringify(job).includes("secrets.RELEASE_GUARD_TOKEN"), + ); + assert.ok(credentialBearingPullRequestJobs.length > 0); + for (const job of credentialBearingPullRequestJobs) { + assert.deepEqual(job.environment, { + name: "release-guard-health", + deployment: false, + }); + } assert.equal( normalizeExpression(healthJob.env.EXPECTED_HEALTH_SHA), "${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}", @@ -205,6 +216,11 @@ test("credential health is pre-merge reachable, immutable, read-only, and incapa const commands = healthJob.steps.map((step) => step.run ?? "").join("\n"); assert.doesNotMatch(commands, /npm\s+publish|git\s+tag|gh\s+release|publish-packages/i); assert.doesNotMatch(healthWorkflowSource, /id-token:\s*write|contents:\s*write/); + assert.match(releaseRunbook, /environment-scoped.*RELEASE_GUARD_TOKEN/is); + assert.match(releaseRunbook, /required reviewers/i); + assert.match(releaseRunbook, /prevent\s+self-review/i); + assert.match(releaseRunbook, /administrator bypass.*disabled/i); + assert.match(releaseRunbook, /before.*uploading.*environment secret/i); }); test("the workflow invokes one shared publisher and owns no package roster", () => { From 7c802a16dd5406225ee2bb55127e63680e2b84ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 25 Aug 2026 17:43:40 +0000 Subject: [PATCH 09/11] test(release): pin protected credential boundaries --- docs/contributing/release-channels.mdx | 23 ++++ scripts/publish-workflow.test.mjs | 179 ++++++++++++++++++++++--- 2 files changed, 184 insertions(+), 18 deletions(-) diff --git a/docs/contributing/release-channels.mdx b/docs/contributing/release-channels.mdx index cb58658025..e90bd5306d 100644 --- a/docs/contributing/release-channels.mdx +++ b/docs/contributing/release-channels.mdx @@ -82,6 +82,29 @@ organization secret. The publish workflow passes this credential only to the rea checkout, tag creation, npm provenance, and GitHub Release writes continue to use their existing credentials. +Before the initial upload and before every rotation, run this read-only API verification. It fails +unless the exact environment has a non-bypassable required-reviewer boundary with self-review +prevention and at least one reviewer. Do not upload either environment-secret copy unless it exits 0. + + +```bash +ENVIRONMENT_JSON="$(gh api repos/heygen-com/hyperframes/environments/release-guard-health)" +jq -e ' + .can_admins_bypass == false and + any( + .protection_rules[]?; + .type == "required_reviewers" and + .prevent_self_review == true and + ((.reviewers // []) | length >= 1) + ) +' <<<"$ENVIRONMENT_JSON" >/dev/null || { + echo "release-guard-health is not protected; do not upload RELEASE_GUARD_TOKEN." >&2 + exit 1 +} +echo "release-guard-health protection verified; environment secret upload may proceed." +``` + + After provisioning or rotation, an administrator must run the read-only capability check from a protected environment that injects the secret without putting its value on the command line: diff --git a/scripts/publish-workflow.test.mjs b/scripts/publish-workflow.test.mjs index f9731e82f1..db41a72144 100644 --- a/scripts/publish-workflow.test.mjs +++ b/scripts/publish-workflow.test.mjs @@ -1,10 +1,19 @@ import assert from "node:assert/strict"; import { execFileSync, spawnSync } from "node:child_process"; -import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; -import { parse } from "yaml"; +import { parse, stringify } from "yaml"; const workflow = readFileSync(new URL("../.github/workflows/publish.yml", import.meta.url), "utf8"); const guardSource = readFileSync(new URL("./stable-release-guard.mjs", import.meta.url), "utf8"); @@ -19,6 +28,12 @@ const healthWorkflowPath = new URL( const healthWorkflowSource = existsSync(healthWorkflowPath) ? readFileSync(healthWorkflowPath, "utf8") : ""; +const workflowDirectory = join(import.meta.dirname, "..", ".github", "workflows"); +const workflowSources = Object.fromEntries( + readdirSync(workflowDirectory) + .filter((name) => /\.ya?ml$/.test(name)) + .map((name) => [name, readFileSync(join(workflowDirectory, name), "utf8")]), +); const config = parse(workflow); const publish = config.jobs.publish; const checkout = publish.steps.find((step) => step.uses?.startsWith("actions/checkout@")); @@ -50,6 +65,85 @@ function runCreateReleaseTag(cwd, version) { }); } +function normalizeEnvironment(environment) { + return typeof environment === "string" ? { name: environment, deployment: true } : environment; +} + +function credentialConsumers([workflowName, source]) { + const workflowConfig = parse(source); + if (!workflowConfig.on?.pull_request) return []; + return Object.entries(workflowConfig.jobs ?? {}).flatMap(([jobName, job]) => + JSON.stringify(job).includes("secrets.RELEASE_GUARD_TOKEN") + ? [{ workflowName, jobName, environment: normalizeEnvironment(job.environment) }] + : [], + ); +} + +function collectPullRequestCredentialConsumers(sources) { + return Object.entries(sources) + .flatMap(credentialConsumers) + .sort((left, right) => + `${left.workflowName}:${left.jobName}`.localeCompare( + `${right.workflowName}:${right.jobName}`, + ), + ); +} + +function assertPullRequestCredentialBoundaries(sources) { + assert.deepEqual(collectPullRequestCredentialConsumers(sources), [ + { + workflowName: "publish.yml", + jobName: "publish", + environment: { name: "npm-publish", deployment: true }, + }, + { + workflowName: "release-guard-health.yml", + jobName: "health", + environment: { name: "release-guard-health", deployment: false }, + }, + ]); +} + +function environmentVerificationCommand(markdown) { + const match = markdown.match( + /\s*```bash\n([\s\S]*?)\n```\s*/, + ); + assert.ok(match, "runbook must contain the copy-pasteable environment API verification block"); + return match[1]; +} + +function runEnvironmentVerification(response) { + const root = mkdtempSync(join(tmpdir(), "release-guard-environment-test-")); + const bin = join(root, "bin"); + mkdirSync(bin); + const gh = join(bin, "gh"); + writeFileSync( + gh, + [ + "#!/usr/bin/env bash", + 'test "$*" = "api repos/heygen-com/hyperframes/environments/release-guard-health"', + "printf '%s\\n' \"$FAKE_GH_RESPONSE\"", + ].join("\n"), + ); + chmodSync(gh, 0o755); + try { + return spawnSync( + "bash", + ["-euo", "pipefail", "-c", environmentVerificationCommand(releaseRunbook)], + { + encoding: "utf8", + env: { + ...process.env, + PATH: `${bin}:${process.env.PATH}`, + FAKE_GH_RESPONSE: JSON.stringify(response), + }, + }, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + test("stable publishing has one reviewed immutable event path", () => { assert.deepEqual(config.on.push.tags, ["v*-*"]); assert.equal(config.on.workflow_dispatch, undefined); @@ -179,17 +273,7 @@ test("credential health is pre-merge reachable, immutable, read-only, and incapa "(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) || (github.event_name != 'pull_request' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch))", ); assert.deepEqual(healthJob.permissions, { contents: "read" }); - const credentialBearingPullRequestJobs = Object.values(healthConfig.jobs).filter( - (job) => - healthConfig.on.pull_request && JSON.stringify(job).includes("secrets.RELEASE_GUARD_TOKEN"), - ); - assert.ok(credentialBearingPullRequestJobs.length > 0); - for (const job of credentialBearingPullRequestJobs) { - assert.deepEqual(job.environment, { - name: "release-guard-health", - deployment: false, - }); - } + assertPullRequestCredentialBoundaries(workflowSources); assert.equal( normalizeExpression(healthJob.env.EXPECTED_HEALTH_SHA), "${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}", @@ -216,11 +300,70 @@ test("credential health is pre-merge reachable, immutable, read-only, and incapa const commands = healthJob.steps.map((step) => step.run ?? "").join("\n"); assert.doesNotMatch(commands, /npm\s+publish|git\s+tag|gh\s+release|publish-packages/i); assert.doesNotMatch(healthWorkflowSource, /id-token:\s*write|contents:\s*write/); - assert.match(releaseRunbook, /environment-scoped.*RELEASE_GUARD_TOKEN/is); - assert.match(releaseRunbook, /required reviewers/i); - assert.match(releaseRunbook, /prevent\s+self-review/i); - assert.match(releaseRunbook, /administrator bypass.*disabled/i); - assert.match(releaseRunbook, /before.*uploading.*environment secret/i); + for (const requirement of [ + /environment-scoped.*RELEASE_GUARD_TOKEN/is, + /required reviewers/i, + /prevent\s+self-review/i, + /administrator bypass.*disabled/i, + /before.*uploading.*environment secret/i, + ]) { + assert.match(releaseRunbook, requirement); + } +}); + +test("every pull-request credential consumer is mutation-pinned to its environment", () => { + assertPullRequestCredentialBoundaries(workflowSources); + for (const [workflowName, jobName] of [ + ["publish.yml", "publish"], + ["release-guard-health.yml", "health"], + ]) { + const mutatedConfig = parse(workflowSources[workflowName]); + delete mutatedConfig.jobs[jobName].environment; + assert.throws(() => + assertPullRequestCredentialBoundaries({ + ...workflowSources, + [workflowName]: stringify(mutatedConfig), + }), + ); + } +}); + +test("the pre-upload environment API command rejects every unprotected shape", () => { + const protectedEnvironment = { + can_admins_bypass: false, + protection_rules: [ + { + type: "required_reviewers", + prevent_self_review: true, + reviewers: [{ type: "User", reviewer: { login: "release-reviewer" } }], + }, + ], + }; + const passing = runEnvironmentVerification(protectedEnvironment); + assert.equal(passing.status, 0, `${passing.stdout}\n${passing.stderr}`); + + for (const environment of [ + { ...protectedEnvironment, can_admins_bypass: true }, + { ...protectedEnvironment, protection_rules: [] }, + { + ...protectedEnvironment, + protection_rules: [ + { + type: "required_reviewers", + prevent_self_review: false, + reviewers: [{ type: "User", reviewer: { login: "release-reviewer" } }], + }, + ], + }, + { + ...protectedEnvironment, + protection_rules: [{ type: "required_reviewers", prevent_self_review: true, reviewers: [] }], + }, + ]) { + const failing = runEnvironmentVerification(environment); + assert.equal(failing.status, 1, `${failing.stdout}\n${failing.stderr}`); + assert.match(failing.stderr, /do not upload RELEASE_GUARD_TOKEN/i); + } }); test("the workflow invokes one shared publisher and owns no package roster", () => { From 31d243eaf7615a538015d46c1cabd21b94997f1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 25 Aug 2026 17:56:17 +0000 Subject: [PATCH 10/11] fix(docs): keep release verifier valid MDX --- docs/contributing/release-channels.mdx | 4 ++-- scripts/publish-workflow.test.mjs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/contributing/release-channels.mdx b/docs/contributing/release-channels.mdx index e90bd5306d..e5768f662f 100644 --- a/docs/contributing/release-channels.mdx +++ b/docs/contributing/release-channels.mdx @@ -86,7 +86,7 @@ Before the initial upload and before every rotation, run this read-only API veri unless the exact environment has a non-bypassable required-reviewer boundary with self-review prevention and at least one reviewer. Do not upload either environment-secret copy unless it exits 0. - +{/* release-guard-environment-verification:start */} ```bash ENVIRONMENT_JSON="$(gh api repos/heygen-com/hyperframes/environments/release-guard-health)" jq -e ' @@ -103,7 +103,7 @@ jq -e ' } echo "release-guard-health protection verified; environment secret upload may proceed." ``` - +{/* release-guard-environment-verification:end */} After provisioning or rotation, an administrator must run the read-only capability check from a protected environment that injects the secret without putting its value on the command line: diff --git a/scripts/publish-workflow.test.mjs b/scripts/publish-workflow.test.mjs index db41a72144..d47ca25f3f 100644 --- a/scripts/publish-workflow.test.mjs +++ b/scripts/publish-workflow.test.mjs @@ -106,7 +106,7 @@ function assertPullRequestCredentialBoundaries(sources) { function environmentVerificationCommand(markdown) { const match = markdown.match( - /\s*```bash\n([\s\S]*?)\n```\s*/, + /\{\/\* release-guard-environment-verification:start \*\/\}\s*```bash\n([\s\S]*?)\n```\s*\{\/\* release-guard-environment-verification:end \*\/\}/, ); assert.ok(match, "runbook must contain the copy-pasteable environment API verification block"); return match[1]; From f9a74f8532688a7993965340f77d053e3b76183f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 25 Aug 2026 18:08:43 +0000 Subject: [PATCH 11/11] test(release): simplify health workflow contract --- scripts/publish-workflow.test.mjs | 111 +++++++++++++++++------------- 1 file changed, 64 insertions(+), 47 deletions(-) diff --git a/scripts/publish-workflow.test.mjs b/scripts/publish-workflow.test.mjs index d47ca25f3f..35e033a7c6 100644 --- a/scripts/publish-workflow.test.mjs +++ b/scripts/publish-workflow.test.mjs @@ -144,6 +144,66 @@ function runEnvironmentVerification(response) { } } +function assertHealthTriggerContract(healthConfig) { + assert.deepEqual(Object.keys(healthConfig.on).sort(), [ + "pull_request", + "schedule", + "workflow_dispatch", + ]); + assert.deepEqual(healthConfig.on.pull_request, { + types: ["opened", "synchronize", "reopened"], + branches: ["main"], + paths: [".github/workflows/release-guard-health.yml", "scripts/stable-release-guard.mjs"], + }); + assert.equal(healthConfig.on.schedule.length, 1); + assert.match(healthConfig.on.schedule[0].cron, /^\d+ \d+ \* \* \d$/); +} + +function assertHealthCheckoutContract(healthJob) { + assert.equal( + normalizeExpression(healthJob.env.EXPECTED_HEALTH_SHA), + "${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}", + ); + const checkoutStep = healthJob.steps.find((step) => step.uses?.startsWith("actions/checkout@")); + assert.equal(checkoutStep.with.ref, "${{ env.EXPECTED_HEALTH_SHA }}"); + const checkoutGuard = healthJob.steps.find( + (step) => step.name === "Verify immutable health checkout", + ); + assert.ok(checkoutGuard); + assert.equal(checkoutGuard.if, undefined); + assert.equal(checkoutGuard["continue-on-error"], undefined); + assert.match(checkoutGuard.run, /git rev-parse HEAD/); + assert.match(checkoutGuard.run, /EXPECTED_HEALTH_SHA/); + return checkoutGuard; +} + +function assertHealthCredentialContract(healthJob, checkoutGuard) { + const healthStep = healthJob.steps.find((step) => step.name === "Verify release guard health"); + assert.ok(healthStep); + assert.ok(healthJob.steps.indexOf(checkoutGuard) < healthJob.steps.indexOf(healthStep)); + assert.equal(healthStep.env.RELEASE_GUARD_TOKEN, "${{ secrets.RELEASE_GUARD_TOKEN }}"); + assert.equal(healthStep.run.trim(), "node scripts/stable-release-guard.mjs --health"); + for (const step of healthJob.steps.filter((candidate) => candidate !== healthStep)) { + assert.equal(step.env?.RELEASE_GUARD_TOKEN, undefined); + } + assert.equal(healthWorkflowSource.match(/secrets\.RELEASE_GUARD_TOKEN/g)?.length, 1); + const commands = healthJob.steps.map((step) => step.run ?? "").join("\n"); + assert.doesNotMatch(commands, /npm\s+publish|git\s+tag|gh\s+release|publish-packages/i); + assert.doesNotMatch(healthWorkflowSource, /id-token:\s*write|contents:\s*write/); +} + +function assertHealthRunbookContract() { + for (const requirement of [ + /environment-scoped.*RELEASE_GUARD_TOKEN/is, + /required reviewers/i, + /prevent\s+self-review/i, + /administrator bypass.*disabled/i, + /before.*uploading.*environment secret/i, + ]) { + assert.match(releaseRunbook, requirement); + } +} + test("stable publishing has one reviewed immutable event path", () => { assert.deepEqual(config.on.push.tags, ["v*-*"]); assert.equal(config.on.workflow_dispatch, undefined); @@ -255,18 +315,7 @@ test("effective non-check rule enforcement and maintenance are explicit", () => test("credential health is pre-merge reachable, immutable, read-only, and incapable of publishing", () => { assert.notEqual(healthWorkflowSource, "", "release-guard-health.yml must exist"); const healthConfig = parse(healthWorkflowSource); - assert.deepEqual(Object.keys(healthConfig.on).sort(), [ - "pull_request", - "schedule", - "workflow_dispatch", - ]); - assert.deepEqual(healthConfig.on.pull_request, { - types: ["opened", "synchronize", "reopened"], - branches: ["main"], - paths: [".github/workflows/release-guard-health.yml", "scripts/stable-release-guard.mjs"], - }); - assert.equal(healthConfig.on.schedule.length, 1); - assert.match(healthConfig.on.schedule[0].cron, /^\d+ \d+ \* \* \d$/); + assertHealthTriggerContract(healthConfig); const healthJob = healthConfig.jobs.health; assert.equal( normalizeExpression(healthJob.if), @@ -274,41 +323,9 @@ test("credential health is pre-merge reachable, immutable, read-only, and incapa ); assert.deepEqual(healthJob.permissions, { contents: "read" }); assertPullRequestCredentialBoundaries(workflowSources); - assert.equal( - normalizeExpression(healthJob.env.EXPECTED_HEALTH_SHA), - "${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}", - ); - const healthCheckout = healthJob.steps.find((step) => step.uses?.startsWith("actions/checkout@")); - assert.equal(healthCheckout.with.ref, "${{ env.EXPECTED_HEALTH_SHA }}"); - const healthCheckoutGuard = healthJob.steps.find( - (step) => step.name === "Verify immutable health checkout", - ); - assert.ok(healthCheckoutGuard); - assert.equal(healthCheckoutGuard.if, undefined); - assert.equal(healthCheckoutGuard["continue-on-error"], undefined); - assert.match(healthCheckoutGuard.run, /git rev-parse HEAD/); - assert.match(healthCheckoutGuard.run, /EXPECTED_HEALTH_SHA/); - const healthStep = healthJob.steps.find((step) => step.name === "Verify release guard health"); - assert.ok(healthStep); - assert.ok(healthJob.steps.indexOf(healthCheckoutGuard) < healthJob.steps.indexOf(healthStep)); - assert.equal(healthStep.env.RELEASE_GUARD_TOKEN, "${{ secrets.RELEASE_GUARD_TOKEN }}"); - assert.equal(healthStep.run.trim(), "node scripts/stable-release-guard.mjs --health"); - for (const step of healthJob.steps.filter((candidate) => candidate !== healthStep)) { - assert.equal(step.env?.RELEASE_GUARD_TOKEN, undefined); - } - assert.equal(healthWorkflowSource.match(/secrets\.RELEASE_GUARD_TOKEN/g)?.length, 1); - const commands = healthJob.steps.map((step) => step.run ?? "").join("\n"); - assert.doesNotMatch(commands, /npm\s+publish|git\s+tag|gh\s+release|publish-packages/i); - assert.doesNotMatch(healthWorkflowSource, /id-token:\s*write|contents:\s*write/); - for (const requirement of [ - /environment-scoped.*RELEASE_GUARD_TOKEN/is, - /required reviewers/i, - /prevent\s+self-review/i, - /administrator bypass.*disabled/i, - /before.*uploading.*environment secret/i, - ]) { - assert.match(releaseRunbook, requirement); - } + const checkoutGuard = assertHealthCheckoutContract(healthJob); + assertHealthCredentialContract(healthJob, checkoutGuard); + assertHealthRunbookContract(); }); test("every pull-request credential consumer is mutation-pinned to its environment", () => {