@rtorcato/repo-tooling 3.2.5 → 3.4.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 +48 -1
- package/dist/cli/commands/doctor.js +8 -1
- package/dist/cli/commands/fix-targets.js +11 -0
- package/dist/cli/commands/setup-presets.js +4 -2
- package/dist/cli/generators/github-actions.js +22 -2
- package/dist/cli/utils/copy-preset.js +5 -0
- package/dist/languages/js/fixers.js +15 -8
- package/dist/languages/swift/checks.js +152 -2
- package/dist/languages/swift/ci.js +43 -0
- package/dist/languages/swift/fixers.js +78 -1
- package/dist/languages/swift/scaffold.js +19 -1
- package/package.json +2 -1
- package/tooling/swift/swift-format.json +10 -0
package/README.md
CHANGED
|
@@ -72,6 +72,14 @@ See the [Getting Started guide](https://rtorcato.github.io/repo-tooling/guides/g
|
|
|
72
72
|
| `doctor` | Diagnose an existing project for missing or drifted tooling. | `npx @rtorcato/repo-tooling doctor` |
|
|
73
73
|
| `fix [target]` | Apply scaffolders for what `doctor` flagged (`--yes`, `--dry-run`, `--diff`). | `npx @rtorcato/repo-tooling fix` |
|
|
74
74
|
|
|
75
|
+
Prefer to run the audit in CI? `doctor` also ships as a GitHub Action:
|
|
76
|
+
|
|
77
|
+
```yaml
|
|
78
|
+
- uses: rtorcato/repo-tooling@v3.2.5
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
See the [GitHub Actions reference](https://rtorcato.github.io/repo-tooling/reference/github-actions/#run-doctor-as-a-github-action) for its inputs and outputs.
|
|
82
|
+
|
|
75
83
|
Every command takes `-d, --directory <path>`; run any with `--help` for its full flags. Run `list` (or `list --json`) for the full set of `fix` targets — it's the source of truth. Notable ones include `fix docs-site` (scaffold a [Docusaurus docs site](https://rtorcato.github.io/repo-tooling/guides/docs-site/)) and `fix bun` (Bun runtime config).
|
|
76
84
|
|
|
77
85
|
## The `.repo-tooling.json` lockfile
|
package/dist/base/checks.js
CHANGED
|
@@ -71,7 +71,27 @@ export async function checkCommunityHealth(dir) {
|
|
|
71
71
|
hint: 'Run `npx @rtorcato/repo-tooling fix community-health` to scaffold them',
|
|
72
72
|
};
|
|
73
73
|
}
|
|
74
|
-
|
|
74
|
+
/**
|
|
75
|
+
* `org/action@vN`, the only form the generators emit. Same shape as the scan in
|
|
76
|
+
* tests/cli/generators/action-pins.test.ts — major-only, because that's the
|
|
77
|
+
* granularity every emitted pin uses.
|
|
78
|
+
*/
|
|
79
|
+
const ACTION_PIN = /\b([a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9-]*)@v(\d+)/g;
|
|
80
|
+
function actionPins(yaml) {
|
|
81
|
+
const pins = new Map();
|
|
82
|
+
for (const [, action, major] of yaml.matchAll(ACTION_PIN)) {
|
|
83
|
+
if (action && major)
|
|
84
|
+
pins.set(action, major);
|
|
85
|
+
}
|
|
86
|
+
return pins;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* @param preset the ci.yml this repo's generator would render right now, or null
|
|
90
|
+
* for a language whose CI generator isn't wired up. Supplied by the caller
|
|
91
|
+
* rather than rendered here: the JS preset needs a ProjectConfig and the Swift
|
|
92
|
+
* one needs Package.swift, neither of which belongs in a base check.
|
|
93
|
+
*/
|
|
94
|
+
export async function checkGitHubActions(dir, preset = null) {
|
|
75
95
|
const workflowsDir = path.join(dir, '.github', 'workflows');
|
|
76
96
|
if (!(await fs.pathExists(workflowsDir))) {
|
|
77
97
|
return {
|
|
@@ -92,6 +112,33 @@ export async function checkGitHubActions(dir) {
|
|
|
92
112
|
hint: 'Add a workflow file (e.g. ci.yml) under .github/workflows/',
|
|
93
113
|
};
|
|
94
114
|
}
|
|
115
|
+
// "A workflow exists" said nothing about whether it still matches the preset,
|
|
116
|
+
// so doctor reported ok on a ci.yml that had drifted arbitrarily far — the
|
|
117
|
+
// blind half of #349/#340. Compare action pins rather than the whole file: a
|
|
118
|
+
// consuming repo is *expected* to add jobs and steps, so a byte diff is noise
|
|
119
|
+
// on every customized repo, while a pin major that disagrees with the preset
|
|
120
|
+
// is exactly the drift that loops (a bump here, a sync there, forever).
|
|
121
|
+
const ciPath = path.join(workflowsDir, 'ci.yml');
|
|
122
|
+
if (preset && (await fs.pathExists(ciPath))) {
|
|
123
|
+
const ours = actionPins(preset);
|
|
124
|
+
const deltas = [];
|
|
125
|
+
for (const [action, major] of actionPins(await fs.readFile(ciPath, 'utf-8'))) {
|
|
126
|
+
const expected = ours.get(action);
|
|
127
|
+
// Only the intersection is comparable — an action the repo runs but the
|
|
128
|
+
// preset never emits is the consumer's own business.
|
|
129
|
+
if (expected && expected !== major) {
|
|
130
|
+
deltas.push(`${action}@v${major} (preset emits @v${expected})`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (deltas.length > 0) {
|
|
134
|
+
return {
|
|
135
|
+
check: 'GitHub Actions',
|
|
136
|
+
status: 'drift',
|
|
137
|
+
detail: `ci.yml action pins disagree with the preset: ${deltas.join('; ')}`,
|
|
138
|
+
hint: 'Run `npx @rtorcato/repo-tooling fix github-actions --diff` to see the delta before overwriting — a pin *ahead* of the preset means regenerating would downgrade it',
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
}
|
|
95
142
|
return {
|
|
96
143
|
check: 'GitHub Actions',
|
|
97
144
|
status: 'ok',
|
|
@@ -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,12 @@ const SWIFT_FIX_TARGETS = {
|
|
|
57
57
|
SwiftLint: 'swiftlint',
|
|
58
58
|
Periphery: 'periphery',
|
|
59
59
|
'Swift .gitignore': 'swift-gitignore',
|
|
60
|
+
'Release automation': 'swift-release',
|
|
61
|
+
'swift-format': 'swift-format',
|
|
62
|
+
DocC: 'docc',
|
|
63
|
+
// `Swift tests` is deliberately absent: when it fails for the manifest half
|
|
64
|
+
// (no `.testTarget(`) there's nothing to run, and rewriting Package.swift
|
|
65
|
+
// isn't safe. The check's own hint covers both halves.
|
|
60
66
|
};
|
|
61
67
|
export function getFixTargetForCheck(checkName, language) {
|
|
62
68
|
if (language === 'swift' && SWIFT_FIX_TARGETS[checkName]) {
|
|
@@ -97,6 +103,10 @@ export function declinedInLock(lock, checkName) {
|
|
|
97
103
|
c.linting?.tool === 'none' &&
|
|
98
104
|
c.testing?.framework === 'none');
|
|
99
105
|
case 'semantic-release':
|
|
106
|
+
// The Swift shape of the same recorded choice (#310): `semanticRelease`
|
|
107
|
+
// is the config's release-automation flag, and on a SwiftPM repo that
|
|
108
|
+
// means a tag-triggered workflow rather than an npm publish.
|
|
109
|
+
case 'Release automation':
|
|
100
110
|
return c.semanticRelease === false;
|
|
101
111
|
case 'Dependabot':
|
|
102
112
|
case 'CodeQL':
|
|
@@ -160,6 +170,7 @@ export function lockfilePatchForTarget(target, lock) {
|
|
|
160
170
|
case 'swift-git-hooks':
|
|
161
171
|
return c.gitHooks ? null : { gitHooks: true };
|
|
162
172
|
case 'semantic-release':
|
|
173
|
+
case 'swift-release':
|
|
163
174
|
return c.semanticRelease ? null : { semanticRelease: true };
|
|
164
175
|
case 'dependabot':
|
|
165
176
|
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
|
}
|
|
@@ -41,6 +41,11 @@ export const PRESETS = {
|
|
|
41
41
|
target: '.swiftlint.yml',
|
|
42
42
|
desc: 'SwiftLint configuration (lint + --fix; the standard swift-common runs)',
|
|
43
43
|
},
|
|
44
|
+
'swift-format': {
|
|
45
|
+
source: 'tooling/swift/swift-format.json',
|
|
46
|
+
target: '.swift-format',
|
|
47
|
+
desc: "Apple swift-format configuration (4-space, 120 columns — matches the module's .editorconfig)",
|
|
48
|
+
},
|
|
44
49
|
periphery: {
|
|
45
50
|
source: 'tooling/swift/periphery.yml',
|
|
46
51
|
target: '.periphery.yml',
|
|
@@ -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
|
},
|
|
@@ -5,8 +5,10 @@
|
|
|
5
5
|
* (lint + `--fix` in pre-commit, `--strict` in CI) and Periphery for dead code.
|
|
6
6
|
* Deliberately *not* checked:
|
|
7
7
|
*
|
|
8
|
-
* - SwiftFormat — SwiftLint's `--fix` does the
|
|
9
|
-
* would fight it.
|
|
8
|
+
* - SwiftFormat (the Nick Lockwood one) — SwiftLint's `--fix` does the
|
|
9
|
+
* formatting; a second *rewriting* formatter would fight it. Apple's
|
|
10
|
+
* `swift-format` is checked as of #311, but only as an optional slot: a repo
|
|
11
|
+
* that opts into it runs it *instead of* `swiftlint --fix`.
|
|
10
12
|
* - `.swift-version` — the toolchain is pinned in CI (`setup-xcode`) and the
|
|
11
13
|
* package declares `// swift-tools-version:`, so a root file would be a third
|
|
12
14
|
* place to keep in sync.
|
|
@@ -33,6 +35,17 @@ const SWIFT_FILE_CHECKS = [
|
|
|
33
35
|
optional: true,
|
|
34
36
|
hint: 'Run `npx @rtorcato/repo-tooling fix periphery` to scaffold a dead-code scan config',
|
|
35
37
|
},
|
|
38
|
+
{
|
|
39
|
+
// Apple's swift-format, the formatter slot (#311) — not a second linter.
|
|
40
|
+
// Optional because SwiftLint's `--fix` already formats: a repo runs one,
|
|
41
|
+
// the other, or both, so its absence is a choice rather than drift.
|
|
42
|
+
check: 'swift-format',
|
|
43
|
+
candidates: ['.swift-format', '.swift-format.json'],
|
|
44
|
+
expected: 'is a valid swift-format configuration',
|
|
45
|
+
matcher: /"(version|lineLength|indentation|rules)"\s*:/,
|
|
46
|
+
optional: true,
|
|
47
|
+
hint: 'Run `npx @rtorcato/repo-tooling fix swift-format` to scaffold one (SwiftLint `--fix` formats without it)',
|
|
48
|
+
},
|
|
36
49
|
];
|
|
37
50
|
/**
|
|
38
51
|
* Build artefacts that must never be committed. `.build` and `DerivedData` are
|
|
@@ -105,6 +118,140 @@ export async function checkPackageSwift(dir) {
|
|
|
105
118
|
detail: `Package.swift declares tools ${toolsVersion} and explicit platforms`,
|
|
106
119
|
};
|
|
107
120
|
}
|
|
121
|
+
/**
|
|
122
|
+
* Directory names under `Sources/`. For a SwiftPM package these are the target
|
|
123
|
+
* names — a target's sources and its DocC catalogue live in the same folder,
|
|
124
|
+
* so this is where both the check and the `docc` fixer have to look.
|
|
125
|
+
*/
|
|
126
|
+
export async function swiftSourceTargets(dir) {
|
|
127
|
+
try {
|
|
128
|
+
const entries = await fs.readdir(path.join(dir, 'Sources'), { withFileTypes: true });
|
|
129
|
+
return entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return [];
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
async function findDoccCatalogue(dir) {
|
|
136
|
+
for (const target of await swiftSourceTargets(dir)) {
|
|
137
|
+
const entries = await fs.readdir(path.join(dir, 'Sources', target));
|
|
138
|
+
const catalogue = entries.find((e) => e.endsWith('.docc'));
|
|
139
|
+
if (catalogue)
|
|
140
|
+
return `Sources/${target}/${catalogue}`;
|
|
141
|
+
}
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* DocC, the Swift shape of the TypeDoc check (#311). Two halves have to line
|
|
146
|
+
* up: a `.docc` catalogue holds the prose, and `swift-docc-plugin` is what
|
|
147
|
+
* makes `swift package generate-documentation` exist — either alone is docs
|
|
148
|
+
* that nobody can build, or a build command with nothing to say.
|
|
149
|
+
*/
|
|
150
|
+
export async function checkDocC(dir) {
|
|
151
|
+
const check = 'DocC';
|
|
152
|
+
const hint = 'Run `npx @rtorcato/repo-tooling fix docc` to scaffold a DocC catalogue';
|
|
153
|
+
const manifest = path.join(dir, 'Package.swift');
|
|
154
|
+
const contents = (await fs.pathExists(manifest)) ? await fs.readFile(manifest, 'utf-8') : '';
|
|
155
|
+
const hasPlugin = contents.includes('swift-docc-plugin');
|
|
156
|
+
const catalogue = await findDoccCatalogue(dir);
|
|
157
|
+
if (catalogue && hasPlugin) {
|
|
158
|
+
return { check, status: 'ok', detail: `${catalogue} with swift-docc-plugin declared` };
|
|
159
|
+
}
|
|
160
|
+
if (catalogue) {
|
|
161
|
+
return {
|
|
162
|
+
check,
|
|
163
|
+
status: 'drift',
|
|
164
|
+
detail: `${catalogue} found but Package.swift declares no swift-docc-plugin`,
|
|
165
|
+
hint: 'Add `.package(url: "https://github.com/apple/swift-docc-plugin", from: "1.4.0")` to Package.swift',
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
if (hasPlugin) {
|
|
169
|
+
return {
|
|
170
|
+
check,
|
|
171
|
+
status: 'drift',
|
|
172
|
+
detail: 'swift-docc-plugin declared but no .docc catalogue under Sources/',
|
|
173
|
+
hint,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
return { check, status: 'optional-missing', detail: 'DocC not configured', hint };
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* The test setup (#311). SwiftPM has no test-runner config file to check — the
|
|
180
|
+
* suite is declared in the manifest and run by `swift test` — so the two facts
|
|
181
|
+
* worth asserting are that a test target exists at all and that CI runs it. A
|
|
182
|
+
* green pipeline over a package with no `.testTarget(` proves nothing.
|
|
183
|
+
*/
|
|
184
|
+
export async function checkSwiftTests(dir) {
|
|
185
|
+
const check = 'Swift tests';
|
|
186
|
+
const manifest = path.join(dir, 'Package.swift');
|
|
187
|
+
if (!(await fs.pathExists(manifest))) {
|
|
188
|
+
return { check, status: 'missing', detail: 'no Package.swift' };
|
|
189
|
+
}
|
|
190
|
+
if (!/\.testTarget\(/.test(await fs.readFile(manifest, 'utf-8'))) {
|
|
191
|
+
return {
|
|
192
|
+
check,
|
|
193
|
+
status: 'missing',
|
|
194
|
+
detail: 'Package.swift declares no `.testTarget(`',
|
|
195
|
+
hint: 'Add a `.testTarget(name: "<Target>Tests", dependencies: ["<Target>"])` to Package.swift',
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
const candidates = ['.gitlab-ci.yml', '.gitlab-ci.yaml'];
|
|
199
|
+
const workflowsDir = path.join(dir, '.github', 'workflows');
|
|
200
|
+
if (await fs.pathExists(workflowsDir)) {
|
|
201
|
+
const files = (await fs.readdir(workflowsDir)).filter((f) => f.endsWith('.yml') || f.endsWith('.yaml'));
|
|
202
|
+
candidates.unshift(...files.map((f) => path.join('.github', 'workflows', f)));
|
|
203
|
+
}
|
|
204
|
+
for (const candidate of candidates) {
|
|
205
|
+
const filepath = path.join(dir, candidate);
|
|
206
|
+
if (!(await fs.pathExists(filepath)))
|
|
207
|
+
continue;
|
|
208
|
+
if (/\bswift\s+test\b/.test(await fs.readFile(filepath, 'utf-8'))) {
|
|
209
|
+
return { check, status: 'ok', detail: `test target declared and run by ${candidate}` };
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return {
|
|
213
|
+
check,
|
|
214
|
+
status: 'drift',
|
|
215
|
+
detail: 'test target declared but no CI job runs `swift test`',
|
|
216
|
+
hint: 'Run `npx @rtorcato/repo-tooling fix swift-ci` to regenerate a workflow that runs `swift test`',
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Release automation for a SwiftPM package (#310). There is no publish step to
|
|
221
|
+
* look for — a release *is* a semver git tag consumers resolve with
|
|
222
|
+
* `.package(url:from:)` — so "configured" means a workflow that fires on a tag
|
|
223
|
+
* push. semantic-release is deliberately not accepted as evidence: its pipeline
|
|
224
|
+
* is npm end to end, and a Swift repo running it is publishing the wrong thing.
|
|
225
|
+
*
|
|
226
|
+
* Only the trigger section is searched (everything above `jobs:`), because
|
|
227
|
+
* `tags:` also appears inside job steps — docker/metadata-action emits one — and
|
|
228
|
+
* a step that mentions tags is not a release trigger.
|
|
229
|
+
*/
|
|
230
|
+
export async function checkSwiftRelease(dir) {
|
|
231
|
+
const check = 'Release automation';
|
|
232
|
+
const hint = 'Run `npx @rtorcato/repo-tooling fix swift-release` to scaffold a tag-triggered release workflow';
|
|
233
|
+
const workflowsDir = path.join(dir, '.github', 'workflows');
|
|
234
|
+
if (await fs.pathExists(workflowsDir)) {
|
|
235
|
+
const files = (await fs.readdir(workflowsDir)).filter((f) => f.endsWith('.yml') || f.endsWith('.yaml'));
|
|
236
|
+
for (const file of files) {
|
|
237
|
+
const contents = await fs.readFile(path.join(workflowsDir, file), 'utf-8');
|
|
238
|
+
const triggers = contents.split(/^jobs:/m)[0] ?? '';
|
|
239
|
+
if (/^\s+tags:/m.test(triggers)) {
|
|
240
|
+
return {
|
|
241
|
+
check,
|
|
242
|
+
status: 'ok',
|
|
243
|
+
detail: `.github/workflows/${file} releases on a tag push`,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return {
|
|
249
|
+
check,
|
|
250
|
+
status: 'optional-missing',
|
|
251
|
+
detail: 'no workflow triggered by a version tag',
|
|
252
|
+
hint,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
108
255
|
/**
|
|
109
256
|
* The Swift shape of the base `Git hooks` / `Pre-push hook` checks (#309).
|
|
110
257
|
* `install` is null because the wiring — `git config core.hooksPath` — is
|
|
@@ -123,5 +270,8 @@ export async function runSwiftChecks(dir) {
|
|
|
123
270
|
await checkPackageSwift(dir),
|
|
124
271
|
...(await Promise.all(SWIFT_FILE_CHECKS.map((spec) => checkFile(dir, spec)))),
|
|
125
272
|
await checkSwiftGitignore(dir),
|
|
273
|
+
await checkSwiftTests(dir),
|
|
274
|
+
await checkDocC(dir),
|
|
275
|
+
await checkSwiftRelease(dir),
|
|
126
276
|
];
|
|
127
277
|
}
|
|
@@ -140,6 +140,49 @@ ${pkg.platforms.map((p) => ` - ${p}`).join('\n')}`,
|
|
|
140
140
|
export function renderSwiftWorkflow(pkg) {
|
|
141
141
|
return renderGitHubWorkflow(swiftGithubJobs(pkg));
|
|
142
142
|
}
|
|
143
|
+
/**
|
|
144
|
+
* The release pipeline (#310). SwiftPM has no registry publish step — a release
|
|
145
|
+
* *is* a semver git tag that consumers resolve with `.package(url:from:)` — so
|
|
146
|
+
* the workflow fires on the tag rather than on a merge, and its only output is
|
|
147
|
+
* a GitHub Release with generated notes.
|
|
148
|
+
*
|
|
149
|
+
* The build/test gate runs before the release is cut because a tag is
|
|
150
|
+
* effectively permanent: SwiftPM caches resolved tags, so re-pointing a bad one
|
|
151
|
+
* doesn't reliably reach consumers who already resolved it.
|
|
152
|
+
*
|
|
153
|
+
* `gh` (preinstalled on the runner) rather than a release action: one fewer
|
|
154
|
+
* third-party pin to track, and `--verify-tag` refuses to invent a tag that
|
|
155
|
+
* isn't actually pushed.
|
|
156
|
+
*/
|
|
157
|
+
export function renderSwiftReleaseWorkflow() {
|
|
158
|
+
return `name: 🏷️ Release
|
|
159
|
+
|
|
160
|
+
on:
|
|
161
|
+
push:
|
|
162
|
+
tags:
|
|
163
|
+
- '[0-9]+.[0-9]+.[0-9]+'
|
|
164
|
+
- 'v[0-9]+.[0-9]+.[0-9]+'
|
|
165
|
+
|
|
166
|
+
jobs:
|
|
167
|
+
release:
|
|
168
|
+
runs-on: ${MACOS}
|
|
169
|
+
permissions:
|
|
170
|
+
contents: write
|
|
171
|
+
steps:
|
|
172
|
+
${XCODE_SETUP}
|
|
173
|
+
|
|
174
|
+
- name: 🏗️ swift build
|
|
175
|
+
run: swift build
|
|
176
|
+
|
|
177
|
+
- name: 🧪 swift test
|
|
178
|
+
run: swift test
|
|
179
|
+
|
|
180
|
+
- name: 🏷️ Publish GitHub Release
|
|
181
|
+
env:
|
|
182
|
+
GH_TOKEN: \${{ github.token }}
|
|
183
|
+
run: gh release create "\${{ github.ref_name }}" --generate-notes --verify-tag
|
|
184
|
+
`;
|
|
185
|
+
}
|
|
143
186
|
/**
|
|
144
187
|
* GitLab runs Swift in the official Linux image. That rules out Xcode — so no
|
|
145
188
|
* platform matrix and no SwiftLint (a Homebrew/macOS tool in practice); the
|
|
@@ -7,9 +7,33 @@ import fs from 'fs-extra';
|
|
|
7
7
|
import { buildPresetConfig } from '../../cli/commands/setup-presets.js';
|
|
8
8
|
import { copyPreset } from '../../cli/utils/copy-preset.js';
|
|
9
9
|
import { LOCKFILE_NAME, writeLockfile } from '../../cli/utils/lockfile.js';
|
|
10
|
-
import {
|
|
10
|
+
import { swiftSourceTargets } from './checks.js';
|
|
11
|
+
import { readSwiftPackage, renderSwiftGitLabCI, renderSwiftReleaseWorkflow, renderSwiftWorkflow, } from './ci.js';
|
|
11
12
|
import { SWIFT_HOOKS_DIR, installSwiftGitHooks } from './git-hooks.js';
|
|
12
13
|
import { ensureSwiftGitignore } from './gitignore.js';
|
|
14
|
+
/**
|
|
15
|
+
* A DocC module page. The heading is a symbol link (double backticks) because
|
|
16
|
+
* that's what binds the article to the module — a plain `# Name` heading makes
|
|
17
|
+
* DocC treat the file as a standalone article instead.
|
|
18
|
+
*/
|
|
19
|
+
function doccLandingPage(target) {
|
|
20
|
+
return `# \`\`${target}\`\`
|
|
21
|
+
|
|
22
|
+
Summary line for ${target} — replace this with what the module is for.
|
|
23
|
+
|
|
24
|
+
## Overview
|
|
25
|
+
|
|
26
|
+
Write the prose documentation for ${target} here. Anything else in this
|
|
27
|
+
catalogue (articles, tutorials, resources) is picked up automatically.
|
|
28
|
+
|
|
29
|
+
Build it with \`swift package generate-documentation\`, or preview it with
|
|
30
|
+
\`swift package --disable-sandbox preview-documentation --target ${target}\`.
|
|
31
|
+
|
|
32
|
+
## Topics
|
|
33
|
+
|
|
34
|
+
### Essentials
|
|
35
|
+
`;
|
|
36
|
+
}
|
|
13
37
|
export const SWIFT_FIXERS = [
|
|
14
38
|
{
|
|
15
39
|
target: 'swiftlint',
|
|
@@ -33,6 +57,46 @@ export const SWIFT_FIXERS = [
|
|
|
33
57
|
return { filesWritten: [result.target] };
|
|
34
58
|
},
|
|
35
59
|
},
|
|
60
|
+
{
|
|
61
|
+
target: 'swift-format',
|
|
62
|
+
description: 'Scaffold .swift-format (Apple swift-format, the optional formatter slot)',
|
|
63
|
+
appliesTo: ['swift-format'],
|
|
64
|
+
outputs: ['.swift-format'],
|
|
65
|
+
canFixDrift: true,
|
|
66
|
+
async run({ targetDir }) {
|
|
67
|
+
const result = await copyPreset('swift-format', targetDir);
|
|
68
|
+
return { filesWritten: [result.target] };
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
target: 'docc',
|
|
73
|
+
description: 'Scaffold a DocC catalogue (Sources/<Target>/<Target>.docc) for the first target',
|
|
74
|
+
appliesTo: ['DocC'],
|
|
75
|
+
// Path depends on the target name, so `fix --dry-run` shows no diff for
|
|
76
|
+
// this one — the file it writes isn't knowable without reading Sources/.
|
|
77
|
+
outputs: ['Sources/<Target>/<Target>.docc/<Target>.md'],
|
|
78
|
+
riskLevel: 'safe-add',
|
|
79
|
+
canFixDrift: true,
|
|
80
|
+
async run({ targetDir }) {
|
|
81
|
+
// The library product's target first — that's the API consumers read
|
|
82
|
+
// docs for — falling back to whatever single target the package has.
|
|
83
|
+
const targets = await swiftSourceTargets(targetDir);
|
|
84
|
+
const { products } = await readSwiftPackage(targetDir);
|
|
85
|
+
const target = products.find((p) => targets.includes(p)) ?? targets[0];
|
|
86
|
+
if (!target) {
|
|
87
|
+
throw new Error('no Sources/<Target>/ directory to put a DocC catalogue in');
|
|
88
|
+
}
|
|
89
|
+
const file = `Sources/${target}/${target}.docc/${target}.md`;
|
|
90
|
+
// safe-add: the catalogue is prose someone wrote, so a re-run (DocC
|
|
91
|
+
// also drifts when the manifest lacks the plugin, which this fixer
|
|
92
|
+
// can't repair) must not overwrite it.
|
|
93
|
+
if (await fs.pathExists(path.join(targetDir, file)))
|
|
94
|
+
return { filesWritten: [] };
|
|
95
|
+
await fs.outputFile(path.join(targetDir, file), doccLandingPage(target));
|
|
96
|
+
console.log(chalk.yellow(' add swift-docc-plugin to Package.swift to build it: .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.4.0")'));
|
|
97
|
+
return { filesWritten: [file] };
|
|
98
|
+
},
|
|
99
|
+
},
|
|
36
100
|
{
|
|
37
101
|
target: 'swift-gitignore',
|
|
38
102
|
description: 'Add the Swift/SwiftPM build artefacts (.build, DerivedData, xcuserdata) to .gitignore',
|
|
@@ -73,6 +137,19 @@ export const SWIFT_FIXERS = [
|
|
|
73
137
|
return { filesWritten: ['.github/workflows/ci.yml'] };
|
|
74
138
|
},
|
|
75
139
|
},
|
|
140
|
+
{
|
|
141
|
+
target: 'swift-release',
|
|
142
|
+
description: 'Scaffold .github/workflows/release.yml (build + test on a version tag, then publish a GitHub Release)',
|
|
143
|
+
appliesTo: ['Release automation'],
|
|
144
|
+
outputs: ['.github/workflows/release.yml'],
|
|
145
|
+
canFixDrift: true,
|
|
146
|
+
async run({ targetDir }) {
|
|
147
|
+
const workflowsDir = path.join(targetDir, '.github', 'workflows');
|
|
148
|
+
await fs.ensureDir(workflowsDir);
|
|
149
|
+
await fs.writeFile(path.join(workflowsDir, 'release.yml'), renderSwiftReleaseWorkflow());
|
|
150
|
+
return { filesWritten: ['.github/workflows/release.yml'] };
|
|
151
|
+
},
|
|
152
|
+
},
|
|
76
153
|
{
|
|
77
154
|
target: 'swift-gitlab-ci',
|
|
78
155
|
description: 'Scaffold .gitlab-ci.yml for Swift (swift build + test on the Linux Swift image)',
|
|
@@ -18,7 +18,7 @@ import { generateEditorConfig } from '../../cli/generators/misc.js';
|
|
|
18
18
|
import { generateCodeQLWorkflow, generateDependabotConfig } from '../../cli/generators/security.js';
|
|
19
19
|
import { copyPreset } from '../../cli/utils/copy-preset.js';
|
|
20
20
|
import { LANGUAGES } from '../registry.js';
|
|
21
|
-
import { readSwiftPackage, renderSwiftWorkflow } from './ci.js';
|
|
21
|
+
import { readSwiftPackage, renderSwiftReleaseWorkflow, renderSwiftWorkflow } from './ci.js';
|
|
22
22
|
import { SWIFT_HOOKS_DIR, installSwiftGitHooks } from './git-hooks.js';
|
|
23
23
|
import { ensureSwiftGitignore } from './gitignore.js';
|
|
24
24
|
/**
|
|
@@ -168,7 +168,18 @@ ${config.projectName}/
|
|
|
168
168
|
- **GitHub Actions** — \`swift build\`/\`swift test\` on macOS, plus \`xcodebuild\` per declared platform
|
|
169
169
|
|
|
170
170
|
Run \`npx @rtorcato/repo-tooling doctor\` any time to audit this repo against the standard.
|
|
171
|
+
${config.semanticRelease
|
|
172
|
+
? `
|
|
173
|
+
## Releasing
|
|
171
174
|
|
|
175
|
+
SwiftPM has no registry: a release is a semver git tag. Pushing one runs the
|
|
176
|
+
build/test gate and publishes a GitHub Release from it.
|
|
177
|
+
|
|
178
|
+
\`\`\`bash
|
|
179
|
+
git tag 1.0.0 && git push origin 1.0.0
|
|
180
|
+
\`\`\`
|
|
181
|
+
`
|
|
182
|
+
: ''}
|
|
172
183
|
## Contributing
|
|
173
184
|
|
|
174
185
|
1. Fork the repository
|
|
@@ -199,6 +210,9 @@ export function swiftFileList(config) {
|
|
|
199
210
|
'.editorconfig',
|
|
200
211
|
'.github/workflows/ci.yml',
|
|
201
212
|
];
|
|
213
|
+
if (config.semanticRelease) {
|
|
214
|
+
files.push('.github/workflows/release.yml');
|
|
215
|
+
}
|
|
202
216
|
if (config.gitHooks) {
|
|
203
217
|
files.push(`${SWIFT_HOOKS_DIR}/pre-commit`, `${SWIFT_HOOKS_DIR}/pre-push`);
|
|
204
218
|
}
|
|
@@ -227,6 +241,10 @@ export async function generateSwiftProject(config, targetDir) {
|
|
|
227
241
|
const workflowsDir = path.join(targetDir, '.github', 'workflows');
|
|
228
242
|
await fs.ensureDir(workflowsDir);
|
|
229
243
|
await fs.writeFile(path.join(workflowsDir, 'ci.yml'), renderSwiftWorkflow(await readSwiftPackage(targetDir)));
|
|
244
|
+
// A tag-triggered release, not an npm publish (#310) — see the preset.
|
|
245
|
+
if (config.semanticRelease) {
|
|
246
|
+
await fs.writeFile(path.join(workflowsDir, 'release.yml'), renderSwiftReleaseWorkflow());
|
|
247
|
+
}
|
|
230
248
|
// `core.hooksPath` won't stick here — `setup` scaffolds into a directory that
|
|
231
249
|
// isn't a git repo yet — so the hooks land as files and the README tells you
|
|
232
250
|
// the one-line config to run after `git init`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rtorcato/repo-tooling",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.4.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": [
|
|
@@ -102,6 +102,7 @@
|
|
|
102
102
|
"tooling/claude/*.md",
|
|
103
103
|
"tooling/mcp/mcp.json.example",
|
|
104
104
|
"tooling/swift/*.yml",
|
|
105
|
+
"tooling/swift/*.json",
|
|
105
106
|
"tooling/github-actions/workflows/*.yml",
|
|
106
107
|
"README.md",
|
|
107
108
|
"AGENTS.md"
|