Skip to content

Commit 80dc2ab

Browse files
authored
fix(repo): wait for npm availability before release dispatches (#9788)
1 parent 304c0b6 commit 80dc2ab

5 files changed

Lines changed: 202 additions & 7 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---

‎.github/workflows/release.yml‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ jobs:
2424
name: Release
2525
if: ${{ github.event_name == 'push' && github.repository == 'clerk/javascript' }}
2626
runs-on: ${{ vars.RUNNER_NORMAL || 'ubuntu-latest' }}
27-
timeout-minutes: ${{ vars.TIMEOUT_MINUTES_NORMAL && fromJSON(vars.TIMEOUT_MINUTES_NORMAL) || 10 }}
27+
timeout-minutes: 25
2828

2929
permissions:
3030
actions: read
@@ -269,7 +269,7 @@ jobs:
269269
name: Canary release
270270
if: ${{ github.event_name == 'push' && github.repository == 'clerk/javascript' }}
271271
runs-on: ${{ vars.RUNNER_NORMAL || 'ubuntu-latest' }}
272-
timeout-minutes: ${{ vars.TIMEOUT_MINUTES_NORMAL && fromJSON(vars.TIMEOUT_MINUTES_NORMAL) || 10 }}
272+
timeout-minutes: 25
273273
permissions:
274274
contents: read
275275
id-token: write
@@ -369,7 +369,7 @@ jobs:
369369
name: Snapshot release
370370
if: ${{ github.event_name == 'issue_comment' && startsWith(github.event.comment.body, '!snapshot') && github.repository == 'clerk/javascript' && github.event.issue.pull_request }}
371371
runs-on: ${{ vars.RUNNER_LARGE || 'ubuntu-latest-l' }}
372-
timeout-minutes: ${{ vars.TIMEOUT_MINUTES_NORMAL && fromJSON(vars.TIMEOUT_MINUTES_NORMAL) || 10 }}
372+
timeout-minutes: 25
373373

374374
permissions:
375375
contents: read

‎package.json‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,10 @@
2929
"nuke": "node ./scripts/nuke.mjs",
3030
"prepare": "husky install",
3131
"pub": "pkglab pub",
32-
"release": "changeset publish && git push --follow-tags",
33-
"release:canary": "changeset publish --tag canary --no-git-tag",
34-
"release:canary-core3": "changeset publish --tag canary-core3 --no-git-tag",
35-
"release:snapshot": "changeset publish --tag snapshot --no-git-tag",
32+
"release": "changeset publish && git push --follow-tags && node scripts/wait-for-packages-on-npm.mjs",
33+
"release:canary": "changeset publish --tag canary --no-git-tag && node scripts/wait-for-packages-on-npm.mjs",
34+
"release:canary-core3": "changeset publish --tag canary-core3 --no-git-tag && node scripts/wait-for-packages-on-npm.mjs",
35+
"release:snapshot": "changeset publish --tag snapshot --no-git-tag && node scripts/wait-for-packages-on-npm.mjs",
3636
"release:status": "changeset status --output .changeset-status.json",
3737
"test": "FORCE_COLOR=1 turbo test --concurrency=${TURBO_CONCURRENCY:-80%}",
3838
"test:cache:clear": "FORCE_COLOR=1 turbo test:cache:clear --continue --concurrency=${TURBO_CONCURRENCY:-80%}",
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
#!/usr/bin/env node
2+
3+
/*
4+
This script:
5+
6+
1. Finds root package manifests plus electron-passkeys platform manifests.
7+
2. Excludes packages with "private": true.
8+
3. Reads each package’s name and current version.
9+
4. Requests: https://registry.npmjs.org/<package-name>/<version>
10+
5. Retries only versions that aren’t available yet.
11+
*/
12+
13+
import { readFile } from 'node:fs/promises';
14+
import { resolve } from 'node:path';
15+
import { fileURLToPath } from 'node:url';
16+
17+
import { glob } from 'tinyglobby';
18+
19+
// Give npm ~10 minutes after the publish command to make the packages available, fail after that
20+
// This is an arbitrarily chosen timeframe, we've seen ~6 minute delays, so 10 is just that + leeway
21+
export const NPM_AVAILABILITY_MAX_ATTEMPTS = 60;
22+
export const NPM_AVAILABILITY_DELAY_MS = 10_000;
23+
24+
const PACKAGE_MANIFEST_PATTERNS = ['packages/*/package.json', 'packages/electron-passkeys/npm/*/package.json'];
25+
26+
export async function getPublicPackages(patterns = PACKAGE_MANIFEST_PATTERNS) {
27+
const manifests = await glob(patterns);
28+
const packages = await Promise.all(
29+
manifests.map(async manifest => {
30+
const packageJson = JSON.parse(await readFile(manifest, 'utf8'));
31+
return packageJson.private ? undefined : { name: packageJson.name, version: packageJson.version };
32+
}),
33+
);
34+
35+
return packages
36+
.filter(packageInfo => packageInfo?.name && packageInfo.version)
37+
.sort((a, b) => a.name.localeCompare(b.name));
38+
}
39+
40+
export async function getUnavailablePackages(packages, fetchPackage = fetch) {
41+
const results = await Promise.all(
42+
packages.map(async packageInfo => {
43+
const url = `https://registry.npmjs.org/${packageInfo.name}/${packageInfo.version}`;
44+
45+
try {
46+
const response = await fetchPackage(url);
47+
await response.body?.cancel();
48+
return response.ok ? undefined : { ...packageInfo, status: response.status };
49+
} catch (error) {
50+
return { ...packageInfo, error: error instanceof Error ? error.message : String(error) };
51+
}
52+
}),
53+
);
54+
55+
return results.filter(Boolean);
56+
}
57+
58+
export async function waitForPackagesOnNpm(
59+
packages,
60+
{
61+
fetchPackage = fetch,
62+
maxAttempts = NPM_AVAILABILITY_MAX_ATTEMPTS,
63+
delayMs = NPM_AVAILABILITY_DELAY_MS,
64+
sleep = duration => new Promise(resolvePromise => setTimeout(resolvePromise, duration)),
65+
log = console.log,
66+
} = {},
67+
) {
68+
let unavailable = packages;
69+
70+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
71+
unavailable = await getUnavailablePackages(unavailable, fetchPackage);
72+
73+
if (unavailable.length === 0) {
74+
log(`All ${packages.length} public package versions are available on npm.`);
75+
return;
76+
}
77+
78+
const versions = unavailable.map(packageInfo => `${packageInfo.name}@${packageInfo.version}`).join(', ');
79+
80+
if (attempt < maxAttempts) {
81+
log(`Attempt ${attempt}/${maxAttempts}: waiting for ${versions}`);
82+
await sleep(delayMs);
83+
}
84+
}
85+
86+
const versions = unavailable.map(packageInfo => `${packageInfo.name}@${packageInfo.version}`).join(', ');
87+
throw new Error(`Package versions did not become available on npm after ${maxAttempts} attempts: ${versions}`);
88+
}
89+
90+
async function main() {
91+
const packages = await getPublicPackages();
92+
await waitForPackagesOnNpm(packages);
93+
}
94+
95+
if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) {
96+
await main();
97+
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
2+
import { tmpdir } from 'node:os';
3+
import { join } from 'node:path';
4+
5+
import { afterEach, describe, expect, test, vi } from 'vitest';
6+
7+
import { getPublicPackages, getUnavailablePackages, waitForPackagesOnNpm } from './wait-for-packages-on-npm.mjs';
8+
9+
const roots = [];
10+
11+
async function createManifest(root, directory, packageJson) {
12+
const packageDirectory = join(root, directory);
13+
await mkdir(packageDirectory, { recursive: true });
14+
await writeFile(join(packageDirectory, 'package.json'), `${JSON.stringify(packageJson)}\n`);
15+
}
16+
17+
afterEach(async () => {
18+
await Promise.all(roots.splice(0).map(root => rm(root, { force: true, recursive: true })));
19+
});
20+
21+
describe('getPublicPackages', () => {
22+
test('returns sorted public package versions', async () => {
23+
const root = await mkdtemp(join(tmpdir(), 'npm-availability-'));
24+
roots.push(root);
25+
26+
await createManifest(root, 'packages/zeta', { name: '@clerk/zeta', version: '2.0.0' });
27+
await createManifest(root, 'packages/alpha', { name: '@clerk/alpha', version: '1.0.0' });
28+
await createManifest(root, 'packages/private', { name: '@clerk/private', private: true, version: '1.0.0' });
29+
30+
await expect(getPublicPackages([join(root, 'packages/*/package.json')])).resolves.toEqual([
31+
{ name: '@clerk/alpha', version: '1.0.0' },
32+
{ name: '@clerk/zeta', version: '2.0.0' },
33+
]);
34+
});
35+
});
36+
37+
describe('getUnavailablePackages', () => {
38+
test('returns failed responses and network errors', async () => {
39+
const packages = [
40+
{ name: '@clerk/available', version: '1.0.0' },
41+
{ name: '@clerk/missing', version: '2.0.0' },
42+
{ name: '@clerk/error', version: '3.0.0' },
43+
];
44+
const fetchPackage = vi.fn(url => {
45+
if (url.includes('/available/')) {
46+
return { ok: true, status: 200 };
47+
}
48+
if (url.includes('/missing/')) {
49+
return { ok: false, status: 404 };
50+
}
51+
throw new Error('network unavailable');
52+
});
53+
54+
await expect(getUnavailablePackages(packages, fetchPackage)).resolves.toEqual([
55+
{ name: '@clerk/missing', version: '2.0.0', status: 404 },
56+
{ name: '@clerk/error', version: '3.0.0', error: 'network unavailable' },
57+
]);
58+
});
59+
});
60+
61+
describe('waitForPackagesOnNpm', () => {
62+
test('retries only unavailable packages', async () => {
63+
const packages = [
64+
{ name: '@clerk/available', version: '1.0.0' },
65+
{ name: '@clerk/delayed', version: '2.0.0' },
66+
];
67+
const attempts = new Map();
68+
const fetchPackage = vi.fn(url => {
69+
const count = (attempts.get(url) ?? 0) + 1;
70+
attempts.set(url, count);
71+
return { ok: !url.includes('/delayed/') || count === 2, status: 200 };
72+
});
73+
const sleep = vi.fn();
74+
const log = vi.fn();
75+
76+
await waitForPackagesOnNpm(packages, { fetchPackage, maxAttempts: 3, delayMs: 1, sleep, log });
77+
78+
expect(fetchPackage).toHaveBeenCalledTimes(3);
79+
expect(sleep).toHaveBeenCalledTimes(1);
80+
expect(sleep).toHaveBeenCalledWith(1);
81+
expect(log).toHaveBeenLastCalledWith('All 2 public package versions are available on npm.');
82+
});
83+
84+
test('throws after exhausting retries', async () => {
85+
const packages = [{ name: '@clerk/missing', version: '1.0.0' }];
86+
const fetchPackage = vi.fn().mockResolvedValue({ ok: false, status: 404 });
87+
const sleep = vi.fn();
88+
89+
await expect(
90+
waitForPackagesOnNpm(packages, { fetchPackage, maxAttempts: 2, delayMs: 1, sleep, log: vi.fn() }),
91+
).rejects.toThrow('Package versions did not become available on npm after 2 attempts: @clerk/missing@1.0.0');
92+
expect(fetchPackage).toHaveBeenCalledTimes(2);
93+
expect(sleep).toHaveBeenCalledTimes(1);
94+
expect(sleep).toHaveBeenCalledWith(1);
95+
});
96+
});

0 commit comments

Comments
 (0)