@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 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
 
@@ -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), then run `brand/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 workflowsDir = path.join(dir, '.github', 'workflows');
397
- if (await fs.pathExists(workflowsDir)) {
398
- try {
399
- const files = (await fs.readdir(workflowsDir)).filter((f) => f.endsWith('.yml') || f.endsWith('.yaml'));
400
- for (const f of files) {
401
- const content = await fs.readFile(path.join(workflowsDir, f), 'utf-8');
402
- if (/codecov\/codecov-action/.test(content)) {
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',
@@ -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, and repoint a README still on root-level banner paths',
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 and the README edit rewrites two
262
- // image paths — hand-edited art is never clobbered.
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 filesWritten = await generateBrand(pkg, targetDir, lock?.rules?.brand?.tagline);
267
- if (filesWritten.some((f) => f.endsWith('.svg'))) {
268
- console.error(chalk.dim(' next: run `brand/render.sh` to render the PNGs (needs librsvg — `brew install librsvg`)'));
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
- // Flags a release workflow still authenticating npm publish with a long-lived
72
- // NPM_TOKEN secret instead of OIDC trusted publishing (#201). npm is deprecating
73
- // 2FA-bypass tokens; OIDC needs no secret and adds provenance for free. Only
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 = 'npm OIDC publish';
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 files = await fs.readdir(workflowsDir);
86
- for (const f of files) {
87
- if (!(f.endsWith('.yml') || f.endsWith('.yaml')))
88
- continue;
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
- console.log(JSON.stringify({ directory: targetDir, config, files }, null, 2));
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(`[![npm version](https://img.shields.io/npm/v/${name})](https://www.npmjs.com/package/${name})`);
45
45
  badges.push(`[![npm downloads](https://img.shields.io/npm/dm/${name})](https://www.npmjs.com/package/${name})`);
46
+ }
47
+ if (!isPrivate && name && bundled) {
46
48
  badges.push(`[![Bundle size](https://img.shields.io/bundlephobia/minzip/${name})](https://bundlephobia.com/package/${name})`);
47
49
  }
48
- if (!isPrivate && slug) {
50
+ if (!isPrivate && slug && uploadsCoverage) {
49
51
  badges.push(`[![Coverage](https://codecov.io/gh/${slug}/branch/${branch}/graph/badge.svg)](https://codecov.io/gh/${slug})`);
50
52
  }
51
53
  badges.push('[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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
- const candidates = [path.join('apps', 'docs', 'static', 'img', 'favicon.svg'), 'favicon.svg'];
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 mark: a rounded square in the accent carrying the project's initial.
127
- * Authored on a 32 viewBox so it matches the favicon's geometry and can be
128
- * swapped for the real favicon glyph verbatim.
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 mark(meta, translate, scale) {
132
+ export function faviconSvg(meta) {
131
133
  const initial = esc((meta.name[0] ?? '?').toUpperCase());
132
- return ` <!-- Logo mark: a 32 viewBox, so the real favicon glyph can be pasted in over it. -->
133
- <g transform="translate(${translate}) scale(${scale})">
134
- <rect width="32" height="32" rx="8" fill="${meta.accent}"/>
135
- <text x="16" y="23" text-anchor="middle" font-family="Avenir Next" font-weight="800" font-size="19" fill="${INK}">${initial}</text>
136
- </g>`;
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(meta, '60 88', 2.25)}
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(meta, '565 104', 4.6875)}
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(meta, '590 120', 3.125)}
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
- echo "rendered: brand/banner.png brand/banner-mobile.png"
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 + the render script, then repoint a
284
- * README still on the old root-level paths. Every file is written only when
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.
@@ -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', 'npm OIDC publish'],
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rtorcato/repo-tooling",
3
- "version": "4.3.1",
3
+ "version": "4.5.0",
4
4
  "description": "One CLI to scaffold, audit and fix your repo's whole toolchain — linting, tests, commits, releases & CI.",
5
5
  "type": "module",
6
6
  "keywords": [