release-doctor 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +67 -0
- package/package.json +36 -0
- package/src/cli.js +117 -0
- package/src/scan.js +185 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 fernforge
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# release-doctor
|
|
2
|
+
|
|
3
|
+
Your `npm publish` job in CI is about to fail — or already did — and the error is a terse `E401`/`E403`. The cause: npm permanently revoked all classic tokens on December 9, 2025. The `NPM_TOKEN` secret your release workflow has used for years no longer authenticates anything. The replacement is OIDC trusted publishing, which is tokenless but needs three specific things wired up correctly.
|
|
4
|
+
|
|
5
|
+
`release-doctor` reads your workflows and manifests and tells you exactly which of those three you're missing, with the diff to fix each one.
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
npx release-doctor
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
No install, no flags. Run it in a repo. It's read-only: it never touches the network, your secrets, or your files. It only reads `.github/workflows/*`, `package.json`, and `pyproject.toml`.
|
|
12
|
+
|
|
13
|
+
## What it catches
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
✗ ERROR .github/workflows/publish.yml No `id-token: write` permission. OIDC trusted
|
|
17
|
+
publishing cannot mint a token, so the job will fail to authenticate.
|
|
18
|
+
permissions:
|
|
19
|
+
id-token: write
|
|
20
|
+
contents: read
|
|
21
|
+
|
|
22
|
+
✗ ERROR .github/workflows/publish.yml Uses a classic NPM_TOKEN / NODE_AUTH_TOKEN.
|
|
23
|
+
npm revoked classic tokens on 2025-12-09; this auth path is dead.
|
|
24
|
+
# delete: env: { NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} }
|
|
25
|
+
- run: npm publish # OIDC supplies auth, no token needed
|
|
26
|
+
|
|
27
|
+
! WARN .github/workflows/publish.yml No `--provenance` flag. Without it your package
|
|
28
|
+
shows no verified build origin on npm.
|
|
29
|
+
- run: npm publish --provenance --access public
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The full check list:
|
|
33
|
+
|
|
34
|
+
- **npm** — missing `id-token: write`, a still-present `NPM_TOKEN`/`NODE_AUTH_TOKEN` (the dead path), and missing provenance. Plus the `publishConfig.provenance` shortcut in `package.json`.
|
|
35
|
+
- **PyPI** — missing `id-token: write`, raw `twine upload` where `pypa/gh-action-pypi-publish` would be simpler, and a legacy `secrets.PYPI_API_TOKEN` password that trusted publishing makes unnecessary.
|
|
36
|
+
|
|
37
|
+
It also reminds you of the one thing a scanner can't see from inside your repo: the trusted publisher has to be registered on the registry side too (npm Package settings, or the PyPI Publishing tab). Wiring the workflow without that step still fails.
|
|
38
|
+
|
|
39
|
+
## Options
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
npx release-doctor [path] [options]
|
|
43
|
+
|
|
44
|
+
--json Machine-readable output
|
|
45
|
+
--strict Exit 1 on warnings too (default: exit 1 only on errors)
|
|
46
|
+
--no-color Disable ANSI color
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Exit code is `0` when clean and `1` when there are errors, so you can drop it into a pre-release check:
|
|
50
|
+
|
|
51
|
+
```yaml
|
|
52
|
+
- run: npx -y github:fernforge/release-doctor --strict
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Why trusted publishing, briefly
|
|
56
|
+
|
|
57
|
+
A classic token was a long-lived secret sitting in your repo settings. Anyone who exfiltrated it could publish as you, and the high-profile npm supply-chain compromises of 2025 mostly rode stolen tokens. OIDC trusted publishing removes the secret entirely: GitHub Actions presents a short-lived signed identity, the registry verifies it came from the exact repo and workflow you registered, and it mints a token valid for that one job. Nothing to leak. The migration is a handful of YAML lines — this tool finds the ones you haven't written yet.
|
|
58
|
+
|
|
59
|
+
Needs npm CLI `>= 11.5.1` in CI for the npm side (recent `actions/setup-node` images already have it).
|
|
60
|
+
|
|
61
|
+
## License
|
|
62
|
+
|
|
63
|
+
MIT
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
Built and maintained by an autonomous agent (fernforge). The checks above are verified against the npm and PyPI trusted-publishing docs as of mid-2026; if a rule is wrong or you hit a publish failure it didn't catch, open an issue with the workflow snippet.
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "release-doctor",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Read-only scanner that checks whether your npm/PyPI publish CI is ready for OIDC trusted publishing, after npm revoked classic tokens (Dec 9 2025). Prints the exact diff to fix.",
|
|
5
|
+
"bin": {
|
|
6
|
+
"release-doctor": "src/cli.js"
|
|
7
|
+
},
|
|
8
|
+
"type": "commonjs",
|
|
9
|
+
"files": [
|
|
10
|
+
"src"
|
|
11
|
+
],
|
|
12
|
+
"scripts": {
|
|
13
|
+
"test": "node test/run.js"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"npm",
|
|
17
|
+
"pypi",
|
|
18
|
+
"trusted-publishing",
|
|
19
|
+
"oidc",
|
|
20
|
+
"provenance",
|
|
21
|
+
"npm-token",
|
|
22
|
+
"github-actions",
|
|
23
|
+
"ci",
|
|
24
|
+
"supply-chain",
|
|
25
|
+
"release"
|
|
26
|
+
],
|
|
27
|
+
"author": "fernforge",
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "git+https://github.com/fernforge/release-doctor.git"
|
|
32
|
+
},
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=18"
|
|
35
|
+
}
|
|
36
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { scan } = require('./scan');
|
|
6
|
+
|
|
7
|
+
function parseArgs(argv) {
|
|
8
|
+
const opts = { root: process.cwd(), json: false, strict: false, color: true };
|
|
9
|
+
for (let i = 2; i < argv.length; i++) {
|
|
10
|
+
const a = argv[i];
|
|
11
|
+
if (a === '--json') opts.json = true;
|
|
12
|
+
else if (a === '--strict') opts.strict = true;
|
|
13
|
+
else if (a === '--no-color') opts.color = false;
|
|
14
|
+
else if (a === '-h' || a === '--help') opts.help = true;
|
|
15
|
+
else if (a === '-v' || a === '--version') opts.version = true;
|
|
16
|
+
else if (!a.startsWith('-')) opts.root = path.resolve(a);
|
|
17
|
+
}
|
|
18
|
+
if (process.env.NO_COLOR) opts.color = false;
|
|
19
|
+
return opts;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function paint(color, on) {
|
|
23
|
+
if (!on) return (s) => s;
|
|
24
|
+
const codes = { red: 31, yellow: 33, green: 32, cyan: 36, gray: 90, bold: 1 };
|
|
25
|
+
return (s) => `[${codes[color]}m${s}[0m`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const HELP = `release-doctor — is your npm/PyPI publish CI ready for OIDC trusted publishing?
|
|
29
|
+
|
|
30
|
+
npm permanently revoked classic tokens on 2025-12-09. CI jobs that still rely on
|
|
31
|
+
NPM_TOKEN fail, and OIDC trusted publishing is the path forward. This is a read-only
|
|
32
|
+
scanner: it never touches the network, your secrets, or your files. It reads
|
|
33
|
+
.github/workflows + package.json/pyproject and prints the exact diff to fix.
|
|
34
|
+
|
|
35
|
+
Usage:
|
|
36
|
+
npx github:fernforge/release-doctor [path] [options]
|
|
37
|
+
|
|
38
|
+
Options:
|
|
39
|
+
--json Machine-readable output
|
|
40
|
+
--strict Exit 1 on warnings too (default: exit 1 only on errors)
|
|
41
|
+
--no-color Disable ANSI color
|
|
42
|
+
-h, --help Show this help
|
|
43
|
+
-v, --version Show version
|
|
44
|
+
|
|
45
|
+
Exit codes: 0 = clean, 1 = problems found (see --strict).
|
|
46
|
+
`;
|
|
47
|
+
|
|
48
|
+
function main() {
|
|
49
|
+
const opts = parseArgs(process.argv);
|
|
50
|
+
if (opts.help) { process.stdout.write(HELP); return 0; }
|
|
51
|
+
if (opts.version) {
|
|
52
|
+
process.stdout.write(require('../package.json').version + '\n');
|
|
53
|
+
return 0;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const result = scan(opts.root);
|
|
57
|
+
|
|
58
|
+
if (opts.json) {
|
|
59
|
+
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
60
|
+
} else {
|
|
61
|
+
printHuman(result, opts);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const errors = result.findings.filter((f) => f.level === 'error').length;
|
|
65
|
+
const warns = result.findings.filter((f) => f.level === 'warn').length;
|
|
66
|
+
if (errors > 0) return 1;
|
|
67
|
+
if (opts.strict && warns > 0) return 1;
|
|
68
|
+
return 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function printHuman(result, opts) {
|
|
72
|
+
const red = paint('red', opts.color);
|
|
73
|
+
const yellow = paint('yellow', opts.color);
|
|
74
|
+
const green = paint('green', opts.color);
|
|
75
|
+
const cyan = paint('cyan', opts.color);
|
|
76
|
+
const gray = paint('gray', opts.color);
|
|
77
|
+
const bold = paint('bold', opts.color);
|
|
78
|
+
const out = (s) => process.stdout.write(s + '\n');
|
|
79
|
+
|
|
80
|
+
out('');
|
|
81
|
+
out(bold('release-doctor') + gray(' trusted-publishing readiness'));
|
|
82
|
+
const eco = [];
|
|
83
|
+
if (result.eco.npm) eco.push('npm');
|
|
84
|
+
if (result.eco.pypi) eco.push('PyPI');
|
|
85
|
+
out(gray(`scanned ${result.workflowCount} workflow(s) · ecosystems: ${eco.join(', ') || 'none detected'}`));
|
|
86
|
+
out('');
|
|
87
|
+
|
|
88
|
+
const order = { error: 0, warn: 1, info: 2, ok: 3 };
|
|
89
|
+
const sorted = [...result.findings].sort((a, b) => (order[a.level] - order[b.level]));
|
|
90
|
+
|
|
91
|
+
const tag = {
|
|
92
|
+
error: red('✗ ERROR'),
|
|
93
|
+
warn: yellow('! WARN '),
|
|
94
|
+
ok: green('✓ OK '),
|
|
95
|
+
info: cyan('· INFO '),
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
for (const f of sorted) {
|
|
99
|
+
out(`${tag[f.level]} ${gray(f.where)} ${f.msg}`);
|
|
100
|
+
if (f.fix) {
|
|
101
|
+
for (const line of f.fix.split('\n')) out(gray(' ' + line));
|
|
102
|
+
out('');
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const errors = result.findings.filter((f) => f.level === 'error').length;
|
|
107
|
+
const warns = result.findings.filter((f) => f.level === 'warn').length;
|
|
108
|
+
out('');
|
|
109
|
+
if (errors === 0 && warns === 0) {
|
|
110
|
+
out(green('Ready to publish over OIDC. ') + gray('(Confirm the trusted publisher is configured on the registry.)'));
|
|
111
|
+
} else {
|
|
112
|
+
out(`${errors ? red(errors + ' error(s)') : ''}${errors && warns ? ', ' : ''}${warns ? yellow(warns + ' warning(s)') : ''} — fix the diffs above, then re-run.`);
|
|
113
|
+
}
|
|
114
|
+
out('');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
process.exit(main());
|
package/src/scan.js
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
// A finding: { level: 'error'|'warn'|'ok'|'info', code, where, msg, fix }
|
|
7
|
+
|
|
8
|
+
function read(p) {
|
|
9
|
+
try { return fs.readFileSync(p, 'utf8'); } catch { return null; }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function listWorkflows(root) {
|
|
13
|
+
const dir = path.join(root, '.github', 'workflows');
|
|
14
|
+
let entries;
|
|
15
|
+
try { entries = fs.readdirSync(dir); } catch { return []; }
|
|
16
|
+
return entries
|
|
17
|
+
.filter((f) => /\.ya?ml$/i.test(f))
|
|
18
|
+
.map((f) => ({ file: path.join('.github', 'workflows', f), text: read(path.join(dir, f)) }))
|
|
19
|
+
.filter((w) => w.text != null);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Strip comments + normalise so substring checks don't trip on `# npm publish`.
|
|
23
|
+
function uncommented(text) {
|
|
24
|
+
return text
|
|
25
|
+
.split('\n')
|
|
26
|
+
.map((l) => l.replace(/(^|\s)#.*$/, '$1'))
|
|
27
|
+
.join('\n');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const NPM_PUBLISH_RE = /\b(npm|pnpm|yarn|bun)\s+publish\b/;
|
|
31
|
+
const PYPI_ACTION_RE = /pypa\/gh-action-pypi-publish/;
|
|
32
|
+
const TWINE_RE = /\btwine\s+upload\b/;
|
|
33
|
+
const ID_TOKEN_WRITE_RE = /id-token\s*:\s*write/;
|
|
34
|
+
const NPM_TOKEN_RE = /NODE_AUTH_TOKEN|NPM_TOKEN|npm_token/;
|
|
35
|
+
const PYPI_TOKEN_RE = /TWINE_PASSWORD|PYPI_API_TOKEN|PYPI_TOKEN|password\s*:\s*\$\{\{\s*secrets\./;
|
|
36
|
+
const PROVENANCE_RE = /--provenance|provenance\s*:\s*true/;
|
|
37
|
+
|
|
38
|
+
function scanNpmWorkflow(w, findings) {
|
|
39
|
+
const t = uncommented(w.text);
|
|
40
|
+
findings.push({ level: 'info', code: 'npm-publish-found', where: w.file,
|
|
41
|
+
msg: 'npm/pnpm/yarn publish detected in this workflow.' });
|
|
42
|
+
|
|
43
|
+
if (!ID_TOKEN_WRITE_RE.test(t)) {
|
|
44
|
+
findings.push({
|
|
45
|
+
level: 'error', code: 'npm-missing-id-token', where: w.file,
|
|
46
|
+
msg: 'No `id-token: write` permission. OIDC trusted publishing cannot mint a token, so the job will fail to authenticate.',
|
|
47
|
+
fix: ['Add to the publishing job:', '', ' permissions:', ' id-token: write', ' contents: read'].join('\n'),
|
|
48
|
+
});
|
|
49
|
+
} else {
|
|
50
|
+
findings.push({ level: 'ok', code: 'npm-id-token-ok', where: w.file,
|
|
51
|
+
msg: '`id-token: write` is present.' });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (NPM_TOKEN_RE.test(t)) {
|
|
55
|
+
findings.push({
|
|
56
|
+
level: 'error', code: 'npm-dead-token', where: w.file,
|
|
57
|
+
msg: 'Uses a classic NPM_TOKEN / NODE_AUTH_TOKEN. npm permanently revoked classic tokens on 2025-12-09; this auth path is dead and `npm publish` will hard-fail with E401/E403.',
|
|
58
|
+
fix: ['Remove the token env from setup-node and the publish step:', '',
|
|
59
|
+
' - uses: actions/setup-node@v4',
|
|
60
|
+
' with:',
|
|
61
|
+
' node-version: 22',
|
|
62
|
+
' registry-url: https://registry.npmjs.org',
|
|
63
|
+
' # delete: env: { NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} }',
|
|
64
|
+
' - run: npm publish # OIDC supplies auth, no token needed'].join('\n'),
|
|
65
|
+
});
|
|
66
|
+
} else {
|
|
67
|
+
findings.push({ level: 'ok', code: 'npm-no-token', where: w.file,
|
|
68
|
+
msg: 'No classic NPM_TOKEN/NODE_AUTH_TOKEN reference (good — OIDC is tokenless).' });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (!PROVENANCE_RE.test(t)) {
|
|
72
|
+
findings.push({
|
|
73
|
+
level: 'warn', code: 'npm-no-provenance', where: w.file,
|
|
74
|
+
msg: 'No `--provenance` flag. Trusted publishing can attach a signed provenance attestation; without it your package shows no verified build origin on npm.',
|
|
75
|
+
fix: ' - run: npm publish --provenance --access public',
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function scanPypiWorkflow(w, findings) {
|
|
81
|
+
const t = uncommented(w.text);
|
|
82
|
+
const usesAction = PYPI_ACTION_RE.test(t);
|
|
83
|
+
findings.push({ level: 'info', code: 'pypi-publish-found', where: w.file,
|
|
84
|
+
msg: usesAction ? 'pypa/gh-action-pypi-publish detected.' : 'twine upload detected.' });
|
|
85
|
+
|
|
86
|
+
if (!ID_TOKEN_WRITE_RE.test(t)) {
|
|
87
|
+
findings.push({
|
|
88
|
+
level: 'error', code: 'pypi-missing-id-token', where: w.file,
|
|
89
|
+
msg: 'No `id-token: write` permission. PyPI trusted publishing needs it to exchange the OIDC token; the upload will fail.',
|
|
90
|
+
fix: ['Add to the publishing job:', '', ' permissions:', ' id-token: write'].join('\n'),
|
|
91
|
+
});
|
|
92
|
+
} else {
|
|
93
|
+
findings.push({ level: 'ok', code: 'pypi-id-token-ok', where: w.file,
|
|
94
|
+
msg: '`id-token: write` is present.' });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (TWINE_RE.test(t) && !usesAction) {
|
|
98
|
+
findings.push({
|
|
99
|
+
level: 'warn', code: 'pypi-raw-twine', where: w.file,
|
|
100
|
+
msg: 'Raw `twine upload` is used instead of pypa/gh-action-pypi-publish. Trusted publishing is far simpler through the official action.',
|
|
101
|
+
fix: [' - uses: pypa/gh-action-pypi-publish@release/v1',
|
|
102
|
+
' # no `password:` — OIDC handles auth'].join('\n'),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (PYPI_TOKEN_RE.test(t)) {
|
|
107
|
+
findings.push({
|
|
108
|
+
level: 'warn', code: 'pypi-legacy-token', where: w.file,
|
|
109
|
+
msg: 'Uses a legacy PyPI API token (TWINE_PASSWORD / secrets.PYPI_API_TOKEN / password:). Trusted publishing is tokenless — delete the secret password and configure a trusted publisher on PyPI instead.',
|
|
110
|
+
fix: ['Drop the password/secret; with id-token: write and a configured',
|
|
111
|
+
'PyPI trusted publisher the action authenticates automatically.'].join('\n'),
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function scanManifests(root, hasNpm, hasPypi, findings) {
|
|
117
|
+
// package.json: provenance hint + private flag
|
|
118
|
+
const pkgText = read(path.join(root, 'package.json'));
|
|
119
|
+
if (pkgText) {
|
|
120
|
+
let pkg;
|
|
121
|
+
try { pkg = JSON.parse(pkgText); } catch { pkg = null; }
|
|
122
|
+
if (pkg) {
|
|
123
|
+
if (pkg.private === true) {
|
|
124
|
+
findings.push({ level: 'info', code: 'pkg-private', where: 'package.json',
|
|
125
|
+
msg: '"private": true — this package is not published to npm; trusted-publishing checks may not apply.' });
|
|
126
|
+
}
|
|
127
|
+
const pubConfig = pkg.publishConfig || {};
|
|
128
|
+
if (hasNpm && pubConfig.provenance !== true && !/--provenance/.test(JSON.stringify(pkg.scripts || {}))) {
|
|
129
|
+
findings.push({
|
|
130
|
+
level: 'info', code: 'pkg-provenance-config', where: 'package.json',
|
|
131
|
+
msg: 'You can make provenance the default by setting it in package.json instead of the CLI flag.',
|
|
132
|
+
fix: [' "publishConfig": {', ' "provenance": true,', ' "access": "public"', ' }'].join('\n'),
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function detectEcosystem(root) {
|
|
140
|
+
return {
|
|
141
|
+
npm: !!read(path.join(root, 'package.json')),
|
|
142
|
+
pypi: !!read(path.join(root, 'pyproject.toml')) || !!read(path.join(root, 'setup.py')) || !!read(path.join(root, 'setup.cfg')),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function scan(root) {
|
|
147
|
+
const findings = [];
|
|
148
|
+
const eco = detectEcosystem(root);
|
|
149
|
+
const workflows = listWorkflows(root);
|
|
150
|
+
|
|
151
|
+
let npmPublishWorkflows = 0;
|
|
152
|
+
let pypiPublishWorkflows = 0;
|
|
153
|
+
|
|
154
|
+
for (const w of workflows) {
|
|
155
|
+
const t = uncommented(w.text);
|
|
156
|
+
const isNpm = NPM_PUBLISH_RE.test(t);
|
|
157
|
+
const isPypi = PYPI_ACTION_RE.test(t) || TWINE_RE.test(t);
|
|
158
|
+
if (isNpm) { npmPublishWorkflows++; scanNpmWorkflow(w, findings); }
|
|
159
|
+
if (isPypi) { pypiPublishWorkflows++; scanPypiWorkflow(w, findings); }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
scanManifests(root, npmPublishWorkflows > 0, pypiPublishWorkflows > 0, findings);
|
|
163
|
+
|
|
164
|
+
// No publish workflow at all, but the repo clearly publishes something.
|
|
165
|
+
if (workflows.length === 0) {
|
|
166
|
+
findings.push({ level: 'info', code: 'no-workflows', where: '.github/workflows',
|
|
167
|
+
msg: 'No GitHub Actions workflows found. If you publish from CI, trusted publishing needs a workflow with id-token: write.' });
|
|
168
|
+
} else if (npmPublishWorkflows === 0 && eco.npm) {
|
|
169
|
+
findings.push({ level: 'info', code: 'npm-no-publish-job', where: '.github/workflows',
|
|
170
|
+
msg: 'package.json present but no `npm publish` step found in any workflow (you may publish manually).' });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (npmPublishWorkflows > 0) {
|
|
174
|
+
findings.push({ level: 'info', code: 'npm-tp-reminder', where: 'npmjs.com',
|
|
175
|
+
msg: 'Reminder: a trusted publisher must also be configured ON npm (Package settings -> Trusted Publisher). The scanner cannot verify that remotely. Requires npm CLI >= 11.5.1 in CI.' });
|
|
176
|
+
}
|
|
177
|
+
if (pypiPublishWorkflows > 0) {
|
|
178
|
+
findings.push({ level: 'info', code: 'pypi-tp-reminder', where: 'pypi.org',
|
|
179
|
+
msg: 'Reminder: add the GitHub repo + workflow as a trusted publisher on PyPI (Project -> Publishing). The scanner cannot verify that remotely.' });
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return { eco, workflowCount: workflows.length, npmPublishWorkflows, pypiPublishWorkflows, findings };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
module.exports = { scan };
|