From 1efa97a55a57c7fac1b001fb228fa566af10e2d2 Mon Sep 17 00:00:00 2001 From: ponderingdemocritus Date: Sun, 22 Mar 2026 12:15:21 +1100 Subject: [PATCH] fix release npm publish preflight --- .github/workflows/release-bot.yml | 6 + .github/workflows/release.yml | 39 +++--- .../__tests__/changeset-publish-utils.test.ts | 72 ++++++++++ scripts/changeset-publish-utils.ts | 88 ++++++++++++ scripts/changeset-publish.ts | 130 +++++++++++++++++- 5 files changed, 312 insertions(+), 23 deletions(-) create mode 100644 scripts/__tests__/changeset-publish-utils.test.ts create mode 100644 scripts/changeset-publish-utils.ts diff --git a/.github/workflows/release-bot.yml b/.github/workflows/release-bot.yml index 104b6f46..e6c65914 100644 --- a/.github/workflows/release-bot.yml +++ b/.github/workflows/release-bot.yml @@ -52,6 +52,12 @@ jobs: echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > ~/.npmrc cat ~/.npmrc + - name: Preflight npm publish access + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: bun run scripts/changeset-publish.ts --preflight-only + - name: Create Version PR or Publish uses: changesets/action@v1 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 727096f7..cc90993d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -89,6 +89,29 @@ jobs: fs.appendFileSync(process.env.GITHUB_OUTPUT, `primary_package=${primary.name}\n`); EOF + - name: Verify npm token + if: steps.metadata.outputs.should_publish == 'true' && github.event.inputs.dry_run != 'true' + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + if [ -z "$NPM_TOKEN" ]; then + echo "::error::NPM_TOKEN secret is required to publish packages." + exit 1 + fi + + - name: Configure npm auth + if: steps.metadata.outputs.should_publish == 'true' && github.event.inputs.dry_run != 'true' + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + run: echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > ~/.npmrc + + - name: Preflight npm publish access + if: steps.metadata.outputs.should_publish == 'true' && github.event.inputs.dry_run != 'true' + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: bun run scripts/changeset-publish.ts --preflight-only + - name: Configure git user if: steps.metadata.outputs.should_publish == 'true' && github.event.inputs.dry_run != 'true' run: | @@ -114,22 +137,6 @@ jobs: run: | git tag -a "${RELEASE_TAG}" -m "${RELEASE_NAME}" - - name: Verify npm token - if: steps.metadata.outputs.should_publish == 'true' && github.event.inputs.dry_run != 'true' - env: - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - run: | - if [ -z "$NPM_TOKEN" ]; then - echo "::error::NPM_TOKEN secret is required to publish packages." - exit 1 - fi - - - name: Configure npm auth - if: steps.metadata.outputs.should_publish == 'true' && github.event.inputs.dry_run != 'true' - env: - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - run: echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > ~/.npmrc - - name: Publish packages if: steps.metadata.outputs.should_publish == 'true' && github.event.inputs.dry_run != 'true' env: diff --git a/scripts/__tests__/changeset-publish-utils.test.ts b/scripts/__tests__/changeset-publish-utils.test.ts new file mode 100644 index 00000000..43448d1c --- /dev/null +++ b/scripts/__tests__/changeset-publish-utils.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, test } from 'bun:test'; + +import { + describeNpmAccessFailure, + describeNpmPublishFailure, + partitionPublishArgs, +} from '../changeset-publish-utils'; + +describe('describeNpmAccessFailure', () => { + test('explains invalid npm token errors', () => { + const message = describeNpmAccessFailure({ + output: `npm ERR! code E401 +npm ERR! Unable to authenticate, your authentication token seems to be invalid.`, + packageName: '@lucid-agents/core', + scope: '@lucid-agents', + }); + + expect(message).toContain('NPM_TOKEN is missing or invalid'); + expect(message).toContain('@lucid-agents/core'); + }); + + test('explains missing scope permissions', () => { + const message = describeNpmAccessFailure({ + output: `npm ERR! code E404 +npm ERR! 404 Not Found - GET https://registry.npmjs.org/-/package/@lucid-agents%2fcore/collaborators?format=cli`, + packageName: '@lucid-agents/core', + scope: '@lucid-agents', + }); + + expect(message).toContain('lacks collaborator or publish access'); + expect(message).toContain('@lucid-agents'); + }); +}); + +describe('describeNpmPublishFailure', () => { + test('turns publish put 404s into a permission hint', () => { + const message = describeNpmPublishFailure({ + output: `npm ERR! code E404 +npm ERR! 404 Not Found - PUT https://registry.npmjs.org/@lucid-agents%2fanalytics - Not found +npm ERR! 404 '@lucid-agents/analytics@0.3.3' is not in this registry.`, + scope: '@lucid-agents', + }); + + expect(message).toContain('likely a permissions problem'); + expect(message).toContain('@lucid-agents'); + }); + + test('returns nothing for unrelated publish failures', () => { + const message = describeNpmPublishFailure({ + output: 'npm ERR! code E500\nnpm ERR! Internal server error', + scope: '@lucid-agents', + }); + + expect(message).toBeUndefined(); + }); +}); + +describe('partitionPublishArgs', () => { + test('extracts the preflight-only flag', () => { + const result = partitionPublishArgs(['--preflight-only', '--tag', 'next']); + + expect(result.preflightOnly).toBe(true); + expect(result.passthroughArgs).toEqual(['--tag', 'next']); + }); + + test('passes through regular changeset publish args unchanged', () => { + const result = partitionPublishArgs(['--tag', 'beta']); + + expect(result.preflightOnly).toBe(false); + expect(result.passthroughArgs).toEqual(['--tag', 'beta']); + }); +}); diff --git a/scripts/changeset-publish-utils.ts b/scripts/changeset-publish-utils.ts new file mode 100644 index 00000000..45535653 --- /dev/null +++ b/scripts/changeset-publish-utils.ts @@ -0,0 +1,88 @@ +type NpmFailureContext = { + output: string; + packageName?: string; + scope: string; +}; + +export function getPackageScope(packageName: string): string | undefined { + if (!packageName.startsWith("@")) return undefined; + const slashIndex = packageName.indexOf("/"); + if (slashIndex <= 1) return undefined; + return packageName.slice(0, slashIndex); +} + +export function describeNpmAccessFailure({ + output, + packageName, + scope, +}: NpmFailureContext): string | undefined { + if (/\bE401\b/i.test(output) || /Unable to authenticate/i.test(output)) { + return [ + `NPM_TOKEN is missing or invalid.`, + packageName + ? `npm access preflight failed for ${packageName}.` + : "npm access preflight failed.", + "Use an npm automation token with publish access to this scope.", + ].join(" "); + } + + if ( + /\bE403\b/i.test(output) || + /\bE404\b/i.test(output) || + /collaborators/i.test(output) + ) { + return [ + `NPM_TOKEN authenticates, but it lacks collaborator or publish access for ${scope}.`, + packageName + ? `The preflight probe against ${packageName} was rejected by npm.` + : "The publish access probe was rejected by npm.", + `Use a token from an npm owner or package collaborator that can publish ${scope} packages.`, + ].join(" "); + } + + return undefined; +} + +export function describeNpmPublishFailure({ + output, + scope, +}: NpmFailureContext): string | undefined { + if ( + /\bE401\b/i.test(output) || + /Unable to authenticate/i.test(output) || + /ENEEDAUTH/i.test(output) + ) { + return `npm rejected the publish because NPM_TOKEN is missing or invalid for ${scope}.`; + } + + if ( + /\bE404\b/i.test(output) && + /Not Found - PUT https:\/\/registry\.npmjs\.org\//i.test(output) + ) { + return [ + `npm returned a PUT 404 while publishing ${scope} packages.`, + `For scoped packages that already exist on npm, this is likely a permissions problem with NPM_TOKEN rather than missing package metadata.`, + `Verify that the token belongs to an npm owner or collaborator with publish access to ${scope}.`, + ].join(" "); + } + + return undefined; +} + +export function partitionPublishArgs(argv: string[]): { + preflightOnly: boolean; + passthroughArgs: string[]; +} { + const passthroughArgs: string[] = []; + let preflightOnly = false; + + for (const arg of argv) { + if (arg === "--preflight-only") { + preflightOnly = true; + continue; + } + passthroughArgs.push(arg); + } + + return { preflightOnly, passthroughArgs }; +} diff --git a/scripts/changeset-publish.ts b/scripts/changeset-publish.ts index 1bac87bd..672e3cbf 100644 --- a/scripts/changeset-publish.ts +++ b/scripts/changeset-publish.ts @@ -3,6 +3,13 @@ import { existsSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { + describeNpmAccessFailure, + describeNpmPublishFailure, + getPackageScope, + partitionPublishArgs, +} from "./changeset-publish-utils"; + type DependencyBlocks = | "dependencies" | "devDependencies" @@ -27,6 +34,11 @@ type Backup = { contents: string; }; +type ExecResult = { + code: number; + output: string; +}; + const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const repoRoot = path.resolve(__dirname, ".."); @@ -168,7 +180,76 @@ function restoreBackups(backups: Backup[]) { } } +async function verifyNpmPublishAccess() { + if (process.env.LUCID_SKIP_NPM_PUBLISH_PREFLIGHT === "1") { + console.log("Skipping npm publish preflight via LUCID_SKIP_NPM_PUBLISH_PREFLIGHT=1"); + return; + } + + const publicPackages = packages + .filter((pkg) => !pkg.manifest.private && pkg.manifest.name) + .sort((a, b) => (a.manifest.name ?? "").localeCompare(b.manifest.name ?? "")); + const scopes = new Set(); + + for (const pkg of publicPackages) { + const scope = pkg.manifest.name ? getPackageScope(pkg.manifest.name) : undefined; + if (scope) scopes.add(scope); + } + + if (!scopes.size) return; + + const auth = await exec(["npm", "whoami"], { allowFailure: true }); + if (auth.code !== 0) { + const scope = scopes.values().next().value ?? "the configured npm scope"; + const message = + describeNpmAccessFailure({ output: auth.output, scope }) ?? + `npm publish preflight failed before publishing ${scope} packages.`; + throw new Error(message); + } + + for (const scope of scopes) { + const probe = await findPublishedPackageForScope(publicPackages, scope); + if (!probe) { + console.warn( + `Skipping npm collaborator preflight for ${scope}: no existing published package found to probe.` + ); + continue; + } + + const access = await exec( + ["npm", "access", "list", "collaborators", probe, "--json"], + { allowFailure: true } + ); + if (access.code !== 0) { + const message = + describeNpmAccessFailure({ + output: access.output, + packageName: probe, + scope, + }) ?? + `npm publish preflight failed while checking collaborator access for ${probe}.`; + throw new Error(message); + } + } +} + +async function findPublishedPackageForScope( + candidates: PackageInfo[], + scope: string +): Promise { + for (const pkg of candidates) { + const name = pkg.manifest.name; + if (!name || getPackageScope(name) !== scope) continue; + const view = await exec(["npm", "view", name, "version", "--json"], { + allowFailure: true, + }); + if (view.code === 0) return name; + } + return undefined; +} + async function runPublish() { + const parsedArgs = partitionPublishArgs(process.argv.slice(2)); const backups: Backup[] = []; const sanitisedPackages: string[] = []; @@ -191,8 +272,28 @@ async function runPublish() { } try { - const extraArgs = process.argv.slice(2); - await exec(["bun", "x", "changeset", "publish", ...extraArgs]); + await verifyNpmPublishAccess(); + if (parsedArgs.preflightOnly) { + console.log("npm publish preflight succeeded."); + return; + } + + const extraArgs = parsedArgs.passthroughArgs; + const publish = await exec(["bun", "x", "changeset", "publish", ...extraArgs], { + allowFailure: true, + }); + if (publish.code !== 0) { + const scope = packages + .map((pkg) => pkg.manifest.name) + .find((name): name is string => Boolean(name)) + ?.match(/^@[^/]+/)?.[0]; + const message = + scope && describeNpmPublishFailure({ output: publish.output, scope }); + if (message) { + throw new Error(`${message}\n\nbun x changeset publish exited with code ${publish.code}`); + } + throw new Error(`bun x changeset publish exited with code ${publish.code}`); + } } finally { if (backups.length) { restoreBackups(backups); @@ -203,17 +304,32 @@ async function runPublish() { } } -async function exec(argv: string[]) { +async function exec( + argv: string[], + opts: { allowFailure?: boolean } = {} +): Promise { const proc = Bun.spawn(argv, { cwd: repoRoot, stdin: "inherit", - stdout: "inherit", - stderr: "inherit", + stdout: "pipe", + stderr: "pipe", }); - const code = await proc.exited; - if (code !== 0) { + + const [stdout, stderr, code] = await Promise.all([ + proc.stdout ? new Response(proc.stdout).text() : "", + proc.stderr ? new Response(proc.stderr).text() : "", + proc.exited, + ]); + + if (stdout) process.stdout.write(stdout); + if (stderr) process.stderr.write(stderr); + + const output = [stdout, stderr].filter(Boolean).join("\n"); + if (code !== 0 && !opts.allowFailure) { throw new Error(`${argv.join(" ")} exited with code ${code}`); } + + return { code, output }; } await runPublish().catch((err) => {