Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 41 additions & 1 deletion .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
149 changes: 149 additions & 0 deletions scripts/prepare-rc-release.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
devDependencies?: Record<string, string>;
peerDependencies?: Record<string, string>;
optionalDependencies?: Record<string, string>;
};

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<T>(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<PackageJson>(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}`);
Loading