@rtorcato/repo-tooling 4.5.0 → 4.7.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.
@@ -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
  }
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
  `;
@@ -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 { addReadmeBanner, generateBrand, renderBrand, resolveBrandMeta, } from '../cli/generators/brand.js';
18
+ import { addReadmeBanner, generateBrand, renderBrand, resolveBrandMeta, syncBrandToDocs, } 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';
@@ -274,6 +274,7 @@ export const BASE_FIXERS = [
274
274
  const tagline = lock?.rules?.brand?.tagline;
275
275
  const filesWritten = await generateBrand(pkg, targetDir, tagline);
276
276
  filesWritten.push(...((await renderBrand(targetDir)) ?? []));
277
+ filesWritten.push(...(await syncBrandToDocs(targetDir)));
277
278
  const { name } = await resolveBrandMeta(pkg, targetDir, tagline);
278
279
  const readme = await addReadmeBanner(targetDir, name);
279
280
  if (readme && !filesWritten.includes(readme))
@@ -2,7 +2,7 @@ import path from 'node:path';
2
2
  import chalk from 'chalk';
3
3
  import fs from 'fs-extra';
4
4
  import { renderGitHubWorkflow } from '../../base/ci.js';
5
- import { githubJobs, scriptsOf } from '../../languages/js/ci.js';
5
+ import { githubJobs, hasBin, scriptsOf } from '../../languages/js/ci.js';
6
6
  import { inferProjectConfig } from '../../languages/js/fixers.js';
7
7
  import { checkNpmTrustedPublisher, checkPublishJob, findNpmPublishJob, NPM_OIDC_CHECK, } from '../../languages/js/npm-trust.js';
8
8
  import { PERL_GIT_HOOKS, runPerlChecks } from '../../languages/perl/checks.js';
@@ -391,7 +391,7 @@ export async function runDoctor(dir) {
391
391
  },
392
392
  // Same `scripts` gating the fixer applies, or the workflow doctor
393
393
  // compares against would reference steps the fixer never writes (#364).
394
- presetWorkflow: renderGitHubWorkflow(githubJobs(inferProjectConfig(pkg), { scripts: scriptsOf(pkg) })),
394
+ presetWorkflow: renderGitHubWorkflow(githubJobs(inferProjectConfig(pkg), { scripts: scriptsOf(pkg), bin: hasBin(pkg) })),
395
395
  language,
396
396
  codeqlLanguages: languageModule.codeqlLanguages,
397
397
  })));
@@ -402,6 +402,29 @@ export async function renderBrand(targetDir) {
402
402
  }
403
403
  return written;
404
404
  }
405
+ /** brand/ file → docs-site static/img file. The ico and card PNG exist only once rendered. */
406
+ const DOCS_ASSETS = ['favicon.svg', 'favicon.ico', 'social-card.png'];
407
+ /**
408
+ * Copy the brand favicon and social card into the docs site's `static/img`
409
+ * (#680). Copy-if-missing, and a no-op without `apps/docs`, so `fix brand` and
410
+ * `fix docs-site` reach the same tree in either order — each calls it.
411
+ */
412
+ export async function syncBrandToDocs(targetDir) {
413
+ if (!(await fs.pathExists(path.join(targetDir, 'apps', 'docs'))))
414
+ return [];
415
+ const img = path.join('apps', 'docs', 'static', 'img');
416
+ const written = [];
417
+ for (const name of DOCS_ASSETS) {
418
+ const src = path.join(targetDir, 'brand', name);
419
+ const dest = path.join(targetDir, img, name);
420
+ if (!(await fs.pathExists(src)) || (await fs.pathExists(dest)))
421
+ continue;
422
+ await fs.ensureDir(path.dirname(dest));
423
+ await fs.copyFile(src, dest);
424
+ written.push(path.join(img, name));
425
+ }
426
+ return written;
427
+ }
405
428
  export const BANNER_START = '<!-- js-tooling:banner:start -->';
406
429
  export const BANNER_END = '<!-- js-tooling:banner:end -->';
407
430
  /** The README `<picture>` banner, mobile variant under 640px, as a delimited block. */
@@ -1,10 +1,12 @@
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';
4
5
  import { coverageUploadWorkflow } from '../../base/checks.js';
5
6
  import { jsBadgeAudience } from '../../languages/js/checks.js';
6
7
  import { copyPreset, PRESETS } from '../utils/copy-preset.js';
7
8
  import { buildBadgeRow, parseRepository } from './badges.js';
9
+ import { syncBrandToDocs } from './brand.js';
8
10
  import { DOCS_SITE_BUILDS, mergeAllowBuilds } from './pnpm-workspace.js';
9
11
  import { inferSubpathsFromExports } from './treeshake.js';
10
12
  /**
@@ -175,6 +177,7 @@ ${typedocPlugins}\t\t[
175
177
  \t],
176
178
 
177
179
  \tthemeConfig: {
180
+ \t\timage: 'img/social-card.png',
178
181
  \t\tcolorMode: {
179
182
  \t\t\tdefaultMode: 'dark',
180
183
  \t\t\trespectPrefersColorScheme: true,
@@ -344,15 +347,18 @@ on:
344
347
  paths:
345
348
  - 'apps/docs/**'
346
349
  - '.github/workflows/docs.yml'
347
- # The changelog page is built from GitHub Releases, and no release commit
348
- # lands on main any more (see #417) — so a push trigger alone would never
349
- # rebuild the site after a release.
350
- release:
351
- types: [published]
350
+ # The changelog page is built from GitHub Releases. A release created with
351
+ # GITHUB_TOKEN never fires \`release: published\`, so rebuild once CI (which
352
+ # runs semantic-release) succeeds on main instead — no PAT needed (#691).
353
+ workflow_run:
354
+ workflows: ['${CI_WORKFLOW_NAME}']
355
+ types: [completed]
356
+ branches: [main]
352
357
  workflow_dispatch:
353
358
 
354
359
  jobs:
355
360
  docs:
361
+ if: github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success'
356
362
  permissions:
357
363
  contents: read
358
364
  pages: write
@@ -423,6 +429,10 @@ export async function generateDocsSite(pkg, targetDir, options = {}) {
423
429
  if (w)
424
430
  written.push(w);
425
431
  }
432
+ // static/img always exists (the config points at img/favicon.ico); the brand
433
+ // assets go in when brand/ has them (#680).
434
+ await fs.ensureDir(path.join(targetDir, DOCS_APP, 'static', 'img'));
435
+ written.push(...(await syncBrandToDocs(targetDir)));
426
436
  const ws = await ensureWorkspace(targetDir);
427
437
  if (ws)
428
438
  written.push(ws);
@@ -31,7 +31,7 @@ export const CI_WORKFLOW = '.github/workflows/ci.yml';
31
31
  * scripts itself as part of the same scaffold.
32
32
  * @returns the files actually written, relative to targetDir.
33
33
  */
34
- export async function generateGitHubActions(config, targetDir, { overwrite = false, scripts } = {}) {
34
+ export async function generateGitHubActions(config, targetDir, { overwrite = false, scripts, bin, } = {}) {
35
35
  const workflowsDir = path.join(targetDir, '.github', 'workflows');
36
36
  await fs.ensureDir(workflowsDir);
37
37
  // This is the JS path specifically. Swift (#287) renders its own workflow
@@ -40,7 +40,7 @@ export async function generateGitHubActions(config, targetDir, { overwrite = fal
40
40
  // shared entry point would mean inventing a fake config to pass in. Both
41
41
  // paths meet at renderGitHubWorkflow() in src/base/ci.ts, which is the seam
42
42
  // that actually matters.
43
- const workflow = renderGitHubWorkflow(githubJobs(config, { scripts }));
43
+ const workflow = renderGitHubWorkflow(githubJobs(config, { scripts, bin }));
44
44
  const ciPath = path.join(workflowsDir, 'ci.yml');
45
45
  const existing = (await fs.pathExists(ciPath)) ? await fs.readFile(ciPath, 'utf-8') : null;
46
46
  const filesWritten = [];
@@ -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';
@@ -64,6 +73,10 @@ const DEPENDENCIES_JOB = {
64
73
  - name: 📦 Install dependencies
65
74
  run: pnpm install --frozen-lockfile`,
66
75
  };
76
+ /** `JobOptions.bin` for a real repo. */
77
+ export function hasBin(pkg) {
78
+ return Boolean(pkg?.bin);
79
+ }
67
80
  /**
68
81
  * `JobOptions.scripts` for a real repo. A package.json with no `scripts` block
69
82
  * yields `{}` — "this repo has no scripts", which gates every step off —
@@ -151,6 +164,21 @@ export function githubJobs(config, opts = {}) {
151
164
  const publint = config.publint
152
165
  ? '\n - name: 🔍 Validate package with publint\n run: pnpm exec publint --strict\n'
153
166
  : '';
167
+ // npm's own `bin[…] invalid and removed` warning and a bin pnpm never
168
+ // links both hide until the tarball is installed somewhere else (#693).
169
+ const packSmoke = opts.bin
170
+ ? `
171
+ - name: 📦 Smoke-test the packed tarball
172
+ run: |
173
+ set -e
174
+ BIN=$(node -p "const p=require('./package.json');typeof p.bin==='string'?p.name.split('/').pop():Object.keys(p.bin)[0]")
175
+ TARBALL="$PWD/$(npm pack --silent | tail -n1)"
176
+ tar -tzf "$TARBALL" | grep -q . || { echo "::error::npm pack produced an empty tarball"; exit 1; }
177
+ SMOKE=$(mktemp -d)
178
+ cd "$SMOKE" && npm init -y >/dev/null && npm install "$TARBALL"
179
+ ./node_modules/.bin/"$BIN" --version
180
+ `
181
+ : '';
154
182
  jobs.push({
155
183
  id: 'build',
156
184
  needs: ['dependencies'],
@@ -158,7 +186,7 @@ export function githubJobs(config, opts = {}) {
158
186
 
159
187
  - name: 🏗️ Build project
160
188
  run: pnpm build
161
- ${attw}${publint}
189
+ ${attw}${publint}${packSmoke}
162
190
  - name: 📦 Upload build artifacts
163
191
  uses: actions/upload-artifact@v7
164
192
  with:
@@ -175,7 +203,7 @@ ${attw}${publint}
175
203
  id: 'release',
176
204
  // Gate the publish on everything that ran before it.
177
205
  needs: jobs.map((job) => job.id),
178
- if: "github.ref == 'refs/heads/main'",
206
+ if: RELEASE_IF,
179
207
  extra: ` permissions:
180
208
  contents: write
181
209
  issues: write
@@ -224,7 +252,14 @@ ${attw}${publint}
224
252
  # for the package on npmjs.com (Settings → Trusted Publisher).
225
253
  env:
226
254
  GITHUB_TOKEN: \${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
227
- run: npx semantic-release`,
255
+ run: |
256
+ set -o pipefail
257
+ npx semantic-release 2>&1 | tee release.log
258
+ # semantic-release exits 0 when main moved on since this run started,
259
+ # publishing nothing (#690). Say so instead of going quietly green.
260
+ if grep -q "is behind the remote one" release.log; then
261
+ echo "::warning::main moved on; nothing was published. Re-run via workflow_dispatch."
262
+ fi`,
228
263
  });
229
264
  }
230
265
  return jobs;
@@ -9,7 +9,7 @@ import { generateGitLabCI } from '../../cli/generators/gitlab-ci.js';
9
9
  import { GH_WORKFLOWS, generateGhWorkflow } from '../../cli/generators/github-workflows.js';
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
- import { scriptsOf } from './ci.js';
12
+ import { hasBin, scriptsOf } from './ci.js';
13
13
  import { NPM_OIDC_CHECK, NPM_TRUST_FIXER } from './npm-trust.js';
14
14
  import { usesPnpm } from './checks.js';
15
15
  import { SIZE_LIMIT_SCRIPTS, SIZE_LIMIT_VERSION, composeVerifyScriptFromPkg, ensureScripts, } from '../../cli/generators/package-json.js';
@@ -451,6 +451,7 @@ export const FIXERS = [
451
451
  overwrite: result.check === 'GitHub Actions',
452
452
  // Only reference scripts this repo actually has (#364).
453
453
  scripts: scriptsOf(pkg),
454
+ bin: hasBin(pkg),
454
455
  });
455
456
  // `pnpm/action-setup` is emitted without a `version:` input, so without
456
457
  // this every job dies at setup with "No pnpm version is specified".
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rtorcato/repo-tooling",
3
- "version": "4.5.0",
3
+ "version": "4.7.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": [
@@ -248,7 +248,7 @@
248
248
  "@next/eslint-plugin-next": "^16.3.2",
249
249
  "@playwright/test": "^1.62.1",
250
250
  "@rollup/plugin-typescript": "^12.3.0",
251
- "@rtorcato/repo-ai": "^3.1.1",
251
+ "@rtorcato/repo-ai": "^3.1.2",
252
252
  "@semantic-release/commit-analyzer": "^13.0.1",
253
253
  "@semantic-release/exec": "^7.1.0",
254
254
  "@semantic-release/github": "^12.0.9",