From 1c2911bb847f55930c04ef0c7c72c1853dccfa16 Mon Sep 17 00:00:00 2001 From: Egge Date: Sun, 31 May 2026 20:18:02 +0200 Subject: [PATCH] feat: publish npm RCs from prerelease releases --- .github/workflows/publish.yml | 42 +++++++++- scripts/prepare-rc-release.ts | 149 ++++++++++++++++++++++++++++++++++ 2 files changed, 190 insertions(+), 1 deletion(-) create mode 100644 scripts/prepare-rc-release.ts diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index bc172889..0e3a72b7 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -2,10 +2,11 @@ name: Publish to npm on: release: - types: [created] + types: [published] jobs: publish: + if: ${{ !github.event.release.prerelease }} runs-on: ubuntu-latest permissions: contents: read @@ -33,3 +34,42 @@ jobs: - name: Publish to npm run: bunx changeset publish + + publish-rc: + if: ${{ github.event.release.prerelease }} + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write # Required for npm trusted publishing + steps: + - name: Checkout repository + uses: actions/checkout@v5 + with: + ref: ${{ github.event.release.tag_name }} + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 24 + registry-url: 'https://registry.npmjs.org' + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.2.18 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Version RC packages + run: | + bunx changeset pre enter rc + bunx changeset version + bun scripts/prepare-rc-release.ts + rm .changeset/pre.json + + - name: Build packages + run: bun run build + + - name: Publish RC to npm + run: bunx changeset publish --tag rc diff --git a/scripts/prepare-rc-release.ts b/scripts/prepare-rc-release.ts new file mode 100644 index 00000000..6ccdf6c3 --- /dev/null +++ b/scripts/prepare-rc-release.ts @@ -0,0 +1,149 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +type PackageJson = { + private?: boolean; + name?: string; + version?: string | null; + dependencies?: Record; + devDependencies?: Record; + peerDependencies?: Record; + optionalDependencies?: Record; +}; + +const prereleaseTag = process.env.PRERELEASE_TAG ?? 'rc'; +const npmViewTimeoutMs = Number(process.env.NPM_VIEW_TIMEOUT_MS ?? '10000'); +const versionPattern = new RegExp(`^(\\d+\\.\\d+\\.\\d+)-${prereleaseTag}\\.(\\d+)$`); + +function readJson(path: string): T { + return JSON.parse(readFileSync(path, 'utf8')) as T; +} + +function writeJson(path: string, value: unknown) { + writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`); +} + +function packageJsonPaths(): string[] { + return readdirSync('packages', { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => join('packages', entry.name, 'package.json')) + .filter((path) => existsSync(path)); +} + +function npmVersions(packageName: string): string[] { + try { + const stdout = execFileSync('npm', ['view', packageName, 'versions', '--json'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: npmViewTimeoutMs, + }).trim(); + + if (!stdout) { + return []; + } + + const versions = JSON.parse(stdout) as string | string[]; + return Array.isArray(versions) ? versions : [versions]; + } catch (error) { + const { stderr } = error as { stderr?: Buffer | string }; + const errorOutput = String(stderr ?? ''); + + if (errorOutput.includes('E404') || errorOutput.includes('404')) { + return []; + } + + throw error; + } +} + +const packages = packageJsonPaths().map((path) => ({ + path, + json: readJson(path), +})); +const publishablePackages = packages.filter( + ({ json }) => !json.private && json.name && typeof json.version === 'string', +); + +if (publishablePackages.length === 0) { + throw new Error('No publishable packages found'); +} + +const generatedVersions = publishablePackages.map(({ json }) => { + const match = json.version?.match(versionPattern); + + if (!match) { + throw new Error(`${json.name} version ${json.version} is not an ${prereleaseTag} version`); + } + + return { + baseVersion: match[1], + rcNumber: Number(match[2]), + }; +}); + +const [firstVersion] = generatedVersions; + +if (!firstVersion) { + throw new Error('No generated versions found'); +} + +for (const version of generatedVersions) { + if ( + version.baseVersion !== firstVersion.baseVersion || + version.rcNumber !== firstVersion.rcNumber + ) { + throw new Error('Publishable packages must share the same generated RC version'); + } +} + +let latestPublishedRc = -1; + +for (const { json } of publishablePackages) { + const versions = npmVersions(json.name!); + + for (const version of versions) { + const match = version.match(versionPattern); + + if (match && match[1] === firstVersion.baseVersion) { + latestPublishedRc = Math.max(latestPublishedRc, Number(match[2])); + } + } +} + +const nextRc = latestPublishedRc + 1; +const generatedVersion = `${firstVersion.baseVersion}-${prereleaseTag}.${firstVersion.rcNumber}`; +const nextVersion = `${firstVersion.baseVersion}-${prereleaseTag}.${nextRc}`; + +function updateDependencyVersion(dependencyVersion: string): string { + for (const prefix of ['', '^', '~']) { + if (dependencyVersion === `${prefix}${generatedVersion}`) { + return `${prefix}${nextVersion}`; + } + } + + return dependencyVersion; +} + +for (const { path, json } of publishablePackages) { + json.version = nextVersion; + + for (const dependencySet of [ + json.dependencies, + json.devDependencies, + json.peerDependencies, + json.optionalDependencies, + ]) { + if (!dependencySet) { + continue; + } + + for (const [dependencyName, dependencyVersion] of Object.entries(dependencySet)) { + dependencySet[dependencyName] = updateDependencyVersion(dependencyVersion); + } + } + + writeJson(path, json); +} + +console.log(`Prepared ${nextVersion} for npm dist-tag ${prereleaseTag}`);