diff --git a/package.json b/package.json index c0053d1d16..c2a29dca21 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "typecheck:packages": "pnpm -r --filter \"@daypicker/*\" typecheck && pnpm --filter react-day-picker typecheck", "check:versions": "node ./scripts/check-package-versions.mjs", "pack:dry-run": "node ./scripts/npm-pack-dry-run.mjs", + "release:ci": "pnpm exec tsx ./scripts/release-ci.ts", "typecheck-watch": "tsc --project ./tsconfig.json --noEmit --watch" }, "devDependencies": { diff --git a/scripts/create-github-release.test.ts b/scripts/create-github-release.test.ts new file mode 100644 index 0000000000..e8081e4ee8 --- /dev/null +++ b/scripts/create-github-release.test.ts @@ -0,0 +1,159 @@ +type CreateGitHubReleaseScriptModule = typeof import("./create-github-release"); + +let createGitHubRelease: CreateGitHubReleaseScriptModule["createGitHubRelease"]; +let releaseContext: ReturnType; +let createReleaseFetchMock: jest.MockedFunction; +const originalCreateReleaseFetch = global.fetch; + +beforeAll(async function loadModule() { + ({ createGitHubRelease } = await import("./create-github-release")); +}); + +beforeEach(function setupTestState() { + releaseContext = createReleaseContext(); + createReleaseFetchMock = jest.fn() as jest.MockedFunction; + global.fetch = createReleaseFetchMock; +}); + +afterEach(function restoreFetch() { + global.fetch = originalCreateReleaseFetch; +}); + +function createReleaseContext( + overrides: Partial<{ + repository: string; + token: string; + commitSha: string; + packageVersion: string; + }> = {}, +) { + return { + repository: "gpbl/react-day-picker", + token: "test-token", + commitSha: "abc123", + packageVersion: "10.0.0-next.1", + ...overrides, + }; +} + +function createReleasePayload( + overrides: Partial<{ + html_url: string; + }> = {}, +) { + return { + html_url: + "https://github.com/gpbl/react-day-picker/releases/tag/v10.0.0-next.1", + ...overrides, + }; +} + +function createGitHubFetchResponse( + overrides: Partial<{ + json: () => Promise; + ok: boolean; + status: number; + }> = {}, +) { + return { + ok: true, + status: 200, + json: async () => createReleasePayload(), + ...overrides, + } as Response; +} + +describe("createGitHubRelease", function describeCreateGitHubRelease() { + test("it reuses an existing release for the repo version", async function testExistingRelease() { + createReleaseFetchMock.mockResolvedValueOnce(createGitHubFetchResponse()); + + await expect(createGitHubRelease(releaseContext)).resolves.toEqual({ + created: false, + release: createReleasePayload(), + tag: "v10.0.0-next.1", + }); + + expect(createReleaseFetchMock).toHaveBeenCalledTimes(1); + }); + + test("it creates the release when the tag does not exist yet", async function testCreateReleaseOn404() { + createReleaseFetchMock + .mockResolvedValueOnce( + createGitHubFetchResponse({ + ok: false, + status: 404, + }), + ) + .mockResolvedValueOnce( + createGitHubFetchResponse({ + json: async () => createReleasePayload(), + }), + ); + + await expect(createGitHubRelease(releaseContext)).resolves.toEqual({ + created: true, + release: createReleasePayload(), + tag: "v10.0.0-next.1", + }); + + expect(createReleaseFetchMock).toHaveBeenNthCalledWith( + 2, + "https://api.github.com/repos/gpbl/react-day-picker/releases", + expect.objectContaining({ + method: "POST", + }), + ); + }); + + test("it creates stable releases without the prerelease flag", async function testStableReleaseFlag() { + releaseContext.packageVersion = "10.0.0"; + createReleaseFetchMock + .mockResolvedValueOnce( + createGitHubFetchResponse({ + ok: false, + status: 404, + }), + ) + .mockResolvedValueOnce(createGitHubFetchResponse()); + + await createGitHubRelease(releaseContext); + + const createRequest = createReleaseFetchMock.mock.calls[1]?.[1]; + const createBody = + createRequest && + typeof createRequest === "object" && + "body" in createRequest && + typeof createRequest.body === "string" + ? JSON.parse(createRequest.body) + : undefined; + + expect(createBody).toMatchObject({ + name: "v10.0.0", + prerelease: false, + tag_name: "v10.0.0", + target_commitish: "abc123", + }); + }); + + test("it rejects invalid repository values", async function testInvalidRepository() { + releaseContext.repository = "react-day-picker"; + + await expect(createGitHubRelease(releaseContext)).rejects.toThrow( + "Invalid GITHUB_REPOSITORY value: react-day-picker", + ); + expect(createReleaseFetchMock).not.toHaveBeenCalled(); + }); + + test("it rethrows unexpected lookup failures", async function testUnexpectedLookupFailure() { + createReleaseFetchMock.mockResolvedValueOnce( + createGitHubFetchResponse({ + ok: false, + status: 500, + }), + ); + + await expect(createGitHubRelease(releaseContext)).rejects.toThrow( + "Could not read GitHub Release v10.0.0-next.1 (HTTP 500).", + ); + }); +}); diff --git a/scripts/create-github-release.ts b/scripts/create-github-release.ts new file mode 100644 index 0000000000..8842a8ad60 --- /dev/null +++ b/scripts/create-github-release.ts @@ -0,0 +1,143 @@ +/** + * Reads the GitHub Release for the version tag if it already exists. + */ +async function fetchReleaseByTag(request: { + owner: string; + repo: string; + tag: string; + token: string; +}): Promise<{ + html_url: string; +}> { + const { owner, repo, tag, token } = request; + const response = await fetch( + `https://api.github.com/repos/${owner}/${repo}/releases/tags/${encodeURIComponent(tag)}`, + { + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + // Pin the REST API version so release automation does not drift with + // GitHub's default behavior over time. + "X-GitHub-Api-Version": "2022-11-28", + }, + }, + ); + + if (response.status === 404) { + throw Object.assign(new Error(`GitHub Release ${tag} was not found.`), { + status: 404, + }); + } + + if (!response.ok) { + throw new Error( + `Could not read GitHub Release ${tag} (HTTP ${response.status}).`, + ); + } + + return response.json() as Promise<{ + html_url: string; + }>; +} + +/** + * Creates the repo-level GitHub Release after npm publish succeeds. + */ +async function createRelease(request: { + owner: string; + repo: string; + tag: string; + token: string; + commitSha: string; + prerelease: boolean; +}): Promise<{ + html_url: string; +}> { + const { owner, repo, tag, token, commitSha, prerelease } = request; + const response = await fetch( + `https://api.github.com/repos/${owner}/${repo}/releases`, + { + method: "POST", + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + // Pin the REST API version so release automation does not drift with + // GitHub's default behavior over time. + "X-GitHub-Api-Version": "2022-11-28", + }, + body: JSON.stringify({ + tag_name: tag, + target_commitish: commitSha, + name: tag, + draft: false, + prerelease, + generate_release_notes: true, + }), + }, + ); + + if (!response.ok) { + throw new Error( + `Could not create GitHub Release ${tag} (HTTP ${response.status}).`, + ); + } + + return response.json() as Promise<{ + html_url: string; + }>; +} + +/** + * Ensures the repo has a single GitHub Release for the published version. + * + * The helper is intentionally idempotent so reruns can recover from a partial + * publish where npm succeeded but GitHub Release creation did not. + */ +export async function createGitHubRelease(context: { + repository: string; + token: string; + commitSha: string; + packageVersion: string; +}): Promise<{ + created: boolean; + release: { + html_url: string; + }; + tag: string; +}> { + const [owner, repo] = context.repository.split("/"); + if (!owner || !repo) { + throw new Error(`Invalid GITHUB_REPOSITORY value: ${context.repository}`); + } + + const tag = `v${context.packageVersion}`; + const isPrereleaseVersion = context.packageVersion.includes("-next"); + + try { + const existingRelease = await fetchReleaseByTag({ + owner, + repo, + tag, + token: context.token, + }); + return { created: false, release: existingRelease, tag }; + } catch (error) { + if ( + !(error instanceof Error && "status" in error && error.status === 404) + ) { + throw error; + } + } + + const createdRelease = await createRelease({ + owner, + repo, + tag, + token: context.token, + commitSha: context.commitSha, + prerelease: isPrereleaseVersion, + }); + + return { created: true, release: createdRelease, tag }; +} diff --git a/scripts/publish-packages.ts b/scripts/publish-packages.ts index 6ec5a01467..7dff106396 100644 --- a/scripts/publish-packages.ts +++ b/scripts/publish-packages.ts @@ -14,17 +14,10 @@ const packageDirs = [ "packages/persian", ] as const; -export interface PackageInfo { +export function readPackageInfo(packageDir: string): { name: string; version: string; -} - -export interface UnpublishedPackage { - packageDir: string; - packageInfo: PackageInfo; -} - -export function readPackageInfo(packageDir: string): PackageInfo { +} { const packageJsonPath = new URL(`${packageDir}/package.json`, repoRoot); const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { name: string; @@ -59,7 +52,10 @@ function isPackageVersionMissingError(error: unknown): boolean { ); } -function isPackageVersionPublished(packageInfo: PackageInfo): boolean { +function isPackageVersionPublished(packageInfo: { + name: string; + version: string; +}): boolean { try { execFileSync( "npm", @@ -78,7 +74,13 @@ function isPackageVersionPublished(packageInfo: PackageInfo): boolean { } } -export function getUnpublishedPackages(): UnpublishedPackage[] { +export function getUnpublishedPackages(): Array<{ + packageDir: string; + packageInfo: { + name: string; + version: string; + }; +}> { return packageDirs.flatMap((packageDir) => { const packageInfo = readPackageInfo(packageDir); return isPackageVersionPublished(packageInfo) @@ -87,22 +89,6 @@ export function getUnpublishedPackages(): UnpublishedPackage[] { }); } -function publishPackage( - packageDir: string, - packageInfo: PackageInfo, - tag: string, -): void { - const publishArgs = ["publish", "--provenance", "--tag", tag]; - if (packageInfo.name.startsWith("@")) { - publishArgs.push("--access", "public"); - } - - execFileSync("npm", publishArgs, { - cwd: new URL(`../${packageDir}`, import.meta.url), - stdio: "inherit", - }); -} - export function publishPackages(tag: string): void { if (!tag) { throw new Error("Usage: publish-packages "); @@ -117,11 +103,19 @@ export function publishPackages(tag: string): void { continue; } - publishPackage(packageDir, packageInfo, tag); + const publishArgs = ["publish", "--provenance", "--tag", tag]; + if (packageInfo.name.startsWith("@")) { + publishArgs.push("--access", "public"); + } + + execFileSync("npm", publishArgs, { + cwd: new URL(`../${packageDir}`, import.meta.url), + stdio: "inherit", + }); } } -export function main(): void { +function main(): void { publishPackages(process.argv[2] || ""); } diff --git a/scripts/release-ci.test.ts b/scripts/release-ci.test.ts new file mode 100644 index 0000000000..71a9a0a88a --- /dev/null +++ b/scripts/release-ci.test.ts @@ -0,0 +1,190 @@ +type ReleaseCiModule = typeof import("./release-ci"); + +type ReleaseCiExecCall = { + args: string[]; + command: string; + options?: unknown; +}; + +let releaseCi: ReleaseCiModule["releaseCi"]; +let execFileSyncMock: jest.Mock; +let createGitHubReleaseMock: jest.Mock; +let getUnpublishedPackagesMock: jest.Mock; +let publishPackagesMock: jest.Mock; +let readPackageInfoMock: jest.Mock; +let shouldPublishReleaseMock: jest.Mock; +let releaseCiExecCalls: ReleaseCiExecCall[]; +let originalEnv: NodeJS.ProcessEnv; + +jest.mock("node:child_process", () => ({ + execFileSync: jest.fn(), +})); + +jest.mock("./create-github-release", () => ({ + createGitHubRelease: jest.fn(), +})); + +jest.mock("./publish-packages", () => ({ + getUnpublishedPackages: jest.fn(), + publishPackages: jest.fn(), + readPackageInfo: jest.fn(), +})); + +jest.mock("./should-publish-release", () => ({ + shouldPublishRelease: jest.fn(), +})); + +beforeAll(async function loadModule() { + execFileSyncMock = (await import("node:child_process")) + .execFileSync as unknown as jest.Mock; + createGitHubReleaseMock = (await import("./create-github-release")) + .createGitHubRelease as unknown as jest.Mock; + getUnpublishedPackagesMock = (await import("./publish-packages")) + .getUnpublishedPackages as unknown as jest.Mock; + publishPackagesMock = (await import("./publish-packages")) + .publishPackages as unknown as jest.Mock; + readPackageInfoMock = (await import("./publish-packages")) + .readPackageInfo as unknown as jest.Mock; + shouldPublishReleaseMock = (await import("./should-publish-release")) + .shouldPublishRelease as unknown as jest.Mock; + ({ releaseCi } = await import("./release-ci")); +}); + +beforeEach(function setupReleaseCiTestState() { + releaseCiExecCalls = []; + originalEnv = { ...process.env }; + process.env = { + ...process.env, + GITHUB_REPOSITORY: "gpbl/react-day-picker", + GITHUB_TOKEN: "test-token", + }; + + jest.resetAllMocks(); + + execFileSyncMock.mockImplementation( + function mockExecFile(command, args, options) { + releaseCiExecCalls.push({ + command: String(command), + args: Array.isArray(args) ? [...args] : [], + options, + }); + if (command === "git" && Array.isArray(args) && args[0] === "rev-parse") { + return "abc123\n"; + } + return ""; + }, + ); + + readPackageInfoMock.mockImplementation(function mockReadPackage(_packageDir) { + return { + name: "react-day-picker", + version: "10.0.0-next.3", + }; + }); + + getUnpublishedPackagesMock.mockImplementation(function mockGetUnpublished() { + return [ + { + packageDir: "packages/react-day-picker", + packageInfo: { + name: "react-day-picker", + version: "10.0.0-next.3", + }, + }, + ]; + }); + + publishPackagesMock.mockImplementation(function mockPublish() {}); + + shouldPublishReleaseMock.mockImplementation( + async function mockShouldPublish() { + return true; + }, + ); + + createGitHubReleaseMock.mockImplementation( + async function mockCreateRelease() { + return { + created: true, + release: { + html_url: + "https://github.com/gpbl/react-day-picker/releases/tag/v10.0.0-next.3", + }, + tag: "v10.0.0-next.3", + }; + }, + ); +}); + +afterEach(function restoreEnv() { + process.env = originalEnv; +}); + +describe("releaseCi", function describeReleaseCi() { + test("it skips when the checked-out commit is not the merged release PR", async function testSkipNonReleaseCommit() { + shouldPublishReleaseMock.mockResolvedValue(false); + + await expect(releaseCi()).resolves.toEqual({ + shouldPublish: false, + publishedPackages: false, + releaseCreated: false, + }); + + expect(getUnpublishedPackagesMock).not.toHaveBeenCalled(); + expect(publishPackagesMock).not.toHaveBeenCalled(); + expect(createGitHubReleaseMock).not.toHaveBeenCalled(); + }); + + test("it validates, publishes, and creates the repo release when versions are unpublished", async function testPublishPath() { + await expect(releaseCi()).resolves.toEqual({ + shouldPublish: true, + publishedPackages: true, + releaseCreated: true, + }); + + expect(shouldPublishReleaseMock).toHaveBeenCalledWith({ + repository: "gpbl/react-day-picker", + token: "test-token", + commitSha: "abc123", + expectedHeadBranch: "changesets-release/main", + expectedBaseBranch: "main", + }); + expect( + releaseCiExecCalls.map((call) => [call.command, ...call.args]), + ).toEqual([ + ["git", "rev-parse", "HEAD"], + ["pnpm", "typecheck"], + ["pnpm", "lint", "ci", ".", "--reporter=github"], + ["pnpm", "test"], + ["pnpm", "test:tz"], + ["pnpm", "build"], + ["pnpm", "check:versions"], + ["pnpm", "pack:dry-run"], + ["pnpm", "test:build"], + ]); + expect(getUnpublishedPackagesMock).toHaveBeenCalledWith(); + expect(publishPackagesMock).toHaveBeenCalledWith("next"); + expect(createGitHubReleaseMock).toHaveBeenCalledWith({ + repository: "gpbl/react-day-picker", + token: "test-token", + commitSha: "abc123", + packageVersion: "10.0.0-next.3", + }); + }); + + test("it still creates the repo release when packages are already published", async function testCreateReleaseWithoutPublishing() { + getUnpublishedPackagesMock.mockReturnValue([]); + + await expect(releaseCi()).resolves.toEqual({ + shouldPublish: true, + publishedPackages: false, + releaseCreated: true, + }); + + expect( + releaseCiExecCalls.map((call) => [call.command, ...call.args]), + ).toEqual([["git", "rev-parse", "HEAD"]]); + expect(publishPackagesMock).not.toHaveBeenCalled(); + expect(createGitHubReleaseMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/scripts/release-ci.ts b/scripts/release-ci.ts new file mode 100644 index 0000000000..55d7722967 --- /dev/null +++ b/scripts/release-ci.ts @@ -0,0 +1,144 @@ +import { execFileSync } from "node:child_process"; +import process from "node:process"; +import { pathToFileURL } from "node:url"; +import { createGitHubRelease } from "./create-github-release"; +import { + getUnpublishedPackages, + publishPackages, + readPackageInfo, +} from "./publish-packages"; +import { shouldPublishRelease } from "./should-publish-release"; + +const repoRoot = new URL("../", import.meta.url); +const mainPackageDir = "packages/react-day-picker"; +const expectedReleasePrBranch = "changesets-release/main"; +const expectedReleaseBaseBranch = "main"; + +// Keep the release workflow's validations in one ordered list so the publish +// path runs the same checks locally and in GitHub Actions. +const validationCommands = [ + ["typecheck"], + ["lint", "ci", ".", "--reporter=github"], + ["test"], + ["test:tz"], + ["build"], + ["check:versions"], + ["pack:dry-run"], + ["test:build"], +] as const; + +/** + * Runs the repo's release automation after Changesets marks a merge as + * publishable. + * + * The flow is: + * 1. verify that the current commit came from the merged release PR + * 2. publish any package versions that are still missing on npm + * 3. ensure the repo-level GitHub Release exists for that version + */ +export async function releaseCi(): Promise<{ + shouldPublish: boolean; + publishedPackages: boolean; + releaseCreated: boolean; +}> { + const repository = process.env.GITHUB_REPOSITORY; + const token = process.env.GITHUB_TOKEN; + if (!repository) { + throw new Error("Missing required environment variable: GITHUB_REPOSITORY"); + } + if (!token) { + throw new Error("Missing required environment variable: GITHUB_TOKEN"); + } + + const commitSha = String( + execFileSync("git", ["rev-parse", "HEAD"], { + cwd: repoRoot, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }), + ).trim(); + const packageInfo = readPackageInfo(mainPackageDir); + + const isReleaseCommit = await shouldPublishRelease({ + repository, + token, + commitSha, + expectedHeadBranch: expectedReleasePrBranch, + expectedBaseBranch: expectedReleaseBaseBranch, + }); + + if (!isReleaseCommit) { + console.log( + "This commit did not come from the merged Changesets release PR. Skipping release automation.", + ); + return { + shouldPublish: false, + publishedPackages: false, + releaseCreated: false, + }; + } + + const unpublishedPackageVersions = getUnpublishedPackages(); + let publishedPackages = false; + + if (unpublishedPackageVersions.length > 0) { + for (const commandArgs of validationCommands) { + execFileSync("pnpm", [...commandArgs], { + cwd: repoRoot, + stdio: "inherit", + }); + } + + const npmTag = packageInfo.version.includes("-next") ? "next" : "latest"; + console.log(`Publishing ${packageInfo.version} with dist-tag ${npmTag}.`); + publishPackages(npmTag); + publishedPackages = true; + } else { + console.log("All publishable package versions are already on npm."); + } + + const releaseResult = await createGitHubRelease({ + repository, + token, + commitSha, + packageVersion: packageInfo.version, + }); + + return { + shouldPublish: true, + publishedPackages, + releaseCreated: releaseResult.created, + }; +} + +/** + * CLI entrypoint used by the release workflow and manual recovery runs. + */ +async function main(): Promise { + const result = await releaseCi(); + if (!result.shouldPublish) { + return; + } + + if (result.publishedPackages) { + console.log("Published package versions to npm."); + } + + if (result.releaseCreated) { + console.log("Created the repo GitHub Release."); + } else { + console.log("The repo GitHub Release already exists."); + } +} + +const scriptPath = process.argv[1]; +if (scriptPath && import.meta.url === pathToFileURL(scriptPath).href) { + main().catch((error: unknown) => { + if (error instanceof Error) { + console.error(error.message); + } else { + console.error(error); + } + process.exit(1); + }); +} diff --git a/scripts/should-publish-release.test.ts b/scripts/should-publish-release.test.ts new file mode 100644 index 0000000000..a8e759cea4 --- /dev/null +++ b/scripts/should-publish-release.test.ts @@ -0,0 +1,137 @@ +type ShouldPublishScriptModule = typeof import("./should-publish-release"); + +let shouldPublishRelease: ShouldPublishScriptModule["shouldPublishRelease"]; +let publishContext: ReturnType; +let shouldPublishFetchMock: jest.MockedFunction; +const originalShouldPublishFetch = global.fetch; + +beforeAll(async function loadModule() { + ({ shouldPublishRelease } = await import("./should-publish-release")); +}); + +beforeEach(function setupTestState() { + publishContext = createShouldPublishContext(); + shouldPublishFetchMock = jest.fn() as jest.MockedFunction; + global.fetch = shouldPublishFetchMock; +}); + +afterEach(function restoreFetch() { + global.fetch = originalShouldPublishFetch; +}); + +function createShouldPublishContext( + overrides: Partial<{ + repository: string; + token: string; + commitSha: string; + expectedHeadBranch: string; + expectedBaseBranch: string; + }> = {}, +) { + return { + repository: "gpbl/react-day-picker", + token: "test-token", + commitSha: "abc123", + expectedHeadBranch: "changesets-release/main", + expectedBaseBranch: "main", + ...overrides, + }; +} + +function createPullRequest( + overrides: Partial<{ + user: { login?: string } | null; + base: { ref?: string } | null; + head: { ref?: string } | null; + merged_at: string | null; + }> = {}, +) { + return { + user: { login: "github-actions[bot]" }, + base: { ref: "main" }, + head: { ref: "changesets-release/main" }, + merged_at: "2026-04-24T10:00:00.000Z", + ...overrides, + }; +} + +function createShouldPublishFetchResponse( + pullRequests: Array>, + overrides: Partial<{ + ok: boolean; + status: number; + }> = {}, +) { + return { + ok: true, + status: 200, + json: async () => pullRequests, + ...overrides, + } as Response; +} + +describe("shouldPublishRelease", function describeShouldPublishRelease() { + test("it returns true for the expected merged release PR", async function testReleasePullRequest() { + shouldPublishFetchMock.mockResolvedValueOnce( + createShouldPublishFetchResponse([createPullRequest()]), + ); + + await expect(shouldPublishRelease(publishContext)).resolves.toBe(true); + }); + + test("it looks up pull requests for the pushed commit", async function testLookupRequest() { + shouldPublishFetchMock.mockResolvedValueOnce( + createShouldPublishFetchResponse([createPullRequest()]), + ); + + await shouldPublishRelease(publishContext); + + expect(shouldPublishFetchMock).toHaveBeenCalledWith( + "https://api.github.com/repos/gpbl/react-day-picker/commits/abc123/pulls", + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "Bearer test-token", + }), + }), + ); + }); + + test("it returns false when no associated pull request matches", async function testNoMatch() { + shouldPublishFetchMock.mockResolvedValueOnce( + createShouldPublishFetchResponse([ + createPullRequest({ head: { ref: "docs/tweak-homepage-copy" } }), + ]), + ); + + await expect(shouldPublishRelease(publishContext)).resolves.toBe(false); + }); + + test("it ignores the pull request author when the release branch matches", async function testIgnoreAuthor() { + shouldPublishFetchMock.mockResolvedValueOnce( + createShouldPublishFetchResponse([ + createPullRequest({ user: { login: "someone-else" } }), + ]), + ); + + await expect(shouldPublishRelease(publishContext)).resolves.toBe(true); + }); + + test("it rejects invalid repository values", async function testInvalidRepository() { + publishContext.repository = "react-day-picker"; + + await expect(shouldPublishRelease(publishContext)).rejects.toThrow( + "Invalid GITHUB_REPOSITORY value: react-day-picker", + ); + expect(shouldPublishFetchMock).not.toHaveBeenCalled(); + }); + + test("it rejects unmerged release pull requests", async function testUnmergedPullRequest() { + shouldPublishFetchMock.mockResolvedValueOnce( + createShouldPublishFetchResponse([ + createPullRequest({ merged_at: null }), + ]), + ); + + await expect(shouldPublishRelease(publishContext)).resolves.toBe(false); + }); +}); diff --git a/scripts/should-publish-release.ts b/scripts/should-publish-release.ts new file mode 100644 index 0000000000..7055333145 --- /dev/null +++ b/scripts/should-publish-release.ts @@ -0,0 +1,79 @@ +/** + * Reads the pull requests GitHub associates with the given commit SHA. + */ +async function fetchAssociatedPullRequests(request: { + owner: string; + repo: string; + commitSha: string; + token: string; +}): Promise< + Array<{ + user: { login?: string } | null; + base: { ref?: string } | null; + head: { ref?: string } | null; + merged_at: string | null; + }> +> { + const { owner, repo, commitSha, token } = request; + const response = await fetch( + `https://api.github.com/repos/${owner}/${repo}/commits/${encodeURIComponent(commitSha)}/pulls`, + { + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + // Pin the REST API version so the publish gate keeps the same GitHub + // semantics even if the default API version changes later. + "X-GitHub-Api-Version": "2022-11-28", + }, + }, + ); + + if (!response.ok) { + throw new Error( + `Could not read pull requests associated with commit ${commitSha} (HTTP ${response.status}).`, + ); + } + + return response.json() as Promise< + Array<{ + user: { login?: string } | null; + base: { ref?: string } | null; + head: { ref?: string } | null; + merged_at: string | null; + }> + >; +} + +/** + * Returns true only when the commit belongs to the merged Changesets release + * PR for this repo. + * + * This protects the publish step from running on arbitrary pushes to `main`. + */ +export async function shouldPublishRelease(context: { + repository: string; + token: string; + commitSha: string; + expectedHeadBranch: string; + expectedBaseBranch: string; +}): Promise { + const [owner, repo] = context.repository.split("/"); + if (!owner || !repo) { + throw new Error(`Invalid GITHUB_REPOSITORY value: ${context.repository}`); + } + + const pullRequests = await fetchAssociatedPullRequests({ + owner, + repo, + commitSha: context.commitSha, + token: context.token, + }); + + return pullRequests.some((pullRequest) => { + return ( + pullRequest.head?.ref === context.expectedHeadBranch && + pullRequest.base?.ref === context.expectedBaseBranch && + Boolean(pullRequest.merged_at) + ); + }); +}