From 160f95ee88141f95b641f5588aae1affbfe9a8a6 Mon Sep 17 00:00:00 2001 From: Giampaolo Bellavite Date: Sun, 26 Apr 2026 09:14:04 +0200 Subject: [PATCH 1/4] build: run publish script with tsx --- .github/workflows/package.yml | 2 +- package.json | 1 + pnpm-lock.yaml | 55 ++++++-- scripts/publish-packages.d.mts | 45 ------- scripts/publish-packages.mjs | 190 -------------------------- scripts/publish-packages.test.ts | 224 +++++++++++++++++++------------ scripts/publish-packages.ts | 140 +++++++++++++++++++ tsconfig.json | 3 +- 8 files changed, 324 insertions(+), 336 deletions(-) delete mode 100644 scripts/publish-packages.d.mts delete mode 100644 scripts/publish-packages.mjs create mode 100644 scripts/publish-packages.ts diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index 471f1ae62a..88bc5955a5 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -210,4 +210,4 @@ jobs: echo "tag=$tag" >> "$GITHUB_OUTPUT" echo "Publishing $PACKAGE_VERSION with dist-tag $tag" - name: Publish packages - run: node ./scripts/publish-packages.mjs ${{ steps.npm-tag.outputs.tag }} + run: pnpm exec tsx ./scripts/publish-packages.ts ${{ steps.npm-tag.outputs.tag }} diff --git a/package.json b/package.json index 145b5a2809..c0053d1d16 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "react-day-picker": "workspace:*", "react-dom": "^19.2.5", "ts-node": "^10.9.2", + "tsx": "^4.20.6", "tslib": "^2.8.1", "typescript": "^6.0.3", "typescript-css-modules": "^1.0.4" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bac4980c68..69bbdc31b3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -98,6 +98,9 @@ importers: tslib: specifier: ^2.8.1 version: 2.8.1 + tsx: + specifier: ^4.20.6 + version: 4.21.0 typescript: specifier: ^6.0.3 version: 6.0.3 @@ -140,13 +143,13 @@ importers: version: 19.2.3(@types/react@19.2.14) '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.46.1)(yaml@2.8.3)) + version: 6.0.1(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) typescript: specifier: ^6.0.3 version: 6.0.3 vite: specifier: ^8.0.8 - version: 8.0.8(@types/node@25.6.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.46.1)(yaml@2.8.3) + version: 8.0.8(@types/node@25.6.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) apps/shadcn-app-v9: dependencies: @@ -155,7 +158,7 @@ importers: version: 1.2.4(@types/react@19.2.14)(react@19.2.5) '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.46.1)(yaml@2.8.3)) + version: 4.2.2(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -198,13 +201,13 @@ importers: version: 19.2.3(@types/react@19.2.14) '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.46.1)(yaml@2.8.3)) + version: 6.0.1(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) typescript: specifier: ^6.0.3 version: 6.0.3 vite: specifier: ^8.0.8 - version: 8.0.8(@types/node@25.6.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.46.1)(yaml@2.8.3) + version: 8.0.8(@types/node@25.6.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) apps/shadcn-app-workspace: dependencies: @@ -213,7 +216,7 @@ importers: version: 1.2.4(@types/react@19.2.14)(react@19.2.5) '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.46.1)(yaml@2.8.3)) + version: 4.2.2(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -256,13 +259,13 @@ importers: version: 19.2.3(@types/react@19.2.14) '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.46.1)(yaml@2.8.3)) + version: 6.0.1(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) typescript: specifier: ^6.0.3 version: 6.0.3 vite: specifier: ^8.0.8 - version: 8.0.8(@types/node@25.6.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.46.1)(yaml@2.8.3) + version: 8.0.8(@types/node@25.6.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) apps/website: dependencies: @@ -5211,6 +5214,9 @@ packages: resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} engines: {node: '>=18'} + get-tsconfig@4.14.0: + resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + github-slugger@1.5.0: resolution: {integrity: sha512-wIh+gKBI9Nshz2o46B0B3f5k/W+WI9ZAv6y5Dn5WJ5SK1t0TnDimB4WE5rmTD05ZAIn8HALCZVmCsvj0w0v0lw==} @@ -7742,6 +7748,9 @@ packages: resolve-pathname@3.0.0: resolution: {integrity: sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng==} + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + resolve-url@0.2.1: resolution: {integrity: sha512-ZuF55hVUQaaczgOIwqWzkEcEidmlD/xl44x1UZnhOXcYuFN2S6+rcxpG+C1N3So0wvNI3DmJICUFfu2SxhBmvg==} deprecated: https://github.com/lydell/resolve-url#deprecated @@ -8310,6 +8319,11 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + tsyringe@4.10.0: resolution: {integrity: sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==} engines: {node: '>= 6.0.0'} @@ -12668,12 +12682,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.2.2 '@tailwindcss/oxide-win32-x64-msvc': 4.2.2 - '@tailwindcss/vite@4.2.2(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.46.1)(yaml@2.8.3))': + '@tailwindcss/vite@4.2.2(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@tailwindcss/node': 4.2.2 '@tailwindcss/oxide': 4.2.2 tailwindcss: 4.2.2 - vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.46.1)(yaml@2.8.3) + vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) '@testing-library/dom@10.4.1': dependencies: @@ -13032,10 +13046,10 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true - '@vitejs/plugin-react@6.0.1(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.46.1)(yaml@2.8.3))': + '@vitejs/plugin-react@6.0.1(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.7 - vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.46.1)(yaml@2.8.3) + vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) '@webassemblyjs/ast@1.14.1': dependencies: @@ -14233,7 +14247,6 @@ snapshots: '@esbuild/win32-arm64': 0.27.3 '@esbuild/win32-ia32': 0.27.3 '@esbuild/win32-x64': 0.27.3 - optional: true escalade@3.2.0: {} @@ -14655,6 +14668,10 @@ snapshots: '@sec-ant/readable-stream': 0.4.1 is-stream: 4.0.1 + get-tsconfig@4.14.0: + dependencies: + resolve-pkg-maps: 1.0.0 + github-slugger@1.5.0: {} glob-parent@5.1.2: @@ -17900,6 +17917,8 @@ snapshots: resolve-pathname@3.0.0: {} + resolve-pkg-maps@1.0.0: {} + resolve-url@0.2.1: {} resolve@1.22.12: @@ -18564,6 +18583,13 @@ snapshots: tslib@2.8.1: {} + tsx@4.21.0: + dependencies: + esbuild: 0.27.3 + get-tsconfig: 4.14.0 + optionalDependencies: + fsevents: 2.3.3 + tsyringe@4.10.0: dependencies: tslib: 1.14.1 @@ -18811,7 +18837,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.46.1)(yaml@2.8.3): + vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -18824,6 +18850,7 @@ snapshots: fsevents: 2.3.3 jiti: 2.6.1 terser: 5.46.1 + tsx: 4.21.0 yaml: 2.8.3 w3c-xmlserializer@5.0.0: diff --git a/scripts/publish-packages.d.mts b/scripts/publish-packages.d.mts deleted file mode 100644 index 112e8c1bdd..0000000000 --- a/scripts/publish-packages.d.mts +++ /dev/null @@ -1,45 +0,0 @@ -import type { readFileSync } from "node:fs"; - -export interface PackageInfo { - name: string; - version: string; -} - -export type ExecFile = ( - command: string, - args: string[], - options?: object, -) => Buffer | string; - -export interface PublishPackagesOptions { - packages?: string[]; - execFile?: ExecFile; - readPackage?: typeof readPackageInfo; -} - -export const packageDirs: string[]; - -export function readPackageInfo( - packageDir: string, - readFile?: typeof readFileSync, -): PackageInfo; - -export function isPackageVersionMissingError(error: unknown): boolean; - -export function isPackageVersionPublished( - packageInfo: PackageInfo, - execFile?: ExecFile, -): boolean; - -export function publishPackage( - packageDir: string, - tag: string, - execFile?: ExecFile, -): void; - -export function publishPackages( - tag: string, - options?: PublishPackagesOptions, -): void; - -export function isEntrypoint(): boolean; diff --git a/scripts/publish-packages.mjs b/scripts/publish-packages.mjs deleted file mode 100644 index 53cc6c42b1..0000000000 --- a/scripts/publish-packages.mjs +++ /dev/null @@ -1,190 +0,0 @@ -import { execFileSync } from "node:child_process"; -import { readFileSync } from "node:fs"; -import process from "node:process"; -import { pathToFileURL } from "node:url"; - -const repoRoot = new URL("../", import.meta.url); - -export const packageDirs = [ - "packages/react-day-picker", - "packages/buddhist", - "packages/ethiopic", - "packages/hebrew", - "packages/hijri", - "packages/persian", -]; - -/** - * @typedef {object} PackageInfo - * @property {string} name - Package name from package.json. - * @property {string} version - Package version from package.json. - */ - -/** - * Reads the package name and version for a workspace package. - * - * @param {string} packageDir - Package directory relative to the repo root. - * @param {typeof readFileSync} [readFile=readFileSync] - File reader. Default - * is `readFileSync` - * @returns {PackageInfo} Package metadata needed for npm publication. - */ -export function readPackageInfo(packageDir, readFile = readFileSync) { - const packageJsonPath = new URL(`${packageDir}/package.json`, repoRoot); - const packageJson = JSON.parse(readFile(packageJsonPath, "utf8")); - return { - name: packageJson.name, - version: packageJson.version, - }; -} - -/** - * Returns whether an npm view failure means the exact package version is - * absent. - * - * @param {unknown} error - Error thrown by npm view. - * @returns {boolean} True for npm not-found errors. - */ -export function isPackageVersionMissingError(error) { - if (!(error instanceof Error)) { - return false; - } - - const { status, stderr, stdout } = /** - * @type {Error & { - * status?: number; - * stderr?: unknown; - * stdout?: unknown; - * }} - */ (error); - const output = [error.message, stderr, stdout] - .filter(Boolean) - .map(String) - .join("\n"); - - return ( - status === 1 && - (output.includes("E404") || - output.includes("404 Not Found") || - output.includes("No matching version found")) - ); -} - -/** - * Checks whether a package version already exists in the npm registry. - * - * @param {PackageInfo} packageInfo - Package name and version to check. - * @param {typeof execFileSync} [execFile=execFileSync] - Command runner. - * Default is `execFileSync` - * @returns {boolean} True when npm already has the package version. - */ -export function isPackageVersionPublished( - packageInfo, - execFile = execFileSync, -) { - try { - execFile( - "npm", - ["view", `${packageInfo.name}@${packageInfo.version}`, "version"], - { - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], - }, - ); - return true; - } catch (error) { - if (isPackageVersionMissingError(error)) { - return false; - } - throw error; - } -} - -/** - * Publishes a package directory to npm. - * - * @param {string} packageDir - Package directory relative to the repo root. - * @param {PackageInfo} packageInfo - Package name and version being published. - * @param {string} tag - Npm dist-tag to publish under. - * @param {typeof execFileSync} [execFile=execFileSync] - Command runner. - * Default is `execFileSync` - * @returns {void} - */ -export function publishPackage( - packageDir, - packageInfo, - tag, - execFile = execFileSync, -) { - const publishArgs = ["publish", "--provenance", "--tag", tag]; - if (packageInfo.name.startsWith("@")) { - publishArgs.push("--access", "public"); - } - - execFile("npm", publishArgs, { - cwd: new URL(`../${packageDir}`, import.meta.url), - stdio: "inherit", - }); -} - -/** - * Publishes all missing package versions and skips versions already on npm. - * - * @param {string} tag - Npm dist-tag to publish under. - * @param {object} [options] - Test hooks and package list override. - * @param {string[]} [options.packages=packageDirs] - Package directories. - * Default is `packageDirs` - * @param {typeof execFileSync} [options.execFile=execFileSync] - Command - * runner. Default is `execFileSync` - * @param {typeof readPackageInfo} [options.readPackage=readPackageInfo] - - * Package metadata reader. Default is `readPackageInfo` - * @returns {void} - */ -export function publishPackages(tag, options = {}) { - if (!tag) { - throw new Error("Usage: node ./scripts/publish-packages.mjs "); - } - - const { - packages = packageDirs, - execFile = execFileSync, - readPackage = readPackageInfo, - } = options; - - for (const packageDir of packages) { - const packageInfo = readPackage(packageDir); - if (isPackageVersionPublished(packageInfo, execFile)) { - console.log( - `Skipping ${packageInfo.name}@${packageInfo.version}; already published.`, - ); - continue; - } - - publishPackage(packageDir, packageInfo, tag, execFile); - } -} - -/** - * Returns whether this module is being executed directly by Node.js. - * - * @returns {boolean} True when the file is the active CLI entrypoint. - */ -export function isEntrypoint() { - const scriptPath = process.argv[1]; - if (!scriptPath) { - return false; - } - return import.meta.url === pathToFileURL(scriptPath).href; -} - -if (isEntrypoint()) { - try { - publishPackages(process.argv[2]); - } catch (error) { - if (error instanceof Error) { - console.error(error.message); - } else { - console.error(error); - } - process.exit(1); - } -} diff --git a/scripts/publish-packages.test.ts b/scripts/publish-packages.test.ts index 0170bc4831..b51204d0e6 100644 --- a/scripts/publish-packages.test.ts +++ b/scripts/publish-packages.test.ts @@ -1,4 +1,4 @@ -type PublishPackagesModule = typeof import("./publish-packages.mjs"); +type PublishPackagesScriptModule = typeof import("./publish-packages"); type ExecCall = { args: string[]; @@ -6,20 +6,77 @@ type ExecCall = { options?: unknown; }; -type ExecFile = (command: string, args: string[], options?: unknown) => string; +const packageInfoByDir: Record = { + "packages/react-day-picker": { + name: "react-day-picker", + version: "10.0.0-next.1", + }, + "packages/buddhist": { + name: "@daypicker/buddhist", + version: "10.0.0-next.1", + }, + "packages/ethiopic": { + name: "@daypicker/ethiopic", + version: "10.0.0-next.1", + }, + "packages/hebrew": { + name: "@daypicker/hebrew", + version: "10.0.0-next.1", + }, + "packages/hijri": { + name: "@daypicker/hijri", + version: "10.0.0-next.1", + }, + "packages/persian": { + name: "@daypicker/persian", + version: "10.0.0-next.1", + }, +}; -let isPackageVersionMissingError: PublishPackagesModule["isPackageVersionMissingError"]; -let publishPackages: PublishPackagesModule["publishPackages"]; +let getUnpublishedPackages: PublishPackagesScriptModule["getUnpublishedPackages"]; +let publishPackages: PublishPackagesScriptModule["publishPackages"]; +let publishPackagesExecFileSyncMock: jest.Mock; +let publishPackagesReadFileSyncMock: jest.Mock; let consoleLogSpy: jest.SpiedFunction; +let execCalls: ExecCall[]; + +jest.mock("node:child_process", () => ({ + execFileSync: jest.fn(), +})); + +jest.mock("node:fs", () => ({ + readFileSync: jest.fn(), +})); beforeAll(async function loadModule() { - ({ isPackageVersionMissingError, publishPackages } = await import( - "./publish-packages.mjs" + publishPackagesExecFileSyncMock = (await import("node:child_process")) + .execFileSync as unknown as jest.Mock; + publishPackagesReadFileSyncMock = (await import("node:fs")) + .readFileSync as unknown as jest.Mock; + ({ getUnpublishedPackages, publishPackages } = await import( + "./publish-packages" )); }); -beforeEach(function setupConsoleSpy() { +beforeEach(function setupPublishPackagesTestState() { + execCalls = []; + jest.resetAllMocks(); consoleLogSpy = jest.spyOn(console, "log").mockImplementation(() => {}); + + publishPackagesReadFileSyncMock.mockImplementation(function mockReadFile( + file: unknown, + ) { + const path = String(file); + const packageDir = Object.keys(packageInfoByDir).find((candidate) => + path.includes(`/${candidate}/package.json`), + ); + + if (!packageDir) { + throw new Error(`Unknown test package.json path: ${path}`); + } + + return JSON.stringify(packageInfoByDir[packageDir]); + }); }); afterEach(function restoreConsoleSpy() { @@ -38,109 +95,108 @@ function createNpmError( }); } -function createPackageReader() { - const packageInfoByDir: Record = { - "packages/react-day-picker": { - name: "react-day-picker", - version: "10.0.0-next.1", - }, - "packages/buddhist": { - name: "@daypicker/buddhist", - version: "10.0.0-next.1", - }, - }; - - return jest.fn(function readPackage(packageDir: string) { - const packageInfo = packageInfoByDir[packageDir]; - if (!packageInfo) { - throw new Error(`Unknown test package: ${packageDir}`); - } - return packageInfo; - }); -} +describe("publishPackages", function describePublishPackages() { + test("it returns only workspace packages that still need publishing", function testGetUnpublishedPackages() { + publishPackagesExecFileSyncMock.mockImplementation( + function mockExecFile(command, args, options) { + execCalls.push({ + command: String(command), + args: Array.isArray(args) ? [...args] : [], + options, + }); + + if (command !== "npm" || !Array.isArray(args) || args[0] !== "view") { + return ""; + } -describe("isPackageVersionMissingError", function describeIsPackageVersionMissingError() { - test("it detects npm not-found responses", function testNotFoundResponse() { - expect( - isPackageVersionMissingError( - createNpmError("npm ERR! code E404\nnpm ERR! 404 Not Found"), - ), - ).toBe(true); - }); + if (args[1] === "@daypicker/buddhist@10.0.0-next.1") { + throw createNpmError("npm ERR! code E404\nnpm ERR! 404 Not Found"); + } + + return "10.0.0-next.1\n"; + }, + ); - test("it rejects unrelated npm failures", function testUnrelatedFailure() { - expect( - isPackageVersionMissingError( - createNpmError("npm ERR! network timeout", { status: 1 }), - ), - ).toBe(false); + expect(getUnpublishedPackages()).toEqual([ + { + packageDir: "packages/buddhist", + packageInfo: { + name: "@daypicker/buddhist", + version: "10.0.0-next.1", + }, + }, + ]); }); -}); -describe("publishPackages", function describePublishPackages() { - test("it skips versions already published on npm", function testSkipPublishedVersion() { - const execCalls: ExecCall[] = []; - const execFile = jest.fn(function execFile( - command: string, - args: string[], - options?: unknown, - ) { - execCalls.push({ command, args, options }); - - if (args[0] === "view") { - if (args[1] === "react-day-picker@10.0.0-next.1") { + test("it skips versions already published on npm and publishes missing scoped packages publicly", function testSkipPublishedVersion() { + publishPackagesExecFileSyncMock.mockImplementation( + function mockExecFile(command, args, options) { + execCalls.push({ + command: String(command), + args: Array.isArray(args) ? [...args] : [], + options, + }); + + if (command !== "npm" || !Array.isArray(args)) { + return ""; + } + + if (args[0] === "view") { + if (args[1] === "@daypicker/buddhist@10.0.0-next.1") { + throw createNpmError("npm ERR! code E404"); + } return "10.0.0-next.1\n"; } - throw createNpmError("npm ERR! code E404"); - } - return ""; - }) as jest.MockedFunction; + return ""; + }, + ); - publishPackages("next", { - execFile, - packages: ["packages/react-day-picker", "packages/buddhist"], - readPackage: createPackageReader(), - }); + publishPackages("next"); expect(execCalls.map((call) => call.args)).toEqual([ ["view", "react-day-picker@10.0.0-next.1", "version"], ["view", "@daypicker/buddhist@10.0.0-next.1", "version"], ["publish", "--provenance", "--tag", "next", "--access", "public"], + ["view", "@daypicker/ethiopic@10.0.0-next.1", "version"], + ["view", "@daypicker/hebrew@10.0.0-next.1", "version"], + ["view", "@daypicker/hijri@10.0.0-next.1", "version"], + ["view", "@daypicker/persian@10.0.0-next.1", "version"], ]); const publishOptions = execCalls[2]?.options as | { cwd?: URL; stdio?: string } | undefined; expect(publishOptions?.stdio).toBe("inherit"); expect(publishOptions?.cwd?.href).toContain("packages/buddhist"); + expect(consoleLogSpy).toHaveBeenCalledWith( + "Skipping react-day-picker@10.0.0-next.1; already published.", + ); }); test("it rethrows npm view failures that are not missing versions", function testRethrowUnexpectedViewFailure() { - const execFile = jest.fn(function execFile( - _command: string, - args: string[], - ) { - if (args[0] === "view") { - throw createNpmError("npm ERR! network timeout"); - } - - return ""; - }) as jest.MockedFunction; - - expect(() => - publishPackages("next", { - execFile, - packages: ["packages/react-day-picker"], - readPackage: createPackageReader(), - }), - ).toThrow("npm ERR! network timeout"); - - expect(execFile).toHaveBeenCalledTimes(1); + publishPackagesExecFileSyncMock.mockImplementation( + function mockExecFile(command, args, options) { + execCalls.push({ + command: String(command), + args: Array.isArray(args) ? [...args] : [], + options, + }); + + if (command === "npm" && Array.isArray(args) && args[0] === "view") { + throw createNpmError("npm ERR! network timeout"); + } + + return ""; + }, + ); + + expect(() => publishPackages("next")).toThrow("npm ERR! network timeout"); + expect(publishPackagesExecFileSyncMock).toHaveBeenCalledTimes(1); }); test("it requires an npm tag", function testMissingTag() { expect(() => publishPackages("")).toThrow( - "Usage: node ./scripts/publish-packages.mjs ", + "Usage: publish-packages ", ); }); }); diff --git a/scripts/publish-packages.ts b/scripts/publish-packages.ts new file mode 100644 index 0000000000..6ec5a01467 --- /dev/null +++ b/scripts/publish-packages.ts @@ -0,0 +1,140 @@ +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import process from "node:process"; +import { pathToFileURL } from "node:url"; + +const repoRoot = new URL("../", import.meta.url); + +const packageDirs = [ + "packages/react-day-picker", + "packages/buddhist", + "packages/ethiopic", + "packages/hebrew", + "packages/hijri", + "packages/persian", +] as const; + +export interface PackageInfo { + 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; + version: string; + }; + return { + name: packageJson.name, + version: packageJson.version, + }; +} + +function isPackageVersionMissingError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; + } + + const npmError = error as Error & { + status?: number; + stderr?: unknown; + stdout?: unknown; + }; + const output = [error.message, npmError.stderr, npmError.stdout] + .filter(Boolean) + .map(String) + .join("\n"); + + return ( + npmError.status === 1 && + (output.includes("E404") || + output.includes("404 Not Found") || + output.includes("No matching version found")) + ); +} + +function isPackageVersionPublished(packageInfo: PackageInfo): boolean { + try { + execFileSync( + "npm", + ["view", `${packageInfo.name}@${packageInfo.version}`, "version"], + { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }, + ); + return true; + } catch (error) { + if (isPackageVersionMissingError(error)) { + return false; + } + throw error; + } +} + +export function getUnpublishedPackages(): UnpublishedPackage[] { + return packageDirs.flatMap((packageDir) => { + const packageInfo = readPackageInfo(packageDir); + return isPackageVersionPublished(packageInfo) + ? [] + : [{ packageDir, packageInfo }]; + }); +} + +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 "); + } + + for (const packageDir of packageDirs) { + const packageInfo = readPackageInfo(packageDir); + if (isPackageVersionPublished(packageInfo)) { + console.log( + `Skipping ${packageInfo.name}@${packageInfo.version}; already published.`, + ); + continue; + } + + publishPackage(packageDir, packageInfo, tag); + } +} + +export function main(): void { + publishPackages(process.argv[2] || ""); +} + +const scriptPath = process.argv[1]; +if (scriptPath && import.meta.url === pathToFileURL(scriptPath).href) { + try { + main(); + } catch (error) { + if (error instanceof Error) { + console.error(error.message); + } else { + console.error(error); + } + process.exit(1); + } +} diff --git a/tsconfig.json b/tsconfig.json index 2d04f0023b..6690e723a3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,8 +10,7 @@ "test", "**/*.test.*", "examples", - "scripts/**/*.d.ts", - "scripts/**/*.d.mts" + "scripts/**/*.ts" ], "exclude": ["apps/website"] } From 1018f3bec0d5e949d0a7000959690585e521b178 Mon Sep 17 00:00:00 2001 From: Giampaolo Bellavite Date: Sun, 26 Apr 2026 09:22:17 +0200 Subject: [PATCH 2/4] build: add changesets release helpers --- package.json | 1 + scripts/create-github-release.test.ts | 159 ++++++++++++++++++++ scripts/create-github-release.ts | 181 +++++++++++++++++++++++ scripts/release-ci.test.ts | 194 +++++++++++++++++++++++++ scripts/release-ci.ts | 130 +++++++++++++++++ scripts/should-publish-release.test.ts | 129 ++++++++++++++++ scripts/should-publish-release.ts | 114 +++++++++++++++ 7 files changed, 908 insertions(+) create mode 100644 scripts/create-github-release.test.ts create mode 100644 scripts/create-github-release.ts create mode 100644 scripts/release-ci.test.ts create mode 100644 scripts/release-ci.ts create mode 100644 scripts/should-publish-release.test.ts create mode 100644 scripts/should-publish-release.ts 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..e6920593f6 --- /dev/null +++ b/scripts/create-github-release.ts @@ -0,0 +1,181 @@ +import process from "node:process"; +import { pathToFileURL } from "node:url"; + +export interface GitHubRelease { + html_url: string; +} + +export interface CreateReleaseContext { + repository: string; + token: string; + commitSha: string; + packageVersion: string; +} + +interface ReleaseLookupRequest { + owner: string; + repo: string; + tag: string; + token: string; +} + +interface ReleaseCreateRequest extends ReleaseLookupRequest { + commitSha: string; + prerelease: boolean; +} + +async function fetchReleaseByTag( + request: ReleaseLookupRequest, +): Promise { + 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}`, + "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; +} + +async function createRelease( + request: ReleaseCreateRequest, +): Promise { + 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", + "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; +} + +export async function createGitHubRelease( + context: CreateReleaseContext, +): Promise<{ + created: boolean; + release: GitHubRelease; + 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 prerelease = 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, + }); + + return { created: true, release: createdRelease, tag }; +} + +export async function main(): Promise { + const repository = process.env.GITHUB_REPOSITORY; + const token = process.env.GITHUB_TOKEN; + const commitSha = process.env.GITHUB_SHA; + const packageVersion = process.env.PACKAGE_VERSION; + + if (!repository) { + throw new Error("Missing required environment variable: GITHUB_REPOSITORY"); + } + if (!token) { + throw new Error("Missing required environment variable: GITHUB_TOKEN"); + } + if (!commitSha) { + throw new Error("Missing required environment variable: GITHUB_SHA"); + } + if (!packageVersion) { + throw new Error("Missing required environment variable: PACKAGE_VERSION"); + } + + const result = await createGitHubRelease({ + repository, + token, + commitSha, + packageVersion, + }); + + if (result.created) { + console.log( + `Created GitHub release ${result.tag} at ${result.release.html_url}.`, + ); + } else { + console.log( + `GitHub release ${result.tag} already exists at ${result.release.html_url}.`, + ); + } +} + +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/release-ci.test.ts b/scripts/release-ci.test.ts new file mode 100644 index 0000000000..5b08d91b94 --- /dev/null +++ b/scripts/release-ci.test.ts @@ -0,0 +1,194 @@ +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", + EXPECTED_PR_AUTHOR: "github-actions[bot]", + EXPECTED_BASE_BRANCH: "main", + EXPECTED_PR_BRANCH: "changesets-release/main", + }; + + 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", + expectedAuthor: "github-actions[bot]", + 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..5ba15e3965 --- /dev/null +++ b/scripts/release-ci.ts @@ -0,0 +1,130 @@ +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 validationCommands = [ + ["typecheck"], + ["lint", "ci", ".", "--reporter=github"], + ["test"], + ["test:tz"], + ["build"], + ["check:versions"], + ["pack:dry-run"], + ["test:build"], +] as const; + +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 publishAllowed = await shouldPublishRelease({ + repository, + token, + commitSha, + expectedHeadBranch: + process.env.EXPECTED_PR_BRANCH || "changesets-release/main", + expectedAuthor: process.env.EXPECTED_PR_AUTHOR || "github-actions[bot]", + expectedBaseBranch: process.env.EXPECTED_BASE_BRANCH || "main", + }); + + if (!publishAllowed) { + console.log( + "This commit did not come from the merged Changesets release PR. Skipping release automation.", + ); + return { + shouldPublish: false, + publishedPackages: false, + releaseCreated: false, + }; + } + + const unpublishedPackages = getUnpublishedPackages(); + let publishedPackages = false; + + if (unpublishedPackages.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, + }; +} + +export 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..87ef565ea5 --- /dev/null +++ b/scripts/should-publish-release.test.ts @@ -0,0 +1,129 @@ +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; + expectedAuthor: string; + expectedBaseBranch: string; + }> = {}, +) { + return { + repository: "gpbl/react-day-picker", + token: "test-token", + commitSha: "abc123", + expectedHeadBranch: "changesets-release/main", + expectedAuthor: "github-actions[bot]", + 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 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..a36f00565c --- /dev/null +++ b/scripts/should-publish-release.ts @@ -0,0 +1,114 @@ +import process from "node:process"; +import { pathToFileURL } from "node:url"; + +export interface AssociatedPullRequest { + user: { login?: string } | null; + base: { ref?: string } | null; + head: { ref?: string } | null; + merged_at: string | null; +} + +export interface ShouldPublishContext { + repository: string; + token: string; + commitSha: string; + expectedHeadBranch: string; + expectedAuthor: string; + expectedBaseBranch: string; +} + +interface AssociatedPullRequestRequest { + owner: string; + repo: string; + commitSha: string; + token: string; +} + +async function fetchAssociatedPullRequests( + request: AssociatedPullRequestRequest, +): Promise { + 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}`, + "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; +} + +export async function shouldPublishRelease( + context: ShouldPublishContext, +): 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.user?.login === context.expectedAuthor && + pullRequest.base?.ref === context.expectedBaseBranch && + Boolean(pullRequest.merged_at) + ); + }); +} + +export async function main(): Promise { + const repository = process.env.GITHUB_REPOSITORY; + const token = process.env.GITHUB_TOKEN; + const commitSha = process.env.GITHUB_SHA; + + if (!repository) { + throw new Error("Missing required environment variable: GITHUB_REPOSITORY"); + } + if (!token) { + throw new Error("Missing required environment variable: GITHUB_TOKEN"); + } + if (!commitSha) { + throw new Error("Missing required environment variable: GITHUB_SHA"); + } + + const shouldPublish = await shouldPublishRelease({ + repository, + token, + commitSha, + expectedHeadBranch: + process.env.EXPECTED_PR_BRANCH || "changesets-release/main", + expectedAuthor: process.env.EXPECTED_PR_AUTHOR || "github-actions[bot]", + expectedBaseBranch: process.env.EXPECTED_BASE_BRANCH || "main", + }); + + console.log(shouldPublish ? "true" : "false"); +} + +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); + }); +} From a81eef67be8d5364374f3629291bfca16db497d1 Mon Sep 17 00:00:00 2001 From: Giampaolo Bellavite Date: Sun, 26 Apr 2026 09:35:24 +0200 Subject: [PATCH 3/4] build: install deps before publish script --- .github/workflows/package.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index 88bc5955a5..a378fec044 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -167,6 +167,7 @@ jobs: node-version: 24 registry-url: https://registry.npmjs.org/ always-auth: false + - run: pnpm install --frozen-lockfile - uses: actions/download-artifact@v4 with: name: rdp-dist From d705399eccc055f3af150413c87b712eb198cbcd Mon Sep 17 00:00:00 2001 From: Giampaolo Bellavite Date: Sun, 26 Apr 2026 11:46:14 +0200 Subject: [PATCH 4/4] refactor: simplify release helper scripts --- scripts/create-github-release.ts | 132 +++++++++---------------- scripts/publish-packages.ts | 52 +++++----- scripts/release-ci.test.ts | 4 - scripts/release-ci.ts | 32 ++++-- scripts/should-publish-release.test.ts | 12 ++- scripts/should-publish-release.ts | 105 +++++++------------- 6 files changed, 138 insertions(+), 199 deletions(-) diff --git a/scripts/create-github-release.ts b/scripts/create-github-release.ts index e6920593f6..8842a8ad60 100644 --- a/scripts/create-github-release.ts +++ b/scripts/create-github-release.ts @@ -1,32 +1,14 @@ -import process from "node:process"; -import { pathToFileURL } from "node:url"; - -export interface GitHubRelease { - html_url: string; -} - -export interface CreateReleaseContext { - repository: string; - token: string; - commitSha: string; - packageVersion: string; -} - -interface ReleaseLookupRequest { +/** + * Reads the GitHub Release for the version tag if it already exists. + */ +async function fetchReleaseByTag(request: { owner: string; repo: string; tag: string; token: string; -} - -interface ReleaseCreateRequest extends ReleaseLookupRequest { - commitSha: string; - prerelease: boolean; -} - -async function fetchReleaseByTag( - request: ReleaseLookupRequest, -): Promise { +}): 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)}`, @@ -34,6 +16,8 @@ async function fetchReleaseByTag( 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", }, }, @@ -51,12 +35,24 @@ async function fetchReleaseByTag( ); } - return response.json() as Promise; + return response.json() as Promise<{ + html_url: string; + }>; } -async function createRelease( - request: ReleaseCreateRequest, -): Promise { +/** + * 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`, @@ -66,6 +62,8 @@ async function createRelease( 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({ @@ -85,14 +83,27 @@ async function createRelease( ); } - return response.json() as Promise; + return response.json() as Promise<{ + html_url: string; + }>; } -export async function createGitHubRelease( - context: CreateReleaseContext, -): Promise<{ +/** + * 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: GitHubRelease; + release: { + html_url: string; + }; tag: string; }> { const [owner, repo] = context.repository.split("/"); @@ -101,7 +112,7 @@ export async function createGitHubRelease( } const tag = `v${context.packageVersion}`; - const prerelease = context.packageVersion.includes("-next"); + const isPrereleaseVersion = context.packageVersion.includes("-next"); try { const existingRelease = await fetchReleaseByTag({ @@ -125,57 +136,8 @@ export async function createGitHubRelease( tag, token: context.token, commitSha: context.commitSha, - prerelease, + prerelease: isPrereleaseVersion, }); return { created: true, release: createdRelease, tag }; } - -export async function main(): Promise { - const repository = process.env.GITHUB_REPOSITORY; - const token = process.env.GITHUB_TOKEN; - const commitSha = process.env.GITHUB_SHA; - const packageVersion = process.env.PACKAGE_VERSION; - - if (!repository) { - throw new Error("Missing required environment variable: GITHUB_REPOSITORY"); - } - if (!token) { - throw new Error("Missing required environment variable: GITHUB_TOKEN"); - } - if (!commitSha) { - throw new Error("Missing required environment variable: GITHUB_SHA"); - } - if (!packageVersion) { - throw new Error("Missing required environment variable: PACKAGE_VERSION"); - } - - const result = await createGitHubRelease({ - repository, - token, - commitSha, - packageVersion, - }); - - if (result.created) { - console.log( - `Created GitHub release ${result.tag} at ${result.release.html_url}.`, - ); - } else { - console.log( - `GitHub release ${result.tag} already exists at ${result.release.html_url}.`, - ); - } -} - -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/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 index 5b08d91b94..71a9a0a88a 100644 --- a/scripts/release-ci.test.ts +++ b/scripts/release-ci.test.ts @@ -57,9 +57,6 @@ beforeEach(function setupReleaseCiTestState() { ...process.env, GITHUB_REPOSITORY: "gpbl/react-day-picker", GITHUB_TOKEN: "test-token", - EXPECTED_PR_AUTHOR: "github-actions[bot]", - EXPECTED_BASE_BRANCH: "main", - EXPECTED_PR_BRANCH: "changesets-release/main", }; jest.resetAllMocks(); @@ -150,7 +147,6 @@ describe("releaseCi", function describeReleaseCi() { token: "test-token", commitSha: "abc123", expectedHeadBranch: "changesets-release/main", - expectedAuthor: "github-actions[bot]", expectedBaseBranch: "main", }); expect( diff --git a/scripts/release-ci.ts b/scripts/release-ci.ts index 5ba15e3965..55d7722967 100644 --- a/scripts/release-ci.ts +++ b/scripts/release-ci.ts @@ -11,7 +11,11 @@ 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"], @@ -23,6 +27,15 @@ const validationCommands = [ ["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; @@ -46,17 +59,15 @@ export async function releaseCi(): Promise<{ ).trim(); const packageInfo = readPackageInfo(mainPackageDir); - const publishAllowed = await shouldPublishRelease({ + const isReleaseCommit = await shouldPublishRelease({ repository, token, commitSha, - expectedHeadBranch: - process.env.EXPECTED_PR_BRANCH || "changesets-release/main", - expectedAuthor: process.env.EXPECTED_PR_AUTHOR || "github-actions[bot]", - expectedBaseBranch: process.env.EXPECTED_BASE_BRANCH || "main", + expectedHeadBranch: expectedReleasePrBranch, + expectedBaseBranch: expectedReleaseBaseBranch, }); - if (!publishAllowed) { + if (!isReleaseCommit) { console.log( "This commit did not come from the merged Changesets release PR. Skipping release automation.", ); @@ -67,10 +78,10 @@ export async function releaseCi(): Promise<{ }; } - const unpublishedPackages = getUnpublishedPackages(); + const unpublishedPackageVersions = getUnpublishedPackages(); let publishedPackages = false; - if (unpublishedPackages.length > 0) { + if (unpublishedPackageVersions.length > 0) { for (const commandArgs of validationCommands) { execFileSync("pnpm", [...commandArgs], { cwd: repoRoot, @@ -100,7 +111,10 @@ export async function releaseCi(): Promise<{ }; } -export async function main(): Promise { +/** + * CLI entrypoint used by the release workflow and manual recovery runs. + */ +async function main(): Promise { const result = await releaseCi(); if (!result.shouldPublish) { return; diff --git a/scripts/should-publish-release.test.ts b/scripts/should-publish-release.test.ts index 87ef565ea5..a8e759cea4 100644 --- a/scripts/should-publish-release.test.ts +++ b/scripts/should-publish-release.test.ts @@ -25,7 +25,6 @@ function createShouldPublishContext( token: string; commitSha: string; expectedHeadBranch: string; - expectedAuthor: string; expectedBaseBranch: string; }> = {}, ) { @@ -34,7 +33,6 @@ function createShouldPublishContext( token: "test-token", commitSha: "abc123", expectedHeadBranch: "changesets-release/main", - expectedAuthor: "github-actions[bot]", expectedBaseBranch: "main", ...overrides, }; @@ -108,6 +106,16 @@ describe("shouldPublishRelease", function describeShouldPublishRelease() { 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"; diff --git a/scripts/should-publish-release.ts b/scripts/should-publish-release.ts index a36f00565c..7055333145 100644 --- a/scripts/should-publish-release.ts +++ b/scripts/should-publish-release.ts @@ -1,32 +1,19 @@ -import process from "node:process"; -import { pathToFileURL } from "node:url"; - -export interface AssociatedPullRequest { - user: { login?: string } | null; - base: { ref?: string } | null; - head: { ref?: string } | null; - merged_at: string | null; -} - -export interface ShouldPublishContext { - repository: string; - token: string; - commitSha: string; - expectedHeadBranch: string; - expectedAuthor: string; - expectedBaseBranch: string; -} - -interface AssociatedPullRequestRequest { +/** + * Reads the pull requests GitHub associates with the given commit SHA. + */ +async function fetchAssociatedPullRequests(request: { owner: string; repo: string; commitSha: string; token: string; -} - -async function fetchAssociatedPullRequests( - request: AssociatedPullRequestRequest, -): Promise { +}): 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`, @@ -34,6 +21,8 @@ async function fetchAssociatedPullRequests( 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", }, }, @@ -45,12 +34,29 @@ async function fetchAssociatedPullRequests( ); } - return response.json() as Promise; + return response.json() as Promise< + Array<{ + user: { login?: string } | null; + base: { ref?: string } | null; + head: { ref?: string } | null; + merged_at: string | null; + }> + >; } -export async function shouldPublishRelease( - context: ShouldPublishContext, -): Promise { +/** + * 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}`); @@ -66,49 +72,8 @@ export async function shouldPublishRelease( return pullRequests.some((pullRequest) => { return ( pullRequest.head?.ref === context.expectedHeadBranch && - pullRequest.user?.login === context.expectedAuthor && pullRequest.base?.ref === context.expectedBaseBranch && Boolean(pullRequest.merged_at) ); }); } - -export async function main(): Promise { - const repository = process.env.GITHUB_REPOSITORY; - const token = process.env.GITHUB_TOKEN; - const commitSha = process.env.GITHUB_SHA; - - if (!repository) { - throw new Error("Missing required environment variable: GITHUB_REPOSITORY"); - } - if (!token) { - throw new Error("Missing required environment variable: GITHUB_TOKEN"); - } - if (!commitSha) { - throw new Error("Missing required environment variable: GITHUB_SHA"); - } - - const shouldPublish = await shouldPublishRelease({ - repository, - token, - commitSha, - expectedHeadBranch: - process.env.EXPECTED_PR_BRANCH || "changesets-release/main", - expectedAuthor: process.env.EXPECTED_PR_AUTHOR || "github-actions[bot]", - expectedBaseBranch: process.env.EXPECTED_BASE_BRANCH || "main", - }); - - console.log(shouldPublish ? "true" : "false"); -} - -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); - }); -}