diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 657c33e924..77ab7c1bf2 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: contents: write @@ -83,6 +83,15 @@ 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: + RELEASE_GUARD_TOKEN: ${{ secrets.RELEASE_GUARD_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 + - name: Create release tag if: github.event_name == 'pull_request' env: @@ -127,67 +136,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/.github/workflows/release-guard-health.yml b/.github/workflows/release-guard-health.yml new file mode 100644 index 0000000000..a3d4592b9b --- /dev/null +++ b/.github/workflows/release-guard-health.yml @@ -0,0 +1,54 @@ +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: {} + +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 + environment: + name: release-guard-health + deployment: false + 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: + 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 09239099b0..e5768f662f 100644 --- a/docs/contributing/release-channels.mdx +++ b/docs/contributing/release-channels.mdx @@ -69,3 +69,90 @@ 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 + +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. 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. + +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. + +{/* release-guard-environment-verification:start */} +```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." +``` +{/* 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: + +```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 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 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. 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. + +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 +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. + +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/package.json b/package.json index 25866d6189..64758a5aa9 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/check-large-files.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/check-large-files.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-packages.test.ts b/scripts/publish-packages.test.ts new file mode 100644 index 0000000000..9eb91b2d64 --- /dev/null +++ b/scripts/publish-packages.test.ts @@ -0,0 +1,153 @@ +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, + sanitizePublishError, + 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("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; + 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..3cddb95fb3 --- /dev/null +++ b/scripts/publish-packages.ts @@ -0,0 +1,169 @@ +#!/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[] }; + +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( + 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: [] }; + 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`); + 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 (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(", ")}\n${failedDetails.join("\n")}`, + ); + } + 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, maxBuffer: 16 * 1024 * 1024 }); + }, + 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/publish-workflow.test.mjs b/scripts/publish-workflow.test.mjs index 051543daff..35e033a7c6 100644 --- a/scripts/publish-workflow.test.mjs +++ b/scripts/publish-workflow.test.mjs @@ -1,12 +1,39 @@ import assert from "node:assert/strict"; import { execFileSync, spawnSync } from "node:child_process"; -import { 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"); +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 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@")); @@ -14,6 +41,9 @@ 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 createGitHubRelease = publish.steps.find((step) => step.name === "Create GitHub Release"); const normalizeExpression = (expression) => expression.replace(/\s+/g, " ").trim(); @@ -35,6 +65,145 @@ 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( + /\{\/\* 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]; +} + +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 }); + } +} + +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); @@ -99,6 +268,128 @@ 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.deepEqual(publish.permissions, { contents: "write", "id-token": "write" }); + 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, + /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); + 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.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 pre-merge reachable, immutable, read-only, and incapable of publishing", () => { + assert.notEqual(healthWorkflowSource, "", "release-guard-health.yml must exist"); + const healthConfig = parse(healthWorkflowSource); + assertHealthTriggerContract(healthConfig); + 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" }); + assertPullRequestCredentialBoundaries(workflowSources); + const checkoutGuard = assertHealthCheckoutContract(healthJob); + assertHealthCredentialContract(healthJob, checkoutGuard); + assertHealthRunbookContract(); +}); + +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", () => { + 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/release-packages.test.ts b/scripts/release-packages.test.ts new file mode 100644 index 0000000000..b4dc7cc6d2 --- /dev/null +++ b/scripts/release-packages.test.ts @@ -0,0 +1,145 @@ +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, + validateRepositoryPublishablePackages, + 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("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 { + 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..1c0b2a61b5 --- /dev/null +++ b/scripts/release-packages.ts @@ -0,0 +1,181 @@ +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 = { + 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; +} + +/** CI-facing contract that directly reconciles the checked-out repository tree. */ +export function validateRepositoryPublishablePackages(): readonly PublishablePackage[] { + return validatePublishablePackages(REPOSITORY_ROOT); +} 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 diff --git a/scripts/stable-release-guard.mjs b/scripts/stable-release-guard.mjs new file mode 100644 index 0000000000..621b3838c7 --- /dev/null +++ b/scripts/stable-release-guard.mjs @@ -0,0 +1,667 @@ +#!/usr/bin/env node +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; + +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; +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 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", + "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 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.`); + } +} + +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) { + 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; +} + +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; +} + +// 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) || + check.integration_id <= 0 + ) { + 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; + }; + 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, + 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(", ")}.`); + // 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.`, + ); + + 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); + } +} + +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"; + 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", + Authorization: `Bearer ${token}`, + "X-GitHub-Api-Version": "2022-11-28", + }, + signal: AbortSignal.timeout(Math.max(1, requestBudgetMs)), + }); + 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, 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, + endpoint, + ); + const pageItems = itemKey ? response?.[itemKey] : response; + if (!Array.isArray(pageItems)) + 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"); + }, + 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(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"), + listReviews: (number, 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) { + 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, + "effective branch rules", + ), + ), + listCheckRuns: (sha, requestBudgetMs) => + paginate( + `/repos/${repository}/commits/${sha}/check-runs?filter=all`, + "check_runs", + requestBudgetMs, + "check runs", + ), + }; +} + +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 (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"); + 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, + now: Date.now, + sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)), + timeoutMs, + 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..abbdcf04f1 --- /dev/null +++ b/scripts/stable-release-guard.test.mjs @@ -0,0 +1,825 @@ +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, + createGitHubClient, + evaluateRequiredChecks, + extractEffectiveRules, + parseGuardTimeoutMs, + runStableReleaseGuard, +} from "./stable-release-guard.mjs"; + +const headSha = "1".repeat(40); +const mergeSha = "2".repeat(40); +const capabilityArgs = ["main", 42, mergeSha, 1_000]; + +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); + 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, + ); + } + }); +}); + +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()], + 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("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")]], + [ + [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)); + }); +}); + +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("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", "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; + }); + } + }); +}); + +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/, + ); + } + }); +}); + +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, 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.doesNotMatch(endpoints.join("\n"), 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`], + ]; + 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; + }); + } + }); +});