@rtorcato/repo-tooling 3.11.0 → 3.12.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.
@@ -1,5 +1,6 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import path from 'node:path';
3
+ import { pathToFileURL } from 'node:url';
3
4
  import chalk from 'chalk';
4
5
  import fs from 'fs-extra';
5
6
  const GH_TIMEOUT_MS = 10_000;
@@ -345,10 +346,55 @@ async function hasCodeScanningRuleset(gh, nwo, branch) {
345
346
  }
346
347
  return 'no';
347
348
  }
349
+ /** Release config filenames semantic-release picks up that we can import. */
350
+ const RELEASE_CONFIG_FILES = ['release.config.mjs', 'release.config.js'];
351
+ /** How to stop the GH013 collision, wherever it is reported. */
352
+ const GIT_PLUGIN_REMEDY = 'Drop `@semantic-release/git` and `@semantic-release/changelog` from the release config (rtorcato/repo-tooling#417) — the shipped `semantic-release/github` preset already has, so a bare re-export of it is enough. Do NOT add a bypass actor to the ruleset: it exempts the one commit nobody reviews';
353
+ /**
354
+ * Does the repo's release config still resolve `@semantic-release/git`?
355
+ *
356
+ * That plugin pushes the release commit straight to the default branch, which a
357
+ * `code_scanning` ruleset rejects with GH013 — a commit created seconds earlier
358
+ * can never carry CodeQL results (#417). The two are individually correct and
359
+ * jointly unwinnable, and the failure is silent, because a merge that produces
360
+ * no release goes green either way.
361
+ *
362
+ * The config is imported rather than grepped, because every cheaper signal is
363
+ * wrong on a config we ship or recommend: a bare re-export names no plugin at
364
+ * all; a pinned older repo-tooling re-exports a preset that *does* have it; the
365
+ * current preset mentions it in a comment explaining its absence; and the
366
+ * documented way to drop it lists the name in a filter. Only the resolved
367
+ * `plugins` array distinguishes those. Importing config is what semantic-release
368
+ * itself does with this file.
369
+ *
370
+ * `false` on anything unreadable — a false negative is a missed warning, a false
371
+ * positive is a wrong one.
372
+ */
373
+ export async function releaseUsesGitPlugin(dir) {
374
+ for (const name of RELEASE_CONFIG_FILES) {
375
+ const file = path.join(dir, name);
376
+ if (!(await fs.pathExists(file)))
377
+ continue;
378
+ try {
379
+ const mod = await import(pathToFileURL(file).href);
380
+ const plugins = mod.default?.plugins;
381
+ if (!Array.isArray(plugins))
382
+ return false;
383
+ return plugins.some((p) => (Array.isArray(p) ? p[0] : p) === '@semantic-release/git');
384
+ }
385
+ catch {
386
+ return false;
387
+ }
388
+ }
389
+ return false;
390
+ }
348
391
  /**
349
392
  * The #269 gap: CodeQL results are advisory by default — a High alert still
350
393
  * merges unless a branch ruleset requires the code-scanning check. Only
351
394
  * meaningful where CodeQL is actually on, so it no-ops otherwise.
395
+ *
396
+ * It also reports the #419 collision: a gate that is correctly in place is still
397
+ * drift when the release config pushes to the branch it guards.
352
398
  */
353
399
  async function checkCodeScanningRuleset(gh, nwo, branch, dir) {
354
400
  const check = CODE_SCANNING_CHECK;
@@ -358,14 +404,25 @@ async function checkCodeScanningRuleset(gh, nwo, branch, dir) {
358
404
  const found = await hasCodeScanningRuleset(gh, nwo, branch);
359
405
  if (found === 'skip')
360
406
  return skip(check, 'could not read rulesets');
407
+ const gitPlugin = await releaseUsesGitPlugin(dir);
361
408
  if (found === 'yes') {
409
+ if (gitPlugin) {
410
+ return {
411
+ check,
412
+ status: 'drift',
413
+ detail: `active ruleset requires code-scanning on ${branch}, but the release config still uses \`@semantic-release/git\` — its push to ${branch} will be rejected with GH013, and the release fails silently`,
414
+ hint: GIT_PLUGIN_REMEDY,
415
+ };
416
+ }
362
417
  return { check, status: 'ok', detail: `active ruleset requires code-scanning on ${branch}` };
363
418
  }
364
419
  return {
365
420
  check,
366
421
  status: 'drift',
367
422
  detail: `CodeQL is on but no active ruleset requires code-scanning on ${branch} (High alerts stay advisory)`,
368
- hint: 'Run `npx @rtorcato/repo-tooling fix github-settings` to add a code_scanning branch ruleset that blocks merge on High+ CodeQL alerts',
423
+ hint: gitPlugin
424
+ ? `Run \`npx @rtorcato/repo-tooling fix github-settings\` to add a code_scanning branch ruleset that blocks merge on High+ CodeQL alerts. That ruleset will reject this repo's release commit with GH013 while the config uses \`@semantic-release/git\`. ${GIT_PLUGIN_REMEDY}`
425
+ : 'Run `npx @rtorcato/repo-tooling fix github-settings` to add a code_scanning branch ruleset that blocks merge on High+ CodeQL alerts',
369
426
  };
370
427
  }
371
428
  /** The branch-protection body PUT to the API — mirrors the doctor standard. */
@@ -484,9 +541,13 @@ export async function applyGithubSettings(dir, exec) {
484
541
  console.error(chalk.yellow(` could not apply ${cmd.label}: ${r.stderr.trim() || 'gh error'}`));
485
542
  }
486
543
  // Code-scanning ruleset (#269): POST only when CodeQL is on and no active gate
487
- // covers the default branch — the check re-read keeps this idempotent.
488
- const cs = await checkCodeScanningRuleset(gh, info.nwo, info.branch, dir);
489
- if (cs.status === 'drift') {
544
+ // covers the default branch. Asked directly rather than via the check's
545
+ // status, because that status is also `drift` when the gate is already
546
+ // installed and merely collides with the release config (#419) — keying the
547
+ // POST off it would file a duplicate ruleset.
548
+ const codeql = await codeqlEnabled(gh, info.nwo, dir);
549
+ const ruleset = codeql ? await hasCodeScanningRuleset(gh, info.nwo, info.branch) : 'skip';
550
+ if (ruleset === 'no') {
490
551
  const label = `code-scanning ruleset on ${info.branch}`;
491
552
  const r = await gh(['api', '-X', 'POST', `repos/${info.nwo}/rulesets`, '--input', '-'], CODE_SCANNING_RULESET_BODY);
492
553
  if (r.ok)
@@ -494,6 +555,14 @@ export async function applyGithubSettings(dir, exec) {
494
555
  else
495
556
  console.error(chalk.yellow(` could not apply ${label}: ${r.stderr.trim() || 'gh error'}`));
496
557
  }
558
+ // #419: the gate is right and the release config is wrong, so say so instead
559
+ // of quietly installing the half that breaks the next release. Warned on
560
+ // `yes` too — that repo is already broken, it just hasn't released yet.
561
+ if (ruleset !== 'skip' && (await releaseUsesGitPlugin(dir))) {
562
+ console.error(chalk.yellow(` warning: code-scanning is enforced on ${info.branch}, but the release config still uses \`@semantic-release/git\`.\n` +
563
+ ` Its push to ${info.branch} will be rejected with GH013 and the release will fail silently.\n` +
564
+ ` ${GIT_PLUGIN_REMEDY}.`));
565
+ }
497
566
  if (applied.length === 0)
498
567
  console.error(chalk.gray(' already configured — nothing to apply'));
499
568
  return applied;
@@ -123,41 +123,15 @@ export default mergeConfig(preset, defineConfig({ plugins: [react()] }))
123
123
  `;
124
124
  await fs.writeFile(viteConfigPath, viteConfig);
125
125
  }
126
- // Plugins the github/gitlab preset activates that semantic-release core does
127
- // NOT bundle (core bundles only commit-analyzer, release-notes-generator, npm,
128
- // github). Without these in the consumer's deps, `semantic-release` crashes
129
- // with "Cannot find module '@semantic-release/changelog'" on first run.
130
- const RELEASE_PLUGIN_DEPS = {
131
- '@semantic-release/changelog': '^6.0.0',
132
- '@semantic-release/git': '^10.0.0',
133
- };
134
126
  export async function generateSemanticReleaseConfig(targetDir) {
135
127
  const releaseConfigPath = path.join(targetDir, 'release.config.mjs');
136
128
  const releaseConfig = `export { default } from '@rtorcato/repo-tooling/semantic-release/github'
137
129
  `;
130
+ // No extra plugin deps to inject: the github preset now uses only what
131
+ // semantic-release core bundles (commit-analyzer, release-notes-generator,
132
+ // npm, github). The changelog and git plugins are gone — see #417.
138
133
  await fs.writeFile(releaseConfigPath, releaseConfig);
139
- const written = ['release.config.mjs'];
140
- // Ensure the preset's non-bundled plugins are installed; otherwise the
141
- // scaffolded release.config.mjs references modules the consumer lacks.
142
- const pkgPath = path.join(targetDir, 'package.json');
143
- if (await fs.pathExists(pkgPath)) {
144
- const pkg = (await fs.readJson(pkgPath));
145
- const devDeps = (pkg.devDependencies ?? {});
146
- const deps = (pkg.dependencies ?? {});
147
- let changed = false;
148
- for (const [name, version] of Object.entries(RELEASE_PLUGIN_DEPS)) {
149
- if (!devDeps[name] && !deps[name]) {
150
- devDeps[name] = version;
151
- changed = true;
152
- }
153
- }
154
- if (changed) {
155
- pkg.devDependencies = devDeps;
156
- await fs.writeJson(pkgPath, pkg, { spaces: 2 });
157
- written.push('package.json');
158
- }
159
- }
160
- return written;
134
+ return ['release.config.mjs'];
161
135
  }
162
136
  export async function generateChangesetsConfig(targetDir) {
163
137
  // Drop the canonical Changesets config into .changeset/config.json. The user
@@ -333,8 +333,12 @@ on:
333
333
  branches: [main]
334
334
  paths:
335
335
  - 'apps/docs/**'
336
- - 'CHANGELOG.md'
337
336
  - '.github/workflows/docs.yml'
337
+ # The changelog page is built from GitHub Releases, and no release commit
338
+ # lands on main any more (see #417) — so a push trigger alone would never
339
+ # rebuild the site after a release.
340
+ release:
341
+ types: [published]
338
342
  workflow_dispatch:
339
343
 
340
344
  jobs:
@@ -377,14 +377,12 @@ function getDependencies(config) {
377
377
  deps['commitizen'] = '^4.3.1';
378
378
  deps['cz-conventional-changelog'] = '^3.3.0';
379
379
  }
380
- // Semantic release. The shipped github preset activates the changelog and
381
- // git plugins (and @semantic-release/github), so they must be installed too
382
- // or `semantic-release` crashes with "Cannot find module".
380
+ // Semantic release. The shipped github preset uses only plugins that
381
+ // semantic-release core bundles, plus @semantic-release/github — no
382
+ // changelog/git plugins (see tooling/semantic-release/github.mjs, #417).
383
383
  if (config.semanticRelease) {
384
384
  deps['semantic-release'] = '^25.0.0';
385
385
  deps['@semantic-release/github'] = '^12.0.0';
386
- deps['@semantic-release/changelog'] = '^6.0.0';
387
- deps['@semantic-release/git'] = '^10.0.0';
388
386
  }
389
387
  return deps;
390
388
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rtorcato/repo-tooling",
3
- "version": "3.11.0",
3
+ "version": "3.12.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": [
@@ -244,11 +244,9 @@
244
244
  "@ianvs/prettier-plugin-sort-imports": "^4.4.2",
245
245
  "@next/eslint-plugin-next": "^16.2.11",
246
246
  "@playwright/test": "^1.62.0",
247
- "@semantic-release/changelog": "^7.0.0",
248
247
  "@rollup/plugin-typescript": "^12.0.0",
249
248
  "@semantic-release/commit-analyzer": "^13.0.1",
250
249
  "@semantic-release/exec": "^7.1.0",
251
- "@semantic-release/git": "^11.0.1",
252
250
  "@semantic-release/github": "^12.0.9",
253
251
  "@semantic-release/npm": "^13.1.5",
254
252
  "@semantic-release/release-notes-generator": "^14.1.1",
@@ -1,11 +1,19 @@
1
1
  // Canonical sync-changelog for @rtorcato/* docs sites (shipped by
2
2
  // @rtorcato/repo-tooling — copy via `repo-tooling copy docusaurus-sync-changelog`).
3
3
  //
4
- // Copies the root CHANGELOG.md into the docs site's changelog page with
5
- // frontmatter, so semantic-release keeps owning a single canonical changelog
6
- // while the docs site renders it in-nav. The output file is gitignored — it is
7
- // regenerated on every docs build (wire it into the docs app's `build`/`start`
8
- // scripts; pnpm 8 doesn't run `pre*` hooks reliably, so chain it explicitly).
4
+ // Builds the docs site's changelog page from **GitHub Releases**, not from
5
+ // CHANGELOG.md. The output file is gitignored — it is regenerated on every docs
6
+ // build (wire it into the docs app's `build`/`start` scripts; pnpm 8 doesn't run
7
+ // `pre*` hooks reliably, so chain it explicitly).
8
+ //
9
+ // Why Releases and not the file: the shipped semantic-release github preset
10
+ // does not use @semantic-release/git, because the `code-scanning-main` ruleset
11
+ // rejects the release commit with GH013 (see tooling/semantic-release/github.mjs
12
+ // and rtorcato/repo-tooling#417). Nothing commits CHANGELOG.md back, so it is
13
+ // frozen at whatever the last release with that plugin wrote — and it freezes at
14
+ // a *real* release, which is why it reads as current. @semantic-release/github
15
+ // writes Releases from the same notes that used to build CHANGELOG.md, so
16
+ // concatenating their bodies reproduces the old page and cannot go stale.
9
17
  //
10
18
  // The target defaults to Docusaurus's `apps/docs/docs/changelog.md`. Override
11
19
  // with the CHANGELOG_TARGET env var (path relative to the repo root) for other
@@ -13,6 +21,9 @@
13
21
  // CHANGELOG_TARGET=apps/web/content/docs/changelog.mdx node scripts/sync-changelog.mjs
14
22
  // The title/description frontmatter is framework-neutral (both Docusaurus and
15
23
  // Fumadocs read it), so only the path changes between frameworks.
24
+ //
25
+ // The docs workflow must rebuild on `release: types: [published]` — with no
26
+ // release commit landing on the default branch, a `push` trigger never fires.
16
27
 
17
28
  import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
18
29
  import { dirname, resolve } from 'node:path'
@@ -20,21 +31,74 @@ import { fileURLToPath } from 'node:url'
20
31
 
21
32
  const here = dirname(fileURLToPath(import.meta.url))
22
33
  const repoRoot = resolve(here, '..')
23
- const source = resolve(repoRoot, 'CHANGELOG.md')
24
34
  const target = resolve(repoRoot, process.env.CHANGELOG_TARGET ?? 'apps/docs/docs/changelog.md')
25
35
 
36
+ /** `owner/name` — from CI, else from package.json `repository`. */
37
+ function resolveRepo() {
38
+ if (process.env.GITHUB_REPOSITORY) return process.env.GITHUB_REPOSITORY
39
+ const pkg = JSON.parse(readFileSync(resolve(repoRoot, 'package.json'), 'utf8'))
40
+ const url = typeof pkg.repository === 'string' ? pkg.repository : pkg.repository?.url
41
+ const match = /github\.com[/:]([^/]+\/[^/.]+)/.exec(url ?? '')
42
+ if (!match) throw new Error('no GITHUB_REPOSITORY and no github repository in package.json')
43
+ return match[1]
44
+ }
45
+
26
46
  const frontmatter = `---
27
47
  title: Changelog
28
- description: Release notes, generated by semantic-release.
48
+ description: Release notes, generated by semantic-release and published to GitHub Releases.
29
49
  ---
30
50
 
31
51
  `
32
52
 
33
- let body = ''
53
+ const footer = (repo, note) =>
54
+ `\n\n---\n\n${note}Every entry above is generated by semantic-release and published to [GitHub Releases](https://github.com/${repo}/releases). Anything older than the first GitHub Release is recorded in [CHANGELOG.md](https://github.com/${repo}/blob/main/CHANGELOG.md).\n`
55
+
56
+ async function fetchReleases(repo) {
57
+ const headers = { Accept: 'application/vnd.github+json' }
58
+ // Optional: lifts the 60/hr unauthenticated rate limit. CI passes it; local
59
+ // builds work fine without it at a typical repo's release count.
60
+ // ponytail: one page of 100. Paginate when a repo outgrows it.
61
+ const token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN
62
+ if (token) headers.Authorization = `Bearer ${token}`
63
+
64
+ const res = await fetch(`https://api.github.com/repos/${repo}/releases?per_page=100`, { headers })
65
+ if (!res.ok) throw new Error(`GitHub API ${res.status} ${res.statusText}`)
66
+ return res.json()
67
+ }
68
+
69
+ function render(releases) {
70
+ // The API does not guarantee date order, and a release body already carries
71
+ // its own version heading and date — so sort here and emit bodies verbatim.
72
+ const sorted = releases
73
+ .filter((r) => !r.draft && r.body?.trim())
74
+ .sort((a, b) => new Date(b.published_at) - new Date(a.published_at))
75
+
76
+ if (sorted.length === 0) throw new Error('no releases with a body')
77
+ return sorted.map((r) => r.body.trim()).join('\n\n')
78
+ }
79
+
80
+ let repo = ''
81
+ let body
34
82
  try {
35
- body = readFileSync(source, 'utf8')
36
- } catch {
37
- body = '# Changelog\n\nNo releases recorded yet.\n'
83
+ repo = resolveRepo()
84
+ body = render(await fetchReleases(repo)) + footer(repo, '')
85
+ console.log(`sync-changelog: built from ${repo} GitHub Releases`)
86
+ } catch (error) {
87
+ // Never fail the docs build on a network blip — fall back to the frozen file
88
+ // and say so on the page rather than shipping a silently truncated one.
89
+ console.warn(`sync-changelog: ${error.message} — falling back to CHANGELOG.md`)
90
+ try {
91
+ body =
92
+ readFileSync(resolve(repoRoot, 'CHANGELOG.md'), 'utf8').trim() +
93
+ (repo
94
+ ? footer(
95
+ repo,
96
+ '**This page was built from the repository CHANGELOG.md because GitHub Releases could not be reached, and may be out of date.** '
97
+ )
98
+ : '')
99
+ } catch {
100
+ body = '# Changelog\n\nNo releases recorded yet.\n'
101
+ }
38
102
  }
39
103
 
40
104
  mkdirSync(dirname(target), { recursive: true })
@@ -41,7 +41,10 @@ export default {
41
41
  [
42
42
  '@semantic-release/github',
43
43
  {
44
- assets: ['CHANGELOG.md', 'package.json', 'README.md'],
44
+ // README.md only. CHANGELOG.md and package.json are frozen on the
45
+ // default branch (see the note at the bottom of this file), so
46
+ // attaching them to each release would ship stale artifacts.
47
+ assets: ['README.md'],
45
48
  message: 'chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}',
46
49
  // Don't label the "release is failing" issue. The default is
47
50
  // ['semantic-release'], and GitHub rejects issue creation outright when
@@ -52,12 +55,6 @@ export default {
52
55
  labels: false,
53
56
  },
54
57
  ],
55
- [
56
- '@semantic-release/changelog',
57
- {
58
- changelogFile: 'CHANGELOG.md',
59
- },
60
- ],
61
58
  // npm publishing goes through OIDC trusted publishing — no NPM_TOKEN. The
62
59
  // release job runs with `id-token: write` and npm (>=11.5.1) authenticates
63
60
  // via the GitHub Actions trusted publisher configured on the package, and
@@ -65,19 +62,34 @@ export default {
65
62
  // @semantic-release/npm; a public repo that only wants GitHub releases can
66
63
  // opt out with NPM_PUBLISH=false (the version in package.json is still bumped).
67
64
  ['@semantic-release/npm', { npmPublish: process.env.NPM_PUBLISH !== 'false', pkgRoot: '.' }],
68
- [
69
- '@semantic-release/git',
70
- {
71
- assets: ['package.json', 'CHANGELOG.md'],
72
- message: 'chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}',
73
- // Skip hooks by disabling Husky
74
- skipCommitHooks: true,
75
- prepareCmd: 'pnpm exec biome format package.json',
76
- },
77
- ],
78
65
  ],
79
66
  }
80
67
 
68
+ // Two plugins are deliberately absent, and the second follows from the first.
69
+ //
70
+ // @semantic-release/git — it committed package.json + CHANGELOG.md and pushed
71
+ // them straight to the default branch. `fix github-settings` installs a
72
+ // `code-scanning-main` ruleset requiring CodeQL results on that branch, and a
73
+ // commit created seconds earlier can never have them: the push fails with
74
+ // GH013. Unwinnable rather than flaky — the commit must exist to be scanned,
75
+ // and be scanned to exist. See rtorcato/repo-tooling#417.
76
+ //
77
+ // @semantic-release/changelog — it writes CHANGELOG.md, which only
78
+ // @semantic-release/git ever committed back. Without that push it regenerates
79
+ // the file inside the CI workspace and throws it away every release, implying
80
+ // CHANGELOG.md is still maintained when it is frozen. The two are one feature
81
+ // in two plugins; shipping one without the other is the trap, not the fix.
82
+ //
83
+ // The consequence, stated plainly because it is easy to miss: **CHANGELOG.md
84
+ // and the `version` field in package.json stop moving on the default branch.**
85
+ // The git tag, the npm publish and the GitHub Release are unaffected and are
86
+ // the source of truth for what shipped. Build any user-facing changelog from
87
+ // GitHub Releases — `repo-tooling copy docusaurus-sync-changelog` does exactly
88
+ // that — never from the frozen file.
89
+ //
90
+ // Adding a bypass actor to the ruleset would also make the push succeed. It is
91
+ // not the answer: it exempts the one commit nobody reviews.
92
+
81
93
  // [
82
94
  // '@semantic-release/exec',
83
95
  // {