@rtorcato/repo-tooling 4.4.0 → 4.6.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
 
@@ -142,11 +142,34 @@ export async function checkBrand(dir) {
142
142
  if (problems.length > 0) {
143
143
  return { check, status: 'drift', detail: problems.join('; '), hint: BRAND_HINT };
144
144
  }
145
+ // Name the pieces still missing (#679). Severity is unchanged: brand is optional.
146
+ const missing = [];
147
+ if (!(await has('brand/favicon.svg')))
148
+ missing.push('favicon (brand/favicon.svg)');
149
+ const unrendered = [];
150
+ for (const n of ['banner', 'banner-mobile', 'social-card', 'favicon-512']) {
151
+ if (!(await has(`brand/${n}.png`)))
152
+ unrendered.push(`${n}.png`);
153
+ }
154
+ if (unrendered.length > 0)
155
+ missing.push(`rendered PNGs (brand/${unrendered.join(', ')})`);
156
+ const readmeFile = path.join(dir, 'README.md');
157
+ const readmeText = (await fs.pathExists(readmeFile)) ? await fs.readFile(readmeFile, 'utf-8') : '';
158
+ if (!/brand\/banner(?:-mobile)?\.png/.test(readmeText))
159
+ missing.push('README banner');
145
160
  if (!brandDir) {
146
161
  return {
147
162
  check,
148
163
  status: 'optional-missing',
149
- detail: 'no brand/ folder — the repo ships no regenerable brand sources',
164
+ detail: `no brand/ folder — missing: ${missing.join('; ')}`,
165
+ hint: BRAND_HINT,
166
+ };
167
+ }
168
+ if (missing.length > 0) {
169
+ return {
170
+ check,
171
+ status: 'ok',
172
+ detail: `brand/ holds the SVG sources and render.sh; missing: ${missing.join('; ')}`,
150
173
  hint: BRAND_HINT,
151
174
  };
152
175
  }
@@ -380,6 +403,22 @@ export async function checkCodeQL(dir, languages) {
380
403
  hint: 'Run `npx @rtorcato/repo-tooling fix codeql` to scaffold CodeQL security scanning',
381
404
  };
382
405
  }
406
+ /** The workflow file that uploads coverage via codecov-action, or null when none does. */
407
+ export async function coverageUploadWorkflow(dir) {
408
+ const workflowsDir = path.join(dir, '.github', 'workflows');
409
+ try {
410
+ const files = (await fs.readdir(workflowsDir)).filter((f) => f.endsWith('.yml') || f.endsWith('.yaml'));
411
+ for (const f of files) {
412
+ const content = await fs.readFile(path.join(workflowsDir, f), 'utf-8');
413
+ if (/codecov\/codecov-action/.test(content))
414
+ return f;
415
+ }
416
+ }
417
+ catch {
418
+ // no workflows dir
419
+ }
420
+ return null;
421
+ }
383
422
  // A README that advertises a Codecov badge but a CI that never uploads coverage
384
423
  // leaves the badge permanently red. Only flags when the badge is actually present
385
424
  // (no badge → nothing to back, so it's not applicable).
@@ -393,24 +432,13 @@ export async function checkCoverageUpload(dir) {
393
432
  detail: 'no coverage badge in README (nothing to back)',
394
433
  };
395
434
  }
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
- }
435
+ const workflow = await coverageUploadWorkflow(dir);
436
+ if (workflow) {
437
+ return {
438
+ check: 'Coverage upload',
439
+ status: 'ok',
440
+ detail: `coverage badge backed by codecov-action in .github/workflows/${workflow}`,
441
+ };
414
442
  }
415
443
  return {
416
444
  check: 'Coverage upload',
package/dist/base/ci.js CHANGED
@@ -13,7 +13,9 @@
13
13
  /** Every job is gated on the skip-CI check; extra conditions are ANDed on. */
14
14
  const SKIP_GUARD = "needs.check-skip.outputs.should-skip != 'true'";
15
15
  /** Header through `jobs:` — triggers and concurrency are language-independent. */
16
- const WORKFLOW_HEADER = `name: 🚀 CI/CD Pipeline
16
+ /** The generated CI workflow's `name:` — the docs workflow's `workflow_run` must match it. */
17
+ export const CI_WORKFLOW_NAME = '🚀 CI/CD Pipeline';
18
+ const WORKFLOW_HEADER = `name: ${CI_WORKFLOW_NAME}
17
19
 
18
20
  on:
19
21
  push:
@@ -24,7 +26,9 @@ on:
24
26
 
25
27
  concurrency:
26
28
  group: \${{ github.workflow }}-\${{ github.ref }}
27
- cancel-in-progress: true
29
+ # Never cancel on main: a newer push would kill a release that is waiting on
30
+ # approval or halfway through publishing.
31
+ cancel-in-progress: \${{ github.event_name == 'pull_request' }}
28
32
 
29
33
  jobs:
30
34
  `;
@@ -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/' };
@@ -377,6 +359,7 @@ export async function runDoctor(dir) {
377
359
  results.push(await checkSizeLimit(targetDir, pkg));
378
360
  results.push(await checkReleaseToken(targetDir));
379
361
  results.push(await checkNpmOidcPublish(targetDir, pkg));
362
+ results.push(await checkNpmTrustedPublisher(targetDir, pkg));
380
363
  results.push(await checkTypedoc(targetDir, pkg));
381
364
  results.push(await checkAreTheTypesWrong(targetDir, pkg));
382
365
  results.push(await checkPublint(targetDir, pkg));
@@ -22,6 +22,8 @@ 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',
@@ -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)');
@@ -1,6 +1,9 @@
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 { CI_WORKFLOW_NAME } from '../../base/ci.js';
5
+ import { coverageUploadWorkflow } from '../../base/checks.js';
6
+ import { jsBadgeAudience } from '../../languages/js/checks.js';
4
7
  import { copyPreset, PRESETS } from '../utils/copy-preset.js';
5
8
  import { buildBadgeRow, parseRepository } from './badges.js';
6
9
  import { DOCS_SITE_BUILDS, mergeAllowBuilds } from './pnpm-workspace.js';
@@ -342,15 +345,18 @@ on:
342
345
  paths:
343
346
  - 'apps/docs/**'
344
347
  - '.github/workflows/docs.yml'
345
- # The changelog page is built from GitHub Releases, and no release commit
346
- # lands on main any more (see #417) — so a push trigger alone would never
347
- # rebuild the site after a release.
348
- release:
349
- types: [published]
348
+ # The changelog page is built from GitHub Releases. A release created with
349
+ # GITHUB_TOKEN never fires \`release: published\`, so rebuild once CI (which
350
+ # runs semantic-release) succeeds on main instead — no PAT needed (#691).
351
+ workflow_run:
352
+ workflows: ['${CI_WORKFLOW_NAME}']
353
+ types: [completed]
354
+ branches: [main]
350
355
  workflow_dispatch:
351
356
 
352
357
  jobs:
353
358
  docs:
359
+ if: github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success'
354
360
  permissions:
355
361
  contents: read
356
362
  pages: write
@@ -385,11 +391,15 @@ export async function generateDocsSite(pkg, targetDir, options = {}) {
385
391
  // The docs homepage carries the same badge set as the README (#169), derived
386
392
  // from package.json + repo; visibility-aware (private repos drop npm/coverage).
387
393
  // Plain row (no upsert delimiters) to stay MDX-safe in the generated intro.
394
+ // Bundlephobia only for a published library, Codecov only when CI uploads
395
+ // coverage — the same rules doctor's badge/coverage checks use (#675).
388
396
  const badges = buildBadgeRow({
389
397
  name: pkg?.name,
390
398
  owner: meta.owner ?? undefined,
391
399
  repo: meta.repo ?? undefined,
392
400
  isPrivate: pkg?.private === true,
401
+ bundled: jsBadgeAudience(pkg) === 'public',
402
+ uploadsCoverage: (await coverageUploadWorkflow(targetDir)) !== null,
393
403
  });
394
404
  // Opt-in TypeDoc API section (#229): only wire it when enabled AND the
395
405
  // package actually exposes source modules to document.
@@ -1,3 +1,12 @@
1
+ /**
2
+ * Conventional-commit types that cut a release, mirroring the `releaseRules` in
3
+ * tooling/semantic-release (a test keeps the two in step). `feat!:` and `fix!:`
4
+ * start with these too, so breaking changes are covered.
5
+ */
6
+ export const RELEASE_TYPES = ['feat', 'fix', 'perf', 'refactor', 'revert', 'update'];
7
+ // Match only the START of the squash subject: the message carries the whole PR
8
+ // body, so searching it matched PRs that merely mentioned `BREAKING CHANGE`.
9
+ const RELEASE_IF = `github.ref == 'refs/heads/main' && (github.event_name == 'workflow_dispatch' || github.event_name == 'push' && (${RELEASE_TYPES.map((t) => `startsWith(github.event.head_commit.message, '${t}')`).join(' || ')}))`;
1
10
  /** Coverage is uploaded when Vitest is the test runner (it emits an lcov report). */
2
11
  export function usesCoverage(config) {
3
12
  return config.testing.framework === 'vitest';
@@ -175,7 +184,7 @@ ${attw}${publint}
175
184
  id: 'release',
176
185
  // Gate the publish on everything that ran before it.
177
186
  needs: jobs.map((job) => job.id),
178
- if: "github.ref == 'refs/heads/main'",
187
+ if: RELEASE_IF,
179
188
  extra: ` permissions:
180
189
  contents: write
181
190
  issues: write
@@ -212,13 +221,26 @@ ${attw}${publint}
212
221
  git config --global user.name "github-actions[bot]"
213
222
  git config --global user.email "github-actions[bot]@users.noreply.github.com"
214
223
 
224
+ # OIDC trusted publishing needs npm >= 11.5.1; Node 22 bundles npm 10, whose
225
+ # \`npm publish\` fails with ENEEDAUTH. Pinned to a range, not \`latest\`, so a
226
+ # future npm major can't change publishing unannounced.
227
+ - name: 📦 Upgrade npm for OIDC trusted publishing
228
+ run: npm install -g npm@^11.5.1
229
+
215
230
  - name: 🚀 Run semantic-release
216
231
  # Publishes to npm via OIDC trusted publishing — no NPM_TOKEN. Requires
217
232
  # the \`id-token: write\` permission above + a trusted publisher configured
218
233
  # for the package on npmjs.com (Settings → Trusted Publisher).
219
234
  env:
220
235
  GITHUB_TOKEN: \${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
221
- run: npx semantic-release`,
236
+ run: |
237
+ set -o pipefail
238
+ npx semantic-release 2>&1 | tee release.log
239
+ # semantic-release exits 0 when main moved on since this run started,
240
+ # publishing nothing (#690). Say so instead of going quietly green.
241
+ if grep -q "is behind the remote one" release.log; then
242
+ echo "::warning::main moved on; nothing was published. Re-run via workflow_dispatch."
243
+ fi`,
222
244
  });
223
245
  }
224
246
  return jobs;
@@ -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.4.0",
3
+ "version": "4.6.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": [