@rtorcato/repo-tooling 3.2.4 → 3.3.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/README.md +8 -0
- package/dist/base/checks.js +457 -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/commands/doctor.js +8 -1
- package/dist/cli/commands/fix-targets.js +6 -0
- package/dist/cli/commands/setup-presets.js +4 -2
- package/dist/cli/generators/github-actions.js +22 -2
- package/dist/languages/js/ci.js +1 -1
- package/dist/languages/js/fixers.js +15 -8
- package/dist/languages/swift/checks.js +37 -0
- package/dist/languages/swift/ci.js +43 -0
- package/dist/languages/swift/fixers.js +14 -1
- package/dist/languages/swift/scaffold.js +19 -1
- package/package.json +2 -1
- package/tooling/github-actions/workflows/cloudflare-pages.yml +1 -1
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import fs from 'fs-extra';
|
|
5
|
+
const GH_TIMEOUT_MS = 10_000;
|
|
6
|
+
/**
|
|
7
|
+
* Real `gh` runner — never rejects; a missing/failing gh resolves ok:false.
|
|
8
|
+
* `cwd` scopes gh's repo resolution to the target dir so `-d/--directory` is
|
|
9
|
+
* honored (gh otherwise resolves the remote from process.cwd()). Not annotated
|
|
10
|
+
* `: GhExec` so the optional cwd stays callable; still assignable where GhExec
|
|
11
|
+
* is expected.
|
|
12
|
+
*/
|
|
13
|
+
export const realGhExec = (args, stdin, cwd) => new Promise((resolve) => {
|
|
14
|
+
let settled = false;
|
|
15
|
+
const done = (r) => {
|
|
16
|
+
if (settled)
|
|
17
|
+
return;
|
|
18
|
+
settled = true;
|
|
19
|
+
clearTimeout(timer);
|
|
20
|
+
resolve(r);
|
|
21
|
+
};
|
|
22
|
+
// gh args are internal/derived from gh itself (never user free-text), so
|
|
23
|
+
// shell:false + an args array keeps this injection-safe.
|
|
24
|
+
const child = spawn('gh', args, {
|
|
25
|
+
cwd,
|
|
26
|
+
stdio: [stdin === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'],
|
|
27
|
+
});
|
|
28
|
+
let stdout = '';
|
|
29
|
+
let stderr = '';
|
|
30
|
+
const timer = setTimeout(() => {
|
|
31
|
+
child.kill();
|
|
32
|
+
done({ ok: false, stdout: '', stderr: 'gh timed out', code: null });
|
|
33
|
+
}, GH_TIMEOUT_MS);
|
|
34
|
+
child.stdout?.on('data', (d) => {
|
|
35
|
+
stdout += d;
|
|
36
|
+
});
|
|
37
|
+
child.stderr?.on('data', (d) => {
|
|
38
|
+
stderr += d;
|
|
39
|
+
});
|
|
40
|
+
child.on('close', (code) => done({ ok: code === 0, stdout, stderr, code }));
|
|
41
|
+
child.on('error', (err) => done({ ok: false, stdout: '', stderr: String(err), code: null }));
|
|
42
|
+
if (stdin !== undefined && child.stdin) {
|
|
43
|
+
child.stdin.write(stdin);
|
|
44
|
+
child.stdin.end();
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
/** The standard doctor checks these settings against. */
|
|
48
|
+
export const GITHUB_STANDARD = {
|
|
49
|
+
// Required status contexts on the default branch (strict off — see below).
|
|
50
|
+
requiredContexts: ['lint', 'typecheck', 'build', 'test'],
|
|
51
|
+
};
|
|
52
|
+
const CODE_SCANNING_CHECK = 'Code-scanning gate';
|
|
53
|
+
const CHECK_NAMES = [
|
|
54
|
+
'Branch protection',
|
|
55
|
+
'Merge settings',
|
|
56
|
+
'Workflow permissions',
|
|
57
|
+
CODE_SCANNING_CHECK,
|
|
58
|
+
];
|
|
59
|
+
// Recommended CodeQL alert thresholds — GitHub's UI defaults. This is the
|
|
60
|
+
// override surface: bump them here for a stricter/looser fleet-wide baseline.
|
|
61
|
+
// ponytail: module constants, not per-repo config, until a repo actually needs to differ.
|
|
62
|
+
const CODE_SCANNING_THRESHOLDS = {
|
|
63
|
+
security_alerts_threshold: 'high_or_higher',
|
|
64
|
+
alerts_threshold: 'errors',
|
|
65
|
+
};
|
|
66
|
+
const CODE_SCANNING_RULESET_NAME = 'code-scanning-main';
|
|
67
|
+
/** The branch ruleset POSTed to require code-scanning results before merge (#269). */
|
|
68
|
+
const CODE_SCANNING_RULESET_BODY = JSON.stringify({
|
|
69
|
+
name: CODE_SCANNING_RULESET_NAME,
|
|
70
|
+
target: 'branch',
|
|
71
|
+
enforcement: 'active',
|
|
72
|
+
// ~DEFAULT_BRANCH keeps this branch-name-agnostic across the fleet.
|
|
73
|
+
conditions: { ref_name: { include: ['~DEFAULT_BRANCH'], exclude: [] } },
|
|
74
|
+
rules: [
|
|
75
|
+
{
|
|
76
|
+
type: 'code_scanning',
|
|
77
|
+
parameters: {
|
|
78
|
+
code_scanning_tools: [{ tool: 'CodeQL', ...CODE_SCANNING_THRESHOLDS }],
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
});
|
|
83
|
+
/** All three checks as an `ok` skip — keeps them out of next-steps and exit code. */
|
|
84
|
+
function skipAll(reason) {
|
|
85
|
+
return CHECK_NAMES.map((check) => ({ check, status: 'ok', detail: `skipped — ${reason}` }));
|
|
86
|
+
}
|
|
87
|
+
function skip(check, reason) {
|
|
88
|
+
return { check, status: 'ok', detail: `skipped — ${reason}` };
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* One combined probe: proves gh is installed + authed + has a GitHub remote +
|
|
92
|
+
* online, and carries identity, default branch, and the merge settings. Reads
|
|
93
|
+
* the REST repo endpoint (gh resolves the `{owner}/{repo}` placeholder from the
|
|
94
|
+
* remote) rather than `gh repo view --json` because auto-merge (`allow_auto_merge`)
|
|
95
|
+
* is not a `gh repo view` field at all — requesting it fails the whole call.
|
|
96
|
+
*/
|
|
97
|
+
async function probeRepo(exec) {
|
|
98
|
+
const r = await exec(['api', 'repos/{owner}/{repo}']);
|
|
99
|
+
if (!r.ok)
|
|
100
|
+
return { skip: probeFailureReason(r) };
|
|
101
|
+
let d;
|
|
102
|
+
try {
|
|
103
|
+
d = JSON.parse(r.stdout);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return { skip: 'could not parse gh output' };
|
|
107
|
+
}
|
|
108
|
+
const nwo = typeof d.full_name === 'string' ? d.full_name : undefined;
|
|
109
|
+
if (!nwo)
|
|
110
|
+
return { skip: 'no GitHub remote' };
|
|
111
|
+
return {
|
|
112
|
+
info: {
|
|
113
|
+
nwo,
|
|
114
|
+
branch: typeof d.default_branch === 'string' ? d.default_branch : 'main',
|
|
115
|
+
autoMerge: d.allow_auto_merge === true,
|
|
116
|
+
squashMerge: d.allow_squash_merge === true,
|
|
117
|
+
deleteOnMerge: d.delete_branch_on_merge === true,
|
|
118
|
+
mergeVisible: ['allow_auto_merge', 'allow_squash_merge', 'delete_branch_on_merge'].every((k) => typeof d[k] === 'boolean'),
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
export async function checkGitHubSettings(dir, exec) {
|
|
123
|
+
// Cheap gate first: no .git → never spawn (keeps tmp-dir doctor runs offline).
|
|
124
|
+
if (!(await fs.pathExists(path.join(dir, '.git'))))
|
|
125
|
+
return skipAll('not a git repository');
|
|
126
|
+
// Bind gh's cwd to the target dir so its repo resolution honors `-d` (#218).
|
|
127
|
+
const gh = exec ?? ((args, stdin) => realGhExec(args, stdin, dir));
|
|
128
|
+
const probe = await probeRepo(gh);
|
|
129
|
+
if ('skip' in probe)
|
|
130
|
+
return skipAll(probe.skip);
|
|
131
|
+
const { info } = probe;
|
|
132
|
+
return [
|
|
133
|
+
await checkBranchProtection(gh, info.nwo, info.branch),
|
|
134
|
+
checkMergeSettings(info),
|
|
135
|
+
await checkWorkflowPermissions(gh, info.nwo),
|
|
136
|
+
await checkCodeScanningRuleset(gh, info.nwo, info.branch, dir),
|
|
137
|
+
];
|
|
138
|
+
}
|
|
139
|
+
function probeFailureReason(probe) {
|
|
140
|
+
if (probe.code === null)
|
|
141
|
+
return 'gh not installed';
|
|
142
|
+
if (/not logged|gh auth login|authentication/i.test(probe.stderr))
|
|
143
|
+
return 'gh not authenticated';
|
|
144
|
+
return 'not a GitHub repo or gh unavailable';
|
|
145
|
+
}
|
|
146
|
+
async function checkBranchProtection(exec, nwo, branch) {
|
|
147
|
+
const check = 'Branch protection';
|
|
148
|
+
const r = await exec(['api', `repos/${nwo}/branches/${branch}/protection`]);
|
|
149
|
+
if (!r.ok) {
|
|
150
|
+
if (/404|not found/i.test(r.stderr)) {
|
|
151
|
+
return {
|
|
152
|
+
check,
|
|
153
|
+
status: 'optional-missing',
|
|
154
|
+
detail: `${branch} is unprotected`,
|
|
155
|
+
hint: `Protect ${branch}: require checks ${GITHUB_STANDARD.requiredContexts.join(', ')}, no force-push/deletions`,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
if (/403|forbidden/i.test(r.stderr))
|
|
159
|
+
return skip(check, 'token lacks admin access');
|
|
160
|
+
return skip(check, 'could not read branch protection');
|
|
161
|
+
}
|
|
162
|
+
let p;
|
|
163
|
+
try {
|
|
164
|
+
p = JSON.parse(r.stdout);
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return skip(check, 'could not parse protection response');
|
|
168
|
+
}
|
|
169
|
+
const deltas = [];
|
|
170
|
+
const contexts = p.required_status_checks?.contexts ?? [];
|
|
171
|
+
// A matrix job reports each leg as `test (node 22)`, `test (node 24)`, etc.
|
|
172
|
+
// Treat any `<ctx> (...)` variant as satisfying the bare `<ctx>` requirement,
|
|
173
|
+
// so matrix CIs aren't falsely flagged as missing the check.
|
|
174
|
+
const isSatisfied = (c) => contexts.some((ctx) => ctx === c || ctx.startsWith(`${c} (`));
|
|
175
|
+
const missing = GITHUB_STANDARD.requiredContexts.filter((c) => !isSatisfied(c));
|
|
176
|
+
if (missing.length)
|
|
177
|
+
deltas.push(`missing required checks: ${missing.join(', ')}`);
|
|
178
|
+
if (p.required_status_checks?.strict === true)
|
|
179
|
+
deltas.push('strict status checks on (should be off)');
|
|
180
|
+
// enforce_admins must stay off so the App/RELEASE_TOKEN can bypass protection
|
|
181
|
+
// to push semantic-release's version commit (see Release-token doctor check).
|
|
182
|
+
if (p.enforce_admins?.enabled === true)
|
|
183
|
+
deltas.push('enforce_admins on (blocks release bypass)');
|
|
184
|
+
if (p.allow_force_pushes?.enabled === true)
|
|
185
|
+
deltas.push('force pushes allowed');
|
|
186
|
+
if (p.allow_deletions?.enabled === true)
|
|
187
|
+
deltas.push('branch deletions allowed');
|
|
188
|
+
// Required human review deadlocks solo Dependabot auto-merge.
|
|
189
|
+
if (p.required_pull_request_reviews)
|
|
190
|
+
deltas.push('required PR reviews on (deadlocks auto-merge)');
|
|
191
|
+
if (deltas.length)
|
|
192
|
+
return {
|
|
193
|
+
check,
|
|
194
|
+
status: 'drift',
|
|
195
|
+
detail: deltas.join('; '),
|
|
196
|
+
hint: `Align ${branch} protection with the standard`,
|
|
197
|
+
};
|
|
198
|
+
return { check, status: 'ok', detail: `${branch} protected per standard` };
|
|
199
|
+
}
|
|
200
|
+
function checkMergeSettings(info) {
|
|
201
|
+
const check = 'Merge settings';
|
|
202
|
+
// The token can't see the merge-setting fields (no admin:read) — skip rather
|
|
203
|
+
// than misreport the absent booleans as "disabled" (a false-positive drift).
|
|
204
|
+
if (!info.mergeVisible)
|
|
205
|
+
return skip(check, 'token lacks admin:read for merge settings');
|
|
206
|
+
const deltas = [];
|
|
207
|
+
if (!info.autoMerge)
|
|
208
|
+
deltas.push('auto-merge disabled');
|
|
209
|
+
if (!info.squashMerge)
|
|
210
|
+
deltas.push('squash-merge disabled');
|
|
211
|
+
if (!info.deleteOnMerge)
|
|
212
|
+
deltas.push('delete-branch-on-merge disabled');
|
|
213
|
+
if (deltas.length)
|
|
214
|
+
return {
|
|
215
|
+
check,
|
|
216
|
+
status: 'drift',
|
|
217
|
+
detail: deltas.join('; '),
|
|
218
|
+
hint: 'Enable auto-merge, squash-merge, and delete-branch-on-merge in repo settings',
|
|
219
|
+
};
|
|
220
|
+
return { check, status: 'ok', detail: 'auto-merge, squash-merge, delete-on-merge all on' };
|
|
221
|
+
}
|
|
222
|
+
async function checkWorkflowPermissions(exec, nwo) {
|
|
223
|
+
const check = 'Workflow permissions';
|
|
224
|
+
const r = await exec(['api', `repos/${nwo}/actions/permissions/workflow`]);
|
|
225
|
+
if (!r.ok) {
|
|
226
|
+
if (/403|forbidden/i.test(r.stderr))
|
|
227
|
+
return skip(check, 'token lacks admin access');
|
|
228
|
+
return skip(check, 'could not read workflow permissions');
|
|
229
|
+
}
|
|
230
|
+
let p;
|
|
231
|
+
try {
|
|
232
|
+
p = JSON.parse(r.stdout);
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
return skip(check, 'could not parse permissions response');
|
|
236
|
+
}
|
|
237
|
+
const deltas = [];
|
|
238
|
+
if (p.default_workflow_permissions !== 'read')
|
|
239
|
+
deltas.push(`default permissions '${p.default_workflow_permissions}' (should be read)`);
|
|
240
|
+
if (p.can_approve_pull_request_reviews === true)
|
|
241
|
+
deltas.push('workflows can approve PRs (should be off)');
|
|
242
|
+
if (deltas.length)
|
|
243
|
+
return {
|
|
244
|
+
check,
|
|
245
|
+
status: 'drift',
|
|
246
|
+
detail: deltas.join('; '),
|
|
247
|
+
hint: 'Set default workflow permissions to read-only and disable workflow PR approvals',
|
|
248
|
+
};
|
|
249
|
+
return { check, status: 'ok', detail: 'read-only default, no workflow PR approvals' };
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* True when CodeQL/code-scanning is enabled for the repo. Covers both ways it
|
|
253
|
+
* ships: an advanced-setup workflow on disk (what `fix codeql` scaffolds) or
|
|
254
|
+
* GitHub's default setup (no file — ask the code-scanning API). Mirrors doctor's
|
|
255
|
+
* on-disk CodeQL check; kept inline to avoid a doctor↔settings import cycle.
|
|
256
|
+
*/
|
|
257
|
+
async function codeqlEnabled(gh, nwo, dir) {
|
|
258
|
+
const workflowsDir = path.join(dir, '.github', 'workflows');
|
|
259
|
+
if (await fs.pathExists(workflowsDir)) {
|
|
260
|
+
try {
|
|
261
|
+
for (const f of await fs.readdir(workflowsDir)) {
|
|
262
|
+
if (!/\.ya?ml$/.test(f))
|
|
263
|
+
continue;
|
|
264
|
+
const content = await fs.readFile(path.join(workflowsDir, f), 'utf-8');
|
|
265
|
+
if (/github\/codeql-action/.test(content))
|
|
266
|
+
return true;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
// fall through to the API probe
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
// Default setup leaves no workflow file — the API is the only signal.
|
|
274
|
+
const r = await gh(['api', `repos/${nwo}/code-scanning/default-setup`]);
|
|
275
|
+
if (!r.ok)
|
|
276
|
+
return false;
|
|
277
|
+
try {
|
|
278
|
+
return JSON.parse(r.stdout).state === 'configured';
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
/** True when a ruleset's ref_name conditions cover the default branch. */
|
|
285
|
+
function rulesetTargetsBranch(conditions, branch) {
|
|
286
|
+
const include = conditions?.ref_name?.include ?? [];
|
|
287
|
+
return include.some((ref) => ref === '~DEFAULT_BRANCH' || ref === '~ALL' || ref === `refs/heads/${branch}`);
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Does an active branch ruleset enforce a code_scanning rule on the default
|
|
291
|
+
* branch? The list endpoint omits rules/conditions, so active branch rulesets
|
|
292
|
+
* are fetched by id to inspect them. 'skip' on any read failure (self-skips like
|
|
293
|
+
* the other checks).
|
|
294
|
+
*/
|
|
295
|
+
async function hasCodeScanningRuleset(gh, nwo, branch) {
|
|
296
|
+
const list = await gh(['api', `repos/${nwo}/rulesets`]);
|
|
297
|
+
if (!list.ok)
|
|
298
|
+
return 'skip';
|
|
299
|
+
let rulesets;
|
|
300
|
+
try {
|
|
301
|
+
rulesets = JSON.parse(list.stdout);
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
return 'skip';
|
|
305
|
+
}
|
|
306
|
+
if (!Array.isArray(rulesets))
|
|
307
|
+
return 'skip';
|
|
308
|
+
const active = rulesets.filter((r) => r.target === 'branch' && r.enforcement === 'active' && typeof r.id === 'number');
|
|
309
|
+
for (const rs of active) {
|
|
310
|
+
const detail = await gh(['api', `repos/${nwo}/rulesets/${rs.id}`]);
|
|
311
|
+
if (!detail.ok)
|
|
312
|
+
continue;
|
|
313
|
+
let full;
|
|
314
|
+
try {
|
|
315
|
+
full = JSON.parse(detail.stdout);
|
|
316
|
+
}
|
|
317
|
+
catch {
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
const enforcesCodeScanning = (full.rules ?? []).some((r) => r.type === 'code_scanning');
|
|
321
|
+
if (enforcesCodeScanning && rulesetTargetsBranch(full.conditions, branch))
|
|
322
|
+
return 'yes';
|
|
323
|
+
}
|
|
324
|
+
return 'no';
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* The #269 gap: CodeQL results are advisory by default — a High alert still
|
|
328
|
+
* merges unless a branch ruleset requires the code-scanning check. Only
|
|
329
|
+
* meaningful where CodeQL is actually on, so it no-ops otherwise.
|
|
330
|
+
*/
|
|
331
|
+
async function checkCodeScanningRuleset(gh, nwo, branch, dir) {
|
|
332
|
+
const check = CODE_SCANNING_CHECK;
|
|
333
|
+
if (!(await codeqlEnabled(gh, nwo, dir))) {
|
|
334
|
+
return { check, status: 'ok', detail: 'CodeQL not enabled — no code-scanning gate needed' };
|
|
335
|
+
}
|
|
336
|
+
const found = await hasCodeScanningRuleset(gh, nwo, branch);
|
|
337
|
+
if (found === 'skip')
|
|
338
|
+
return skip(check, 'could not read rulesets');
|
|
339
|
+
if (found === 'yes') {
|
|
340
|
+
return { check, status: 'ok', detail: `active ruleset requires code-scanning on ${branch}` };
|
|
341
|
+
}
|
|
342
|
+
return {
|
|
343
|
+
check,
|
|
344
|
+
status: 'drift',
|
|
345
|
+
detail: `CodeQL is on but no active ruleset requires code-scanning on ${branch} (High alerts stay advisory)`,
|
|
346
|
+
hint: 'Run `npx @rtorcato/repo-tooling fix github-settings` to add a code_scanning branch ruleset that blocks merge on High+ CodeQL alerts',
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
/** The branch-protection body PUT to the API — mirrors the doctor standard. */
|
|
350
|
+
const PROTECTION_BODY = JSON.stringify({
|
|
351
|
+
required_status_checks: { strict: false, contexts: GITHUB_STANDARD.requiredContexts },
|
|
352
|
+
// enforce_admins off so the App/RELEASE_TOKEN can bypass to push release commits.
|
|
353
|
+
enforce_admins: false,
|
|
354
|
+
// Required human review would deadlock solo Dependabot auto-merge.
|
|
355
|
+
required_pull_request_reviews: null,
|
|
356
|
+
restrictions: null,
|
|
357
|
+
allow_force_pushes: false,
|
|
358
|
+
allow_deletions: false,
|
|
359
|
+
});
|
|
360
|
+
/** Pure: the exact `gh api` invocations for whatever deviates ([] when compliant). */
|
|
361
|
+
export function buildGhApplyCommands(state) {
|
|
362
|
+
const commands = [];
|
|
363
|
+
if (state.merge)
|
|
364
|
+
commands.push({
|
|
365
|
+
label: 'merge settings (auto-merge, squash, delete-on-merge)',
|
|
366
|
+
args: [
|
|
367
|
+
'api',
|
|
368
|
+
'-X',
|
|
369
|
+
'PATCH',
|
|
370
|
+
`repos/${state.nwo}`,
|
|
371
|
+
'-F',
|
|
372
|
+
'allow_auto_merge=true',
|
|
373
|
+
'-F',
|
|
374
|
+
'allow_squash_merge=true',
|
|
375
|
+
'-F',
|
|
376
|
+
'delete_branch_on_merge=true',
|
|
377
|
+
],
|
|
378
|
+
});
|
|
379
|
+
if (state.protection)
|
|
380
|
+
commands.push({
|
|
381
|
+
label: `branch protection on ${state.branch}`,
|
|
382
|
+
args: [
|
|
383
|
+
'api',
|
|
384
|
+
'-X',
|
|
385
|
+
'PUT',
|
|
386
|
+
`repos/${state.nwo}/branches/${state.branch}/protection`,
|
|
387
|
+
'--input',
|
|
388
|
+
'-',
|
|
389
|
+
],
|
|
390
|
+
stdin: PROTECTION_BODY,
|
|
391
|
+
});
|
|
392
|
+
if (state.workflow)
|
|
393
|
+
commands.push({
|
|
394
|
+
label: 'workflow permissions (read-only)',
|
|
395
|
+
args: [
|
|
396
|
+
'api',
|
|
397
|
+
'-X',
|
|
398
|
+
'PUT',
|
|
399
|
+
`repos/${state.nwo}/actions/permissions/workflow`,
|
|
400
|
+
'-f',
|
|
401
|
+
'default_workflow_permissions=read',
|
|
402
|
+
'-F',
|
|
403
|
+
'can_approve_pull_request_reviews=false',
|
|
404
|
+
],
|
|
405
|
+
});
|
|
406
|
+
return commands;
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* Re-reads GitHub state and applies only the deltas via `gh api` (idempotent —
|
|
410
|
+
* a compliant repo is a no-op). Returns human labels for what changed, or `[]`
|
|
411
|
+
* on skip (no gh/auth/remote, or already compliant), logging the reason. Mirrors
|
|
412
|
+
* the "no package.json found — skipping" fixer pattern. Read-only unless a delta
|
|
413
|
+
* exists, so re-runs (walk-all hits it up to 3×) are safe.
|
|
414
|
+
*/
|
|
415
|
+
export async function applyGithubSettings(dir, exec) {
|
|
416
|
+
if (!(await fs.pathExists(path.join(dir, '.git')))) {
|
|
417
|
+
console.log(chalk.gray(' skipped — not a git repository'));
|
|
418
|
+
return [];
|
|
419
|
+
}
|
|
420
|
+
// Bind gh's cwd to the target dir so its repo resolution honors `-d` (#218).
|
|
421
|
+
const gh = exec ?? ((args, stdin) => realGhExec(args, stdin, dir));
|
|
422
|
+
const probe = await probeRepo(gh);
|
|
423
|
+
if ('skip' in probe) {
|
|
424
|
+
console.log(chalk.gray(` skipped — ${probe.skip}`));
|
|
425
|
+
return [];
|
|
426
|
+
}
|
|
427
|
+
const { info } = probe;
|
|
428
|
+
// Re-read via the same checks so the delta logic stays single-sourced. A 403
|
|
429
|
+
// (no admin) reports `ok` → treated as "nothing to apply", never a failed PUT.
|
|
430
|
+
const bp = await checkBranchProtection(gh, info.nwo, info.branch);
|
|
431
|
+
const wp = await checkWorkflowPermissions(gh, info.nwo);
|
|
432
|
+
const commands = buildGhApplyCommands({
|
|
433
|
+
nwo: info.nwo,
|
|
434
|
+
branch: info.branch,
|
|
435
|
+
merge: checkMergeSettings(info).status === 'drift',
|
|
436
|
+
protection: bp.status === 'optional-missing' || bp.status === 'drift',
|
|
437
|
+
workflow: wp.status === 'drift',
|
|
438
|
+
});
|
|
439
|
+
const applied = [];
|
|
440
|
+
for (const cmd of commands) {
|
|
441
|
+
const r = await gh(cmd.args, cmd.stdin);
|
|
442
|
+
if (r.ok)
|
|
443
|
+
applied.push(cmd.label);
|
|
444
|
+
else
|
|
445
|
+
console.log(chalk.yellow(` could not apply ${cmd.label}: ${r.stderr.trim() || 'gh error'}`));
|
|
446
|
+
}
|
|
447
|
+
// Code-scanning ruleset (#269): POST only when CodeQL is on and no active gate
|
|
448
|
+
// covers the default branch — the check re-read keeps this idempotent.
|
|
449
|
+
const cs = await checkCodeScanningRuleset(gh, info.nwo, info.branch, dir);
|
|
450
|
+
if (cs.status === 'drift') {
|
|
451
|
+
const label = `code-scanning ruleset on ${info.branch}`;
|
|
452
|
+
const r = await gh(['api', '-X', 'POST', `repos/${info.nwo}/rulesets`, '--input', '-'], CODE_SCANNING_RULESET_BODY);
|
|
453
|
+
if (r.ok)
|
|
454
|
+
applied.push(label);
|
|
455
|
+
else
|
|
456
|
+
console.log(chalk.yellow(` could not apply ${label}: ${r.stderr.trim() || 'gh error'}`));
|
|
457
|
+
}
|
|
458
|
+
if (applied.length === 0)
|
|
459
|
+
console.log(chalk.gray(' already configured — nothing to apply'));
|
|
460
|
+
return applied;
|
|
461
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import chalk from 'chalk';
|
|
3
3
|
import fs from 'fs-extra';
|
|
4
|
+
import { renderGitHubWorkflow } from '../../base/ci.js';
|
|
5
|
+
import { githubJobs } from '../../languages/js/ci.js';
|
|
6
|
+
import { inferProjectConfig } from '../../languages/js/fixers.js';
|
|
4
7
|
import { resolveLanguageModule } from '../../languages/registry.js';
|
|
5
8
|
import { SWIFT_GIT_HOOKS, runSwiftChecks } from '../../languages/swift/checks.js';
|
|
9
|
+
import { readSwiftPackage, renderSwiftWorkflow } from '../../languages/swift/ci.js';
|
|
6
10
|
import { detectLanguage } from '../utils/detect-language.js';
|
|
7
11
|
import { checkGitHubSettings } from '../../base/github-settings.js';
|
|
8
12
|
import { checkGitIdentity } from '../../base/git-identity.js';
|
|
@@ -148,7 +152,7 @@ async function runBaseChecks(dir, lock, opts) {
|
|
|
148
152
|
results.push(await checkGitHooks(dir, opts.hooks));
|
|
149
153
|
results.push(await checkPrePushHook(dir, opts.hooks));
|
|
150
154
|
}
|
|
151
|
-
results.push(await checkGitHubActions(dir));
|
|
155
|
+
results.push(await checkGitHubActions(dir, opts.presetWorkflow));
|
|
152
156
|
results.push(await checkDependabot(dir));
|
|
153
157
|
results.push(await checkCodeQL(dir));
|
|
154
158
|
// GitHub repo-settings drift (branch protection, merge settings, workflow
|
|
@@ -186,6 +190,7 @@ export async function runDoctor(dir) {
|
|
|
186
190
|
...(await runBaseChecks(targetDir, lock, {
|
|
187
191
|
hooks: null,
|
|
188
192
|
badges: { audience: 'public', fixTarget: null },
|
|
193
|
+
presetWorkflow: null,
|
|
189
194
|
})),
|
|
190
195
|
];
|
|
191
196
|
return demoteDeclined(results, lock);
|
|
@@ -205,6 +210,7 @@ export async function runDoctor(dir) {
|
|
|
205
210
|
// badges always apply. No fixer: `fix badges` derives the block from
|
|
206
211
|
// package.json name/repository, which a Swift repo hasn't got.
|
|
207
212
|
badges: { audience: 'public', fixTarget: null },
|
|
213
|
+
presetWorkflow: renderSwiftWorkflow(await readSwiftPackage(targetDir)),
|
|
208
214
|
})),
|
|
209
215
|
...(await runSwiftChecks(targetDir)),
|
|
210
216
|
];
|
|
@@ -254,6 +260,7 @@ export async function runDoctor(dir) {
|
|
|
254
260
|
results.push(...(await runBaseChecks(targetDir, lock, {
|
|
255
261
|
hooks: jsGitHooksProfile(pkg),
|
|
256
262
|
badges: { audience: jsBadgeAudience(pkg), fixTarget: 'badges' },
|
|
263
|
+
presetWorkflow: renderGitHubWorkflow(githubJobs(inferProjectConfig(pkg))),
|
|
257
264
|
})));
|
|
258
265
|
return demoteDeclined(results, lock);
|
|
259
266
|
}
|
|
@@ -57,6 +57,7 @@ const SWIFT_FIX_TARGETS = {
|
|
|
57
57
|
SwiftLint: 'swiftlint',
|
|
58
58
|
Periphery: 'periphery',
|
|
59
59
|
'Swift .gitignore': 'swift-gitignore',
|
|
60
|
+
'Release automation': 'swift-release',
|
|
60
61
|
};
|
|
61
62
|
export function getFixTargetForCheck(checkName, language) {
|
|
62
63
|
if (language === 'swift' && SWIFT_FIX_TARGETS[checkName]) {
|
|
@@ -97,6 +98,10 @@ export function declinedInLock(lock, checkName) {
|
|
|
97
98
|
c.linting?.tool === 'none' &&
|
|
98
99
|
c.testing?.framework === 'none');
|
|
99
100
|
case 'semantic-release':
|
|
101
|
+
// The Swift shape of the same recorded choice (#310): `semanticRelease`
|
|
102
|
+
// is the config's release-automation flag, and on a SwiftPM repo that
|
|
103
|
+
// means a tag-triggered workflow rather than an npm publish.
|
|
104
|
+
case 'Release automation':
|
|
100
105
|
return c.semanticRelease === false;
|
|
101
106
|
case 'Dependabot':
|
|
102
107
|
case 'CodeQL':
|
|
@@ -160,6 +165,7 @@ export function lockfilePatchForTarget(target, lock) {
|
|
|
160
165
|
case 'swift-git-hooks':
|
|
161
166
|
return c.gitHooks ? null : { gitHooks: true };
|
|
162
167
|
case 'semantic-release':
|
|
168
|
+
case 'swift-release':
|
|
163
169
|
return c.semanticRelease ? null : { semanticRelease: true };
|
|
164
170
|
case 'dependabot':
|
|
165
171
|
case 'renovate':
|
|
@@ -89,8 +89,10 @@ export function buildPresetConfig(name, projectName) {
|
|
|
89
89
|
// commitlint *is* an npm package and needs node on PATH to run, so
|
|
90
90
|
// the Swift hooks deliberately wire no commit-msg hook.
|
|
91
91
|
commitLint: false,
|
|
92
|
-
//
|
|
93
|
-
|
|
92
|
+
// The release-automation flag, not the npm tool: semantic-release
|
|
93
|
+
// publishes to npm, and SwiftPM consumes git tags, so on this path it
|
|
94
|
+
// means a tag-triggered release workflow instead (#310).
|
|
95
|
+
semanticRelease: true,
|
|
94
96
|
securityAutomation: true,
|
|
95
97
|
bundler: 'none',
|
|
96
98
|
// Every badge URL is derived from package.json (name + repository),
|
|
@@ -16,7 +16,19 @@ const CODECOV_YML = `coverage:
|
|
|
16
16
|
target: auto
|
|
17
17
|
threshold: 1%
|
|
18
18
|
`;
|
|
19
|
-
|
|
19
|
+
/** Matches the fixer's declared output, so `fix` reports the same path it lists. */
|
|
20
|
+
export const CI_WORKFLOW = '.github/workflows/ci.yml';
|
|
21
|
+
/**
|
|
22
|
+
* @param overwrite Replace a ci.yml that no longer matches the preset. Off by
|
|
23
|
+
* default: this used to write unconditionally, so a consuming repo's customized
|
|
24
|
+
* workflow — an extra job, a Dependabot-bumped action pin — was reverted on
|
|
25
|
+
* every sync with no diff, no prompt and no backup (#349, the mechanism behind
|
|
26
|
+
* #340). Same self-enforced safe-add as github-workflows.ts, widened to "or is
|
|
27
|
+
* byte-identical anyway" so a no-op regeneration still reports honestly. Only a
|
|
28
|
+
* caller that has told the user this workflow itself is drifting passes true.
|
|
29
|
+
* @returns the files actually written, relative to targetDir.
|
|
30
|
+
*/
|
|
31
|
+
export async function generateGitHubActions(config, targetDir, { overwrite = false } = {}) {
|
|
20
32
|
const workflowsDir = path.join(targetDir, '.github', 'workflows');
|
|
21
33
|
await fs.ensureDir(workflowsDir);
|
|
22
34
|
// This is the JS path specifically. Swift (#287) renders its own workflow
|
|
@@ -26,10 +38,18 @@ export async function generateGitHubActions(config, targetDir) {
|
|
|
26
38
|
// paths meet at renderGitHubWorkflow() in src/base/ci.ts, which is the seam
|
|
27
39
|
// that actually matters.
|
|
28
40
|
const workflow = renderGitHubWorkflow(githubJobs(config));
|
|
29
|
-
|
|
41
|
+
const ciPath = path.join(workflowsDir, 'ci.yml');
|
|
42
|
+
const existing = (await fs.pathExists(ciPath)) ? await fs.readFile(ciPath, 'utf-8') : null;
|
|
43
|
+
const filesWritten = [];
|
|
44
|
+
if (overwrite || existing === null || existing === workflow) {
|
|
45
|
+
await fs.writeFile(ciPath, workflow);
|
|
46
|
+
filesWritten.push(CI_WORKFLOW);
|
|
47
|
+
}
|
|
30
48
|
// codecov.yml is the CI's coverage-upload companion — emit it alongside ci.yml
|
|
31
49
|
// whenever the workflow uploads coverage, so the codecov badge isn't red.
|
|
32
50
|
if (usesCoverage(config)) {
|
|
33
51
|
await fs.writeFile(path.join(targetDir, 'codecov.yml'), CODECOV_YML);
|
|
52
|
+
filesWritten.push('codecov.yml');
|
|
34
53
|
}
|
|
54
|
+
return filesWritten;
|
|
35
55
|
}
|
package/dist/languages/js/ci.js
CHANGED
|
@@ -4,7 +4,7 @@ import fs from 'fs-extra';
|
|
|
4
4
|
import { buildBadgeBlock, parseRepository, upsertBadges } from '../../cli/generators/badges.js';
|
|
5
5
|
import { generateReleasePleaseConfig, generateRolldownConfig, generateRollupConfig, generateSemanticReleaseConfig, } from '../../cli/generators/build.js';
|
|
6
6
|
import { generateHuskyConfig, generatePrePushHook } from '../../cli/generators/git.js';
|
|
7
|
-
import { generateGitHubActions } from '../../cli/generators/github-actions.js';
|
|
7
|
+
import { CI_WORKFLOW, generateGitHubActions } from '../../cli/generators/github-actions.js';
|
|
8
8
|
import { generateGitLabCI } from '../../cli/generators/gitlab-ci.js';
|
|
9
9
|
import { GH_WORKFLOWS, generateGhWorkflow } from '../../cli/generators/github-workflows.js';
|
|
10
10
|
import { generateESLintConfig, generatePrettierConfig } from '../../cli/generators/linting.js';
|
|
@@ -22,7 +22,8 @@ import { generateDocsSite } from '../../cli/generators/docs-site.js';
|
|
|
22
22
|
import { generateTypedocConfig, generateTypedocWorkflow } from '../../cli/generators/typedoc.js';
|
|
23
23
|
import { copyPreset } from '../../cli/utils/copy-preset.js';
|
|
24
24
|
import { LOCKFILE_NAME, writeLockfile } from '../../cli/utils/lockfile.js';
|
|
25
|
-
|
|
25
|
+
/** Exported so doctor can render the preset ci.yml it compares against (#349). */
|
|
26
|
+
export function inferProjectConfig(pkg) {
|
|
26
27
|
const deps = {
|
|
27
28
|
...(pkg?.dependencies ?? {}),
|
|
28
29
|
...(pkg?.devDependencies ?? {}),
|
|
@@ -303,13 +304,19 @@ export const FIXERS = [
|
|
|
303
304
|
target: 'github-actions',
|
|
304
305
|
description: 'Scaffold .github/workflows/ci.yml (+ codecov.yml when tests run)',
|
|
305
306
|
appliesTo: ['GitHub Actions', 'Coverage upload', 'npm OIDC publish'],
|
|
306
|
-
outputs: [
|
|
307
|
+
outputs: [CI_WORKFLOW, 'codecov.yml'],
|
|
307
308
|
canFixDrift: true,
|
|
308
|
-
async run({ targetDir, pkg }) {
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
309
|
+
async run({ targetDir, pkg, result }) {
|
|
310
|
+
// Only a `GitHub Actions` finding means the user was shown that the
|
|
311
|
+
// workflow itself is wrong (and got the destructive-overwrite prompt).
|
|
312
|
+
// A sibling finding — Coverage upload, npm OIDC publish — must not take a
|
|
313
|
+
// customized ci.yml down with it (#349).
|
|
314
|
+
const filesWritten = await generateGitHubActions(inferProjectConfig(pkg), targetDir, {
|
|
315
|
+
overwrite: result.check === 'GitHub Actions',
|
|
316
|
+
});
|
|
317
|
+
if (!filesWritten.includes(CI_WORKFLOW)) {
|
|
318
|
+
console.log(chalk.yellow(` ${CI_WORKFLOW} differs from the preset — left as-is; run \`fix github-actions --diff\` to see the delta`));
|
|
319
|
+
}
|
|
313
320
|
return { filesWritten };
|
|
314
321
|
},
|
|
315
322
|
},
|