Skip to content

Commit c8ee394

Browse files
committed
feat(tools): nx-npm-access plugin — pre-release readiness check per package
Adds an internal Nx plugin that registers an `npm-check` target on every non-private `packages/*` workspace. The target validates that the package is ready to be published from CI *before* `nx release publish` runs, so blockers like missing `publishConfig.access`, wrong registry pinning, or first-publish packages are surfaced up front instead of mid-release. Usage: bunx nx run-many -t npm-check # check all publishable packages bunx nx run adt-cli:npm-check # check one package Checks performed per package: 1. package.json hygiene — `name`, `version`, `publishConfig.access=public`, `files` allowlist. 2. `npm view <name>` — exists on npm? first publish? network unreachable? 3. `npm access get status <name>` — current public/private visibility. 4. `npm access list collaborators <name>` — who can publish (classic tokens). 5. Trusted publisher (OIDC) — prints npmjs settings URL for manual UI verification; npm v11 has no CLI to list trusted publishers yet. Key detail for this monorepo: the repo-level `.npmrc` pins `@abapify:registry=https://npm.pkg.github.com/` so consumers can install from GHP. The script bypasses that scope override on every npm invocation (`--registry=<npmjs>` + `--@abapify:registry=<npmjs>`), otherwise all probes would hit GHP and fail with 401. This is the same root cause that bit `nx release publish` in the v0.3.0 run. Output per package: a one-line human summary plus a `__NPM_CHECK_JSON__ {…}` line for aggregation. Exits 0 only when `readyForCi` is true and no problems were found; suitable as a CI gate before the publish workflow. Plugin wiring mirrors the existing `nx-tsdown` / `nx-typecheck` tools — `createNodesV2` matches `packages/*/package.json` and attaches the target; `private: true` packages (e.g. adt-fixtures) are skipped automatically.
1 parent 42c879a commit c8ee394

9 files changed

Lines changed: 440 additions & 0 deletions

File tree

nx.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
},
3434
{ "plugin": "./tools/nx-tsdown/src/index.ts" },
3535
{ "plugin": "./tools/nx-vitest/src/index.ts" },
36+
{ "plugin": "./tools/nx-npm-access/src/index.ts" },
3637
{ "plugin": "@nx/vite/plugin", "exclude": ["**/*"] },
3738
{ "plugin": "@nx/eslint/plugin", "options": { "targetName": "lint" } },
3839
{

tools/nx-npm-access/README.md

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
# @abapify/nx-npm-access
2+
3+
Internal Nx plugin that adds an `npm-check` target to every publishable
4+
`packages/*` workspace package. The target verifies that a package is
5+
ready to be published from CI before a release is attempted.
6+
7+
## Usage
8+
9+
```bash
10+
# Check every publishable package at once:
11+
bunx nx run-many -t npm-check
12+
13+
# Check a single package:
14+
bunx nx run adt-cli:npm-check
15+
16+
# Silence the success lines in CI, keep only problems:
17+
bunx nx run-many -t npm-check --parallel=8 --output-style=stream | \
18+
grep -E '^(✗| problems|__NPM_CHECK_JSON__)'
19+
```
20+
21+
The target exits non-zero when a package has `publishConfig.access` missing,
22+
when `npm view` fails unexpectedly, or when required metadata (name,
23+
version) is absent. **First publish** (package not yet on npm) is NOT an
24+
error — it is reported as `NOT on npm — first publish`.
25+
26+
## What is actually checked
27+
28+
For each non-private `packages/*` workspace:
29+
30+
1. `package.json` hygiene — `name`, `version`, `publishConfig.access=public`,
31+
`files` allowlist.
32+
2. `npm view <name>` — does the package already exist on npm? Returns
33+
latest version, maintainers, and dist-tags.
34+
3. `npm access get status <name>` — current public/private visibility.
35+
4. `npm access list collaborators <name>` — who currently has publish
36+
rights (classic tokens).
37+
5. Trusted publisher verification URL — npm v11 does not yet expose a CLI
38+
for listing trusted publishers, so the script prints a direct link to
39+
the package's settings page for manual verification.
40+
41+
## Why not just `npm publish --dry-run`?
42+
43+
`--dry-run` still requires auth and a clean working tree, and it hides the
44+
most common blockers for a scoped public package published via OIDC:
45+
46+
- Repo-level `@scope:registry=` pinned to GitHub Packages (common
47+
`.npmrc` pattern in this monorepo) redirects `npm` metadata requests
48+
away from npmjs.org and makes authless probes fail with 401.
49+
50+
This plugin always probes the public npm registry via `--registry` +
51+
`--<scope>:registry=` overrides, so the `.npmrc` scope pin does not
52+
interfere.
53+
54+
## How it's wired
55+
56+
Registered in the root `nx.json` under `plugins`:
57+
58+
```json
59+
{
60+
"plugin": "./tools/nx-npm-access/src/index.ts"
61+
}
62+
```
63+
64+
The plugin's `createNodesV2` matches `packages/*/package.json`, reads each
65+
manifest, skips packages with `"private": true`, and attaches an
66+
`npm-check` target that invokes `src/check.mjs`.
67+
68+
Options (all optional, set via `nx.json` plugin options):
69+
70+
| option | default | purpose |
71+
| ------------ | ------------------------------- | ------------------------------------------ |
72+
| `targetName` | `"npm-check"` | Name of the target registered per package. |
73+
| `registry` | `"https://registry.npmjs.org/"` | Registry probed by the script. |

tools/nx-npm-access/package.json

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"name": "@abapify/nx-npm-access",
3+
"version": "0.0.1",
4+
"license": "MIT",
5+
"private": true,
6+
"type": "module",
7+
"main": "./src/index.ts",
8+
"module": "./src/index.ts",
9+
"types": "./src/index.ts",
10+
"exports": {
11+
"./package.json": "./package.json",
12+
".": {
13+
"types": "./src/index.ts",
14+
"import": "./src/index.ts",
15+
"default": "./src/index.ts"
16+
}
17+
}
18+
}

tools/nx-npm-access/project.json

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
{
2+
"name": "nx-npm-access",
3+
"$schema": "../../node_modules/nx/schemas/project-schema.json",
4+
"sourceRoot": "tools/nx-npm-access/src",
5+
"projectType": "library",
6+
"targets": {
7+
"build": {
8+
"executor": "@nx/js:tsc",
9+
"outputs": ["{options.outputPath}"],
10+
"options": {
11+
"outputPath": "dist/tools/nx-npm-access",
12+
"main": "tools/nx-npm-access/src/index.ts",
13+
"tsConfig": "tools/nx-npm-access/tsconfig.lib.json",
14+
"assets": [
15+
"tools/nx-npm-access/*.md",
16+
{
17+
"input": "tools/nx-npm-access/src",
18+
"glob": "**/*.mjs",
19+
"output": "./"
20+
}
21+
]
22+
}
23+
}
24+
},
25+
"tags": []
26+
}

tools/nx-npm-access/src/check.mjs

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
#!/usr/bin/env node
2+
// tools/nx-npm-access/src/check.mjs
3+
//
4+
// Validates that a single @abapify/* package (located in $cwd) is ready to be
5+
// published from CI. Run per-package via Nx (`nx run <pkg>:npm-check`) or for
6+
// all publishable packages at once (`nx run-many -t npm-check`).
7+
//
8+
// Checks performed (all read-only, no network writes):
9+
// 1. package.json hygiene — name, version, publishConfig.access, exports.
10+
// 2. Does the package exist on npm? (new packages report "first publish").
11+
// 3. `npm access get status` — current public/private visibility on npm.
12+
// 4. `npm access list collaborators` — who can publish (incl. bot account
13+
// used by GitHub Actions when OIDC is not in play).
14+
// 5. Trusted publisher hint — prints a ready-to-click settings URL where
15+
// the OIDC trusted publisher for this package can be verified. npm has
16+
// no stable CLI for listing trusted publishers yet (as of npm 11).
17+
//
18+
// The script exits with code 0 when the package is "ready to publish from
19+
// CI", 1 otherwise. Errors are printed to stderr; the structured report is
20+
// emitted to stdout as a single JSON line, prefixed with a human summary.
21+
//
22+
// Registry handling: to avoid the repo-level `.npmrc` pinning
23+
// `@abapify:registry=https://npm.pkg.github.com/`, the script passes
24+
// `--<scope>:registry=<registry>` on every npm invocation.
25+
26+
import { readFileSync, existsSync } from 'node:fs';
27+
import { join } from 'node:path';
28+
import { spawnSync } from 'node:child_process';
29+
30+
const args = process.argv.slice(2);
31+
const getFlag = (name, def) => {
32+
const hit = args.find((a) => a.startsWith(`--${name}=`));
33+
return hit ? hit.slice(name.length + 3) : def;
34+
};
35+
const registry = getFlag('registry', 'https://registry.npmjs.org/');
36+
const quiet = args.includes('--quiet');
37+
38+
const pkgPath = join(process.cwd(), 'package.json');
39+
if (!existsSync(pkgPath)) {
40+
console.error(`[npm-check] no package.json in ${process.cwd()}`);
41+
process.exit(2);
42+
}
43+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
44+
45+
if (pkg.private) {
46+
if (!quiet) console.log(`[skip] ${pkg.name ?? '<no-name>'} is private`);
47+
process.exit(0);
48+
}
49+
50+
const name = pkg.name;
51+
const scope = name?.startsWith('@') ? name.split('/')[0] : null;
52+
const scopeFlag = scope ? [`--${scope}:registry=${registry}`] : [];
53+
54+
/**
55+
* Run an npm subcommand. We deliberately do NOT inherit the repo `.npmrc` —
56+
* instead we pass registry overrides explicitly. Auth-free commands are
57+
* preferred (`view`); anything that requires login is still called but
58+
* failure is reported as "needs auth" rather than blocking.
59+
*/
60+
function npm(cmdArgs) {
61+
const result = spawnSync(
62+
'npm',
63+
[...cmdArgs, `--registry=${registry}`, ...scopeFlag, '--json'],
64+
{
65+
encoding: 'utf-8',
66+
// Individual npm calls are short-lived; if the network (or corporate
67+
// proxy) hangs, fail fast instead of wedging the whole `nx run-many`.
68+
timeout: 20_000,
69+
},
70+
);
71+
let parsed = null;
72+
if (result.stdout) {
73+
try {
74+
parsed = JSON.parse(result.stdout);
75+
} catch {
76+
parsed = result.stdout.trim();
77+
}
78+
}
79+
return {
80+
code: result.status ?? -1,
81+
timedOut: result.signal === 'SIGTERM' || result.error?.code === 'ETIMEDOUT',
82+
stdout: result.stdout,
83+
stderr: result.stderr,
84+
json: parsed,
85+
};
86+
}
87+
88+
const report = {
89+
name,
90+
version: pkg.version,
91+
access: pkg.publishConfig?.access ?? null,
92+
registry,
93+
scope,
94+
checks: {},
95+
readyForCi: false,
96+
problems: [],
97+
};
98+
99+
// 1. package.json hygiene
100+
if (!name) report.problems.push('package.json: missing "name"');
101+
if (!pkg.version) report.problems.push('package.json: missing "version"');
102+
if (!pkg.publishConfig || pkg.publishConfig.access !== 'public') {
103+
report.problems.push(
104+
'package.json: publishConfig.access should be "public" (scoped packages default to restricted on npm; CI publishes would 402 Payment Required)',
105+
);
106+
}
107+
if (pkg.files === undefined) {
108+
report.problems.push(
109+
'package.json: "files" not declared — publish will include everything (incl. node_modules, dist build artefacts, tests). Add a narrow allowlist (e.g. ["dist", "README.md"]).',
110+
);
111+
}
112+
113+
// 2. Does it exist on npm?
114+
const view = npm(['view', name]);
115+
if (view.code === 0 && view.json && typeof view.json === 'object') {
116+
report.checks.exists = true;
117+
report.checks.latestVersion = view.json.version;
118+
report.checks.maintainers = view.json.maintainers ?? [];
119+
report.checks.distTags = view.json['dist-tags'] ?? {};
120+
} else if (view.stderr?.includes('E404') || view.json?.error?.code === 'E404') {
121+
report.checks.exists = false;
122+
} else if (view.timedOut) {
123+
report.checks.exists = 'unknown';
124+
report.problems.push(
125+
`npm view timed out after 20s — registry ${registry} unreachable from this host`,
126+
);
127+
} else {
128+
report.checks.exists = 'unknown';
129+
report.problems.push(
130+
`npm view failed unexpectedly: ${view.stderr?.split('\n')[0] ?? 'no stderr'}`,
131+
);
132+
}
133+
134+
// 3. access status (only meaningful if published)
135+
if (report.checks.exists === true) {
136+
const status = npm(['access', 'get', 'status', name]);
137+
if (status.code === 0) {
138+
report.checks.accessStatus = status.json ?? status.stdout.trim();
139+
} else {
140+
report.checks.accessStatus = `ERR: ${status.stderr?.split('\n')[0] ?? status.code}`;
141+
}
142+
143+
// 4. collaborators
144+
const collabs = npm(['access', 'list', 'collaborators', name]);
145+
if (collabs.code === 0) {
146+
report.checks.collaborators = collabs.json ?? collabs.stdout.trim();
147+
} else {
148+
report.checks.collaborators = `ERR: ${collabs.stderr?.split('\n')[0] ?? collabs.code}`;
149+
}
150+
}
151+
152+
// 5. trusted publisher hint (npm has no stable CLI for listing these yet)
153+
if (name?.startsWith('@')) {
154+
const [s, p] = name.slice(1).split('/');
155+
report.checks.trustedPublisherSettingsUrl = `https://www.npmjs.com/settings/${s}/packages?q=${p}`;
156+
report.checks.trustedPublisherPackageUrl = `https://www.npmjs.com/package/${name}/access`;
157+
} else {
158+
report.checks.trustedPublisherPackageUrl = `https://www.npmjs.com/package/${name}/access`;
159+
}
160+
161+
// Decide overall readiness
162+
const hasPublishConfig = pkg.publishConfig?.access === 'public';
163+
const existsOrNew =
164+
report.checks.exists === true || report.checks.exists === false;
165+
report.readyForCi = hasPublishConfig && existsOrNew && name && pkg.version;
166+
167+
// Human summary
168+
const symbol = report.readyForCi ? '✓' : '✗';
169+
const existsTag =
170+
report.checks.exists === true
171+
? `on npm @ ${report.checks.latestVersion}`
172+
: report.checks.exists === false
173+
? 'NOT on npm — first publish'
174+
: 'npm state unknown';
175+
const problemsSummary =
176+
report.problems.length > 0
177+
? `\n problems:\n - ${report.problems.join('\n - ')}`
178+
: '';
179+
180+
console.log(
181+
`${symbol} ${name}@${pkg.version} (${existsTag})${problemsSummary}`,
182+
);
183+
// Structured line for aggregation:
184+
console.log(`__NPM_CHECK_JSON__ ${JSON.stringify(report)}`);
185+
186+
process.exit(report.readyForCi && report.problems.length === 0 ? 0 : 1);

tools/nx-npm-access/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { createNodesV2 } from './plugin';

0 commit comments

Comments
 (0)