@rtorcato/repo-tooling 3.2.3 → 3.2.5
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/dist/base/checks.js +410 -0
- package/dist/base/ci.js +133 -0
- package/dist/base/fixers.js +207 -0
- package/dist/base/git-identity.js +98 -0
- package/dist/base/github-settings.js +461 -0
- package/dist/base/types.js +1 -0
- package/dist/cli/generators/linting.js +11 -1
- package/dist/cli/generators/package-json.js +27 -0
- package/dist/cli/generators/testing.js +12 -1
- package/dist/cli/generators/tsconfig.js +6 -0
- package/dist/languages/js/ci.js +1 -1
- package/package.json +2 -1
- package/tooling/eslint/base.mjs +8 -0
- package/tooling/github-actions/workflows/cloudflare-pages.yml +1 -1
- package/tooling/typescript/tsconfig.base.json +6 -2
- package/tooling/typescript/tsconfig.next.json +8 -1
- package/tooling/typescript/tsconfig.react.json +7 -1
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import fs from 'fs-extra';
|
|
3
|
+
import { BADGE_START, hasPublicOnlyBadges } from '../cli/generators/badges.js';
|
|
4
|
+
export async function checkFile(dir, spec) {
|
|
5
|
+
for (const candidate of spec.candidates) {
|
|
6
|
+
const filepath = path.join(dir, candidate);
|
|
7
|
+
if (!(await fs.pathExists(filepath)))
|
|
8
|
+
continue;
|
|
9
|
+
const contents = await fs.readFile(filepath, 'utf-8');
|
|
10
|
+
if (spec.matcher.test(contents)) {
|
|
11
|
+
return {
|
|
12
|
+
check: spec.check,
|
|
13
|
+
status: 'ok',
|
|
14
|
+
detail: `${candidate} ${spec.expected}`,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
return {
|
|
18
|
+
check: spec.check,
|
|
19
|
+
status: 'drift',
|
|
20
|
+
detail: `${candidate} found but does not ${spec.expected}`,
|
|
21
|
+
hint: spec.hint,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
return {
|
|
25
|
+
check: spec.check,
|
|
26
|
+
status: spec.optional ? 'optional-missing' : 'missing',
|
|
27
|
+
detail: `no ${spec.candidates.join(' / ')} found`,
|
|
28
|
+
hint: spec.hint,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
export async function checkEditorConfig(dir) {
|
|
32
|
+
const exists = await fs.pathExists(path.join(dir, '.editorconfig'));
|
|
33
|
+
return {
|
|
34
|
+
check: 'EditorConfig',
|
|
35
|
+
status: exists ? 'ok' : 'optional-missing',
|
|
36
|
+
detail: exists ? '.editorconfig found' : 'no .editorconfig',
|
|
37
|
+
hint: exists ? undefined : 'Add an .editorconfig for cross-editor formatting consistency',
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
export async function checkCodeowners(dir) {
|
|
41
|
+
for (const candidate of ['CODEOWNERS', '.github/CODEOWNERS', 'docs/CODEOWNERS']) {
|
|
42
|
+
if (await fs.pathExists(path.join(dir, candidate))) {
|
|
43
|
+
return {
|
|
44
|
+
check: 'CODEOWNERS',
|
|
45
|
+
status: 'ok',
|
|
46
|
+
detail: `${candidate} found`,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
check: 'CODEOWNERS',
|
|
52
|
+
status: 'optional-missing',
|
|
53
|
+
detail: 'no CODEOWNERS file',
|
|
54
|
+
hint: 'Run `npx @rtorcato/repo-tooling fix codeowners` to scaffold .github/CODEOWNERS',
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
export async function checkCommunityHealth(dir) {
|
|
58
|
+
const anchors = ['CONTRIBUTING.md', 'SECURITY.md'];
|
|
59
|
+
const present = await Promise.all(anchors.map((f) => fs.pathExists(path.join(dir, f))));
|
|
60
|
+
if (present.every(Boolean)) {
|
|
61
|
+
return {
|
|
62
|
+
check: 'Community health',
|
|
63
|
+
status: 'ok',
|
|
64
|
+
detail: 'CONTRIBUTING.md and SECURITY.md found',
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
check: 'Community health',
|
|
69
|
+
status: 'optional-missing',
|
|
70
|
+
detail: 'missing community-health files (CONTRIBUTING/SECURITY/templates)',
|
|
71
|
+
hint: 'Run `npx @rtorcato/repo-tooling fix community-health` to scaffold them',
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
export async function checkGitHubActions(dir) {
|
|
75
|
+
const workflowsDir = path.join(dir, '.github', 'workflows');
|
|
76
|
+
if (!(await fs.pathExists(workflowsDir))) {
|
|
77
|
+
return {
|
|
78
|
+
check: 'GitHub Actions',
|
|
79
|
+
status: 'optional-missing',
|
|
80
|
+
detail: 'no .github/workflows/',
|
|
81
|
+
hint: 'Run `npx @rtorcato/repo-tooling setup` to scaffold a CI workflow',
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
const files = await fs.readdir(workflowsDir);
|
|
86
|
+
const workflows = files.filter((f) => f.endsWith('.yml') || f.endsWith('.yaml'));
|
|
87
|
+
if (workflows.length === 0) {
|
|
88
|
+
return {
|
|
89
|
+
check: 'GitHub Actions',
|
|
90
|
+
status: 'optional-missing',
|
|
91
|
+
detail: '.github/workflows/ is empty',
|
|
92
|
+
hint: 'Add a workflow file (e.g. ci.yml) under .github/workflows/',
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
check: 'GitHub Actions',
|
|
97
|
+
status: 'ok',
|
|
98
|
+
detail: `${workflows.length} workflow${workflows.length === 1 ? '' : 's'} in .github/workflows/`,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return {
|
|
103
|
+
check: 'GitHub Actions',
|
|
104
|
+
status: 'optional-missing',
|
|
105
|
+
detail: 'unable to read .github/workflows/',
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
export async function checkDependabot(dir) {
|
|
110
|
+
for (const candidate of ['.github/dependabot.yml', '.github/dependabot.yaml']) {
|
|
111
|
+
const candidatePath = path.join(dir, candidate);
|
|
112
|
+
if (await fs.pathExists(candidatePath)) {
|
|
113
|
+
// The canonical standard (apps/docs/docs/guides/dependabot-strategy.md) is
|
|
114
|
+
// the safe-tier + major-tier grouping plus the auto-merge workflow that
|
|
115
|
+
// depends on it. Flag any config that predates it so `fix dependabot` can
|
|
116
|
+
// bring the pair up to standard.
|
|
117
|
+
const content = await fs.readFile(candidatePath, 'utf8');
|
|
118
|
+
const deltas = [];
|
|
119
|
+
for (const group of ['production-minor', 'dev-minor', 'major-updates']) {
|
|
120
|
+
if (!new RegExp(`^\\s*${group}:`, 'm').test(content)) {
|
|
121
|
+
deltas.push(`missing \`${group}\` group`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const automergePath = path.join(dir, '.github', 'workflows', 'dependabot-automerge.yml');
|
|
125
|
+
if (!(await fs.pathExists(automergePath))) {
|
|
126
|
+
deltas.push('missing dependabot-automerge workflow');
|
|
127
|
+
}
|
|
128
|
+
if (deltas.length > 0) {
|
|
129
|
+
return {
|
|
130
|
+
check: 'Dependabot',
|
|
131
|
+
status: 'drift',
|
|
132
|
+
detail: `${candidate} drifts from canonical (${deltas.join('; ')})`,
|
|
133
|
+
hint: 'Run `npx @rtorcato/repo-tooling fix dependabot` to apply the canonical grouping + auto-merge workflow',
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
check: 'Dependabot',
|
|
138
|
+
status: 'ok',
|
|
139
|
+
detail: `${candidate} + auto-merge workflow`,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
for (const candidate of [
|
|
144
|
+
'renovate.json',
|
|
145
|
+
'renovate.json5',
|
|
146
|
+
'.github/renovate.json',
|
|
147
|
+
'.github/renovate.json5',
|
|
148
|
+
'.renovaterc',
|
|
149
|
+
'.renovaterc.json',
|
|
150
|
+
]) {
|
|
151
|
+
if (await fs.pathExists(path.join(dir, candidate))) {
|
|
152
|
+
return {
|
|
153
|
+
check: 'Dependabot',
|
|
154
|
+
status: 'ok',
|
|
155
|
+
detail: `${candidate} found (Renovate)`,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return {
|
|
160
|
+
check: 'Dependabot',
|
|
161
|
+
status: 'optional-missing',
|
|
162
|
+
detail: 'no Dependabot or Renovate config',
|
|
163
|
+
hint: 'Run `npx @rtorcato/repo-tooling fix dependabot` (or `fix renovate`) to scaffold weekly dep updates',
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
export async function checkCodeQL(dir) {
|
|
167
|
+
const workflowsDir = path.join(dir, '.github', 'workflows');
|
|
168
|
+
if (!(await fs.pathExists(workflowsDir))) {
|
|
169
|
+
return {
|
|
170
|
+
check: 'CodeQL',
|
|
171
|
+
status: 'optional-missing',
|
|
172
|
+
detail: 'no .github/workflows/',
|
|
173
|
+
hint: 'Run `npx @rtorcato/repo-tooling fix codeql` to scaffold CodeQL security scanning',
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
for (const candidate of ['codeql.yml', 'codeql.yaml']) {
|
|
177
|
+
if (await fs.pathExists(path.join(workflowsDir, candidate))) {
|
|
178
|
+
return {
|
|
179
|
+
check: 'CodeQL',
|
|
180
|
+
status: 'ok',
|
|
181
|
+
detail: `.github/workflows/${candidate} found`,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
try {
|
|
186
|
+
const files = await fs.readdir(workflowsDir);
|
|
187
|
+
for (const f of files) {
|
|
188
|
+
if (!(f.endsWith('.yml') || f.endsWith('.yaml')))
|
|
189
|
+
continue;
|
|
190
|
+
const content = await fs.readFile(path.join(workflowsDir, f), 'utf-8');
|
|
191
|
+
if (/github\/codeql-action/.test(content)) {
|
|
192
|
+
return {
|
|
193
|
+
check: 'CodeQL',
|
|
194
|
+
status: 'ok',
|
|
195
|
+
detail: `codeql-action referenced in ${f}`,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
// fall through to optional-missing
|
|
202
|
+
}
|
|
203
|
+
return {
|
|
204
|
+
check: 'CodeQL',
|
|
205
|
+
status: 'optional-missing',
|
|
206
|
+
detail: 'no codeql workflow found',
|
|
207
|
+
hint: 'Run `npx @rtorcato/repo-tooling fix codeql` to scaffold CodeQL security scanning',
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
// A README that advertises a Codecov badge but a CI that never uploads coverage
|
|
211
|
+
// leaves the badge permanently red. Only flags when the badge is actually present
|
|
212
|
+
// (no badge → nothing to back, so it's not applicable).
|
|
213
|
+
export async function checkCoverageUpload(dir) {
|
|
214
|
+
const readmePath = path.join(dir, 'README.md');
|
|
215
|
+
const readme = (await fs.pathExists(readmePath)) ? await fs.readFile(readmePath, 'utf8') : '';
|
|
216
|
+
if (!/codecov\.io/.test(readme)) {
|
|
217
|
+
return {
|
|
218
|
+
check: 'Coverage upload',
|
|
219
|
+
status: 'ok',
|
|
220
|
+
detail: 'no coverage badge in README (nothing to back)',
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
const workflowsDir = path.join(dir, '.github', 'workflows');
|
|
224
|
+
if (await fs.pathExists(workflowsDir)) {
|
|
225
|
+
try {
|
|
226
|
+
const files = (await fs.readdir(workflowsDir)).filter((f) => f.endsWith('.yml') || f.endsWith('.yaml'));
|
|
227
|
+
for (const f of files) {
|
|
228
|
+
const content = await fs.readFile(path.join(workflowsDir, f), 'utf-8');
|
|
229
|
+
if (/codecov\/codecov-action/.test(content)) {
|
|
230
|
+
return {
|
|
231
|
+
check: 'Coverage upload',
|
|
232
|
+
status: 'ok',
|
|
233
|
+
detail: `coverage badge backed by codecov-action in .github/workflows/${f}`,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
// fall through to drift
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return {
|
|
243
|
+
check: 'Coverage upload',
|
|
244
|
+
status: 'drift',
|
|
245
|
+
detail: 'README has a Codecov badge but no CI step uploads coverage (badge stays red)',
|
|
246
|
+
hint: 'Run `npx @rtorcato/repo-tooling fix github-actions` to regenerate ci.yml with a Codecov upload step',
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
export async function checkAiSetup(dir) {
|
|
250
|
+
// Consider AI setup present if AGENTS.md carries the js-tooling block or the
|
|
251
|
+
// Claude skill is installed — the two primary markers `fix ai` writes.
|
|
252
|
+
const agentsPath = path.join(dir, 'AGENTS.md');
|
|
253
|
+
const hasAgentsBlock = (await fs.pathExists(agentsPath)) &&
|
|
254
|
+
(await fs.readFile(agentsPath, 'utf8')).includes('<!-- js-tooling:start -->');
|
|
255
|
+
const hasSkill = (await fs.pathExists(path.join(dir, '.claude', 'skills', 'repo-tooling.md'))) ||
|
|
256
|
+
// Pre-rename name — still counts as present until `fix` migrates it.
|
|
257
|
+
(await fs.pathExists(path.join(dir, '.claude', 'skills', 'js-tooling.md')));
|
|
258
|
+
if (hasAgentsBlock || hasSkill) {
|
|
259
|
+
return {
|
|
260
|
+
check: 'AI setup',
|
|
261
|
+
status: 'ok',
|
|
262
|
+
detail: hasAgentsBlock ? 'AGENTS.md has the js-tooling block' : '.claude skill installed',
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
return {
|
|
266
|
+
check: 'AI setup',
|
|
267
|
+
status: 'optional-missing',
|
|
268
|
+
detail: 'no AI agent files (AGENTS.md, CLAUDE.md, Cursor/Copilot rules, Claude skill)',
|
|
269
|
+
hint: 'Run `npx @rtorcato/repo-tooling fix ai` to scaffold agent rules for every AI tool',
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Conventional Commits is a repo convention, not a JavaScript one — the config
|
|
274
|
+
* file is the same in any repo that has node available to run commitlint, so
|
|
275
|
+
* the spec lives here rather than in the JS module's FILE_CHECKS (#309).
|
|
276
|
+
*/
|
|
277
|
+
export const COMMITLINT_FILE_CHECK = {
|
|
278
|
+
check: 'Commitlint',
|
|
279
|
+
candidates: ['commitlint.config.js', 'commitlint.config.mjs', 'commitlint.config.cjs'],
|
|
280
|
+
expected: 'exports "@rtorcato/repo-tooling/commitlint/config"',
|
|
281
|
+
matcher: /@rtorcato\/(?:js|repo)-tooling\/commitlint\/config/,
|
|
282
|
+
optional: true,
|
|
283
|
+
};
|
|
284
|
+
/**
|
|
285
|
+
* True when a shell hook has an uncommented line matching `pattern`. A
|
|
286
|
+
* commented-out line (e.g. `# pnpm verify`) doesn't count — the command never
|
|
287
|
+
* runs, so it isn't real wiring.
|
|
288
|
+
*/
|
|
289
|
+
export function hookHasUncommented(contents, pattern) {
|
|
290
|
+
return contents.split('\n').some((line) => {
|
|
291
|
+
const trimmed = line.trim();
|
|
292
|
+
return trimmed.length > 0 && !trimmed.startsWith('#') && pattern.test(trimmed);
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
/** `swift test` → /\bswift\s+test\b/, so extra whitespace in a hook still matches. */
|
|
296
|
+
function commandMatcher(command) {
|
|
297
|
+
const escaped = command.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\s+/g, '\\s+');
|
|
298
|
+
return new RegExp(`\\b${escaped}\\b`);
|
|
299
|
+
}
|
|
300
|
+
export async function checkGitHooks(dir, p) {
|
|
301
|
+
const check = 'Git hooks';
|
|
302
|
+
const hint = `Run \`npx @rtorcato/repo-tooling fix ${p.fixTarget}\` to enable git hooks`;
|
|
303
|
+
const hooksDir = await fs.pathExists(path.join(dir, p.dir));
|
|
304
|
+
// With no committed install signal, the hooks directory itself is the whole
|
|
305
|
+
// answer — there's no second half that can drift out of sync with it.
|
|
306
|
+
const installed = p.install === null ? hooksDir : p.install.present;
|
|
307
|
+
if (hooksDir && installed) {
|
|
308
|
+
return {
|
|
309
|
+
check,
|
|
310
|
+
status: 'ok',
|
|
311
|
+
detail: p.install ? `${p.dir}/ and ${p.install.label} configured` : `${p.dir}/ found`,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
if (hooksDir || installed) {
|
|
315
|
+
return {
|
|
316
|
+
check,
|
|
317
|
+
status: 'drift',
|
|
318
|
+
detail: hooksDir
|
|
319
|
+
? `${p.dir}/ exists but no ${p.install?.label}`
|
|
320
|
+
: `${p.install?.label} set but no ${p.dir}/ directory`,
|
|
321
|
+
hint: `${hint} (scaffolds both halves)`,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
return { check, status: 'optional-missing', detail: 'git hooks not configured', hint };
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* The pre-push gate. Only asserts that the hook runs the language's verify
|
|
328
|
+
* command — whether that command itself is well-formed is the language module's
|
|
329
|
+
* business (JS keeps a `verify script` check for exactly that).
|
|
330
|
+
*/
|
|
331
|
+
export async function checkPrePushHook(dir, p) {
|
|
332
|
+
const check = 'Pre-push hook';
|
|
333
|
+
if (!(await fs.pathExists(path.join(dir, p.dir)))) {
|
|
334
|
+
return {
|
|
335
|
+
check,
|
|
336
|
+
status: 'optional-missing',
|
|
337
|
+
detail: 'git hooks not configured',
|
|
338
|
+
hint: `Run \`npx @rtorcato/repo-tooling fix ${p.fixTarget}\` to enable git hooks (includes pre-push)`,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
const hookPath = path.join(dir, p.dir, 'pre-push');
|
|
342
|
+
if (!(await fs.pathExists(hookPath))) {
|
|
343
|
+
return {
|
|
344
|
+
check,
|
|
345
|
+
status: 'optional-missing',
|
|
346
|
+
detail: `no ${p.dir}/pre-push`,
|
|
347
|
+
hint: `Run \`npx @rtorcato/repo-tooling fix ${p.fixTarget}\` to scaffold a pre-push hook that runs \`${p.verifyCommand}\``,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
const contents = await fs.readFile(hookPath, 'utf-8');
|
|
351
|
+
if (hookHasUncommented(contents, commandMatcher(p.verifyCommand))) {
|
|
352
|
+
return { check, status: 'ok', detail: `${p.dir}/pre-push runs \`${p.verifyCommand}\`` };
|
|
353
|
+
}
|
|
354
|
+
return {
|
|
355
|
+
check,
|
|
356
|
+
status: 'drift',
|
|
357
|
+
detail: `${p.dir}/pre-push exists but does not run \`${p.verifyCommand}\``,
|
|
358
|
+
hint: `Run \`npx @rtorcato/repo-tooling fix ${p.fixTarget}\` to align the hook with \`${p.verifyCommand}\``,
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
export async function checkReadmeBadges(dir, audience,
|
|
362
|
+
/** `fix` target that rebuilds the badge block, or null when the language has none. */
|
|
363
|
+
fixTarget) {
|
|
364
|
+
const check = 'README badges';
|
|
365
|
+
const hint = fixTarget
|
|
366
|
+
? `Run \`npx @rtorcato/repo-tooling fix ${fixTarget}\` to add CI/npm/coverage/license badges`
|
|
367
|
+
: 'Add CI and license badges to README.md';
|
|
368
|
+
const readmePath = path.join(dir, 'README.md');
|
|
369
|
+
const readme = (await fs.pathExists(readmePath)) ? await fs.readFile(readmePath, 'utf8') : '';
|
|
370
|
+
if (audience === 'private') {
|
|
371
|
+
// Only a problem if a private/app repo carries badges that would 404.
|
|
372
|
+
if (readme && hasPublicOnlyBadges(readme)) {
|
|
373
|
+
return {
|
|
374
|
+
check,
|
|
375
|
+
status: 'drift',
|
|
376
|
+
detail: 'README has npm/coverage badges but the package is private (they 404)',
|
|
377
|
+
hint: fixTarget
|
|
378
|
+
? `Run \`npx @rtorcato/repo-tooling fix ${fixTarget}\` to rebuild badges for a private repo`
|
|
379
|
+
: 'Remove the npm/coverage badges — they 404 for a private package',
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
return { check, status: 'ok', detail: 'not applicable (private package)' };
|
|
383
|
+
}
|
|
384
|
+
if (audience === 'not-applicable') {
|
|
385
|
+
return { check, status: 'ok', detail: 'not applicable (no published exports)' };
|
|
386
|
+
}
|
|
387
|
+
const hasBadges = readme.includes(BADGE_START) ||
|
|
388
|
+
/img\.shields\.io|badge\.svg|badge\.fury\.io|codecov\.io/.test(readme);
|
|
389
|
+
if (hasBadges) {
|
|
390
|
+
return { check, status: 'ok', detail: 'README carries status badges' };
|
|
391
|
+
}
|
|
392
|
+
return { check, status: 'optional-missing', detail: 'no status badges in README', hint };
|
|
393
|
+
}
|
|
394
|
+
export async function checkGitLabCI(dir) {
|
|
395
|
+
for (const candidate of ['.gitlab-ci.yml', '.gitlab-ci.yaml']) {
|
|
396
|
+
if (await fs.pathExists(path.join(dir, candidate))) {
|
|
397
|
+
return {
|
|
398
|
+
check: 'GitLab CI',
|
|
399
|
+
status: 'ok',
|
|
400
|
+
detail: `${candidate} found`,
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
return {
|
|
405
|
+
check: 'GitLab CI',
|
|
406
|
+
status: 'optional-missing',
|
|
407
|
+
detail: 'no .gitlab-ci.yml',
|
|
408
|
+
hint: 'Run `npx @rtorcato/repo-tooling fix gitlab-ci` to scaffold a starter GitLab pipeline',
|
|
409
|
+
};
|
|
410
|
+
}
|
package/dist/base/ci.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Language-agnostic CI skeletons (#283).
|
|
3
|
+
*
|
|
4
|
+
* The *shell* of a pipeline — triggers, concurrency, the skip gate, job
|
|
5
|
+
* scaffolding, stage derivation — is identical whether the repo is JS, Swift,
|
|
6
|
+
* Perl or Python. Only the steps inside each job differ. This module owns the
|
|
7
|
+
* shell; language modules (`src/languages/<id>/ci.ts`) supply the steps.
|
|
8
|
+
*
|
|
9
|
+
* Steps arrive pre-rendered rather than as a YAML AST: the generators' whole
|
|
10
|
+
* job is emitting text, and a tree we'd immediately flatten buys nothing but a
|
|
11
|
+
* serializer to maintain.
|
|
12
|
+
*/
|
|
13
|
+
/** Every job is gated on the skip-CI check; extra conditions are ANDed on. */
|
|
14
|
+
const SKIP_GUARD = "needs.check-skip.outputs.should-skip != 'true'";
|
|
15
|
+
/** Header through `jobs:` — triggers and concurrency are language-independent. */
|
|
16
|
+
const WORKFLOW_HEADER = `name: 🚀 CI/CD Pipeline
|
|
17
|
+
|
|
18
|
+
on:
|
|
19
|
+
push:
|
|
20
|
+
branches: [main, release, develop]
|
|
21
|
+
pull_request:
|
|
22
|
+
branches: [main]
|
|
23
|
+
workflow_dispatch:
|
|
24
|
+
|
|
25
|
+
concurrency:
|
|
26
|
+
group: \${{ github.workflow }}-\${{ github.ref }}
|
|
27
|
+
cancel-in-progress: true
|
|
28
|
+
|
|
29
|
+
jobs:
|
|
30
|
+
`;
|
|
31
|
+
/**
|
|
32
|
+
* Honours `[ci skip]` / `[skip ci]` in the head commit message. Pure shell
|
|
33
|
+
* plumbing — no toolchain involved, so every language shares it verbatim.
|
|
34
|
+
*/
|
|
35
|
+
const CHECK_SKIP_JOB = ` check-skip:
|
|
36
|
+
runs-on: ubuntu-latest
|
|
37
|
+
outputs:
|
|
38
|
+
should-skip: \${{ steps.skip-check.outputs.should-skip }}
|
|
39
|
+
steps:
|
|
40
|
+
- name: Check for skip CI
|
|
41
|
+
id: skip-check
|
|
42
|
+
run: |
|
|
43
|
+
if [[ "\${{ github.event.head_commit.message }}" =~ \\[(ci skip|skip ci)\\] ]]; then
|
|
44
|
+
echo "should-skip=true" >> $GITHUB_OUTPUT
|
|
45
|
+
else
|
|
46
|
+
echo "should-skip=false" >> $GITHUB_OUTPUT
|
|
47
|
+
fi`;
|
|
48
|
+
function renderJob(job) {
|
|
49
|
+
// A lone dependency stays a scalar (`needs: check-skip`) — that's the form
|
|
50
|
+
// the hand-written workflows used, and Actions treats both identically.
|
|
51
|
+
const needs = [...(job.needs ?? []), 'check-skip'];
|
|
52
|
+
const needsYaml = needs.length === 1 ? needs[0] : `[${needs.join(', ')}]`;
|
|
53
|
+
const condition = job.if ? `${SKIP_GUARD} && ${job.if}` : SKIP_GUARD;
|
|
54
|
+
return ` ${job.id}:
|
|
55
|
+
runs-on: ${job.runsOn ?? 'ubuntu-latest'}
|
|
56
|
+
needs: ${needsYaml}
|
|
57
|
+
if: ${condition}
|
|
58
|
+
${job.extra ? `${job.extra}\n` : ''} steps:
|
|
59
|
+
${job.steps}`;
|
|
60
|
+
}
|
|
61
|
+
/** Wrap language-supplied jobs in the shared workflow shell. */
|
|
62
|
+
export function renderGitHubWorkflow(jobs) {
|
|
63
|
+
return `${WORKFLOW_HEADER}${[CHECK_SKIP_JOB, ...jobs.map(renderJob)].join('\n\n')}\n`;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* The CodeQL workflow. Only the matrix languages vary, so the whole file lives
|
|
67
|
+
* here and each language module contributes its `codeqlLanguages`.
|
|
68
|
+
*/
|
|
69
|
+
export function renderCodeQLWorkflow(languages) {
|
|
70
|
+
return `name: CodeQL
|
|
71
|
+
|
|
72
|
+
on:
|
|
73
|
+
push:
|
|
74
|
+
branches: [main]
|
|
75
|
+
pull_request:
|
|
76
|
+
branches: [main]
|
|
77
|
+
schedule:
|
|
78
|
+
- cron: '0 6 * * 1'
|
|
79
|
+
|
|
80
|
+
jobs:
|
|
81
|
+
analyze:
|
|
82
|
+
name: Analyze
|
|
83
|
+
runs-on: ubuntu-latest
|
|
84
|
+
permissions:
|
|
85
|
+
actions: read
|
|
86
|
+
contents: read
|
|
87
|
+
security-events: write
|
|
88
|
+
|
|
89
|
+
strategy:
|
|
90
|
+
fail-fast: false
|
|
91
|
+
matrix:
|
|
92
|
+
language: [${languages.join(', ')}]
|
|
93
|
+
|
|
94
|
+
steps:
|
|
95
|
+
- name: Checkout
|
|
96
|
+
uses: actions/checkout@v7
|
|
97
|
+
|
|
98
|
+
- name: Initialize CodeQL
|
|
99
|
+
uses: github/codeql-action/init@v3
|
|
100
|
+
with:
|
|
101
|
+
languages: \${{ matrix.language }}
|
|
102
|
+
|
|
103
|
+
- name: Perform CodeQL Analysis
|
|
104
|
+
uses: github/codeql-action/analyze@v3
|
|
105
|
+
with:
|
|
106
|
+
category: "/language:\${{ matrix.language }}"
|
|
107
|
+
`;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Wrap language-supplied jobs in the shared `.gitlab-ci.yml` shell. Stages are
|
|
111
|
+
* derived from the jobs in first-appearance order, so a language module never
|
|
112
|
+
* has to keep a stage list in sync with the jobs it emits.
|
|
113
|
+
*/
|
|
114
|
+
export function renderGitLabCI({ image, preamble, jobs }) {
|
|
115
|
+
const stages = [...new Set(jobs.map((job) => job.stage))];
|
|
116
|
+
// A pipeline with no jobs still needs a valid `stages:` key.
|
|
117
|
+
if (stages.length === 0)
|
|
118
|
+
stages.push('test');
|
|
119
|
+
const blocks = jobs.map((job) => `${job.id}:
|
|
120
|
+
stage: ${job.stage}
|
|
121
|
+
script:
|
|
122
|
+
${job.script.map((line) => ` - ${line}`).join('\n')}${job.extra ? `\n${job.extra}` : ''}`);
|
|
123
|
+
return `# .gitlab-ci.yml — generated by @rtorcato/repo-tooling
|
|
124
|
+
# Customize stages and jobs to fit your pipeline.
|
|
125
|
+
|
|
126
|
+
image: ${image}
|
|
127
|
+
|
|
128
|
+
stages:
|
|
129
|
+
${stages.map((s) => ` - ${s}`).join('\n')}
|
|
130
|
+
|
|
131
|
+
${[preamble, ...blocks].join('\n\n')}
|
|
132
|
+
`;
|
|
133
|
+
}
|