diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e4b7e35e6..b26cedc62 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -33,9 +33,12 @@ jobs: shell: bash run: | if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - echo "should_run=true" >> "$GITHUB_OUTPUT" - echo "should_publish=true" >> "$GITHUB_OUTPUT" - echo "should_verify_release_gate_maintenance=false" >> "$GITHUB_OUTPUT" + { + echo "should_run=true" + echo "should_update_release_pr=true" + echo "should_run_publish_gates=true" + echo "should_verify_release_gate_maintenance=false" + } >> "$GITHUB_OUTPUT" exit 0 fi @@ -44,31 +47,43 @@ jobs: base="$(git rev-parse HEAD^)" fi - release_work_pattern='^\.changeset/(pre\.json|[^/]+\.md)$|^packages/[^/]+/(package\.json|CHANGELOG\.md)$|^package\.json$|^pnpm-lock\.yaml$' - release_gate_maintenance_pattern='^\.github/workflows/release\.yml$|^scripts/(changeset-required-check\.mts|normalize-packages\.mjs|package-bin-smoke\.mts|package-entrypoint-smoke\.mts|package-manifest-contracts\.mjs|release-docs-check\.mts)$|^scripts/tests/(changeset-required-check|normalize-packages|package-bin-smoke|package-entrypoint-smoke|release-docs-check|release-workflow)\.spec\.ts$' + release_pr_update_pattern='^\.changeset/(pre\.json|[^/]+\.md)$' + release_pr_update_ignore_pattern='^\.changeset/README\.md$' + publish_candidate_pattern='^packages/[^/]+/(package\.json|CHANGELOG\.md)$|^package\.json$|^pnpm-lock\.yaml$' + release_gate_maintenance_pattern='^\.github/workflows/release\.yml$|^scripts/(changeset-required-check\.mts|normalize-packages\.mjs|package-bin-smoke\.mts|package-entrypoint-smoke\.mts|package-manifest-contracts\.mjs|release-docs-check\.mts|release-metadata-check\.mts)$|^scripts/tests/(changeset-required-check|normalize-packages|package-bin-smoke|package-entrypoint-smoke|release-docs-check|release-metadata-check|release-workflow)\.spec\.ts$' changed_files="$(git diff --name-only "$base" HEAD)" - should_publish=false + should_update_release_pr=false + should_run_publish_gates=false should_verify_release_gate_maintenance=false + should_run=false - if echo "$changed_files" | grep -Eq "$release_work_pattern"; then - should_publish=true + release_pr_update_files="$(echo "$changed_files" | grep -Ev "$release_pr_update_ignore_pattern" || true)" + if echo "$release_pr_update_files" | grep -Eq "$release_pr_update_pattern"; then + should_update_release_pr=true + fi + + if echo "$changed_files" | grep -Eq "$publish_candidate_pattern"; then + should_run_publish_gates=true fi if echo "$changed_files" | grep -Eq "$release_gate_maintenance_pattern"; then should_verify_release_gate_maintenance=true fi - echo "should_publish=${should_publish}" >> "$GITHUB_OUTPUT" - echo "should_verify_release_gate_maintenance=${should_verify_release_gate_maintenance}" >> "$GITHUB_OUTPUT" - - if [ "$should_publish" = "true" ] || [ "$should_verify_release_gate_maintenance" = "true" ]; then - echo "should_run=true" >> "$GITHUB_OUTPUT" + if [ "$should_update_release_pr" = "true" ] || [ "$should_run_publish_gates" = "true" ] || [ "$should_verify_release_gate_maintenance" = "true" ]; then + should_run=true else - echo "should_run=false" >> "$GITHUB_OUTPUT" - echo "No changeset, prerelease state, manifest, changelog, lockfile, or release-gate maintenance changes detected; skipping release pipeline." + echo "No changeset release-PR update, publish candidate, or release-gate maintenance changes detected; skipping release pipeline." fi + { + echo "should_update_release_pr=${should_update_release_pr}" + echo "should_run_publish_gates=${should_run_publish_gates}" + echo "should_verify_release_gate_maintenance=${should_verify_release_gate_maintenance}" + echo "should_run=${should_run}" + } >> "$GITHUB_OUTPUT" + - name: Setup pnpm if: steps.release_work.outputs.should_run == 'true' uses: pnpm/action-setup@v4 @@ -89,9 +104,10 @@ jobs: if: steps.release_work.outputs.should_verify_release_gate_maintenance == 'true' run: | pnpm build - pnpm exec vitest run scripts/tests/release-workflow.spec.ts scripts/tests/package-entrypoint-smoke.spec.ts scripts/tests/package-bin-smoke.spec.ts scripts/tests/release-docs-check.spec.ts scripts/tests/changeset-required-check.spec.ts scripts/tests/normalize-packages.spec.ts --config vitest.config.ts + pnpm exec vitest run scripts/tests/release-workflow.spec.ts scripts/tests/package-entrypoint-smoke.spec.ts scripts/tests/package-bin-smoke.spec.ts scripts/tests/release-docs-check.spec.ts scripts/tests/release-metadata-check.spec.ts scripts/tests/changeset-required-check.spec.ts scripts/tests/normalize-packages.spec.ts --config vitest.config.ts pnpm package-manifests:check pnpm release-docs:check + node --experimental-strip-types scripts/release-metadata-check.mts --allow-pending-changesets pnpm package-entrypoints:smoke pnpm package-bins:smoke @@ -101,35 +117,35 @@ jobs: # audit:prod intentionally ignores GHSA-gv7w-rqvm-qjhr: it covers the # esbuild Deno API install path, while Croco release builds use Node/pnpm. - name: Production dependency audit - if: steps.release_work.outputs.should_publish == 'true' + if: steps.release_work.outputs.should_run_publish_gates == 'true' run: pnpm audit:prod - name: Lint, format, and repository policy checks - if: steps.release_work.outputs.should_publish == 'true' + if: steps.release_work.outputs.should_run_publish_gates == 'true' run: pnpm check - name: Build all packages - if: steps.release_work.outputs.should_publish == 'true' + if: steps.release_work.outputs.should_run_publish_gates == 'true' run: pnpm build - name: Package entrypoint smoke - if: steps.release_work.outputs.should_publish == 'true' + if: steps.release_work.outputs.should_run_publish_gates == 'true' run: pnpm package-entrypoints:smoke - name: Package binary smoke - if: steps.release_work.outputs.should_publish == 'true' + if: steps.release_work.outputs.should_run_publish_gates == 'true' run: pnpm package-bins:smoke - name: TypeScript check - if: steps.release_work.outputs.should_publish == 'true' + if: steps.release_work.outputs.should_run_publish_gates == 'true' run: pnpm typecheck - name: Test - if: steps.release_work.outputs.should_publish == 'true' + if: steps.release_work.outputs.should_run_publish_gates == 'true' run: pnpm test - name: Verify npm provenance configuration - if: steps.release_work.outputs.should_publish == 'true' + if: steps.release_work.outputs.should_run_publish_gates == 'true' shell: bash run: | npm_provenance="$(npm config get provenance)" @@ -141,12 +157,16 @@ jobs: exit 1 fi + - name: Release metadata check + if: steps.release_work.outputs.should_run_publish_gates == 'true' + run: node --experimental-strip-types scripts/release-metadata-check.mts + - name: Dry-run publish gate - if: steps.release_work.outputs.should_publish == 'true' + if: steps.release_work.outputs.should_run_publish_gates == 'true' run: pnpm -r publish --dry-run --no-git-checks - name: Create Release Pull Request or Publish - if: steps.release_work.outputs.should_publish == 'true' + if: steps.release_work.outputs.should_update_release_pr == 'true' || steps.release_work.outputs.should_run_publish_gates == 'true' uses: changesets/action@v1 with: publish: pnpm exec changeset publish diff --git a/packages/migration-runner/src/tests/PublishedCli.spec.ts b/packages/migration-runner/src/tests/PublishedCli.spec.ts index 2d8284700..ec5619ad3 100644 --- a/packages/migration-runner/src/tests/PublishedCli.spec.ts +++ b/packages/migration-runner/src/tests/PublishedCli.spec.ts @@ -1,5 +1,5 @@ import { spawnSync } from "node:child_process"; -import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -79,8 +79,19 @@ describe("published migrate CLI", () => { }); function ensureBuilt(): void { - run("pnpm", ["--filter", "@croco/problems-core", "build"], rootDir); - run("pnpm", ["--filter", "@croco/migration-runner", "build"], rootDir); + const problemsCoreDir = join(rootDir, "packages", "problems-core"); + + if (!hasBuiltFiles(problemsCoreDir, ["index.js", "index.d.ts"])) { + run("pnpm", ["--filter", "@croco/problems-core", "build"], rootDir); + } + + if (!hasBuiltFiles(packageDir, ["cli.js", "cli.d.ts", "index.js", "index.d.ts"])) { + run("pnpm", ["--filter", "@croco/migration-runner", "build"], rootDir); + } +} + +function hasBuiltFiles(packageRoot: string, files: readonly string[]): boolean { + return files.every((file) => existsSync(join(packageRoot, "dist", file))); } function findTarball(directory: string, prefix: string): string { diff --git a/packages/rpc-codegen/src/tests/PublishedCli.spec.ts b/packages/rpc-codegen/src/tests/PublishedCli.spec.ts index 9fea61f52..c9bb45b16 100644 --- a/packages/rpc-codegen/src/tests/PublishedCli.spec.ts +++ b/packages/rpc-codegen/src/tests/PublishedCli.spec.ts @@ -98,21 +98,43 @@ describe("published RPC codegen CLI", () => { }); function ensureBuilt(): void { - if ( - existsSync(join(rootDir, "packages", "problems-core", "dist", "index.js")) && - existsSync(join(rootDir, "packages", "protocols-core", "dist", "index.js")) && - existsSync(join(rootDir, "packages", "framework-preset", "dist", "index.js")) && - existsSync(join(rootDir, "packages", "presentation-preset", "dist", "index.js")) && - existsSync(join(packageDir, "dist", "cli.js")) - ) { - return; + const packages = [ + { + buildArgs: ["--filter", "@croco/problems-core", "build"], + files: ["index.js", "index.d.ts"], + root: join(rootDir, "packages", "problems-core"), + }, + { + buildArgs: ["--filter", "@croco/protocols-core", "build"], + files: ["index.js", "index.d.ts"], + root: join(rootDir, "packages", "protocols-core"), + }, + { + buildArgs: ["--filter", "@croco/framework-preset", "build"], + files: ["index.js", "index.d.ts"], + root: join(rootDir, "packages", "framework-preset"), + }, + { + buildArgs: ["--filter", "@croco/presentation-preset", "build"], + files: ["index.js", "index.d.ts"], + root: join(rootDir, "packages", "presentation-preset"), + }, + { + buildArgs: ["--filter", "@croco/rpc-codegen", "build"], + files: ["cli.js", "cli.d.ts", "index.js", "index.d.ts"], + root: packageDir, + }, + ]; + + for (const packageBuild of packages) { + if (!hasBuiltFiles(packageBuild.root, packageBuild.files)) { + run("pnpm", packageBuild.buildArgs, rootDir); + } } +} - run("pnpm", ["--filter", "@croco/problems-core", "build"], rootDir); - run("pnpm", ["--filter", "@croco/protocols-core", "build"], rootDir); - run("pnpm", ["--filter", "@croco/framework-preset", "build"], rootDir); - run("pnpm", ["--filter", "@croco/presentation-preset", "build"], rootDir); - run("pnpm", ["--filter", "@croco/rpc-codegen", "build"], rootDir); +function hasBuiltFiles(packageRoot: string, files: readonly string[]): boolean { + return files.every((file) => existsSync(join(packageRoot, "dist", file))); } function findTarball(directory: string, prefix: string): string { diff --git a/scripts/package-bin-smoke.mts b/scripts/package-bin-smoke.mts index c3d8e0528..31dc5afaf 100644 --- a/scripts/package-bin-smoke.mts +++ b/scripts/package-bin-smoke.mts @@ -422,15 +422,23 @@ function writeConsumerPackageJson( { name: "croco-package-bin-smoke-consumer", private: true, - pnpm: { - overrides, - }, type: "module", }, null, 2, )}\n`, ); + writeFileSync( + join(consumerRoot, "pnpm-workspace.yaml"), + `${JSON.stringify( + { + packages: [], + overrides, + }, + null, + 2, + )}\n`, + ); } function directInternalPeerDependencyNames(packageInfo: PackageInfo): string[] { diff --git a/scripts/release-metadata-check.mts b/scripts/release-metadata-check.mts new file mode 100644 index 000000000..0304f709b --- /dev/null +++ b/scripts/release-metadata-check.mts @@ -0,0 +1,254 @@ +#!/usr/bin/env node + +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { dirname, join, relative, resolve } from "node:path"; +import { argv, exit, stdout } from "node:process"; +import { fileURLToPath } from "node:url"; +import { findPackageJsonFiles } from "./package-manifest-contracts.mjs"; + +type Options = { + readonly allowPendingChangesets: boolean; + readonly rootDir: string; +}; + +type PackageJson = { + readonly name?: unknown; + readonly private?: unknown; + readonly version?: unknown; +}; + +type DiagnosticCode = + | "MISSING_CHANGELOG" + | "MISSING_NAME" + | "MISSING_VERSION" + | "PLACEHOLDER_VERSION"; + +type PackageDiagnostic = { + readonly codes: readonly DiagnosticCode[]; + readonly messages: readonly string[]; + readonly packageName: string; + readonly relativePath: string; +}; + +const defaultRootDir = dirname(dirname(fileURLToPath(import.meta.url))); +const changesetPackagePattern = + /^['"]?(@?[\w.-]+(?:\/[\w.-]+)?|[\w.-]+)['"]?: (major|minor|patch)$/; +const pendingChangesetRecoverableCodes = new Set([ + "MISSING_CHANGELOG", + "PLACEHOLDER_VERSION", +]); + +function log(message = ""): void { + stdout.write(`${message}\n`); +} + +function parseArgs(args: readonly string[]): Options { + let rootDir = defaultRootDir; + let allowPendingChangesets = false; + + for (let index = 0; index < args.length; index++) { + const arg = args[index]; + + if (arg === "--root") { + const value = args[index + 1]; + if (!value) { + throw new Error("--root requires a path"); + } + rootDir = resolve(value); + index++; + continue; + } + + if (arg === "--allow-pending-changesets") { + allowPendingChangesets = true; + continue; + } + + throw new Error(`Unknown option: ${arg}`); + } + + return { + allowPendingChangesets, + rootDir, + }; +} + +function readPackageJson(packagePath: string): PackageJson { + return JSON.parse(readFileSync(packagePath, "utf-8")) as PackageJson; +} + +function pendingChangesetPackages(rootDir: string): ReadonlySet { + const changesetsDir = join(rootDir, ".changeset"); + const packageNames = new Set(); + + if (!existsSync(changesetsDir)) { + return packageNames; + } + + const entries = readChangesetFiles(changesetsDir); + for (const entryPath of entries) { + const content = readFileSync(entryPath, "utf-8").replace(/\r\n/g, "\n"); + const lines = content.split("\n"); + + if (lines[0] !== "---") { + continue; + } + + const endIndex = lines.indexOf("---", 1); + if (endIndex <= 1) { + continue; + } + + for (const line of lines.slice(1, endIndex)) { + const match = changesetPackagePattern.exec(line.trim()); + if (match?.[1]) { + packageNames.add(match[1]); + } + } + } + + return packageNames; +} + +function readChangesetFiles(changesetsDir: string): string[] { + return readdirSync(changesetsDir, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith(".md") && entry.name !== "README.md") + .map((entry) => join(changesetsDir, entry.name)) + .sort(); +} + +function collectReleaseMetadataDiagnostics(options: Options): { + readonly blockedDiagnostics: readonly PackageDiagnostic[]; + readonly checkedCount: number; + readonly pendingDiagnostics: readonly PackageDiagnostic[]; + readonly skippedPrivateCount: number; +} { + const packageJsonFiles = findPackageJsonFiles(join(options.rootDir, "packages")); + const pendingPackages = options.allowPendingChangesets + ? pendingChangesetPackages(options.rootDir) + : new Set(); + const blockedDiagnostics: PackageDiagnostic[] = []; + const pendingDiagnostics: PackageDiagnostic[] = []; + let checkedCount = 0; + let skippedPrivateCount = 0; + + for (const packagePath of packageJsonFiles) { + const manifest = readPackageJson(packagePath); + + if (manifest.private === true) { + skippedPrivateCount++; + continue; + } + + checkedCount++; + + const packageName = + typeof manifest.name === "string" && manifest.name.length > 0 + ? manifest.name + : relative(options.rootDir, dirname(packagePath)); + const codes: DiagnosticCode[] = []; + const messages: string[] = []; + + if (typeof manifest.name !== "string" || manifest.name.length === 0) { + codes.push("MISSING_NAME"); + messages.push("name must be a non-empty string"); + } + + if (typeof manifest.version !== "string" || manifest.version.length === 0) { + codes.push("MISSING_VERSION"); + messages.push("version must be a non-empty string"); + } else if (manifest.version === "0.0.0") { + codes.push("PLACEHOLDER_VERSION"); + messages.push('version is "0.0.0"'); + } + + if (!existsSync(join(dirname(packagePath), "CHANGELOG.md"))) { + codes.push("MISSING_CHANGELOG"); + messages.push("CHANGELOG.md is missing"); + } + + if (messages.length === 0) { + continue; + } + + const diagnostic = { + codes, + messages, + packageName, + relativePath: relative(options.rootDir, packagePath), + }; + + if ( + options.allowPendingChangesets && + typeof manifest.name === "string" && + pendingPackages.has(manifest.name) && + codes.every((code) => pendingChangesetRecoverableCodes.has(code)) + ) { + pendingDiagnostics.push(diagnostic); + continue; + } + + blockedDiagnostics.push(diagnostic); + } + + return { + blockedDiagnostics, + checkedCount, + pendingDiagnostics, + skippedPrivateCount, + }; +} + +function printDiagnostics(heading: string, diagnostics: readonly PackageDiagnostic[]): void { + if (diagnostics.length === 0) { + return; + } + + log(""); + log(heading); + for (const diagnostic of diagnostics) { + log( + `- ${diagnostic.relativePath} (${diagnostic.packageName}): ${diagnostic.messages.join("; ")}`, + ); + } +} + +function printRecovery(): void { + log(""); + log("Recovery:"); + log( + "- Add or update a .changeset/*.md entry for each affected package, then let `pnpm version-packages` / the Changesets release PR write package versions and CHANGELOG.md.", + ); + log("- Do not manually edit package versions to bypass this gate."); +} + +function main(): void { + const options = parseArgs(argv.slice(2)); + const result = collectReleaseMetadataDiagnostics(options); + + log("=== Release metadata summary ==="); + log(`Checked publishable: ${result.checkedCount}`); + log(`Skipped private/non-published tooling: ${result.skippedPrivateCount}`); + log(`Pending changeset recoveries: ${result.pendingDiagnostics.length}`); + + printDiagnostics("Pending changeset metadata recoveries:", result.pendingDiagnostics); + printDiagnostics("Release metadata violations:", result.blockedDiagnostics); + + if (result.blockedDiagnostics.length > 0) { + printRecovery(); + exit(1); + } + + if (result.pendingDiagnostics.length > 0) { + log(""); + log( + "OK: Release metadata placeholders are covered by pending changesets. Final publish candidates must pass without --allow-pending-changesets.", + ); + return; + } + + log(""); + log("OK: Release metadata is publish-ready."); +} + +main(); diff --git a/scripts/tests/release-metadata-check.spec.ts b/scripts/tests/release-metadata-check.spec.ts new file mode 100644 index 000000000..474ef1f39 --- /dev/null +++ b/scripts/tests/release-metadata-check.spec.ts @@ -0,0 +1,196 @@ +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +const scriptPath = resolve(__dirname, "../release-metadata-check.mts"); +const tempRoots: string[] = []; + +type ScriptResult = { + readonly stdout: string; + readonly stderr: string; + readonly status: number | null; +}; + +describe("release-metadata-check.mts", () => { + afterEach(() => { + for (const root of tempRoots.splice(0)) { + rmSync(root, { force: true, recursive: true }); + } + }); + + it("passes publishable packages with non-placeholder versions and changelogs", () => { + const root = createTempRoot(); + writePackage(root, "valid", { + name: "@croco/valid", + version: "0.1.0", + }); + writePackage( + root, + "private-tooling", + { + name: "@croco/private-tooling", + private: true, + version: "0.0.0", + }, + { changelog: false }, + ); + + const result = runScript(root); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("Checked publishable: 1"); + expect(result.stdout).toContain("Skipped private/non-published tooling: 1"); + expect(result.stdout).toContain("Release metadata is publish-ready"); + }); + + it("reports placeholder versions and missing changelogs with changesets recovery guidance", () => { + const root = createTempRoot(); + writePackage( + root, + "placeholder", + { + name: "@croco/placeholder", + version: "0.0.0", + }, + { changelog: false }, + ); + + const result = runScript(root); + + expect(result.status).toBe(1); + expect(result.stdout).toContain("packages/placeholder/package.json"); + expect(result.stdout).toContain( + '@croco/placeholder): version is "0.0.0"; CHANGELOG.md is missing', + ); + expect(result.stdout).toContain("Add or update a .changeset/*.md entry"); + expect(result.stdout).toContain("let `pnpm version-packages` / the Changesets release PR"); + expect(result.stdout).toContain("Do not manually edit package versions"); + }); + + it("reports missing changelogs separately from valid versions", () => { + const root = createTempRoot(); + writePackage( + root, + "missing-changelog", + { + name: "@croco/missing-changelog", + version: "0.2.0", + }, + { changelog: false }, + ); + + const result = runScript(root); + + expect(result.status).toBe(1); + expect(result.stdout).toContain("packages/missing-changelog/package.json"); + expect(result.stdout).toContain("CHANGELOG.md is missing"); + expect(result.stdout).not.toContain('version is "0.0.0"'); + }); + + it("rejects invalid publishable package metadata", () => { + const root = createTempRoot(); + writePackage(root, "invalid-name", { + version: "0.1.0", + }); + writePackage(root, "invalid-version", { + name: "@croco/invalid-version", + }); + + const result = runScript(root); + + expect(result.status).toBe(1); + expect(result.stdout).toContain("packages/invalid-name/package.json"); + expect(result.stdout).toContain("name must be a non-empty string"); + expect(result.stdout).toContain("packages/invalid-version/package.json"); + expect(result.stdout).toContain("version must be a non-empty string"); + }); + + it("allows placeholder metadata only when pending changesets are explicitly accepted", () => { + const root = createTempRoot(); + writePackage( + root, + "scheduled", + { + name: "@croco/scheduled", + version: "0.0.0", + }, + { changelog: false }, + ); + writeChangeset(root, "scheduled.md", '"@croco/scheduled": minor'); + + const strictResult = runScript(root); + const allowedResult = runScript(root, "--allow-pending-changesets"); + + expect(strictResult.status).toBe(1); + expect(allowedResult.status).toBe(0); + expect(allowedResult.stdout).toContain("Pending changeset metadata recoveries:"); + expect(allowedResult.stdout).toContain( + "Final publish candidates must pass without --allow-pending-changesets", + ); + }); + + it("does not allow pending changesets to hide invalid manifest fields", () => { + const root = createTempRoot(); + writePackage( + root, + "invalid-scheduled", + { + name: "@croco/invalid-scheduled", + }, + { changelog: false }, + ); + writeChangeset(root, "invalid-scheduled.md", '"@croco/invalid-scheduled": patch'); + + const result = runScript(root, "--allow-pending-changesets"); + + expect(result.status).toBe(1); + expect(result.stdout).toContain("version must be a non-empty string"); + }); +}); + +function createTempRoot(): string { + const root = mkdtempSync(join(tmpdir(), "croco-release-metadata-")); + tempRoots.push(root); + mkdirSync(join(root, "packages"), { recursive: true }); + return root; +} + +function writePackage( + root: string, + directoryName: string, + manifest: Record, + options: { readonly changelog?: boolean } = {}, +): void { + const packageDir = join(root, "packages", directoryName); + mkdirSync(packageDir, { recursive: true }); + writeFileSync(join(packageDir, "package.json"), `${JSON.stringify(manifest, null, 2)}\n`); + + if (options.changelog !== false) { + writeFileSync(join(packageDir, "CHANGELOG.md"), `# ${manifest.name ?? directoryName}\n`); + } +} + +function writeChangeset(root: string, filename: string, frontmatterLine: string): void { + const changesetsDir = join(root, ".changeset"); + mkdirSync(changesetsDir, { recursive: true }); + writeFileSync(join(changesetsDir, filename), `---\n${frontmatterLine}\n---\n\nRelease note.\n`); +} + +function runScript(root: string, ...args: string[]): ScriptResult { + const result = spawnSync( + "node", + ["--experimental-strip-types", scriptPath, "--root", root, ...args], + { + encoding: "utf-8", + timeout: 10_000, + }, + ); + + return { + stdout: result.stdout, + stderr: result.stderr, + status: result.status, + }; +} diff --git a/scripts/tests/release-workflow.spec.ts b/scripts/tests/release-workflow.spec.ts index 24d0ad33d..c405d2816 100644 --- a/scripts/tests/release-workflow.spec.ts +++ b/scripts/tests/release-workflow.spec.ts @@ -15,15 +15,29 @@ const getWorkflowPattern = (variableName: string) => { return new RegExp(match?.[1] ?? ""); }; -const getReleaseWorkPattern = () => getWorkflowPattern("release_work_pattern"); +const getReleasePrUpdatePattern = () => getWorkflowPattern("release_pr_update_pattern"); + +const getReleasePrUpdateIgnorePattern = () => + getWorkflowPattern("release_pr_update_ignore_pattern"); + +const getPublishCandidatePattern = () => getWorkflowPattern("publish_candidate_pattern"); const getReleaseGateMaintenancePattern = () => getWorkflowPattern("release_gate_maintenance_pattern"); -const shouldRunReleaseWork = (changedFiles: string[]) => { - const releaseWorkPattern = getReleaseWorkPattern(); +const shouldUpdateReleasePr = (changedFiles: string[]) => { + const releasePrUpdatePattern = getReleasePrUpdatePattern(); + const releasePrUpdateIgnorePattern = getReleasePrUpdateIgnorePattern(); + + return changedFiles.some( + (file) => !releasePrUpdateIgnorePattern.test(file) && releasePrUpdatePattern.test(file), + ); +}; + +const shouldRunPublishGates = (changedFiles: string[]) => { + const publishCandidatePattern = getPublishCandidatePattern(); - return changedFiles.some((file) => releaseWorkPattern.test(file)); + return changedFiles.some((file) => publishCandidatePattern.test(file)); }; const shouldRunReleaseGateMaintenance = (changedFiles: string[]) => { @@ -33,7 +47,7 @@ const shouldRunReleaseGateMaintenance = (changedFiles: string[]) => { }; describe("release workflow quality gates", () => { - it("runs publish-blocking quality gates before dry-run publish and Changesets", () => { + it("runs publish-blocking quality gates before dry-run publish and Changesets publish", () => { const workflow = readReleaseWorkflow(); const orderedMarkers = [ "- name: Install dependencies", @@ -55,6 +69,8 @@ describe("release workflow quality gates", () => { "- name: Verify npm provenance configuration", 'npm_provenance="$(npm config get provenance)"', 'pnpm_provenance="$(pnpm config get provenance)"', + "- name: Release metadata check", + "run: node --experimental-strip-types scripts/release-metadata-check.mts", "- name: Dry-run publish gate", "run: pnpm -r publish --dry-run --no-git-checks", "- name: Create Release Pull Request or Publish", @@ -87,17 +103,47 @@ describe("release workflow quality gates", () => { expect(workflow).toContain("id-token: write"); }); - it("runs for Changesets prerelease state changes", () => { - expect(shouldRunReleaseWork([".changeset/pre.json"])).toBe(true); - expect(shouldRunReleaseGateMaintenance([".changeset/pre.json"])).toBe(false); + it("routes raw changesets to release PR updates without publish gates", () => { + expect(shouldUpdateReleasePr([".changeset/new-version.md"])).toBe(true); + expect(shouldUpdateReleasePr([".changeset/pre.json"])).toBe(true); + expect(shouldUpdateReleasePr([".changeset/README.md"])).toBe(false); + expect(shouldRunPublishGates([".changeset/new-version.md"])).toBe(false); + expect(shouldRunPublishGates([".changeset/pre.json"])).toBe(false); + expect(shouldRunPublishGates([".changeset/README.md"])).toBe(false); + expect(shouldRunReleaseGateMaintenance([".changeset/new-version.md"])).toBe(false); + expect(shouldRunReleaseGateMaintenance([".changeset/README.md"])).toBe(false); }); - it("keeps existing release-work file triggers", () => { - expect(shouldRunReleaseWork([".changeset/new-version.md"])).toBe(true); - expect(shouldRunReleaseWork(["packages/framework-context/package.json"])).toBe(true); - expect(shouldRunReleaseWork(["packages/framework-context/CHANGELOG.md"])).toBe(true); - expect(shouldRunReleaseWork(["package.json"])).toBe(true); - expect(shouldRunReleaseWork(["pnpm-lock.yaml"])).toBe(true); + it("routes versioned package and root publish candidates to publish gates", () => { + const publishCandidateFiles = [ + "packages/framework-context/package.json", + "packages/framework-context/CHANGELOG.md", + "package.json", + "pnpm-lock.yaml", + ]; + + for (const file of publishCandidateFiles) { + expect(shouldRunPublishGates([file]), `${file} should run publish gates`).toBe(true); + expect(shouldUpdateReleasePr([file]), `${file} should not update release PR alone`).toBe( + false, + ); + } + }); + + it("runs both release PR updates and publish gates for mixed changeset and publish candidates", () => { + const changedFiles = [".changeset/new-version.md", "pnpm-lock.yaml"]; + + expect(shouldUpdateReleasePr(changedFiles)).toBe(true); + expect(shouldRunPublishGates(changedFiles)).toBe(true); + }); + + it("keeps manual dispatch on both release PR update and publish-gate paths", () => { + const workflow = readReleaseWorkflow(); + + expect(workflow).toContain('if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then'); + expect(workflow).toContain('echo "should_update_release_pr=true"'); + expect(workflow).toContain('echo "should_run_publish_gates=true"'); + expect(workflow).toContain('} >> "$GITHUB_OUTPUT"'); }); it("runs focused self-checks for release-gate maintenance changes", () => { @@ -110,11 +156,13 @@ describe("release workflow quality gates", () => { "scripts/package-entrypoint-smoke.mts", "scripts/package-manifest-contracts.mjs", "scripts/release-docs-check.mts", + "scripts/release-metadata-check.mts", "scripts/tests/changeset-required-check.spec.ts", "scripts/tests/normalize-packages.spec.ts", "scripts/tests/package-bin-smoke.spec.ts", "scripts/tests/package-entrypoint-smoke.spec.ts", "scripts/tests/release-docs-check.spec.ts", + "scripts/tests/release-metadata-check.spec.ts", "scripts/tests/release-workflow.spec.ts", ]; @@ -123,7 +171,10 @@ describe("release workflow quality gates", () => { shouldRunReleaseGateMaintenance([file]), `${file} should trigger release gate maintenance`, ).toBe(true); - expect(shouldRunReleaseWork([file]), `${file} should not be release metadata`).toBe(false); + expect(shouldUpdateReleasePr([file]), `${file} should not update release PR`).toBe(false); + expect(shouldRunPublishGates([file]), `${file} should not be a publish candidate`).toBe( + false, + ); } expect(workflow).toContain("- name: Release gate maintenance self-check"); @@ -131,18 +182,24 @@ describe("release workflow quality gates", () => { "if: steps.release_work.outputs.should_verify_release_gate_maintenance == 'true'", ); expect(workflow).toContain("pnpm exec vitest run scripts/tests/release-workflow.spec.ts"); - expect(workflow).toContain("scripts/tests/normalize-packages.spec.ts"); + expect(workflow).toContain("scripts/tests/release-metadata-check.spec.ts"); expect(workflow).toContain("pnpm package-manifests:check"); expect(workflow).toContain("pnpm release-docs:check"); + expect(workflow).toContain( + "node --experimental-strip-types scripts/release-metadata-check.mts --allow-pending-changesets", + ); expect(workflow).toContain("pnpm package-entrypoints:smoke"); expect(workflow).toContain("pnpm package-bins:smoke"); - expect(workflow).toContain("if: steps.release_work.outputs.should_publish == 'true'"); + expect(workflow).toContain("if: steps.release_work.outputs.should_run_publish_gates == 'true'"); }); it("skips non-release-only changes", () => { - expect(shouldRunReleaseWork(["RELEASING.md"])).toBe(false); - expect(shouldRunReleaseWork(["packages/framework-context/src/index.ts"])).toBe(false); - expect(shouldRunReleaseWork([".changeset/config.json"])).toBe(false); + expect(shouldUpdateReleasePr(["RELEASING.md"])).toBe(false); + expect(shouldUpdateReleasePr(["packages/framework-context/src/index.ts"])).toBe(false); + expect(shouldUpdateReleasePr([".changeset/config.json"])).toBe(false); + expect(shouldRunPublishGates(["RELEASING.md"])).toBe(false); + expect(shouldRunPublishGates(["packages/framework-context/src/index.ts"])).toBe(false); + expect(shouldRunPublishGates([".changeset/config.json"])).toBe(false); expect(shouldRunReleaseGateMaintenance(["RELEASING.md"])).toBe(false); expect(shouldRunReleaseGateMaintenance(["packages/framework-context/src/index.ts"])).toBe( false,