@rtorcato/repo-tooling 4.7.0 ā 4.8.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.
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import fs from 'fs-extra';
|
|
3
|
+
import { jobEnvironment, realGhExec, workflowJobs } from './github-settings.js';
|
|
4
|
+
const WHERE_TO_GET = {
|
|
5
|
+
CODECOV_TOKEN: 'codecov.io -> your repo -> Settings -> Repository Upload Token',
|
|
6
|
+
RELEASE_TOKEN: 'a fine-grained PAT with contents: write (or a GitHub App token) that can push past branch protection',
|
|
7
|
+
};
|
|
8
|
+
const withoutComments = (s) => s
|
|
9
|
+
.split('\n')
|
|
10
|
+
.filter((l) => !l.trimStart().startsWith('#'))
|
|
11
|
+
.join('\n');
|
|
12
|
+
function usesIn(text, file, job, environment) {
|
|
13
|
+
const clean = withoutComments(text);
|
|
14
|
+
const found = new Map();
|
|
15
|
+
for (const m of clean.matchAll(/secrets\.([A-Za-z_][A-Za-z0-9_]*)/g)) {
|
|
16
|
+
const name = m[1];
|
|
17
|
+
if (name === 'GITHUB_TOKEN' || found.has(name))
|
|
18
|
+
continue;
|
|
19
|
+
const fallback = new RegExp(`secrets\\.${name}\\s*\\|\\|\\s*secrets\\.GITHUB_TOKEN`).test(clean);
|
|
20
|
+
found.set(name, { name, file, job, environment, fallback });
|
|
21
|
+
}
|
|
22
|
+
return [...found.values()];
|
|
23
|
+
}
|
|
24
|
+
/** Every non-GITHUB_TOKEN secret the workflows (and a composite action.yml) reference. */
|
|
25
|
+
export async function collectSecretUses(dir) {
|
|
26
|
+
const uses = [];
|
|
27
|
+
const wfDir = path.join(dir, '.github', 'workflows');
|
|
28
|
+
try {
|
|
29
|
+
const files = (await fs.pathExists(wfDir)) ? (await fs.readdir(wfDir)).sort() : [];
|
|
30
|
+
for (const f of files) {
|
|
31
|
+
if (!/\.ya?ml$/.test(f))
|
|
32
|
+
continue;
|
|
33
|
+
const content = await fs.readFile(path.join(wfDir, f), 'utf-8');
|
|
34
|
+
for (const [job, body] of workflowJobs(content)) {
|
|
35
|
+
uses.push(...usesIn(body, f, job, jobEnvironment(withoutComments(body))));
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
for (const a of ['action.yml', 'action.yaml']) {
|
|
39
|
+
const p = path.join(dir, a);
|
|
40
|
+
if (await fs.pathExists(p))
|
|
41
|
+
uses.push(...usesIn(await fs.readFile(p, 'utf-8'), a, '-', null));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
return uses;
|
|
48
|
+
}
|
|
49
|
+
/** One entry per secret name, for setup's Next Steps. */
|
|
50
|
+
export function requiredSecrets(uses) {
|
|
51
|
+
const byName = new Map();
|
|
52
|
+
for (const u of uses) {
|
|
53
|
+
const r = byName.get(u.name) ?? {
|
|
54
|
+
name: u.name,
|
|
55
|
+
usedBy: [],
|
|
56
|
+
whereToGet: WHERE_TO_GET[u.name] ?? null,
|
|
57
|
+
fallback: true,
|
|
58
|
+
};
|
|
59
|
+
if (!r.usedBy.includes(u.file))
|
|
60
|
+
r.usedBy.push(u.file);
|
|
61
|
+
r.fallback &&= u.fallback;
|
|
62
|
+
byName.set(u.name, r);
|
|
63
|
+
}
|
|
64
|
+
return [...byName.values()];
|
|
65
|
+
}
|
|
66
|
+
/** `gh secret set` lines plus where-to-get, for every secret in the list. */
|
|
67
|
+
export function secretHints(secrets) {
|
|
68
|
+
return secrets.map((s) => `gh secret set ${s.name}${s.whereToGet ? ` # ${s.whereToGet}` : ''}`);
|
|
69
|
+
}
|
|
70
|
+
async function listNames(gh, endpoint) {
|
|
71
|
+
const r = await gh(['api', `${endpoint}?per_page=100`]);
|
|
72
|
+
if (!r.ok)
|
|
73
|
+
return null;
|
|
74
|
+
try {
|
|
75
|
+
const d = JSON.parse(r.stdout);
|
|
76
|
+
return new Set((d.secrets ?? []).flatMap((s) => (s.name ? [s.name] : [])));
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const CHECK = 'Repository secrets';
|
|
83
|
+
const skip = (why) => ({
|
|
84
|
+
check: CHECK,
|
|
85
|
+
status: 'ok',
|
|
86
|
+
detail: `not checked (${why})`,
|
|
87
|
+
});
|
|
88
|
+
export async function checkRepositorySecrets(dir, exec) {
|
|
89
|
+
if (!(await fs.pathExists(path.join(dir, '.git'))))
|
|
90
|
+
return skip('not a git repository');
|
|
91
|
+
const uses = await collectSecretUses(dir);
|
|
92
|
+
if (uses.length === 0)
|
|
93
|
+
return { check: CHECK, status: 'ok', detail: 'no secrets referenced' };
|
|
94
|
+
const gh = exec ?? ((args, stdin) => realGhExec(args, stdin, dir));
|
|
95
|
+
const repo = await listNames(gh, 'repos/{owner}/{repo}/actions/secrets');
|
|
96
|
+
if (!repo)
|
|
97
|
+
return skip("can't list secrets ā needs gh with admin access");
|
|
98
|
+
// Best effort: an org scope that errors just contributes nothing.
|
|
99
|
+
const org = (await listNames(gh, 'repos/{owner}/{repo}/actions/organization-secrets')) ?? new Set();
|
|
100
|
+
const envs = new Map();
|
|
101
|
+
for (const e of new Set(uses.flatMap((u) => (u.environment ? [u.environment] : [])))) {
|
|
102
|
+
envs.set(e, await listNames(gh, `repos/{owner}/{repo}/environments/${e}/secrets`));
|
|
103
|
+
}
|
|
104
|
+
const missing = uses.filter((u) => {
|
|
105
|
+
if (repo.has(u.name) || org.has(u.name))
|
|
106
|
+
return false;
|
|
107
|
+
if (!u.environment)
|
|
108
|
+
return true;
|
|
109
|
+
const env = envs.get(u.environment);
|
|
110
|
+
// An unreadable environment can't prove absence.
|
|
111
|
+
return env ? !env.has(u.name) : false;
|
|
112
|
+
});
|
|
113
|
+
if (missing.length === 0) {
|
|
114
|
+
return { check: CHECK, status: 'ok', detail: 'all referenced secrets are set' };
|
|
115
|
+
}
|
|
116
|
+
const hard = missing.some((u) => !u.fallback);
|
|
117
|
+
const names = missing
|
|
118
|
+
.map((u) => `${u.name} (${u.file})${u.fallback ? ' [falls back to GITHUB_TOKEN]' : ''}`)
|
|
119
|
+
.join(', ');
|
|
120
|
+
return {
|
|
121
|
+
check: CHECK,
|
|
122
|
+
status: hard ? 'missing' : 'drift',
|
|
123
|
+
detail: `not set: ${names}. Dependabot-triggered runs read Dependabot secrets, not these`,
|
|
124
|
+
hint: secretHints(requiredSecrets(missing)).join('\n'),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
@@ -14,6 +14,7 @@ import { SWIFT_GIT_HOOKS, runSwiftChecks } from '../../languages/swift/checks.js
|
|
|
14
14
|
import { readSwiftPackage, renderSwiftWorkflow } from '../../languages/swift/ci.js';
|
|
15
15
|
import { detectAuditLanguage } from '../utils/detect-language.js';
|
|
16
16
|
import { checkGitHubSettings } from '../../base/github-settings.js';
|
|
17
|
+
import { checkRepositorySecrets } from '../../base/secrets.js';
|
|
17
18
|
import { checkMilestones } from '../../base/milestones.js';
|
|
18
19
|
import { checkGitIdentity, checkGitIdentityHistory } from '../../base/git-identity.js';
|
|
19
20
|
import { checkCopiedAssets } from '../utils/copied-assets.js';
|
|
@@ -211,6 +212,7 @@ async function runBaseChecks(dir, lock, opts) {
|
|
|
211
212
|
// GitHub repo-settings drift (branch protection, merge settings, workflow
|
|
212
213
|
// permissions). Read-only; self-skips as `ok` outside a live GitHub repo.
|
|
213
214
|
results.push(...(await checkGitHubSettings(dir)));
|
|
215
|
+
results.push(await checkRepositorySecrets(dir));
|
|
214
216
|
// Milestone hygiene (#397) ā same seam, same self-skip.
|
|
215
217
|
results.push(await checkMilestones(dir));
|
|
216
218
|
results.push(await checkGitLabCI(dir));
|
|
@@ -2,6 +2,7 @@ import path from 'node:path';
|
|
|
2
2
|
import chalk from 'chalk';
|
|
3
3
|
import fs from 'fs-extra';
|
|
4
4
|
import inquirer from 'inquirer';
|
|
5
|
+
import { collectSecretUses, requiredSecrets, secretHints } from '../../base/secrets.js';
|
|
5
6
|
import { formatNpmPublishGuide, npmPublishGuide, } from '../../languages/js/npm-trust.js';
|
|
6
7
|
import { LANGUAGES } from '../../languages/registry.js';
|
|
7
8
|
import { generateConfigs } from '../generators/index.js';
|
|
@@ -182,7 +183,7 @@ export async function setupProject(options) {
|
|
|
182
183
|
await formatGeneratedFiles(config, targetDir);
|
|
183
184
|
}
|
|
184
185
|
console.log(chalk.green('\nā
Setup completed successfully!\n'));
|
|
185
|
-
showNextSteps(config, targetDir);
|
|
186
|
+
await showNextSteps(config, targetDir);
|
|
186
187
|
}
|
|
187
188
|
catch (error) {
|
|
188
189
|
console.error(chalk.red('\nā Setup failed:'), error);
|
|
@@ -580,7 +581,7 @@ export function npmPublishFor(config) {
|
|
|
580
581
|
environment: null,
|
|
581
582
|
});
|
|
582
583
|
}
|
|
583
|
-
function showNextSteps(config,
|
|
584
|
+
async function showNextSteps(config, targetDir) {
|
|
584
585
|
console.log(chalk.bold('\nš Next Steps:\n'));
|
|
585
586
|
const steps = [];
|
|
586
587
|
// Every step in the else-branch names a pnpm script. Swift's equivalents are
|
|
@@ -615,6 +616,13 @@ function showNextSteps(config, _targetDir) {
|
|
|
615
616
|
for (const line of formatNpmPublishGuide(npmPublish, true))
|
|
616
617
|
console.log(` ${line}`);
|
|
617
618
|
}
|
|
619
|
+
const secrets = requiredSecrets(await collectSecretUses(targetDir));
|
|
620
|
+
if (secrets.length > 0) {
|
|
621
|
+
console.log(chalk.bold('\nš GitHub setup (secrets the workflows need):\n'));
|
|
622
|
+
for (const line of secretHints(secrets))
|
|
623
|
+
console.log(` ${line}`);
|
|
624
|
+
console.log(' Then: `fix github-settings` (branch protection), `fix release-environment` (release gate), and `doctor` to confirm');
|
|
625
|
+
}
|
|
618
626
|
const skipped = collectSkippedFixSuggestions(config);
|
|
619
627
|
if (skipped.length > 0) {
|
|
620
628
|
console.log(chalk.bold('\nš” Want to add something you skipped?\n'));
|
|
@@ -104,7 +104,7 @@ function jsString(value) {
|
|
|
104
104
|
return JSON.stringify(value);
|
|
105
105
|
return `'${JSON.stringify(value).slice(1, -1).replaceAll('\\"', '"').replaceAll("'", "\\'")}'`;
|
|
106
106
|
}
|
|
107
|
-
function docusaurusConfig(meta, typedocModules) {
|
|
107
|
+
function docusaurusConfig(meta, typedocModules, siblings = []) {
|
|
108
108
|
const owner = meta.owner ?? 'your-org';
|
|
109
109
|
const repo = meta.repo ?? meta.title;
|
|
110
110
|
const ghUrl = `https://github.com/${owner}/${repo}`;
|
|
@@ -116,6 +116,17 @@ function docusaurusConfig(meta, typedocModules) {
|
|
|
116
116
|
const typedocPlugins = typedocModules.length
|
|
117
117
|
? `\t\t...getTypedocPlugins([${typedocModules.map(jsString).join(', ')}]),\n`
|
|
118
118
|
: '';
|
|
119
|
+
const siblingNav = siblings
|
|
120
|
+
.map((s) => `\t\t\t\t{ href: ${jsString(s.href)}, label: ${jsString(s.label)}, position: 'left' },\n`)
|
|
121
|
+
.join('');
|
|
122
|
+
const siblingFooter = siblings.length
|
|
123
|
+
? `\t\t\t\t{
|
|
124
|
+
\t\t\t\t\ttitle: 'Projects',
|
|
125
|
+
\t\t\t\t\titems: [
|
|
126
|
+
${siblings.map((s) => `\t\t\t\t\t\t{ label: ${jsString(s.label)}, href: ${jsString(s.href)} },\n`).join('')}\t\t\t\t\t],
|
|
127
|
+
\t\t\t\t},
|
|
128
|
+
`
|
|
129
|
+
: '';
|
|
119
130
|
return `import type * as Preset from '@docusaurus/preset-classic'
|
|
120
131
|
import type { Config } from '@docusaurus/types'
|
|
121
132
|
import { themes as prismThemes } from 'prism-react-renderer'
|
|
@@ -186,7 +197,7 @@ ${typedocPlugins}\t\t[
|
|
|
186
197
|
\t\t\ttitle: '${meta.title}',
|
|
187
198
|
\t\t\titems: [
|
|
188
199
|
\t\t\t\t{ to: '/docs', position: 'left', label: 'Docs' },
|
|
189
|
-
\t\t\t\t{
|
|
200
|
+
${siblingNav}\t\t\t\t{
|
|
190
201
|
\t\t\t\t\thref: '${ghUrl}',
|
|
191
202
|
\t\t\t\t\tlabel: 'GitHub',
|
|
192
203
|
\t\t\t\t\tposition: 'right',
|
|
@@ -207,7 +218,7 @@ ${typedocPlugins}\t\t[
|
|
|
207
218
|
\t\t\t\t\t\t{ label: 'Issues', href: '${ghUrl}/issues' },
|
|
208
219
|
\t\t\t\t\t],
|
|
209
220
|
\t\t\t\t},
|
|
210
|
-
\t\t\t],
|
|
221
|
+
${siblingFooter}\t\t\t],
|
|
211
222
|
\t\t\tcopyright: \`Copyright Ā© \${new Date().getFullYear()} ${meta.title}. Built with Docusaurus.\`,
|
|
212
223
|
\t\t},
|
|
213
224
|
\t\t// \`theme\` is the LIGHT-mode Prism theme and \`darkTheme\` the dark one. Both
|
|
@@ -322,7 +333,10 @@ function docsPackageJson(meta, typedoc) {
|
|
|
322
333
|
};
|
|
323
334
|
return `${JSON.stringify(pkg, null, 2)}\n`;
|
|
324
335
|
}
|
|
325
|
-
function introDoc(meta, badges) {
|
|
336
|
+
function introDoc(meta, badges, siblings = []) {
|
|
337
|
+
const related = siblings.length
|
|
338
|
+
? `\n## Related projects\n\n${siblings.map((s) => `- [${s.label}](${s.href})`).join('\n')}\n`
|
|
339
|
+
: '';
|
|
326
340
|
return `---
|
|
327
341
|
title: ${meta.title}
|
|
328
342
|
slug: /
|
|
@@ -336,7 +350,7 @@ ${meta.tagline}
|
|
|
336
350
|
Welcome to the docs. Edit \`apps/docs/docs/intro.md\` to get started, and add
|
|
337
351
|
more markdown files under \`apps/docs/docs/\` ā they appear in the sidebar
|
|
338
352
|
automatically.
|
|
339
|
-
`;
|
|
353
|
+
${related}`;
|
|
340
354
|
}
|
|
341
355
|
/** The per-repo workflow that drives the shared reusable deploy on push to main. */
|
|
342
356
|
function docsWorkflow(meta) {
|
|
@@ -386,6 +400,7 @@ export default function Home() {
|
|
|
386
400
|
export async function generateDocsSite(pkg, targetDir, options = {}) {
|
|
387
401
|
const meta = inferSiteMeta(pkg);
|
|
388
402
|
const accent = options.primaryColor ?? DEFAULT_ACCENT;
|
|
403
|
+
const siblings = options.siblings ?? [];
|
|
389
404
|
const written = [];
|
|
390
405
|
// Shared assets (only-if-missing copies of the shipped presets).
|
|
391
406
|
written.push(...(await copyPresetIfMissing('docusaurus-sync-changelog', targetDir)));
|
|
@@ -409,12 +424,12 @@ export async function generateDocsSite(pkg, targetDir, options = {}) {
|
|
|
409
424
|
// Project-specific scaffold.
|
|
410
425
|
const files = [
|
|
411
426
|
[`${DOCS_APP}/package.json`, docsPackageJson(meta, typedocModules.length > 0)],
|
|
412
|
-
[`${DOCS_APP}/docusaurus.config.ts`, docusaurusConfig(meta, typedocModules)],
|
|
427
|
+
[`${DOCS_APP}/docusaurus.config.ts`, docusaurusConfig(meta, typedocModules, siblings)],
|
|
413
428
|
[`${DOCS_APP}/sidebars.ts`, SIDEBARS],
|
|
414
429
|
[`${DOCS_APP}/tsconfig.json`, TSCONFIG],
|
|
415
430
|
[`${DOCS_APP}/src/css/custom.css`, customCss(accent)],
|
|
416
431
|
[`${DOCS_APP}/src/pages/index.tsx`, HOME_PAGE],
|
|
417
|
-
[`${DOCS_APP}/docs/intro.md`, introDoc(meta, badges)],
|
|
432
|
+
[`${DOCS_APP}/docs/intro.md`, introDoc(meta, badges, siblings)],
|
|
418
433
|
['.github/workflows/docs.yml', docsWorkflow(meta)],
|
|
419
434
|
];
|
|
420
435
|
// TypeDoc emits docs/api/<id> on build ā keep the generated tree out of git.
|
|
@@ -171,6 +171,26 @@ export function lockfileSchema() {
|
|
|
171
171
|
},
|
|
172
172
|
},
|
|
173
173
|
},
|
|
174
|
+
docs: {
|
|
175
|
+
type: 'object',
|
|
176
|
+
additionalProperties: false,
|
|
177
|
+
description: 'Inputs to `fix docs-site`.',
|
|
178
|
+
properties: {
|
|
179
|
+
siblings: {
|
|
180
|
+
type: 'array',
|
|
181
|
+
description: 'Sibling projects to link from the scaffolded docs site (navbar, footer, intro page). Opt-in: when absent the scaffold emits no such links.',
|
|
182
|
+
items: {
|
|
183
|
+
type: 'object',
|
|
184
|
+
additionalProperties: false,
|
|
185
|
+
required: ['label', 'href'],
|
|
186
|
+
properties: {
|
|
187
|
+
label: { type: 'string', minLength: 1 },
|
|
188
|
+
href: { type: 'string', minLength: 1 },
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
},
|
|
174
194
|
},
|
|
175
195
|
},
|
|
176
196
|
},
|
|
@@ -76,7 +76,7 @@ import { generateDocsSite } from '../../cli/generators/docs-site.js';
|
|
|
76
76
|
import { generateTypedocConfig, generateTypedocWorkflow } from '../../cli/generators/typedoc.js';
|
|
77
77
|
import { copyPreset } from '../../cli/utils/copy-preset.js';
|
|
78
78
|
import { identifiablePresetHashes } from '../../cli/utils/copied-assets.js';
|
|
79
|
-
import { LOCKFILE_NAME, writeLockfile } from '../../cli/utils/lockfile.js';
|
|
79
|
+
import { LOCKFILE_NAME, readLockfile, writeLockfile } from '../../cli/utils/lockfile.js';
|
|
80
80
|
// The fixer contract moved to src/base/fixers.ts when Swift became the second
|
|
81
81
|
// module (#286) ā import it from there.
|
|
82
82
|
import { FixerAbort } from '../../base/fixers.js';
|
|
@@ -774,7 +774,10 @@ export const FIXERS = [
|
|
|
774
774
|
}
|
|
775
775
|
}
|
|
776
776
|
const typedoc = hasTypedocConfig || 'typedoc' in deps;
|
|
777
|
-
const filesWritten = await generateDocsSite(pkg, targetDir, {
|
|
777
|
+
const filesWritten = await generateDocsSite(pkg, targetDir, {
|
|
778
|
+
typedoc,
|
|
779
|
+
siblings: (await readLockfile(targetDir))?.rules?.docs?.siblings,
|
|
780
|
+
});
|
|
778
781
|
return { filesWritten };
|
|
779
782
|
},
|
|
780
783
|
},
|
package/package.json
CHANGED