@rtorcato/repo-tooling 4.3.1 → 4.5.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/AGENTS.md +1 -1
- package/dist/base/checks.js +24 -19
- package/dist/base/fixers.js +20 -9
- package/dist/base/github-settings.js +71 -0
- package/dist/cli/commands/doctor.js +22 -27
- package/dist/cli/commands/fix-targets.js +4 -0
- package/dist/cli/commands/setup.js +27 -1
- package/dist/cli/generators/badges.js +4 -2
- package/dist/cli/generators/brand.js +154 -19
- package/dist/cli/generators/docs-site.js +6 -0
- package/dist/languages/js/ci.js +6 -0
- package/dist/languages/js/fixers.js +3 -1
- package/dist/languages/js/npm-trust.js +351 -0
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -92,7 +92,7 @@ npx @rtorcato/repo-tooling fix bun --yes --json # Bun runtime/test confi
|
|
|
92
92
|
|
|
93
93
|
`fix` defaults the confirm prompt to **No** for drift cases (existing file that doesn't extend our preset). The `--yes` flag is required to overwrite drift. Safe-merge fixers (`biome`, `engines`, `husky`, `package-json`) never overwrite — they add/merge — and use friendlier prompt wording. `fix --json` implies `--yes` (prompts would corrupt JSON output).
|
|
94
94
|
|
|
95
|
-
Fixers marked `explicitOnly` are exempt from `fix` all *and* from `fix --yes` — they only run when named as the target. Today that is `release-environment` (changes what a merge does).
|
|
95
|
+
Fixers marked `explicitOnly` are exempt from `fix` all *and* from `fix --yes` — they only run when named as the target. Today that is `release-environment` (changes what a merge does) and `npm-trusted-publisher` (registers an OIDC trusted publisher on npm via `npm trust github`; refuses when the package isn't on npm yet, local npm < 11.15.0, or not logged in).
|
|
96
96
|
|
|
97
97
|
The same goes for every `optional-missing` finding: a bulk `fix` records it `skipped`, because optional tools are often mutually exclusive (Biome / ESLint / Prettier / Oxlint, semantic-release / Changesets / Release Please) and installing them all is never the intent (#630). Name the one you want — `fix editorconfig --yes`.
|
|
98
98
|
|
package/dist/base/checks.js
CHANGED
|
@@ -100,7 +100,7 @@ export async function checkCommunityHealth(dir) {
|
|
|
100
100
|
hint: 'Run `npx @rtorcato/repo-tooling fix community-health` to scaffold them',
|
|
101
101
|
};
|
|
102
102
|
}
|
|
103
|
-
const BRAND_HINT = 'Run `npx @rtorcato/repo-tooling fix brand` to scaffold brand/ (SVG sources + render.sh)
|
|
103
|
+
const BRAND_HINT = 'Run `npx @rtorcato/repo-tooling fix brand` to scaffold brand/ (SVG sources + render.sh) and render the PNGs (needs librsvg)';
|
|
104
104
|
/** The two banners the README consumes — the pair `brand/` exists to keep regenerable. */
|
|
105
105
|
const BANNERS = ['banner', 'banner-mobile'];
|
|
106
106
|
/**
|
|
@@ -380,6 +380,22 @@ export async function checkCodeQL(dir, languages) {
|
|
|
380
380
|
hint: 'Run `npx @rtorcato/repo-tooling fix codeql` to scaffold CodeQL security scanning',
|
|
381
381
|
};
|
|
382
382
|
}
|
|
383
|
+
/** The workflow file that uploads coverage via codecov-action, or null when none does. */
|
|
384
|
+
export async function coverageUploadWorkflow(dir) {
|
|
385
|
+
const workflowsDir = path.join(dir, '.github', 'workflows');
|
|
386
|
+
try {
|
|
387
|
+
const files = (await fs.readdir(workflowsDir)).filter((f) => f.endsWith('.yml') || f.endsWith('.yaml'));
|
|
388
|
+
for (const f of files) {
|
|
389
|
+
const content = await fs.readFile(path.join(workflowsDir, f), 'utf-8');
|
|
390
|
+
if (/codecov\/codecov-action/.test(content))
|
|
391
|
+
return f;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
catch {
|
|
395
|
+
// no workflows dir
|
|
396
|
+
}
|
|
397
|
+
return null;
|
|
398
|
+
}
|
|
383
399
|
// A README that advertises a Codecov badge but a CI that never uploads coverage
|
|
384
400
|
// leaves the badge permanently red. Only flags when the badge is actually present
|
|
385
401
|
// (no badge → nothing to back, so it's not applicable).
|
|
@@ -393,24 +409,13 @@ export async function checkCoverageUpload(dir) {
|
|
|
393
409
|
detail: 'no coverage badge in README (nothing to back)',
|
|
394
410
|
};
|
|
395
411
|
}
|
|
396
|
-
const
|
|
397
|
-
if (
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
return {
|
|
404
|
-
check: 'Coverage upload',
|
|
405
|
-
status: 'ok',
|
|
406
|
-
detail: `coverage badge backed by codecov-action in .github/workflows/${f}`,
|
|
407
|
-
};
|
|
408
|
-
}
|
|
409
|
-
}
|
|
410
|
-
}
|
|
411
|
-
catch {
|
|
412
|
-
// fall through to drift
|
|
413
|
-
}
|
|
412
|
+
const workflow = await coverageUploadWorkflow(dir);
|
|
413
|
+
if (workflow) {
|
|
414
|
+
return {
|
|
415
|
+
check: 'Coverage upload',
|
|
416
|
+
status: 'ok',
|
|
417
|
+
detail: `coverage badge backed by codecov-action in .github/workflows/${workflow}`,
|
|
418
|
+
};
|
|
414
419
|
}
|
|
415
420
|
return {
|
|
416
421
|
check: 'Coverage upload',
|
package/dist/base/fixers.js
CHANGED
|
@@ -15,7 +15,7 @@ import path from 'node:path';
|
|
|
15
15
|
import chalk from 'chalk';
|
|
16
16
|
import fs from 'fs-extra';
|
|
17
17
|
import { installAgentRules, installAiSetup } from '../cli/generators/agent-rules.js';
|
|
18
|
-
import { generateBrand } from '../cli/generators/brand.js';
|
|
18
|
+
import { addReadmeBanner, generateBrand, renderBrand, resolveBrandMeta, } from '../cli/generators/brand.js';
|
|
19
19
|
import { generateCommunityHealth } from '../cli/generators/community-health.js';
|
|
20
20
|
import { generateCommitlintConfig } from '../cli/generators/git.js';
|
|
21
21
|
import { generateCodeowners, generateEditorConfig } from '../cli/generators/misc.js';
|
|
@@ -164,12 +164,13 @@ export const BASE_FIXERS = [
|
|
|
164
164
|
},
|
|
165
165
|
{
|
|
166
166
|
target: 'github-settings',
|
|
167
|
-
description: 'Apply branch protection + auto-merge + workflow permissions + code-scanning ruleset on GitHub via gh api (mutates the remote repo, not files)',
|
|
167
|
+
description: 'Apply branch protection + auto-merge + workflow permissions + Dependabot security updates + code-scanning ruleset on GitHub via gh api (mutates the remote repo, not files)',
|
|
168
168
|
appliesTo: [
|
|
169
169
|
'Branch protection',
|
|
170
170
|
'Merge settings',
|
|
171
171
|
'Workflow permissions',
|
|
172
172
|
'Code-scanning gate',
|
|
173
|
+
'Security updates',
|
|
173
174
|
],
|
|
174
175
|
outputs: ['GitHub repo settings (remote, via gh api)'],
|
|
175
176
|
// safe-add is load-bearing: it exempts this fixer from the `--diff` shadow-run
|
|
@@ -249,24 +250,34 @@ export const BASE_FIXERS = [
|
|
|
249
250
|
{
|
|
250
251
|
target: 'brand',
|
|
251
252
|
selfSafe: true,
|
|
252
|
-
description: 'Scaffold brand/ — banner, mobile-banner and social-card SVG sources + render.sh
|
|
253
|
+
description: 'Scaffold brand/ — favicon, banner, mobile-banner and social-card SVG sources + render.sh — render the PNGs and favicon.ico when rsvg-convert is on PATH, and add the README banner',
|
|
253
254
|
appliesTo: ['Brand assets'],
|
|
254
255
|
outputs: [
|
|
256
|
+
'brand/favicon.svg',
|
|
255
257
|
'brand/banner.svg',
|
|
256
258
|
'brand/banner-mobile.svg',
|
|
257
259
|
'brand/social-card.svg',
|
|
258
260
|
'brand/render.sh',
|
|
261
|
+
'brand/banner.png',
|
|
262
|
+
'brand/banner-mobile.png',
|
|
263
|
+
'brand/social-card.png',
|
|
264
|
+
'brand/favicon-512.png',
|
|
265
|
+
'brand/favicon.ico',
|
|
259
266
|
'README.md',
|
|
260
267
|
],
|
|
261
|
-
// Every SVG is written only when absent
|
|
262
|
-
//
|
|
268
|
+
// Every SVG is written only when absent, PNGs are re-rendered only when
|
|
269
|
+
// older than their source, and the README edit is a delimited block (or
|
|
270
|
+
// two image paths) — hand-edited art is never clobbered.
|
|
263
271
|
riskLevel: 'safe-merge',
|
|
264
272
|
canFixDrift: true,
|
|
265
273
|
async run({ targetDir, pkg, lock }) {
|
|
266
|
-
const
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
}
|
|
274
|
+
const tagline = lock?.rules?.brand?.tagline;
|
|
275
|
+
const filesWritten = await generateBrand(pkg, targetDir, tagline);
|
|
276
|
+
filesWritten.push(...((await renderBrand(targetDir)) ?? []));
|
|
277
|
+
const { name } = await resolveBrandMeta(pkg, targetDir, tagline);
|
|
278
|
+
const readme = await addReadmeBanner(targetDir, name);
|
|
279
|
+
if (readme && !filesWritten.includes(readme))
|
|
280
|
+
filesWritten.push(readme);
|
|
270
281
|
return { filesWritten };
|
|
271
282
|
},
|
|
272
283
|
},
|
|
@@ -54,10 +54,12 @@ export const GITHUB_STANDARD = {
|
|
|
54
54
|
const CODE_SCANNING_CHECK = 'Code-scanning gate';
|
|
55
55
|
export const RELEASE_GATE_CHECK = 'Release gate';
|
|
56
56
|
export const RELEASE_ENV_CHECK = 'Release environment';
|
|
57
|
+
const SECURITY_UPDATES_CHECK = 'Security updates';
|
|
57
58
|
const CHECK_NAMES = [
|
|
58
59
|
'Branch protection',
|
|
59
60
|
'Merge settings',
|
|
60
61
|
'Workflow permissions',
|
|
62
|
+
SECURITY_UPDATES_CHECK,
|
|
61
63
|
CODE_SCANNING_CHECK,
|
|
62
64
|
RELEASE_GATE_CHECK,
|
|
63
65
|
RELEASE_ENV_CHECK,
|
|
@@ -147,6 +149,7 @@ export async function checkGitHubSettings(dir, exec) {
|
|
|
147
149
|
await checkBranchProtection(gh, info.nwo, info.branch),
|
|
148
150
|
checkMergeSettings(info),
|
|
149
151
|
await checkWorkflowPermissions(gh, info.nwo),
|
|
152
|
+
await checkSecurityUpdates(gh, info.nwo),
|
|
150
153
|
await checkCodeScanningRuleset(gh, info.nwo, info.branch, dir),
|
|
151
154
|
...(await checkReleaseGate(gh, info.nwo, dir)),
|
|
152
155
|
];
|
|
@@ -277,6 +280,60 @@ async function checkWorkflowPermissions(exec, nwo) {
|
|
|
277
280
|
};
|
|
278
281
|
return { check, status: 'ok', detail: 'read-only default, no workflow PR approvals' };
|
|
279
282
|
}
|
|
283
|
+
/**
|
|
284
|
+
* Dependabot vulnerability alerts and automated security fixes (#692). A
|
|
285
|
+
* `dependabot.yml` only schedules version bumps; these two repo toggles are what
|
|
286
|
+
* surface and patch advisories, and both default off on a new repo. Neither
|
|
287
|
+
* endpoint has a body worth reading for alerts: 204 is on, 404 is off.
|
|
288
|
+
*/
|
|
289
|
+
async function readSecurityUpdates(exec, nwo) {
|
|
290
|
+
const enabled = async (endpoint) => {
|
|
291
|
+
const r = await exec(['api', `repos/${nwo}/${endpoint}`]);
|
|
292
|
+
if (!r.ok) {
|
|
293
|
+
if (/404|not found/i.test(r.stderr))
|
|
294
|
+
return false;
|
|
295
|
+
if (/403|forbidden/i.test(r.stderr))
|
|
296
|
+
return { skip: 'token lacks admin access' };
|
|
297
|
+
return { skip: `could not read ${endpoint}` };
|
|
298
|
+
}
|
|
299
|
+
if (endpoint === 'vulnerability-alerts')
|
|
300
|
+
return true;
|
|
301
|
+
try {
|
|
302
|
+
return JSON.parse(r.stdout).enabled === true;
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
return { skip: `could not parse ${endpoint} response` };
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
const alerts = await enabled('vulnerability-alerts');
|
|
309
|
+
if (typeof alerts !== 'boolean')
|
|
310
|
+
return alerts;
|
|
311
|
+
const fixes = await enabled('automated-security-fixes');
|
|
312
|
+
if (typeof fixes !== 'boolean')
|
|
313
|
+
return fixes;
|
|
314
|
+
return { alerts, fixes };
|
|
315
|
+
}
|
|
316
|
+
async function checkSecurityUpdates(exec, nwo) {
|
|
317
|
+
const check = SECURITY_UPDATES_CHECK;
|
|
318
|
+
const s = await readSecurityUpdates(exec, nwo);
|
|
319
|
+
if ('skip' in s)
|
|
320
|
+
return skip(check, s.skip);
|
|
321
|
+
const deltas = [];
|
|
322
|
+
if (!s.alerts)
|
|
323
|
+
deltas.push('vulnerability alerts disabled');
|
|
324
|
+
if (!s.fixes)
|
|
325
|
+
deltas.push('automated security fixes disabled');
|
|
326
|
+
// optional-missing, not drift: doctor promotes it when the lock records
|
|
327
|
+
// securityAutomation: true, and demotes it when the lock records false.
|
|
328
|
+
if (deltas.length)
|
|
329
|
+
return {
|
|
330
|
+
check,
|
|
331
|
+
status: 'optional-missing',
|
|
332
|
+
detail: deltas.join('; '),
|
|
333
|
+
hint: 'Run `npx @rtorcato/repo-tooling fix github-settings` to enable Dependabot alerts and security updates',
|
|
334
|
+
};
|
|
335
|
+
return { check, status: 'ok', detail: 'vulnerability alerts and automated security fixes on' };
|
|
336
|
+
}
|
|
280
337
|
/**
|
|
281
338
|
* True when CodeQL/code-scanning is enabled for the repo. Covers both ways it
|
|
282
339
|
* ships: an advanced-setup workflow on disk (what `fix codeql` scaffolds) or
|
|
@@ -764,6 +821,17 @@ export function buildGhApplyCommands(state) {
|
|
|
764
821
|
'can_approve_pull_request_reviews=false',
|
|
765
822
|
],
|
|
766
823
|
});
|
|
824
|
+
// Alerts first: GitHub refuses security fixes on a repo with alerts off.
|
|
825
|
+
if (state.alerts)
|
|
826
|
+
commands.push({
|
|
827
|
+
label: 'vulnerability alerts',
|
|
828
|
+
args: ['api', '-X', 'PUT', `repos/${state.nwo}/vulnerability-alerts`],
|
|
829
|
+
});
|
|
830
|
+
if (state.securityFixes)
|
|
831
|
+
commands.push({
|
|
832
|
+
label: 'automated security fixes',
|
|
833
|
+
args: ['api', '-X', 'PUT', `repos/${state.nwo}/automated-security-fixes`],
|
|
834
|
+
});
|
|
767
835
|
return commands;
|
|
768
836
|
}
|
|
769
837
|
/**
|
|
@@ -795,12 +863,15 @@ export async function applyGithubSettings(dir, exec) {
|
|
|
795
863
|
// (no admin) reports `ok` → treated as "nothing to apply", never a failed PUT.
|
|
796
864
|
const bp = await checkBranchProtection(gh, info.nwo, info.branch);
|
|
797
865
|
const wp = await checkWorkflowPermissions(gh, info.nwo);
|
|
866
|
+
const sec = await readSecurityUpdates(gh, info.nwo);
|
|
798
867
|
const commands = buildGhApplyCommands({
|
|
799
868
|
nwo: info.nwo,
|
|
800
869
|
branch: info.branch,
|
|
801
870
|
merge: checkMergeSettings(info).status === 'drift',
|
|
802
871
|
protection: bp.status === 'optional-missing' || bp.status === 'drift',
|
|
803
872
|
workflow: wp.status === 'drift',
|
|
873
|
+
alerts: !('skip' in sec) && !sec.alerts,
|
|
874
|
+
securityFixes: !('skip' in sec) && !sec.fixes,
|
|
804
875
|
});
|
|
805
876
|
const applied = [];
|
|
806
877
|
for (const cmd of commands) {
|
|
@@ -4,6 +4,7 @@ import fs from 'fs-extra';
|
|
|
4
4
|
import { renderGitHubWorkflow } from '../../base/ci.js';
|
|
5
5
|
import { githubJobs, scriptsOf } from '../../languages/js/ci.js';
|
|
6
6
|
import { inferProjectConfig } from '../../languages/js/fixers.js';
|
|
7
|
+
import { checkNpmTrustedPublisher, checkPublishJob, findNpmPublishJob, NPM_OIDC_CHECK, } from '../../languages/js/npm-trust.js';
|
|
7
8
|
import { PERL_GIT_HOOKS, runPerlChecks } from '../../languages/perl/checks.js';
|
|
8
9
|
import { readPerlProject, renderPerlWorkflow } from '../../languages/perl/ci.js';
|
|
9
10
|
import { PYTHON_GIT_HOOKS, runPythonChecks } from '../../languages/python/checks.js';
|
|
@@ -68,38 +69,19 @@ async function checkReleaseToken(dir) {
|
|
|
68
69
|
};
|
|
69
70
|
}
|
|
70
71
|
}
|
|
71
|
-
//
|
|
72
|
-
// NPM_TOKEN secret
|
|
73
|
-
//
|
|
74
|
-
// relevant for public packages that actually publish to npm.
|
|
72
|
+
// OIDC trusted publishing (#201, #687): the publishing job must not use an
|
|
73
|
+
// NPM_TOKEN secret, must carry `id-token: write`, and must run npm ≥ 11.5.1.
|
|
74
|
+
// Only relevant for public packages that actually publish to npm.
|
|
75
75
|
async function checkNpmOidcPublish(dir, pkg) {
|
|
76
|
-
const check =
|
|
76
|
+
const check = NPM_OIDC_CHECK;
|
|
77
77
|
if (!pkg || pkg.private === true) {
|
|
78
78
|
return { check, status: 'optional-missing', detail: 'private package — no npm publish' };
|
|
79
79
|
}
|
|
80
|
-
const workflowsDir = path.join(dir, '.github', 'workflows');
|
|
81
|
-
if (!(await fs.pathExists(workflowsDir))) {
|
|
82
|
-
return { check, status: 'optional-missing', detail: 'no .github/workflows/' };
|
|
83
|
-
}
|
|
84
80
|
try {
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const content = await fs.readFile(path.join(workflowsDir, f), 'utf-8');
|
|
90
|
-
if (!/semantic-release/.test(content))
|
|
91
|
-
continue;
|
|
92
|
-
if (/secrets\.NPM_TOKEN/.test(content)) {
|
|
93
|
-
return {
|
|
94
|
-
check,
|
|
95
|
-
status: 'drift',
|
|
96
|
-
detail: `${f} authenticates npm publish with NPM_TOKEN`,
|
|
97
|
-
hint: 'Migrate to OIDC trusted publishing: add a Trusted Publisher for each published package on npmjs.com (Settings → Trusted Publisher), then run `fix github-actions` to drop NPM_TOKEN (the release job keeps `id-token: write`). npm is deprecating 2FA-bypass tokens.',
|
|
98
|
-
};
|
|
99
|
-
}
|
|
100
|
-
return { check, status: 'ok', detail: `${f} publishes via OIDC (no NPM_TOKEN)` };
|
|
101
|
-
}
|
|
102
|
-
return { check, status: 'optional-missing', detail: 'no semantic-release workflow found' };
|
|
81
|
+
const job = await findNpmPublishJob(dir);
|
|
82
|
+
if (!job)
|
|
83
|
+
return { check, status: 'optional-missing', detail: 'no semantic-release workflow found' };
|
|
84
|
+
return checkPublishJob(job);
|
|
103
85
|
}
|
|
104
86
|
catch {
|
|
105
87
|
return { check, status: 'optional-missing', detail: 'unable to read .github/workflows/' };
|
|
@@ -140,14 +122,26 @@ function checkLockfile(lock) {
|
|
|
140
122
|
detail: `.repo-tooling.json v${lock.version} (record written by ${lock.record.writtenBy})`,
|
|
141
123
|
};
|
|
142
124
|
}
|
|
125
|
+
// Checks whose absence `securityAutomation: true` turns from an optional gap
|
|
126
|
+
// into drift (#692): the lock says the repo chose them, so silence is a lie.
|
|
127
|
+
const REQUIRED_BY_SECURITY_AUTOMATION = new Set(['Dependabot', 'Security updates']);
|
|
143
128
|
// Lockfile-driven demotion: if the lock records an intentional opt-out for a
|
|
144
129
|
// check that's currently optional-missing, demote it to ok with a clear detail.
|
|
130
|
+
// The converse holds for a recorded opt-in (#692): promote it to drift.
|
|
145
131
|
function demoteDeclined(results, lock) {
|
|
146
132
|
if (!lock)
|
|
147
133
|
return results;
|
|
148
134
|
return results.map((r) => {
|
|
149
135
|
if (r.status !== 'optional-missing')
|
|
150
136
|
return r;
|
|
137
|
+
if (lock.record.config.securityAutomation === true &&
|
|
138
|
+
REQUIRED_BY_SECURITY_AUTOMATION.has(r.check)) {
|
|
139
|
+
return {
|
|
140
|
+
...r,
|
|
141
|
+
status: 'drift',
|
|
142
|
+
detail: `${r.detail}, but .repo-tooling.json records securityAutomation: true`,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
151
145
|
if (!declinedInLock(lock, r.check))
|
|
152
146
|
return r;
|
|
153
147
|
return {
|
|
@@ -365,6 +359,7 @@ export async function runDoctor(dir) {
|
|
|
365
359
|
results.push(await checkSizeLimit(targetDir, pkg));
|
|
366
360
|
results.push(await checkReleaseToken(targetDir));
|
|
367
361
|
results.push(await checkNpmOidcPublish(targetDir, pkg));
|
|
362
|
+
results.push(await checkNpmTrustedPublisher(targetDir, pkg));
|
|
368
363
|
results.push(await checkTypedoc(targetDir, pkg));
|
|
369
364
|
results.push(await checkAreTheTypesWrong(targetDir, pkg));
|
|
370
365
|
results.push(await checkPublint(targetDir, pkg));
|
|
@@ -22,12 +22,15 @@ export const FIX_TARGETS = {
|
|
|
22
22
|
'Tree-shake check': 'treeshake-check',
|
|
23
23
|
'GitHub Actions': 'github-actions',
|
|
24
24
|
'Coverage upload': 'github-actions',
|
|
25
|
+
'npm OIDC publish': 'github-actions',
|
|
26
|
+
'npm trusted publisher': 'npm-trusted-publisher',
|
|
25
27
|
Dependabot: 'dependabot',
|
|
26
28
|
CodeQL: 'codeql',
|
|
27
29
|
'Branch protection': 'github-settings',
|
|
28
30
|
'Merge settings': 'github-settings',
|
|
29
31
|
'Workflow permissions': 'github-settings',
|
|
30
32
|
'Code-scanning gate': 'github-settings',
|
|
33
|
+
'Security updates': 'github-settings',
|
|
31
34
|
Milestones: 'milestones',
|
|
32
35
|
CODEOWNERS: 'codeowners',
|
|
33
36
|
'GitLab CI': 'gitlab-ci',
|
|
@@ -159,6 +162,7 @@ export function declinedInLock(lock, checkName) {
|
|
|
159
162
|
case 'Merge settings':
|
|
160
163
|
case 'Workflow permissions':
|
|
161
164
|
case 'Code-scanning gate':
|
|
165
|
+
case 'Security updates':
|
|
162
166
|
return c.securityAutomation === false;
|
|
163
167
|
case 'publint':
|
|
164
168
|
return c.publint === false;
|
|
@@ -2,6 +2,7 @@ import path from 'node:path';
|
|
|
2
2
|
import chalk from 'chalk';
|
|
3
3
|
import fs from 'fs-extra';
|
|
4
4
|
import inquirer from 'inquirer';
|
|
5
|
+
import { formatNpmPublishGuide, npmPublishGuide, } from '../../languages/js/npm-trust.js';
|
|
5
6
|
import { LANGUAGES } from '../../languages/registry.js';
|
|
6
7
|
import { generateConfigs } from '../generators/index.js';
|
|
7
8
|
import { detectLanguage } from '../utils/detect-language.js';
|
|
@@ -157,7 +158,8 @@ export async function setupProject(options) {
|
|
|
157
158
|
return;
|
|
158
159
|
if (dryRun) {
|
|
159
160
|
const files = computeFileList(config);
|
|
160
|
-
|
|
161
|
+
const npmPublish = npmPublishFor(config);
|
|
162
|
+
console.log(JSON.stringify({ directory: targetDir, config, files, ...(npmPublish && { npmPublish }) }, null, 2));
|
|
161
163
|
return;
|
|
162
164
|
}
|
|
163
165
|
if (!interactive) {
|
|
@@ -561,6 +563,23 @@ async function promptForConfig(targetDir, seed) {
|
|
|
561
563
|
bun: answers.bun ?? false,
|
|
562
564
|
};
|
|
563
565
|
}
|
|
566
|
+
/**
|
|
567
|
+
* The npmjs.com trusted-publisher values for a scaffold whose release job
|
|
568
|
+
* publishes (#687). Owner/repo aren't known yet at setup time, so they stay
|
|
569
|
+
* placeholders; `fix npm-trusted-publisher` derives them once the repo exists.
|
|
570
|
+
* The generated job declares no environment until `fix release-environment`.
|
|
571
|
+
*/
|
|
572
|
+
export function npmPublishFor(config) {
|
|
573
|
+
if (config.language === 'swift' || config.projectType !== 'library' || !config.semanticRelease)
|
|
574
|
+
return null;
|
|
575
|
+
return npmPublishGuide({
|
|
576
|
+
name: config.projectName,
|
|
577
|
+
owner: '<owner>',
|
|
578
|
+
repo: '<repo>',
|
|
579
|
+
file: 'ci.yml',
|
|
580
|
+
environment: null,
|
|
581
|
+
});
|
|
582
|
+
}
|
|
564
583
|
function showNextSteps(config, _targetDir) {
|
|
565
584
|
console.log(chalk.bold('\n📋 Next Steps:\n'));
|
|
566
585
|
const steps = [];
|
|
@@ -589,6 +608,13 @@ function showNextSteps(config, _targetDir) {
|
|
|
589
608
|
steps.forEach((step, index) => {
|
|
590
609
|
console.log(` ${index + 1}. ${step}`);
|
|
591
610
|
});
|
|
611
|
+
// A brand-new package isn't on npm yet, so the bootstrap order always applies.
|
|
612
|
+
const npmPublish = npmPublishFor(config);
|
|
613
|
+
if (npmPublish) {
|
|
614
|
+
console.log(chalk.bold('\n📦 Publish to npm (OIDC trusted publishing — no NPM_TOKEN):\n'));
|
|
615
|
+
for (const line of formatNpmPublishGuide(npmPublish, true))
|
|
616
|
+
console.log(` ${line}`);
|
|
617
|
+
}
|
|
592
618
|
const skipped = collectSkippedFixSuggestions(config);
|
|
593
619
|
if (skipped.length > 0) {
|
|
594
620
|
console.log(chalk.bold('\n💡 Want to add something you skipped?\n'));
|
|
@@ -31,7 +31,7 @@ export function parseRepository(repository) {
|
|
|
31
31
|
* repo-specific is derivable, so a badge row isn't worth adding.
|
|
32
32
|
*/
|
|
33
33
|
export function buildBadgeRow(inputs) {
|
|
34
|
-
const { name, owner, repo, branch = 'main', isPrivate = false } = inputs;
|
|
34
|
+
const { name, owner, repo, branch = 'main', isPrivate = false, bundled = true, uploadsCoverage = true, } = inputs;
|
|
35
35
|
const slug = owner && repo ? `${owner}/${repo}` : null;
|
|
36
36
|
const badges = [];
|
|
37
37
|
let specific = false;
|
|
@@ -43,9 +43,11 @@ export function buildBadgeRow(inputs) {
|
|
|
43
43
|
specific = true;
|
|
44
44
|
badges.push(`[](https://www.npmjs.com/package/${name})`);
|
|
45
45
|
badges.push(`[](https://www.npmjs.com/package/${name})`);
|
|
46
|
+
}
|
|
47
|
+
if (!isPrivate && name && bundled) {
|
|
46
48
|
badges.push(`[](https://bundlephobia.com/package/${name})`);
|
|
47
49
|
}
|
|
48
|
-
if (!isPrivate && slug) {
|
|
50
|
+
if (!isPrivate && slug && uploadsCoverage) {
|
|
49
51
|
badges.push(`[](https://codecov.io/gh/${slug})`);
|
|
50
52
|
}
|
|
51
53
|
badges.push('[](https://opensource.org/licenses/MIT)');
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* and falls back to a neutral grey. Nothing about any particular org is baked
|
|
13
13
|
* in; the templates are meant to be hand-edited afterwards.
|
|
14
14
|
*/
|
|
15
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
15
16
|
import path from 'node:path';
|
|
16
17
|
import fs from 'fs-extra';
|
|
17
18
|
/** Grey, so an unbranded repo reads as unbranded rather than borrowing a colour. */
|
|
@@ -80,10 +81,11 @@ async function accentFromDocsTheme(targetDir) {
|
|
|
80
81
|
return dark[1];
|
|
81
82
|
return css.match(/--ifm-color-primary:\s*(#[0-9a-fA-F]{6})/)?.[1] ?? null;
|
|
82
83
|
}
|
|
84
|
+
/** Where a repo already keeps a favicon — the docs site's first. */
|
|
85
|
+
const EXISTING_FAVICONS = [path.join('apps', 'docs', 'static', 'img', 'favicon.svg'), 'favicon.svg'];
|
|
83
86
|
/** Failing that, the favicon's own ink — the other place a repo commits its colour. */
|
|
84
87
|
async function accentFromFavicon(targetDir) {
|
|
85
|
-
|
|
86
|
-
for (const rel of candidates) {
|
|
88
|
+
for (const rel of EXISTING_FAVICONS) {
|
|
87
89
|
const file = path.join(targetDir, rel);
|
|
88
90
|
if (!(await fs.pathExists(file)))
|
|
89
91
|
continue;
|
|
@@ -123,17 +125,23 @@ export async function resolveBrandMeta(pkg, targetDir, tagline) {
|
|
|
123
125
|
};
|
|
124
126
|
}
|
|
125
127
|
/**
|
|
126
|
-
* The logo
|
|
127
|
-
*
|
|
128
|
-
*
|
|
128
|
+
* The logo tile: a rounded square in the accent carrying the project's
|
|
129
|
+
* initial. It *is* `brand/favicon.svg`, and every canvas below draws that file
|
|
130
|
+
* rather than a copy of it, so swapping in a real glyph is a one-file edit (#678).
|
|
129
131
|
*/
|
|
130
|
-
function
|
|
132
|
+
export function faviconSvg(meta) {
|
|
131
133
|
const initial = esc((meta.name[0] ?? '?').toUpperCase());
|
|
132
|
-
return
|
|
133
|
-
<
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
134
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
|
135
|
+
<title>${esc(meta.name)}</title>
|
|
136
|
+
<rect width="32" height="32" rx="8" fill="${meta.accent}"/>
|
|
137
|
+
<text x="16" y="23" text-anchor="middle" font-family="Avenir Next" font-weight="800" font-size="19" fill="${INK}">${initial}</text>
|
|
138
|
+
</svg>
|
|
139
|
+
`;
|
|
140
|
+
}
|
|
141
|
+
/** The logo mark: `brand/favicon.svg`, drawn `size` px square. */
|
|
142
|
+
function mark(x, y, size) {
|
|
143
|
+
return ` <!-- Logo mark: brand/favicon.svg — edit that file to change it on every canvas. -->
|
|
144
|
+
<image href="favicon.svg" x="${x}" y="${y}" width="${size}" height="${size}"/>`;
|
|
137
145
|
}
|
|
138
146
|
/** `repo-tooling` renders as a muted `repo-` and an accented `tooling`. */
|
|
139
147
|
function wordmark(meta) {
|
|
@@ -188,7 +196,7 @@ function canvas(meta, w, h, glow) {
|
|
|
188
196
|
/** 1280×320 README banner — left-aligned lockup, install pill on the right. */
|
|
189
197
|
export function bannerSvg(meta) {
|
|
190
198
|
return `${canvas(meta, 1280, 320, { cx: 0.16, cy: 0 })}
|
|
191
|
-
${mark(
|
|
199
|
+
${mark(60, 88, 72)}
|
|
192
200
|
|
|
193
201
|
<text x="156" y="150" font-family="Avenir Next" font-weight="800" font-size="62" letter-spacing="-1.5">${wordmark(meta)}</text>
|
|
194
202
|
|
|
@@ -200,7 +208,7 @@ ${installPanel(meta, { x: 845, y: 118, w: 378, h: 84, size: 20 })}
|
|
|
200
208
|
/** 1280×786 mobile banner — the same content stacked so it stays legible on a phone. */
|
|
201
209
|
export function bannerMobileSvg(meta) {
|
|
202
210
|
return `${canvas(meta, 1280, 786, { cx: 0.12, cy: 0.05 })}
|
|
203
|
-
${mark(
|
|
211
|
+
${mark(565, 104, 150)}
|
|
204
212
|
|
|
205
213
|
<text x="640" y="360" text-anchor="middle" font-family="Avenir Next" font-weight="800" font-size="76" letter-spacing="-1.8">${wordmark(meta)}</text>
|
|
206
214
|
|
|
@@ -212,7 +220,7 @@ ${installPanel(meta, { x: 427, y: 650, w: 426, h: 78, size: 24 })}
|
|
|
212
220
|
/** 1280×640 Open Graph / GitHub social card. Keep content inside an ~8% safe inset. */
|
|
213
221
|
export function socialCardSvg(meta) {
|
|
214
222
|
return `${canvas(meta, 1280, 640, { cx: 0.1, cy: 0.05 })}
|
|
215
|
-
${mark(
|
|
223
|
+
${mark(590, 120, 100)}
|
|
216
224
|
|
|
217
225
|
<text x="640" y="300" text-anchor="middle" font-family="Avenir Next" font-weight="800" font-size="76" letter-spacing="-1.8">${wordmark(meta)}</text>
|
|
218
226
|
|
|
@@ -229,7 +237,7 @@ ${installPanel(meta, { x: 427, y: 470, w: 426, h: 78, size: 24 })}
|
|
|
229
237
|
export const RENDER_SH = `#!/usr/bin/env bash
|
|
230
238
|
# Render the committed brand PNGs from their SVG sources.
|
|
231
239
|
# Sizes come from the brand-asset spec: 1280x320 banner, 1280x786 mobile,
|
|
232
|
-
# 1280x640 social card, 512x512 PWA icon.
|
|
240
|
+
# 1280x640 social card, 512x512 PWA icon. (\`fix brand\` also packs favicon.ico.)
|
|
233
241
|
set -euo pipefail
|
|
234
242
|
cd "$(dirname "$0")/.."
|
|
235
243
|
|
|
@@ -241,7 +249,9 @@ fi
|
|
|
241
249
|
|
|
242
250
|
rsvg-convert -w 1280 -h 320 brand/banner.svg -o brand/banner.png
|
|
243
251
|
rsvg-convert -w 1280 -h 786 brand/banner-mobile.svg -o brand/banner-mobile.png
|
|
244
|
-
|
|
252
|
+
rsvg-convert -w 1280 -h 640 brand/social-card.svg -o brand/social-card.png
|
|
253
|
+
rsvg-convert -w 512 -h 512 brand/favicon.svg -o brand/favicon-512.png
|
|
254
|
+
echo "rendered: brand/banner.png brand/banner-mobile.png brand/social-card.png brand/favicon-512.png"
|
|
245
255
|
|
|
246
256
|
# The docs-site assets, rendered only when the site exists to hold them.
|
|
247
257
|
img=apps/docs/static/img
|
|
@@ -279,15 +289,25 @@ export async function repointReadmeBanners(targetDir) {
|
|
|
279
289
|
await fs.writeFile(file, next);
|
|
280
290
|
return 'README.md';
|
|
281
291
|
}
|
|
292
|
+
/** A favicon the repo already commits beats the generated initial tile. */
|
|
293
|
+
async function existingFavicon(targetDir) {
|
|
294
|
+
for (const rel of EXISTING_FAVICONS) {
|
|
295
|
+
const file = path.join(targetDir, rel);
|
|
296
|
+
if (await fs.pathExists(file))
|
|
297
|
+
return fs.readFile(file, 'utf-8');
|
|
298
|
+
}
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
282
301
|
/**
|
|
283
|
-
* Scaffold `brand/`: three SVG sources
|
|
284
|
-
* README still on the old root-level paths.
|
|
285
|
-
* absent, so `fix brand` is idempotent.
|
|
302
|
+
* Scaffold `brand/`: the favicon tile, three SVG sources that draw it, and the
|
|
303
|
+
* render script, then repoint a README still on the old root-level paths.
|
|
304
|
+
* Every file is written only when absent, so `fix brand` is idempotent.
|
|
286
305
|
*/
|
|
287
306
|
export async function generateBrand(pkg, targetDir, tagline) {
|
|
288
307
|
const meta = await resolveBrandMeta(pkg, targetDir, tagline);
|
|
289
308
|
const written = [];
|
|
290
309
|
const files = [
|
|
310
|
+
['brand/favicon.svg', (await existingFavicon(targetDir)) ?? faviconSvg(meta)],
|
|
291
311
|
['brand/banner.svg', bannerSvg(meta)],
|
|
292
312
|
['brand/banner-mobile.svg', bannerMobileSvg(meta)],
|
|
293
313
|
['brand/social-card.svg', socialCardSvg(meta)],
|
|
@@ -307,3 +327,118 @@ export async function generateBrand(pkg, targetDir, tagline) {
|
|
|
307
327
|
written.push(readme);
|
|
308
328
|
return written;
|
|
309
329
|
}
|
|
330
|
+
/** Printed when `rsvg-convert` is not on PATH — the sources are still written. */
|
|
331
|
+
export const RSVG_HINT = ' next: install librsvg to render the brand PNGs (`brew install librsvg`, apt: `apt-get install librsvg2-bin`), then re-run `fix brand` or `brand/render.sh`';
|
|
332
|
+
/** `[source, output, width, height]` under `brand/` — the same set render.sh draws. */
|
|
333
|
+
const RENDERS = [
|
|
334
|
+
['banner.svg', 'banner.png', 1280, 320],
|
|
335
|
+
['banner-mobile.svg', 'banner-mobile.png', 1280, 786],
|
|
336
|
+
['social-card.svg', 'social-card.png', 1280, 640],
|
|
337
|
+
['favicon.svg', 'favicon-512.png', 512, 512],
|
|
338
|
+
['favicon.svg', 'favicon.ico', 32, 32],
|
|
339
|
+
];
|
|
340
|
+
/** Classic favicon sizes packed into favicon.ico. */
|
|
341
|
+
const ICO_SIZES = [16, 32];
|
|
342
|
+
/**
|
|
343
|
+
* An ICO container holding PNG frames — every browser since IE Vista reads
|
|
344
|
+
* PNG-in-ICO, so no bitmap conversion is needed.
|
|
345
|
+
*/
|
|
346
|
+
export function packIco(frames) {
|
|
347
|
+
const header = Buffer.alloc(6 + 16 * frames.length);
|
|
348
|
+
header.writeUInt16LE(1, 2); // type: icon
|
|
349
|
+
header.writeUInt16LE(frames.length, 4);
|
|
350
|
+
let offset = header.length;
|
|
351
|
+
frames.forEach(([size, png], i) => {
|
|
352
|
+
const e = 6 + 16 * i;
|
|
353
|
+
header.writeUInt8(size % 256, e); // 0 means 256
|
|
354
|
+
header.writeUInt8(size % 256, e + 1);
|
|
355
|
+
header.writeUInt16LE(1, e + 4); // colour planes
|
|
356
|
+
header.writeUInt16LE(32, e + 6); // bits per pixel
|
|
357
|
+
header.writeUInt32LE(png.length, e + 8);
|
|
358
|
+
header.writeUInt32LE(offset, e + 12);
|
|
359
|
+
offset += png.length;
|
|
360
|
+
});
|
|
361
|
+
return Buffer.concat([header, ...frames.map(([, png]) => png)]);
|
|
362
|
+
}
|
|
363
|
+
async function mtime(file) {
|
|
364
|
+
return (await fs.stat(file)).mtimeMs;
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Render every `brand/` PNG (and favicon.ico) that is missing or older than its
|
|
368
|
+
* source — or than favicon.svg, which every canvas draws. Returns the files
|
|
369
|
+
* written, or null when `rsvg-convert` is not on PATH (after printing
|
|
370
|
+
* {@link RSVG_HINT}). Nothing stale means nothing to do and no PATH lookup.
|
|
371
|
+
*/
|
|
372
|
+
export async function renderBrand(targetDir) {
|
|
373
|
+
const brand = path.join(targetDir, 'brand');
|
|
374
|
+
const favicon = path.join(brand, 'favicon.svg');
|
|
375
|
+
const stale = [];
|
|
376
|
+
for (const job of RENDERS) {
|
|
377
|
+
const [src, out] = job;
|
|
378
|
+
const srcFile = path.join(brand, src);
|
|
379
|
+
const outFile = path.join(brand, out);
|
|
380
|
+
if (!(await fs.pathExists(srcFile)))
|
|
381
|
+
continue;
|
|
382
|
+
const newest = Math.max(await mtime(srcFile), (await fs.pathExists(favicon)) ? await mtime(favicon) : 0);
|
|
383
|
+
if (!(await fs.pathExists(outFile)) || (await mtime(outFile)) < newest)
|
|
384
|
+
stale.push(job);
|
|
385
|
+
}
|
|
386
|
+
if (stale.length === 0)
|
|
387
|
+
return [];
|
|
388
|
+
if (spawnSync('rsvg-convert', ['--version']).error) {
|
|
389
|
+
// stderr, not stdout: `fix --json` owns stdout (#357).
|
|
390
|
+
console.error(RSVG_HINT);
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
// cwd = brand/ so each canvas's `href="favicon.svg"` resolves beside it.
|
|
394
|
+
const rsvg = (src, w, h) => execFileSync('rsvg-convert', ['-w', String(w), '-h', String(h), src], { cwd: brand });
|
|
395
|
+
const written = [];
|
|
396
|
+
for (const [src, out, w, h] of stale) {
|
|
397
|
+
const png = out.endsWith('.ico')
|
|
398
|
+
? packIco(ICO_SIZES.map((s) => [s, rsvg(src, s, s)]))
|
|
399
|
+
: rsvg(src, w, h);
|
|
400
|
+
await fs.writeFile(path.join(brand, out), png);
|
|
401
|
+
written.push(`brand/${out}`);
|
|
402
|
+
}
|
|
403
|
+
return written;
|
|
404
|
+
}
|
|
405
|
+
export const BANNER_START = '<!-- js-tooling:banner:start -->';
|
|
406
|
+
export const BANNER_END = '<!-- js-tooling:banner:end -->';
|
|
407
|
+
/** The README `<picture>` banner, mobile variant under 640px, as a delimited block. */
|
|
408
|
+
export function buildBannerBlock(name) {
|
|
409
|
+
return `${BANNER_START}
|
|
410
|
+
<picture>
|
|
411
|
+
<source media="(max-width: 640px)" srcset="./brand/banner-mobile.png">
|
|
412
|
+
<img src="./brand/banner.png" alt="${esc(name)} banner" width="1600">
|
|
413
|
+
</picture>
|
|
414
|
+
${BANNER_END}`;
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* Put the banner block at the top of a README. Refreshes an existing block in
|
|
418
|
+
* place; leaves alone a README that already shows a banner outside one (a
|
|
419
|
+
* hand-written `<picture>`); otherwise prepends. Idempotent.
|
|
420
|
+
*/
|
|
421
|
+
export function upsertBanner(readme, block) {
|
|
422
|
+
const start = readme.indexOf(BANNER_START);
|
|
423
|
+
const end = readme.indexOf(BANNER_END);
|
|
424
|
+
if (start !== -1 && end > start) {
|
|
425
|
+
return readme.slice(0, start) + block + readme.slice(end + BANNER_END.length);
|
|
426
|
+
}
|
|
427
|
+
if (/banner(?:-mobile)?\.png/.test(readme))
|
|
428
|
+
return readme;
|
|
429
|
+
return `${block}\n\n${readme}`;
|
|
430
|
+
}
|
|
431
|
+
/** Add the banner block to README.md once `brand/banner.png` exists to show. */
|
|
432
|
+
export async function addReadmeBanner(targetDir, name) {
|
|
433
|
+
const file = path.join(targetDir, 'README.md');
|
|
434
|
+
if (!(await fs.pathExists(file)))
|
|
435
|
+
return null;
|
|
436
|
+
if (!(await fs.pathExists(path.join(targetDir, 'brand', 'banner.png'))))
|
|
437
|
+
return null;
|
|
438
|
+
const readme = await fs.readFile(file, 'utf-8');
|
|
439
|
+
const next = upsertBanner(readme, buildBannerBlock(name));
|
|
440
|
+
if (next === readme)
|
|
441
|
+
return null;
|
|
442
|
+
await fs.writeFile(file, next);
|
|
443
|
+
return 'README.md';
|
|
444
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import fs from 'fs-extra';
|
|
3
3
|
import selfPackageJson from '../../../package.json' with { type: 'json' };
|
|
4
|
+
import { coverageUploadWorkflow } from '../../base/checks.js';
|
|
5
|
+
import { jsBadgeAudience } from '../../languages/js/checks.js';
|
|
4
6
|
import { copyPreset, PRESETS } from '../utils/copy-preset.js';
|
|
5
7
|
import { buildBadgeRow, parseRepository } from './badges.js';
|
|
6
8
|
import { DOCS_SITE_BUILDS, mergeAllowBuilds } from './pnpm-workspace.js';
|
|
@@ -385,11 +387,15 @@ export async function generateDocsSite(pkg, targetDir, options = {}) {
|
|
|
385
387
|
// The docs homepage carries the same badge set as the README (#169), derived
|
|
386
388
|
// from package.json + repo; visibility-aware (private repos drop npm/coverage).
|
|
387
389
|
// Plain row (no upsert delimiters) to stay MDX-safe in the generated intro.
|
|
390
|
+
// Bundlephobia only for a published library, Codecov only when CI uploads
|
|
391
|
+
// coverage — the same rules doctor's badge/coverage checks use (#675).
|
|
388
392
|
const badges = buildBadgeRow({
|
|
389
393
|
name: pkg?.name,
|
|
390
394
|
owner: meta.owner ?? undefined,
|
|
391
395
|
repo: meta.repo ?? undefined,
|
|
392
396
|
isPrivate: pkg?.private === true,
|
|
397
|
+
bundled: jsBadgeAudience(pkg) === 'public',
|
|
398
|
+
uploadsCoverage: (await coverageUploadWorkflow(targetDir)) !== null,
|
|
393
399
|
});
|
|
394
400
|
// Opt-in TypeDoc API section (#229): only wire it when enabled AND the
|
|
395
401
|
// package actually exposes source modules to document.
|
package/dist/languages/js/ci.js
CHANGED
|
@@ -212,6 +212,12 @@ ${attw}${publint}
|
|
|
212
212
|
git config --global user.name "github-actions[bot]"
|
|
213
213
|
git config --global user.email "github-actions[bot]@users.noreply.github.com"
|
|
214
214
|
|
|
215
|
+
# OIDC trusted publishing needs npm >= 11.5.1; Node 22 bundles npm 10, whose
|
|
216
|
+
# \`npm publish\` fails with ENEEDAUTH. Pinned to a range, not \`latest\`, so a
|
|
217
|
+
# future npm major can't change publishing unannounced.
|
|
218
|
+
- name: 📦 Upgrade npm for OIDC trusted publishing
|
|
219
|
+
run: npm install -g npm@^11.5.1
|
|
220
|
+
|
|
215
221
|
- name: 🚀 Run semantic-release
|
|
216
222
|
# Publishes to npm via OIDC trusted publishing — no NPM_TOKEN. Requires
|
|
217
223
|
# the \`id-token: write\` permission above + a trusted publisher configured
|
|
@@ -10,6 +10,7 @@ import { GH_WORKFLOWS, generateGhWorkflow } from '../../cli/generators/github-wo
|
|
|
10
10
|
import { BIOME_CONFIG, generateBiomeConfig, generateESLintConfig, generatePrettierConfig, } from '../../cli/generators/linting.js';
|
|
11
11
|
import { alignNodeVersion, ensureEnginesNode, ensurePackageManager, generateKnipConfig, generateNvmrc, generateSizeLimitConfig, generateVscodeExtensions, } from '../../cli/generators/misc.js';
|
|
12
12
|
import { scriptsOf } from './ci.js';
|
|
13
|
+
import { NPM_OIDC_CHECK, NPM_TRUST_FIXER } from './npm-trust.js';
|
|
13
14
|
import { usesPnpm } from './checks.js';
|
|
14
15
|
import { SIZE_LIMIT_SCRIPTS, SIZE_LIMIT_VERSION, composeVerifyScriptFromPkg, ensureScripts, } from '../../cli/generators/package-json.js';
|
|
15
16
|
/** Kept in step with the biome branch of getScripts() in package-json.ts. */
|
|
@@ -438,7 +439,7 @@ export const FIXERS = [
|
|
|
438
439
|
{
|
|
439
440
|
target: 'github-actions',
|
|
440
441
|
description: 'Scaffold .github/workflows/ci.yml (+ codecov.yml when tests run)',
|
|
441
|
-
appliesTo: ['GitHub Actions', 'Coverage upload',
|
|
442
|
+
appliesTo: ['GitHub Actions', 'Coverage upload', NPM_OIDC_CHECK],
|
|
442
443
|
outputs: [CI_WORKFLOW, 'codecov.yml', 'package.json (packageManager field)'],
|
|
443
444
|
canFixDrift: true,
|
|
444
445
|
async run({ targetDir, pkg, result }) {
|
|
@@ -462,6 +463,7 @@ export const FIXERS = [
|
|
|
462
463
|
return { filesWritten };
|
|
463
464
|
},
|
|
464
465
|
},
|
|
466
|
+
NPM_TRUST_FIXER,
|
|
465
467
|
{
|
|
466
468
|
target: 'gitlab-ci',
|
|
467
469
|
description: 'Scaffold .gitlab-ci.yml (lint/typecheck/test/build mirrored from GitHub Actions)',
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* npm OIDC trusted publishing (#687): what the publishing job needs, whether the
|
|
3
|
+
* registry has a trusted publisher wired to it, and the fixer that registers one.
|
|
4
|
+
*
|
|
5
|
+
* Everything that talks to npm goes through `NpmExec`, the same resolve-never-
|
|
6
|
+
* reject seam as `GhExec`, so tests never touch the registry and a network or
|
|
7
|
+
* auth failure degrades to "couldn't check" rather than a failed doctor run.
|
|
8
|
+
*/
|
|
9
|
+
import { execFile } from 'node:child_process';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import chalk from 'chalk';
|
|
12
|
+
import fs from 'fs-extra';
|
|
13
|
+
import inquirer from 'inquirer';
|
|
14
|
+
import { FixerAbort } from '../../base/fixers.js';
|
|
15
|
+
import { jobEnvironment, workflowJobs } from '../../base/github-settings.js';
|
|
16
|
+
import { parseRepository } from '../../cli/generators/badges.js';
|
|
17
|
+
const NPM_TIMEOUT_MS = 15_000;
|
|
18
|
+
/** Real `npm` runner — never rejects. Args are derived, never free text, and execFile runs no shell. */
|
|
19
|
+
export const realNpmExec = (args) => new Promise((resolve) => {
|
|
20
|
+
execFile('npm', args, { timeout: NPM_TIMEOUT_MS }, (err, stdout, stderr) => {
|
|
21
|
+
const code = err ? (typeof err.code === 'number' ? err.code : null) : 0;
|
|
22
|
+
resolve({ ok: !err, stdout: String(stdout), stderr: String(stderr), code });
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
export const NPM_OIDC_CHECK = 'npm OIDC publish';
|
|
26
|
+
export const NPM_TRUST_CHECK = 'npm trusted publisher';
|
|
27
|
+
export const NPM_TRUST_FIX = 'npm-trusted-publisher';
|
|
28
|
+
/** OIDC publish needs npm ≥ 11.5.1 — Node 22 bundles npm 10. */
|
|
29
|
+
export const NPM_OIDC_MIN = '11.5.1';
|
|
30
|
+
/** `npm trust` landed in npm 11.15.0. */
|
|
31
|
+
export const NPM_TRUST_MIN = '11.15.0';
|
|
32
|
+
export const NPM_UPGRADE_CMD = `npm install -g npm@^${NPM_OIDC_MIN}`;
|
|
33
|
+
const NPM_UPGRADE = /\bnpm\s+(?:install|i)\s+(?:-g|--global)\s+npm@/;
|
|
34
|
+
const PUBLISHES = /semantic-release|\bnpm\s+publish\b/;
|
|
35
|
+
/** a ≥ b for dotted numeric versions (prerelease tags ignored). */
|
|
36
|
+
export function versionAtLeast(a, b) {
|
|
37
|
+
const pa = a.trim().split(/[.-]/).map(Number);
|
|
38
|
+
const pb = b.split('.').map(Number);
|
|
39
|
+
for (let i = 0; i < 3; i++) {
|
|
40
|
+
const x = pa[i] ?? 0;
|
|
41
|
+
const y = pb[i] ?? 0;
|
|
42
|
+
if (Number.isNaN(x))
|
|
43
|
+
return false;
|
|
44
|
+
if (x !== y)
|
|
45
|
+
return x > y;
|
|
46
|
+
}
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
const uncommented = (s) => s
|
|
50
|
+
.split('\n')
|
|
51
|
+
.filter((l) => !l.trimStart().startsWith('#'))
|
|
52
|
+
.join('\n');
|
|
53
|
+
/** The first workflow job that publishes to npm, or null. Throws when the directory can't be read. */
|
|
54
|
+
export async function findNpmPublishJob(dir) {
|
|
55
|
+
const workflowsDir = path.join(dir, '.github', 'workflows');
|
|
56
|
+
if (!(await fs.pathExists(workflowsDir)))
|
|
57
|
+
return null;
|
|
58
|
+
for (const file of (await fs.readdir(workflowsDir)).sort()) {
|
|
59
|
+
if (!/\.ya?ml$/.test(file))
|
|
60
|
+
continue;
|
|
61
|
+
const content = await fs.readFile(path.join(workflowsDir, file), 'utf-8');
|
|
62
|
+
if (!PUBLISHES.test(uncommented(content)))
|
|
63
|
+
continue;
|
|
64
|
+
const jobs = [...workflowJobs(content).values()].map(uncommented);
|
|
65
|
+
const body = jobs.find((b) => PUBLISHES.test(b)) ?? uncommented(content);
|
|
66
|
+
return { file, body, content, environment: jobEnvironment(body) };
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* What the publishing job itself needs for OIDC: no NPM_TOKEN, `id-token: write`,
|
|
72
|
+
* and an npm new enough to use the token.
|
|
73
|
+
*/
|
|
74
|
+
export function checkPublishJob(job) {
|
|
75
|
+
const check = NPM_OIDC_CHECK;
|
|
76
|
+
const f = job.file;
|
|
77
|
+
if (/secrets\.NPM_TOKEN/.test(job.content)) {
|
|
78
|
+
return {
|
|
79
|
+
check,
|
|
80
|
+
status: 'drift',
|
|
81
|
+
detail: `${f} authenticates npm publish with NPM_TOKEN`,
|
|
82
|
+
hint: 'Migrate to OIDC trusted publishing: add a Trusted Publisher for each published package on npmjs.com (Settings → Trusted Publisher), then run `fix github-actions` to drop NPM_TOKEN (the release job keeps `id-token: write`). npm is deprecating 2FA-bypass tokens.',
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
// The grant may sit on the job or at workflow level (everything before `jobs:`).
|
|
86
|
+
const workflowLevel = uncommented(job.content.split(/^jobs:/m)[0] ?? '');
|
|
87
|
+
if (!/id-token:\s*write/.test(job.body) && !/id-token:\s*write/.test(workflowLevel)) {
|
|
88
|
+
return {
|
|
89
|
+
check,
|
|
90
|
+
status: 'drift',
|
|
91
|
+
detail: `${f}: the publishing job lacks \`id-token: write\` — npm can't mint an OIDC token`,
|
|
92
|
+
hint: 'Add `permissions: { id-token: write }` to the publishing job (or run `fix github-actions`)',
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
if (!NPM_UPGRADE.test(job.body)) {
|
|
96
|
+
return {
|
|
97
|
+
check,
|
|
98
|
+
status: 'drift',
|
|
99
|
+
detail: `${f}: the publishing job runs the npm bundled with Node — OIDC publish needs npm ≥ ${NPM_OIDC_MIN} and fails with ENEEDAUTH below it`,
|
|
100
|
+
hint: `add \`${NPM_UPGRADE_CMD}\` before semantic-release`,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
return { check, status: 'ok', detail: `${f} publishes via OIDC (no NPM_TOKEN)` };
|
|
104
|
+
}
|
|
105
|
+
export function npmPublishGuide(opts) {
|
|
106
|
+
const env = opts.environment ? ` --env ${opts.environment}` : '';
|
|
107
|
+
return {
|
|
108
|
+
package: opts.name,
|
|
109
|
+
where: 'npmjs.com → package → Settings → Trusted Publisher → GitHub Actions',
|
|
110
|
+
organizationOrUser: opts.owner,
|
|
111
|
+
repository: opts.repo,
|
|
112
|
+
workflowFilename: opts.file,
|
|
113
|
+
environment: opts.environment,
|
|
114
|
+
command: `npm trust github ${opts.name} --file ${opts.file} --repo ${opts.owner}/${opts.repo}${env} --allow-publish`,
|
|
115
|
+
bootstrap: [
|
|
116
|
+
"Publish one version BELOW semantic-release's next one by hand: `npm publish --access public --provenance=false --otp=<code>`",
|
|
117
|
+
`Register the trusted publisher: \`npx @rtorcato/repo-tooling fix ${NPM_TRUST_FIX}\` (or on npmjs.com)`,
|
|
118
|
+
'Let CI take over — every later release publishes via OIDC',
|
|
119
|
+
],
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
/** The guide as text lines, for Next Steps and doctor hints. Never mentions an NPM_TOKEN secret. */
|
|
123
|
+
export function formatNpmPublishGuide(g, unpublished) {
|
|
124
|
+
const lines = [
|
|
125
|
+
`On ${g.where}:`,
|
|
126
|
+
` Organization or user: ${g.organizationOrUser}`,
|
|
127
|
+
` Repository: ${g.repository}`,
|
|
128
|
+
` Workflow filename: ${g.workflowFilename}`,
|
|
129
|
+
];
|
|
130
|
+
if (g.environment)
|
|
131
|
+
lines.push(` Environment: ${g.environment}`);
|
|
132
|
+
lines.push(`Or from a logged-in npm ≥ ${NPM_TRUST_MIN}: ${g.command}`);
|
|
133
|
+
if (unpublished) {
|
|
134
|
+
lines.push(`${g.package} isn't on npm yet — npm only accepts a trusted publisher for an existing package:`);
|
|
135
|
+
for (const [i, s] of g.bootstrap.entries())
|
|
136
|
+
lines.push(` ${i + 1}. ${s}`);
|
|
137
|
+
}
|
|
138
|
+
return lines;
|
|
139
|
+
}
|
|
140
|
+
/** A trusted-publisher entry's string leaves, lowercased — npm's JSON shape is matched loosely. */
|
|
141
|
+
function stringsOf(v, out = []) {
|
|
142
|
+
if (typeof v === 'string')
|
|
143
|
+
out.push(v.toLowerCase());
|
|
144
|
+
else if (Array.isArray(v))
|
|
145
|
+
for (const x of v)
|
|
146
|
+
stringsOf(x, out);
|
|
147
|
+
else if (v && typeof v === 'object')
|
|
148
|
+
for (const x of Object.values(v))
|
|
149
|
+
stringsOf(x, out);
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
/** `npm trust list --json` → its entries, whether npm wraps them or not. */
|
|
153
|
+
function trustEntries(json) {
|
|
154
|
+
if (Array.isArray(json))
|
|
155
|
+
return json;
|
|
156
|
+
if (json && typeof json === 'object') {
|
|
157
|
+
const arr = Object.values(json).find(Array.isArray);
|
|
158
|
+
if (arr)
|
|
159
|
+
return arr;
|
|
160
|
+
return Object.keys(json).length > 0 ? [json] : [];
|
|
161
|
+
}
|
|
162
|
+
return [];
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Compare `npm trust list` entries against the publishing job. Returns null on a
|
|
166
|
+
* match, else why nothing matches.
|
|
167
|
+
* ponytail: matches on string leaves rather than a fixed schema — npm's JSON
|
|
168
|
+
* shape isn't pinned in its docs; tighten once it is.
|
|
169
|
+
*/
|
|
170
|
+
export function trustMismatch(entries, want) {
|
|
171
|
+
if (entries.length === 0)
|
|
172
|
+
return 'no trusted publisher is registered';
|
|
173
|
+
const nwo = want.nwo.toLowerCase();
|
|
174
|
+
const file = want.file.toLowerCase();
|
|
175
|
+
const env = want.environment?.toLowerCase() ?? null;
|
|
176
|
+
const leaves = entries.map((e) => stringsOf(e));
|
|
177
|
+
const forRepo = leaves.filter((s) => s.some((x) => x === nwo || x.endsWith(`/${nwo}`)));
|
|
178
|
+
if (forRepo.length === 0)
|
|
179
|
+
return `no trusted publisher for ${want.nwo}`;
|
|
180
|
+
const forFile = forRepo.filter((s) => s.some((x) => x === file || x.endsWith(`/${file}`) || x.includes(`/${file}@`)));
|
|
181
|
+
if (forFile.length === 0)
|
|
182
|
+
return `the trusted publisher for ${want.nwo} names a different workflow (want ${want.file})`;
|
|
183
|
+
if (env && !forFile.some((s) => s.includes(env))) {
|
|
184
|
+
return `the trusted publisher for ${want.nwo} has no \`${want.environment}\` environment, but the job declares one`;
|
|
185
|
+
}
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
const NPM_NAME = /^(@[a-z0-9~-][a-z0-9._~-]*\/)?[a-z0-9~-][a-z0-9._~-]*$/;
|
|
189
|
+
const ENV_NAME = /^[\w.-]+$/;
|
|
190
|
+
/** What the check and the fixer both need, or why it can't be had. Network only past the offline gates. */
|
|
191
|
+
async function trustContext(dir, pkg, npm, opts) {
|
|
192
|
+
if (!pkg || pkg.private === true)
|
|
193
|
+
return { skip: 'private package — no npm publish' };
|
|
194
|
+
const name = typeof pkg.name === 'string' ? pkg.name : null;
|
|
195
|
+
if (!name)
|
|
196
|
+
return { skip: 'package.json has no name' };
|
|
197
|
+
// Untrusted (audited repo's package.json): reject anything npm could parse as a flag.
|
|
198
|
+
if (!NPM_NAME.test(name))
|
|
199
|
+
return { skip: 'package.json `name` is not a valid npm package name' };
|
|
200
|
+
let job;
|
|
201
|
+
try {
|
|
202
|
+
job = await findNpmPublishJob(dir);
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
return { skip: 'unable to read .github/workflows/' };
|
|
206
|
+
}
|
|
207
|
+
if (!job)
|
|
208
|
+
return { skip: 'no workflow publishes to npm' };
|
|
209
|
+
if (job.environment && !ENV_NAME.test(job.environment)) {
|
|
210
|
+
return { skip: 'the publish job declares an environment with unsupported characters' };
|
|
211
|
+
}
|
|
212
|
+
const repo = parseRepository(pkg.repository);
|
|
213
|
+
if (!repo)
|
|
214
|
+
return { skip: 'package.json `repository` names no GitHub repo' };
|
|
215
|
+
const nwo = `${repo.owner}/${repo.repo}`;
|
|
216
|
+
const guide = npmPublishGuide({ name, ...repo, file: job.file, environment: job.environment });
|
|
217
|
+
const view = await npm(['view', '--', name, 'version']);
|
|
218
|
+
if (!view.ok) {
|
|
219
|
+
if (/E404|404 Not Found/i.test(view.stderr)) {
|
|
220
|
+
return { skip: `${name} is not on npm yet`, unpublished: true, guide };
|
|
221
|
+
}
|
|
222
|
+
return { skip: 'could not reach the npm registry', guide };
|
|
223
|
+
}
|
|
224
|
+
if (opts.needLogin && process.env.CI)
|
|
225
|
+
return { skip: 'running in CI — no npm login', guide };
|
|
226
|
+
const ver = await npm(['--version']);
|
|
227
|
+
if (!ver.ok || !versionAtLeast(ver.stdout, NPM_TRUST_MIN)) {
|
|
228
|
+
return {
|
|
229
|
+
skip: `local npm ${ver.stdout.trim() || '?'} is older than ${NPM_TRUST_MIN} (\`npm install -g npm@^${NPM_TRUST_MIN}\`)`,
|
|
230
|
+
guide,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
const who = await npm(['whoami']);
|
|
234
|
+
if (!who.ok)
|
|
235
|
+
return { skip: 'not logged in to npm (`npm login`)', guide };
|
|
236
|
+
return { guide, nwo, job };
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Is a trusted publisher registered on npm, matching the publishing job? Every
|
|
240
|
+
* "couldn't check" is `optional-missing` with the manual steps — never a failure.
|
|
241
|
+
*/
|
|
242
|
+
export async function checkNpmTrustedPublisher(dir, pkg, npm = realNpmExec) {
|
|
243
|
+
const check = NPM_TRUST_CHECK;
|
|
244
|
+
const ctx = await trustContext(dir, pkg, npm, { needLogin: true });
|
|
245
|
+
if ('skip' in ctx) {
|
|
246
|
+
return {
|
|
247
|
+
check,
|
|
248
|
+
status: 'optional-missing',
|
|
249
|
+
detail: ctx.skip,
|
|
250
|
+
...(ctx.guide && {
|
|
251
|
+
hint: formatNpmPublishGuide(ctx.guide, ctx.unpublished === true).join('\n'),
|
|
252
|
+
}),
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
const list = await npm(['trust', 'list', '--json', '--', ctx.guide.package]);
|
|
256
|
+
let entries = null;
|
|
257
|
+
if (list.ok) {
|
|
258
|
+
try {
|
|
259
|
+
entries = trustEntries(JSON.parse(list.stdout || '[]'));
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
entries = null;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
if (!entries) {
|
|
266
|
+
return {
|
|
267
|
+
check,
|
|
268
|
+
status: 'optional-missing',
|
|
269
|
+
detail: 'could not read `npm trust list`',
|
|
270
|
+
hint: formatNpmPublishGuide(ctx.guide, false).join('\n'),
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
const why = trustMismatch(entries, {
|
|
274
|
+
nwo: ctx.nwo,
|
|
275
|
+
file: ctx.job.file,
|
|
276
|
+
environment: ctx.job.environment,
|
|
277
|
+
});
|
|
278
|
+
if (why) {
|
|
279
|
+
return {
|
|
280
|
+
check,
|
|
281
|
+
status: 'drift',
|
|
282
|
+
detail: `${ctx.guide.package}: ${why}`,
|
|
283
|
+
hint: `Run \`npx @rtorcato/repo-tooling fix ${NPM_TRUST_FIX}\`, or: ${ctx.guide.command}`,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
return {
|
|
287
|
+
check,
|
|
288
|
+
status: 'ok',
|
|
289
|
+
detail: `${ctx.guide.package} trusts ${ctx.nwo} ${ctx.job.file}${ctx.job.environment ? ` (${ctx.job.environment})` : ''}`,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Register the trusted publisher with `npm trust github`, flags derived from the
|
|
294
|
+
* publishing job. Dry-runs first and asks before the real call.
|
|
295
|
+
*/
|
|
296
|
+
export async function applyNpmTrustedPublisher(dir, pkg, assumeYes, npm = realNpmExec) {
|
|
297
|
+
const ctx = await trustContext(dir, pkg, npm, { needLogin: true });
|
|
298
|
+
if ('skip' in ctx) {
|
|
299
|
+
const hint = ctx.unpublished && ctx.guide ? ctx.guide.bootstrap.join(' → ') : undefined;
|
|
300
|
+
throw new FixerAbort('npm-trust-unavailable', ctx.skip, hint);
|
|
301
|
+
}
|
|
302
|
+
const args = [
|
|
303
|
+
'trust',
|
|
304
|
+
'github',
|
|
305
|
+
'--file',
|
|
306
|
+
ctx.job.file,
|
|
307
|
+
'--repo',
|
|
308
|
+
ctx.nwo,
|
|
309
|
+
...(ctx.job.environment ? ['--env', ctx.job.environment] : []),
|
|
310
|
+
'--allow-publish',
|
|
311
|
+
];
|
|
312
|
+
// `--` last so the name can never be read as a flag.
|
|
313
|
+
const tail = ['--', ctx.guide.package];
|
|
314
|
+
const dry = await npm([...args, '--dry-run', ...tail]);
|
|
315
|
+
if (!dry.ok) {
|
|
316
|
+
throw new FixerAbort('npm-trust-failed', `npm trust --dry-run failed: ${dry.stderr.trim()}`);
|
|
317
|
+
}
|
|
318
|
+
console.error(chalk.gray(dry.stdout.trim()));
|
|
319
|
+
if (!assumeYes) {
|
|
320
|
+
if (!process.stdin.isTTY)
|
|
321
|
+
return [];
|
|
322
|
+
const { confirm } = await inquirer.prompt([
|
|
323
|
+
{
|
|
324
|
+
type: 'confirm',
|
|
325
|
+
name: 'confirm',
|
|
326
|
+
message: 'Register this trusted publisher on npm?',
|
|
327
|
+
default: false,
|
|
328
|
+
},
|
|
329
|
+
]);
|
|
330
|
+
if (confirm !== true)
|
|
331
|
+
return [];
|
|
332
|
+
}
|
|
333
|
+
const real = await npm([...args, '--yes', ...tail]);
|
|
334
|
+
if (!real.ok)
|
|
335
|
+
throw new FixerAbort('npm-trust-failed', `npm trust failed: ${real.stderr.trim()}`);
|
|
336
|
+
return [`npm trusted publisher for ${ctx.guide.package} (remote, via npm trust)`];
|
|
337
|
+
}
|
|
338
|
+
export const NPM_TRUST_FIXER = {
|
|
339
|
+
target: NPM_TRUST_FIX,
|
|
340
|
+
description: 'Register the npm trusted publisher (OIDC) for this package with `npm trust github`, derived from the publishing job (#687)',
|
|
341
|
+
appliesTo: [NPM_TRUST_CHECK],
|
|
342
|
+
outputs: ['npm trusted publisher (remote, via npm trust)'],
|
|
343
|
+
// safe-add keeps `--diff` from shadow-running it (that would call npm for real);
|
|
344
|
+
// explicitOnly because it changes registry state, like release-environment.
|
|
345
|
+
riskLevel: 'safe-add',
|
|
346
|
+
explicitOnly: true,
|
|
347
|
+
canFixDrift: true,
|
|
348
|
+
async run({ targetDir, pkg, assumeYes }) {
|
|
349
|
+
return { filesWritten: await applyNpmTrustedPublisher(targetDir, pkg, assumeYes) };
|
|
350
|
+
},
|
|
351
|
+
};
|
package/package.json
CHANGED