From da7bc5917fc375ff09d8b895f09c0da6c3f22a4f Mon Sep 17 00:00:00 2001 From: jrkropp Date: Tue, 12 May 2026 09:44:02 -0700 Subject: [PATCH] Harden npm release preflight --- .github/workflows/release.yml | 6 ++ README.md | 6 ++ package.json | 1 + scripts/check-release-publish-config.mjs | 124 +++++++++++++++++++++++ 4 files changed, 137 insertions(+) create mode 100644 scripts/check-release-publish-config.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dbd2020..b1d6e40 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -46,6 +46,12 @@ jobs: echo "enabled=false" >> "$GITHUB_OUTPUT" echo "Skipping npm publish. Configure NPM_TOKEN or set the NPM_TRUSTED_PUBLISHING repository variable to true after npm trusted publishing is enabled." fi + - name: Check npm package bootstrap state + if: steps.npm-publishing.outputs.enabled == 'true' + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + NPM_TRUSTED_PUBLISHING: ${{ vars.NPM_TRUSTED_PUBLISHING }} + run: pnpm release:preflight - uses: changesets/action@v1 if: steps.npm-publishing.outputs.enabled == 'true' with: diff --git a/README.md b/README.md index f665409..f737007 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,12 @@ pnpm changeset Merging the Changesets release PR updates `CHANGELOG.md`, bumps package versions, publishes to npm, and creates the GitHub release. +Releases use npm trusted publishing. Existing package names can publish through +OIDC from `.github/workflows/release.yml`; brand-new package names must be +bootstrapped once with an npm token or a manual first publish before trusted +publishing can be configured for them. Run `pnpm release:preflight` to catch +that state before a release can partially publish. + ## License And Attribution `codex-js` is licensed under Apache-2.0. Portions are modified TypeScript ports diff --git a/package.json b/package.json index a7f4440..49a5579 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "pack:dry-run": "pnpm --filter @jrkropp/codex-js --filter @jrkropp/codex-js-react pack:dry-run", "publint": "pnpm --filter @jrkropp/codex-js --filter @jrkropp/codex-js-react publint", "release": "pnpm check && changeset publish", + "release:preflight": "node scripts/check-release-publish-config.mjs", "test": "vitest run --exclude tests/package.test.ts --passWithNoTests", "test:pack": "vitest run tests/package.test.ts", "type-package": "pnpm --filter @jrkropp/codex-js typecheck && pnpm --filter @jrkropp/codex-js-react typecheck", diff --git a/scripts/check-release-publish-config.mjs b/scripts/check-release-publish-config.mjs new file mode 100644 index 0000000..06aa523 --- /dev/null +++ b/scripts/check-release-publish-config.mjs @@ -0,0 +1,124 @@ +import { spawnSync } from "node:child_process"; +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import process from "node:process"; + +const repoRoot = resolve(dirname(new URL(import.meta.url).pathname), ".."); +const packagesRoot = join(repoRoot, "packages"); +const hasNpmToken = Boolean(process.env.NPM_TOKEN); +const trustedPublishing = process.env.NPM_TRUSTED_PUBLISHING === "true"; + +if (!hasNpmToken && !trustedPublishing) { + process.stdout.write( + "npm publishing is disabled; skipping release publish preflight.\n", + ); + process.exit(0); +} + +const packages = readPublishablePackages(); +const unpublishedVersions = []; +const missingPackages = []; +const unexpectedErrors = []; + +for (const packageJson of packages) { + const version = npmView( + `${packageJson.name}@${packageJson.version}`, + "version", + ); + if (version.exists) { + continue; + } + if (version.error) { + unexpectedErrors.push(version.error); + continue; + } + + unpublishedVersions.push(packageJson); + const packageRecord = npmView(packageJson.name, "name"); + if (!packageRecord.exists && !packageRecord.error) { + missingPackages.push(packageJson); + } + if (packageRecord.error) { + unexpectedErrors.push(packageRecord.error); + } +} + +if (unexpectedErrors.length > 0) { + fail([ + "npm release preflight could not verify package publication state.", + "", + ...unexpectedErrors.map((error) => `- ${error}`), + ]); +} + +if (trustedPublishing && !hasNpmToken && missingPackages.length > 0) { + fail([ + "npm release preflight found new package names, but this workflow is configured for trusted publishing without NPM_TOKEN.", + "", + "npm trusted publishing can only be configured after a package exists on the registry. Bootstrap new package names with an npm token or a manual first publish, then configure trusted publishing for each package.", + "", + "New package names:", + ...missingPackages.map( + (packageJson) => `- ${packageJson.name}@${packageJson.version}`, + ), + "", + "Trusted publisher configuration:", + "- Repository: jrkropp/codex-js", + "- Workflow file: release.yml", + "- Environment: none", + ]); +} + +if (unpublishedVersions.length === 0) { + process.stdout.write( + "npm release preflight passed: all versions are published.\n", + ); +} else { + process.stdout.write( + [ + "npm release preflight passed for publishable versions:", + ...unpublishedVersions.map( + (packageJson) => `- ${packageJson.name}@${packageJson.version}`, + ), + "", + ].join("\n"), + ); +} + +function readPublishablePackages() { + return readdirSync(packagesRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => join(packagesRoot, entry.name, "package.json")) + .filter((path) => existsSync(path)) + .map((path) => JSON.parse(readFileSync(path, "utf8"))) + .filter((packageJson) => packageJson.private !== true) + .filter((packageJson) => packageJson.name && packageJson.version) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +function npmView(specifier, field) { + const result = spawnSync("npm", ["view", specifier, field, "--json"], { + cwd: repoRoot, + encoding: "utf8", + env: { + ...process.env, + NPM_CONFIG_LOGLEVEL: "silent", + }, + }); + const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim(); + if (result.status === 0) { + return { exists: true }; + } + if (output.includes("E404") || output.includes("404 Not Found")) { + return { exists: false }; + } + return { + exists: false, + error: `npm view ${specifier} ${field} failed: ${output || result.status}`, + }; +} + +function fail(lines) { + process.stderr.write(`${lines.join("\n")}\n`); + process.exit(1); +}