breakaway 1.4.0-main.2 → 1.4.0-main.21
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/package.json +4 -1
- package/scripts/board-files.mjs +18 -0
- package/scripts/deploy-plan.mjs +135 -0
- package/scripts/lib/deploy-plan.js +102 -0
- package/scripts/lib/package-release.js +71 -0
- package/scripts/package-release.mjs +52 -0
- package/scripts/tasks/cli.js +132 -9
- package/scripts/tasks/init.js +3 -562
- package/scripts/tasks/pipeline.js +747 -0
- package/scripts/tasks.mjs +73 -8
- package/src/cli-version.js +2 -2
- package/src/init.js +590 -0
- package/src/packages.js +56 -0
- package/src/prompt.js +1 -1
- package/src/repos.js +28 -9
- package/src/specs.js +96 -0
- package/template/pipeline/deploy.yml +178 -0
- package/template/pipeline/promote.yml +203 -0
- package/template/pipeline/release.yml +212 -0
- package/template/pipeline/rollback.yml +98 -0
|
@@ -0,0 +1,747 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `npx breakaway pipeline init` and `pipeline check` (BRK-90, docs/specs/IDEA-27-move-ci-cd-to-the-deploy-flow.md,
|
|
3
|
+
* sections 2 and 2b): a repository's Deploy, Promote, and Roll back workflows, its deploy paths, and its package's
|
|
4
|
+
* Release workflow, rendered from one config the repository owns, `.github/breakaway-pipeline.json`. The judgment goes
|
|
5
|
+
* into the config; the files come from the templates in template/pipeline/, so every repository runs the same
|
|
6
|
+
* workflows and a fix to a template reaches them with `pipeline init --update`.
|
|
7
|
+
*
|
|
8
|
+
* The config, the rendering, and the workflow checks are pure (files come through `read`), so they're tested without a
|
|
9
|
+
* disk; `run` is the command, which reads and writes the checkout.
|
|
10
|
+
*/
|
|
11
|
+
import { execFileSync } from 'node:child_process';
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import { dirname, join } from 'node:path';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
|
|
16
|
+
export const CONFIG_PATH = '.github/breakaway-pipeline.json';
|
|
17
|
+
export const DEPLOY_PATHS = '.github/deploy-paths.json';
|
|
18
|
+
/** The first line of every workflow the renderer writes: how `--update` knows a file is its own to replace. */
|
|
19
|
+
export const HEADER = `Rendered by npx breakaway pipeline init from ${CONFIG_PATH}: change that file and run npx breakaway pipeline init --update, not this one.`;
|
|
20
|
+
/** The templates, and where each lands in the repository. */
|
|
21
|
+
export const WORKFLOWS = {
|
|
22
|
+
deploy: '.github/workflows/deploy.yml',
|
|
23
|
+
promote: '.github/workflows/promote.yml',
|
|
24
|
+
rollback: '.github/workflows/rollback.yml',
|
|
25
|
+
release: '.github/workflows/release.yml',
|
|
26
|
+
};
|
|
27
|
+
/** The helpers the rendered workflows run, which `repos init` copies (RELEASE_ENTRIES in scripts/tasks/init.js). */
|
|
28
|
+
export const HELPERS = {
|
|
29
|
+
deploy: [
|
|
30
|
+
'scripts/deploy-plan.mjs',
|
|
31
|
+
'scripts/record-deployment.mjs',
|
|
32
|
+
'scripts/release-artifact.mjs',
|
|
33
|
+
'scripts/check-migrations.mjs',
|
|
34
|
+
],
|
|
35
|
+
promote: [
|
|
36
|
+
'scripts/deploy-plan.mjs',
|
|
37
|
+
'scripts/promote-check.mjs',
|
|
38
|
+
'scripts/record-deployment.mjs',
|
|
39
|
+
'scripts/release-artifact.mjs',
|
|
40
|
+
'scripts/release-notes.mjs',
|
|
41
|
+
],
|
|
42
|
+
rollback: ['scripts/deploy-plan.mjs', 'scripts/record-deployment.mjs'],
|
|
43
|
+
release: ['scripts/deploy-plan.mjs', 'scripts/package-release.mjs'],
|
|
44
|
+
};
|
|
45
|
+
/** The secrets a rendered workflow may read: each lives in a GitHub environment only the default branch can use. */
|
|
46
|
+
const SECRETS = new Set(['CLOUDFLARE_API_TOKEN', 'NPM_TOKEN']);
|
|
47
|
+
|
|
48
|
+
/** A config's or a workflow's mistake, in words the owner or the agent can act on. */
|
|
49
|
+
export class PipelineError extends Error {}
|
|
50
|
+
|
|
51
|
+
const KEYS = [
|
|
52
|
+
'workers',
|
|
53
|
+
'wranglerEnv',
|
|
54
|
+
'branch',
|
|
55
|
+
'checks',
|
|
56
|
+
'install',
|
|
57
|
+
'build',
|
|
58
|
+
'beforeDeploy',
|
|
59
|
+
'deployPaths',
|
|
60
|
+
'healthCheck',
|
|
61
|
+
'package',
|
|
62
|
+
];
|
|
63
|
+
const WORKER = /^[\w.-]{1,100}$/u;
|
|
64
|
+
const WRANGLER_ENV = /^[\w-]{1,64}$/u;
|
|
65
|
+
const BRANCH = /^(?!.*\.\.)[A-Za-z0-9._/-]{1,100}$/u;
|
|
66
|
+
const PACKAGE = /^(?:@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$/u;
|
|
67
|
+
const VERSION = /^\d+\.\d+\.\d+$/u;
|
|
68
|
+
|
|
69
|
+
/** An example config, the one `pipeline init` prints when a repository has none. */
|
|
70
|
+
export const EXAMPLE = {
|
|
71
|
+
workers: { staging: 'widgets-staging', production: 'widgets' },
|
|
72
|
+
checks: ['CI'],
|
|
73
|
+
install: 'npm ci',
|
|
74
|
+
build: 'npm run build',
|
|
75
|
+
beforeDeploy: ['npx wrangler d1 migrations apply DB --remote --env $BREAKAWAY_ENV'],
|
|
76
|
+
deployPaths: { widgets: '^(src|public|migrations)/|^wrangler\\.jsonc$|^package(-lock)?\\.json$' },
|
|
77
|
+
healthCheck: { staging: 'https://widgets-staging.example.workers.dev/', production: 'https://widgets.example.com/' },
|
|
78
|
+
package: { name: 'widgets', directory: '.', access: 'public' },
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const isObject = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
82
|
+
const bad = (message) => {
|
|
83
|
+
throw new PipelineError(message);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
function objectOf(value, field, keys) {
|
|
87
|
+
if (!isObject(value)) bad(`${field} is an object with ${keys.join(' and ')}.`);
|
|
88
|
+
for (const key of Object.keys(value))
|
|
89
|
+
if (!keys.includes(key)) bad(`${field} has no "${key.slice(0, 40)}"; it has ${keys.join(' and ')}.`);
|
|
90
|
+
return value;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** A command the workflows run as it is: one line, and nothing Actions would expand first. */
|
|
94
|
+
function command(value, field) {
|
|
95
|
+
if (typeof value !== 'string' || !value.trim()) bad(`${field} is a command, like "npm ci".`);
|
|
96
|
+
if (/[\r\n]/u.test(value)) bad(`${field} is one line: put several commands in beforeDeploy, one each.`);
|
|
97
|
+
if (value.length > 500) bad(`${field} is 500 characters at most: put a longer one in a script.`);
|
|
98
|
+
if (value.includes('${{')) bad(`${field} can't hold \${{ … }}: Actions would expand it before the shell runs.`);
|
|
99
|
+
return value.trim();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function url(value, field) {
|
|
103
|
+
if (typeof value !== 'string' || !/^https?:\/\/[^\s"'`$\\]+$/u.test(value))
|
|
104
|
+
bad(`${field} is an address to check after a deploy, like https://widgets-staging.example.workers.dev/.`);
|
|
105
|
+
return value;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The config, checked and with its defaults: the field that's wrong is named, and what it should be. `packageJson`
|
|
110
|
+
* is the parsed package.json in the package's directory (null when there is none), read only when the config names a
|
|
111
|
+
* package.
|
|
112
|
+
* @param {any} raw
|
|
113
|
+
* @param {{ packageJson?: (directory: string) => any }} [read]
|
|
114
|
+
*/
|
|
115
|
+
export function checkConfig(raw, { packageJson = () => null } = {}) {
|
|
116
|
+
if (!isObject(raw)) bad(`${CONFIG_PATH} is a JSON object: npx breakaway pipeline init prints an example.`);
|
|
117
|
+
for (const key of Object.keys(raw))
|
|
118
|
+
if (!KEYS.includes(key)) bad(`${CONFIG_PATH} has no "${key.slice(0, 40)}"; it has ${KEYS.join(', ')}.`);
|
|
119
|
+
if (raw.workers == null && raw.package == null)
|
|
120
|
+
bad(
|
|
121
|
+
`${CONFIG_PATH} needs workers (a staging and a production Worker to deploy), package (an npm package to release), or both.`,
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
let workers = null;
|
|
125
|
+
if (raw.workers != null) {
|
|
126
|
+
objectOf(raw.workers, 'workers', ['staging', 'production']);
|
|
127
|
+
for (const key of ['staging', 'production'])
|
|
128
|
+
if (typeof raw.workers[key] !== 'string' || !WORKER.test(raw.workers[key]))
|
|
129
|
+
bad(
|
|
130
|
+
`workers.${key} is the ${key} Worker's name: letters, digits, dots, hyphens, and underscores, like widgets${key === 'staging' ? '-staging' : ''}.`,
|
|
131
|
+
);
|
|
132
|
+
if (raw.workers.staging === raw.workers.production)
|
|
133
|
+
bad('workers.staging and workers.production are two different Workers.');
|
|
134
|
+
workers = { staging: raw.workers.staging, production: raw.workers.production };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const wranglerEnv = { staging: null, production: null };
|
|
138
|
+
if (raw.wranglerEnv != null) {
|
|
139
|
+
if (!workers) bad('wranglerEnv is for workers: leave it out when the repository deploys none.');
|
|
140
|
+
objectOf(raw.wranglerEnv, 'wranglerEnv', ['staging', 'production']);
|
|
141
|
+
for (const key of ['staging', 'production']) {
|
|
142
|
+
const value = raw.wranglerEnv[key];
|
|
143
|
+
if (value == null || value === '') continue;
|
|
144
|
+
if (typeof value !== 'string' || !WRANGLER_ENV.test(value))
|
|
145
|
+
bad(`wranglerEnv.${key} is the name of an environment in the wrangler config, like ${key}.`);
|
|
146
|
+
wranglerEnv[key] = value;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const branch = raw.branch ?? 'main';
|
|
151
|
+
if (typeof branch !== 'string' || !BRANCH.test(branch)) bad('branch is the default branch, like main.');
|
|
152
|
+
|
|
153
|
+
if (!Array.isArray(raw.checks) || !raw.checks.length)
|
|
154
|
+
bad('checks names the workflows that must pass before a deploy or a release, like ["CI"]: the name: line of each.');
|
|
155
|
+
if (raw.checks.length > 20) bad('checks names 20 workflows at most.');
|
|
156
|
+
const checks = raw.checks.map((name, i) => {
|
|
157
|
+
if (typeof name !== 'string' || !name.trim() || name.length > 100 || /[\r\n]|\$\{\{/u.test(name))
|
|
158
|
+
bad(`checks[${i}] is a workflow's name, as its name: line says, like CI.`);
|
|
159
|
+
return name.trim();
|
|
160
|
+
});
|
|
161
|
+
if (new Set(checks).size !== checks.length) bad('checks names each workflow once.');
|
|
162
|
+
|
|
163
|
+
const install = command(raw.install ?? 'npm ci', 'install');
|
|
164
|
+
const build = raw.build == null || raw.build === '' ? '' : command(raw.build, 'build');
|
|
165
|
+
if (raw.beforeDeploy != null && !Array.isArray(raw.beforeDeploy))
|
|
166
|
+
bad('beforeDeploy is a list of commands that run before each deploy, like ["npm run migrate"].');
|
|
167
|
+
if (raw.beforeDeploy?.length && !workers)
|
|
168
|
+
bad('beforeDeploy runs before a Worker deploys: leave it out when the repository deploys none.');
|
|
169
|
+
const beforeDeploy = (raw.beforeDeploy ?? []).map((value, i) => command(value, `beforeDeploy[${i}]`));
|
|
170
|
+
|
|
171
|
+
let deployPaths = null;
|
|
172
|
+
if (workers) {
|
|
173
|
+
if (!isObject(raw.deployPaths) || !Object.keys(raw.deployPaths).length)
|
|
174
|
+
bad(
|
|
175
|
+
'deployPaths says which files need a deploy, as { "<worker>": "<regular expression>" }: the board reads the same file.',
|
|
176
|
+
);
|
|
177
|
+
deployPaths = {};
|
|
178
|
+
for (const [worker, pattern] of Object.entries(raw.deployPaths)) {
|
|
179
|
+
if (!WORKER.test(worker)) bad(`deployPaths has "${worker.slice(0, 40)}", which isn't a Worker name.`);
|
|
180
|
+
if (typeof pattern !== 'string' || !pattern || pattern.length > 2000)
|
|
181
|
+
bad(`deployPaths.${worker} is a regular expression of the paths that need a deploy, like ^src/.`);
|
|
182
|
+
try {
|
|
183
|
+
new RegExp(pattern, 'u');
|
|
184
|
+
} catch {
|
|
185
|
+
bad(`deployPaths.${worker} isn't a regular expression: ${pattern.slice(0, 80)}`);
|
|
186
|
+
}
|
|
187
|
+
deployPaths[worker] = pattern;
|
|
188
|
+
}
|
|
189
|
+
} else if (raw.deployPaths != null) bad('deployPaths is for workers: leave it out when the repository deploys none.');
|
|
190
|
+
|
|
191
|
+
const healthCheck = { staging: null, production: null };
|
|
192
|
+
if (raw.healthCheck != null) {
|
|
193
|
+
if (!workers) bad('healthCheck is for workers: leave it out when the repository deploys none.');
|
|
194
|
+
if (typeof raw.healthCheck === 'string') healthCheck.staging = url(raw.healthCheck, 'healthCheck');
|
|
195
|
+
else {
|
|
196
|
+
objectOf(raw.healthCheck, 'healthCheck', ['staging', 'production']);
|
|
197
|
+
for (const key of ['staging', 'production'])
|
|
198
|
+
if (raw.healthCheck[key] != null && raw.healthCheck[key] !== '')
|
|
199
|
+
healthCheck[key] = url(raw.healthCheck[key], `healthCheck.${key}`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
let pkg = null;
|
|
204
|
+
if (raw.package != null) {
|
|
205
|
+
objectOf(raw.package, 'package', ['name', 'directory', 'access']);
|
|
206
|
+
const { name, directory = '.', access = 'public' } = raw.package;
|
|
207
|
+
if (typeof name !== 'string' || name.length > 214 || !PACKAGE.test(name))
|
|
208
|
+
bad("package.name is the npm package's name, as its package.json says, like widgets or @acme/widgets.");
|
|
209
|
+
const dir =
|
|
210
|
+
typeof directory === 'string'
|
|
211
|
+
? directory
|
|
212
|
+
.trim()
|
|
213
|
+
.replace(/^\.\/+/u, '')
|
|
214
|
+
.replace(/\/+$/u, '') || '.'
|
|
215
|
+
: '';
|
|
216
|
+
if (!dir || dir.startsWith('/') || dir.split('/').includes('..') || !/^[\w./@-]+$/u.test(dir))
|
|
217
|
+
bad('package.directory is the folder of its package.json in this repository, like . or packages/widgets.');
|
|
218
|
+
if (!['public', 'restricted'].includes(access))
|
|
219
|
+
bad('package.access is public or restricted, as npm publish --access takes.');
|
|
220
|
+
const json = packageJson(dir);
|
|
221
|
+
const where = dir === '.' ? 'package.json' : `${dir}/package.json`;
|
|
222
|
+
if (!isObject(json)) bad(`package.directory is ${dir}, but ${where} isn't there.`);
|
|
223
|
+
if (json.name !== name)
|
|
224
|
+
bad(`package.name is ${name}, but ${where} names ${json.name ?? 'no package'}: they have to be the same.`);
|
|
225
|
+
if (json.private === true)
|
|
226
|
+
bad(`${where} says "private": true, so npm won't take it: there is no release flow for it.`);
|
|
227
|
+
if (typeof json.version !== 'string' || !VERSION.test(json.version))
|
|
228
|
+
bad(`${where}'s version is ${json.version ?? 'missing'}: the release flow counts from a version like 1.0.0.`);
|
|
229
|
+
pkg = { name, directory: dir, access };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return { workers, wranglerEnv, branch, checks, install, build, beforeDeploy, deployPaths, healthCheck, package: pkg };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* A template with its values: `{{key}}` is replaced inline, a line that is only `{{@key}}` becomes the list's lines at
|
|
237
|
+
* that indentation (none for an empty list), and lines between `{{#if key}}` and `{{/if}}` stay only when the value is
|
|
238
|
+
* truthy.
|
|
239
|
+
* @param {string} template
|
|
240
|
+
* @param {Record<string, any>} values
|
|
241
|
+
*/
|
|
242
|
+
export function fill(template, values) {
|
|
243
|
+
const out = [];
|
|
244
|
+
const keep = [true];
|
|
245
|
+
for (const line of template.split('\n')) {
|
|
246
|
+
const cond = /^\s*\{\{#if (\w+)\}\}\s*$/u.exec(line);
|
|
247
|
+
if (cond) {
|
|
248
|
+
keep.push(keep.at(-1) && Boolean(values[cond[1]]));
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
if (/^\s*\{\{\/if\}\}\s*$/u.test(line)) {
|
|
252
|
+
if (keep.length === 1) throw new Error('{{/if}} without {{#if}}');
|
|
253
|
+
keep.pop();
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
if (!keep.at(-1)) continue;
|
|
257
|
+
const list = /^(\s*)\{\{@(\w+)\}\}\s*$/u.exec(line);
|
|
258
|
+
if (list) {
|
|
259
|
+
const items = values[list[2]];
|
|
260
|
+
if (!Array.isArray(items)) throw new Error(`{{@${list[2]}}} needs a list`);
|
|
261
|
+
for (const item of items) out.push(`${list[1]}${item}`);
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
out.push(
|
|
265
|
+
line.replace(/(?<!\$)\{\{(\w+)\}\}/gu, (_, key) => {
|
|
266
|
+
if (!(key in values)) throw new Error(`the template's {{${key}}} has no value`);
|
|
267
|
+
return String(values[key]);
|
|
268
|
+
}),
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
if (keep.length !== 1) throw new Error('{{#if}} without {{/if}}');
|
|
272
|
+
return out.join('\n');
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const quote = (value) => JSON.stringify(value ?? '');
|
|
276
|
+
|
|
277
|
+
/** The values the templates take, from a checked config. */
|
|
278
|
+
export function valuesOf(config) {
|
|
279
|
+
const { workers, wranglerEnv, branch, checks, install, build, beforeDeploy, healthCheck, package: pkg } = config;
|
|
280
|
+
const envFlag = (name) => (name ? ` --env ${name}` : '');
|
|
281
|
+
return {
|
|
282
|
+
header: HEADER,
|
|
283
|
+
branch: quote(branch),
|
|
284
|
+
branchName: branch,
|
|
285
|
+
checks: `[${checks.map(quote).join(', ')}]`,
|
|
286
|
+
checksEnv: quote(JSON.stringify(checks)),
|
|
287
|
+
install: [install],
|
|
288
|
+
build: build ? [build] : [],
|
|
289
|
+
beforeDeploy,
|
|
290
|
+
staging: quote(workers?.staging),
|
|
291
|
+
production: quote(workers?.production),
|
|
292
|
+
stagingName: workers?.staging ?? '',
|
|
293
|
+
productionName: workers?.production ?? '',
|
|
294
|
+
stagingEnvFlag: envFlag(wranglerEnv.staging),
|
|
295
|
+
productionEnvFlag: envFlag(wranglerEnv.production),
|
|
296
|
+
healthStaging: quote(healthCheck.staging),
|
|
297
|
+
healthProduction: quote(healthCheck.production),
|
|
298
|
+
package: quote(pkg?.name),
|
|
299
|
+
packageName: pkg?.name ?? '',
|
|
300
|
+
packageDirectory: pkg?.directory ?? '.',
|
|
301
|
+
directory: quote(pkg?.directory),
|
|
302
|
+
access: quote(pkg?.access),
|
|
303
|
+
prefix: quote(pkg ? (workers ? `${pkg.name}@` : 'v') : ''),
|
|
304
|
+
prefixName: pkg ? (workers ? `${pkg.name}@` : 'v') : '',
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* The files a checked config renders to, as `[{ path, text }]`: the three Worker workflows and the deploy paths for
|
|
310
|
+
* `workers`, and release.yml for `package`. `readTemplate(name)` gives template/pipeline/<name>.
|
|
311
|
+
*/
|
|
312
|
+
export function renderPipeline(config, readTemplate) {
|
|
313
|
+
const values = valuesOf(config);
|
|
314
|
+
const files = [];
|
|
315
|
+
if (config.workers) {
|
|
316
|
+
for (const name of ['deploy', 'promote', 'rollback'])
|
|
317
|
+
files.push({ path: WORKFLOWS[name], text: fill(readTemplate(`${name}.yml`), values) });
|
|
318
|
+
files.push({ path: DEPLOY_PATHS, text: `${JSON.stringify(config.deployPaths, null, 2)}\n` });
|
|
319
|
+
}
|
|
320
|
+
if (config.package) files.push({ path: WORKFLOWS.release, text: fill(readTemplate('release.yml'), values) });
|
|
321
|
+
return files;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** The helpers the rendered files run, which the repository needs too. */
|
|
325
|
+
export function helpersFor(config) {
|
|
326
|
+
const names = [...(config.workers ? ['deploy', 'promote', 'rollback'] : []), ...(config.package ? ['release'] : [])];
|
|
327
|
+
return [...new Set(names.flatMap((name) => HELPERS[name]))].sort();
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ---- the workflow checks -------------------------------------------------------------------
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* The YAML the templates use, parsed: block mappings and sequences, `|` block scalars, `[a, b]` flow sequences, and
|
|
334
|
+
* plain, single-, and double-quoted scalars. Anything else is an error with its line, so a broken render fails here
|
|
335
|
+
* instead of on GitHub.
|
|
336
|
+
* @param {string} text
|
|
337
|
+
*/
|
|
338
|
+
export function parseYaml(text) {
|
|
339
|
+
const src = text.split('\n');
|
|
340
|
+
let pos = 0;
|
|
341
|
+
const fail = (message, at = pos) => bad(`line ${at + 1}: ${message}`);
|
|
342
|
+
src.forEach((line, i) => {
|
|
343
|
+
if (/^\s*\t/u.test(line)) fail('a tab in the indentation; YAML indents with spaces', i);
|
|
344
|
+
});
|
|
345
|
+
const indentOf = (line) => line.length - line.trimStart().length;
|
|
346
|
+
const blank = (line) => !line.trim() || line.trim().startsWith('#');
|
|
347
|
+
const skip = () => {
|
|
348
|
+
while (pos < src.length && blank(src[pos])) pos += 1;
|
|
349
|
+
};
|
|
350
|
+
const isItem = (t) => t === '-' || t.startsWith('- ');
|
|
351
|
+
const KEY = /^("[^"]*"|'[^']*'|[^\s'"#[\]{}&*!|>%@`-][^:#]*?|-[^\s:#][^:#]*?):(?:\s+(.*))?$/u;
|
|
352
|
+
|
|
353
|
+
function scalar(raw) {
|
|
354
|
+
const s = raw.trim();
|
|
355
|
+
if (!s) return null;
|
|
356
|
+
if (s[0] === '"') {
|
|
357
|
+
let end = 1;
|
|
358
|
+
while (end < s.length && s[end] !== '"') end += s[end] === '\\' ? 2 : 1;
|
|
359
|
+
if (end >= s.length) fail('a double-quoted string that never ends');
|
|
360
|
+
const rest = s.slice(end + 1).trim();
|
|
361
|
+
if (rest && !rest.startsWith('#')) fail(`text after a quoted string: ${rest.slice(0, 40)}`);
|
|
362
|
+
return JSON.parse(s.slice(0, end + 1));
|
|
363
|
+
}
|
|
364
|
+
if (s[0] === "'") {
|
|
365
|
+
const m = /^'((?:[^']|'')*)'\s*(#.*)?$/u.exec(s);
|
|
366
|
+
if (!m) fail('a single-quoted string that never ends, or text after it');
|
|
367
|
+
return m[1].replace(/''/gu, "'");
|
|
368
|
+
}
|
|
369
|
+
if (s[0] === '[') {
|
|
370
|
+
const end = s.indexOf(']');
|
|
371
|
+
if (end < 0) fail('a [list] that never ends');
|
|
372
|
+
const rest = s.slice(end + 1).trim();
|
|
373
|
+
if (rest && !rest.startsWith('#')) fail(`text after a [list]: ${rest.slice(0, 40)}`);
|
|
374
|
+
const inner = s.slice(1, end).trim();
|
|
375
|
+
return inner ? (inner.match(/"(?:[^"\\]|\\.)*"|'(?:[^']|'')*'|[^,]+/gu) ?? []).map((item) => scalar(item)) : [];
|
|
376
|
+
}
|
|
377
|
+
if (s === '{}') return {};
|
|
378
|
+
if (/^[{&*!|>%@`]/u.test(s)) fail(`a value the templates don't use: ${s.slice(0, 40)}`);
|
|
379
|
+
const plain = s.replace(/\s+#.*$/u, '');
|
|
380
|
+
if (/:\s/u.test(plain) || plain.endsWith(':'))
|
|
381
|
+
fail(`a plain value can't hold ": " (quote it): ${plain.slice(0, 60)}`);
|
|
382
|
+
if (plain === 'true' || plain === 'false') return plain === 'true';
|
|
383
|
+
if (/^-?\d+$/u.test(plain)) return Number(plain);
|
|
384
|
+
return plain;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function blockScalar(parent) {
|
|
388
|
+
const lines = [];
|
|
389
|
+
let indent = null;
|
|
390
|
+
while (pos < src.length) {
|
|
391
|
+
const line = src[pos];
|
|
392
|
+
if (!line.trim()) {
|
|
393
|
+
lines.push('');
|
|
394
|
+
pos += 1;
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
const n = indentOf(line);
|
|
398
|
+
if (n <= parent) break;
|
|
399
|
+
indent ??= n;
|
|
400
|
+
if (n < indent) fail('a line of the block less indented than its first');
|
|
401
|
+
lines.push(line.slice(indent));
|
|
402
|
+
pos += 1;
|
|
403
|
+
}
|
|
404
|
+
while (lines.length && lines.at(-1) === '') lines.pop();
|
|
405
|
+
if (!lines.length) fail('an empty block', pos - 1);
|
|
406
|
+
return `${lines.join('\n')}\n`;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function entry(obj, content, indent) {
|
|
410
|
+
const m = KEY.exec(content);
|
|
411
|
+
if (!m) fail(`expected "key: value": ${content.slice(0, 60)}`);
|
|
412
|
+
const key = /^["']/u.test(m[1]) ? m[1].slice(1, -1) : m[1].trim();
|
|
413
|
+
if (Object.hasOwn(obj, key)) fail(`"${key}" twice in one mapping`);
|
|
414
|
+
const rest = (m[2] ?? '').trim();
|
|
415
|
+
if (rest && !rest.startsWith('#') && !/^\|-?(\s+#.*)?$/u.test(rest)) {
|
|
416
|
+
if (rest.startsWith('>')) fail('folded blocks (>) are not used here: use |');
|
|
417
|
+
obj[key] = scalar(rest);
|
|
418
|
+
pos += 1;
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
pos += 1;
|
|
422
|
+
if (!rest || rest.startsWith('#')) {
|
|
423
|
+
skip();
|
|
424
|
+
const next = src[pos];
|
|
425
|
+
if (next !== undefined && indentOf(next) === indent && isItem(next.trim())) obj[key] = sequence(indent);
|
|
426
|
+
else obj[key] = block(indent + 1);
|
|
427
|
+
} else obj[key] = blockScalar(indent);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function mapping(indent, obj = {}) {
|
|
431
|
+
for (;;) {
|
|
432
|
+
skip();
|
|
433
|
+
if (pos >= src.length) return obj;
|
|
434
|
+
const n = indentOf(src[pos]);
|
|
435
|
+
if (n < indent) return obj;
|
|
436
|
+
if (n > indent) fail('more indented than the lines before it');
|
|
437
|
+
const t = src[pos].trim();
|
|
438
|
+
if (isItem(t)) return obj;
|
|
439
|
+
entry(obj, t, indent);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function sequence(indent) {
|
|
444
|
+
const list = [];
|
|
445
|
+
for (;;) {
|
|
446
|
+
skip();
|
|
447
|
+
if (pos >= src.length) return list;
|
|
448
|
+
const n = indentOf(src[pos]);
|
|
449
|
+
if (n < indent) return list;
|
|
450
|
+
if (n > indent) fail('more indented than the lines before it');
|
|
451
|
+
const t = src[pos].trim();
|
|
452
|
+
if (!isItem(t)) return list;
|
|
453
|
+
const content = t.slice(1).trimStart();
|
|
454
|
+
const at = n + (t.length - content.length);
|
|
455
|
+
if (!content) {
|
|
456
|
+
pos += 1;
|
|
457
|
+
list.push(block(indent + 1));
|
|
458
|
+
} else if (KEY.test(content) && !/^["'[]/u.test(content.split(':')[0])) {
|
|
459
|
+
const obj = {};
|
|
460
|
+
entry(obj, content, at);
|
|
461
|
+
list.push(mapping(at, obj));
|
|
462
|
+
} else {
|
|
463
|
+
list.push(scalar(content));
|
|
464
|
+
pos += 1;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function block(min) {
|
|
470
|
+
skip();
|
|
471
|
+
if (pos >= src.length) return null;
|
|
472
|
+
const n = indentOf(src[pos]);
|
|
473
|
+
if (n < min) return null;
|
|
474
|
+
return isItem(src[pos].trim()) ? sequence(n) : mapping(n);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const doc = block(0);
|
|
478
|
+
skip();
|
|
479
|
+
if (pos < src.length) fail('a line outside the document');
|
|
480
|
+
return doc ?? {};
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const CONTEXTS = new Set([
|
|
484
|
+
'github',
|
|
485
|
+
'inputs',
|
|
486
|
+
'secrets',
|
|
487
|
+
'vars',
|
|
488
|
+
'steps',
|
|
489
|
+
'env',
|
|
490
|
+
'job',
|
|
491
|
+
'needs',
|
|
492
|
+
'runner',
|
|
493
|
+
'matrix',
|
|
494
|
+
'strategy',
|
|
495
|
+
]);
|
|
496
|
+
|
|
497
|
+
/** The `${{ }}` expressions in a value, and the value itself when it's an `if:` (an expression without the braces). */
|
|
498
|
+
function expressionsIn(text, isIf) {
|
|
499
|
+
const found = [...String(text).matchAll(/\$\{\{([\s\S]*?)\}\}/gu)].map((m) => m[1]);
|
|
500
|
+
if (isIf && !found.length) found.push(String(text));
|
|
501
|
+
return found;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** Every string under a value, with whether it's an `if:`. */
|
|
505
|
+
function stringsIn(value, key = '') {
|
|
506
|
+
if (typeof value === 'string') return [{ text: value, isIf: key === 'if' }];
|
|
507
|
+
if (Array.isArray(value)) return value.flatMap((item) => stringsIn(item));
|
|
508
|
+
if (isObject(value)) return Object.entries(value).flatMap(([k, v]) => stringsIn(v, k));
|
|
509
|
+
return [];
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* The problems GitHub (or actionlint) would find in a rendered workflow, as messages; none when it's sound. It parses
|
|
514
|
+
* the YAML, then checks the shape (on, permissions, jobs with runs-on, a timeout, and steps), that every action is
|
|
515
|
+
* pinned to a commit, that every expression names a context Actions has, a step that ran before, an input the workflow
|
|
516
|
+
* takes, or a secret the pipeline uses, that secrets are read only in a job with an environment, that run scripts take
|
|
517
|
+
* no `${{ }}` (they read env instead), and that nothing of the template is left over.
|
|
518
|
+
* @param {string} text
|
|
519
|
+
* @returns {string[]}
|
|
520
|
+
*/
|
|
521
|
+
export function lintWorkflow(text) {
|
|
522
|
+
const problems = [];
|
|
523
|
+
const leftover = text.replace(/\$\{\{[\s\S]*?\}\}/gu, '').match(/\{\{[#/@]?\w+\}\}/u);
|
|
524
|
+
if (leftover) problems.push(`the template's ${leftover[0]} is left over`);
|
|
525
|
+
let doc;
|
|
526
|
+
try {
|
|
527
|
+
doc = parseYaml(text);
|
|
528
|
+
} catch (error) {
|
|
529
|
+
return [...problems, error.message];
|
|
530
|
+
}
|
|
531
|
+
if (typeof doc.name !== 'string' || !doc.name) problems.push('it has no name');
|
|
532
|
+
if (!isObject(doc.on)) problems.push('it has no on: triggers');
|
|
533
|
+
else if ('pull_request_target' in doc.on) problems.push('pull_request_target runs untrusted code with secrets');
|
|
534
|
+
if (!isObject(doc.permissions) || Object.keys(doc.permissions).length)
|
|
535
|
+
problems.push('the workflow sets permissions: {}, and each job asks for what it needs');
|
|
536
|
+
if (!isObject(doc.jobs) || !Object.keys(doc.jobs).length) return [...problems, 'it has no jobs'];
|
|
537
|
+
const inputs = Object.keys(doc.on?.workflow_dispatch?.inputs ?? {});
|
|
538
|
+
for (const [name, input] of Object.entries(doc.on?.workflow_dispatch?.inputs ?? {}))
|
|
539
|
+
if (!['string', 'boolean', 'choice', 'number', 'environment'].includes(input?.type))
|
|
540
|
+
problems.push(`input ${name} has no type`);
|
|
541
|
+
|
|
542
|
+
const checkExpressions = (strings, where, steps, jobLevel) => {
|
|
543
|
+
for (const { text: value, isIf } of strings)
|
|
544
|
+
for (const expression of expressionsIn(value, isIf)) {
|
|
545
|
+
const code = expression.replace(/'(?:[^']|'')*'/gu, "''");
|
|
546
|
+
for (const m of code.matchAll(/(?<![\w.-])([A-Za-z_][\w-]*)((?:\.[\w-]+)*)/gu)) {
|
|
547
|
+
const [, context, path] = m;
|
|
548
|
+
if (!path) {
|
|
549
|
+
if (!/^\s*\(/u.test(code.slice(m.index + context.length)) && !['true', 'false', 'null'].includes(context))
|
|
550
|
+
problems.push(`${where}: "${context}" isn't a context or a function`);
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
553
|
+
const [first] = path.slice(1).split('.');
|
|
554
|
+
if (!CONTEXTS.has(context)) problems.push(`${where}: no context "${context}"`);
|
|
555
|
+
else if (context === 'secrets' && !SECRETS.has(first))
|
|
556
|
+
problems.push(`${where}: the pipeline uses no secret ${first}`);
|
|
557
|
+
else if (context === 'inputs' && !inputs.includes(first)) problems.push(`${where}: no input ${first}`);
|
|
558
|
+
else if (context === 'steps' && !steps.includes(first))
|
|
559
|
+
problems.push(`${where}: no step ${first} before this one`);
|
|
560
|
+
else if (context === 'env' && jobLevel) problems.push(`${where}: env isn't available here`);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
};
|
|
564
|
+
checkExpressions(stringsIn({ on: doc.on, env: doc.env, concurrency: doc.concurrency }), 'the workflow', [], false);
|
|
565
|
+
|
|
566
|
+
for (const [jobName, job] of Object.entries(doc.jobs)) {
|
|
567
|
+
const where = `job ${jobName}`;
|
|
568
|
+
if (!isObject(job)) {
|
|
569
|
+
problems.push(`${where} isn't a mapping`);
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
572
|
+
if (!job['runs-on']) problems.push(`${where} has no runs-on`);
|
|
573
|
+
if (typeof job['timeout-minutes'] !== 'number') problems.push(`${where} has no timeout-minutes`);
|
|
574
|
+
if (!isObject(job.permissions)) problems.push(`${where} doesn't say its permissions`);
|
|
575
|
+
for (const need of [job.needs ?? []].flat())
|
|
576
|
+
if (!(need in doc.jobs)) problems.push(`${where} needs ${need}, which isn't a job`);
|
|
577
|
+
const { steps = [], ...rest } = job;
|
|
578
|
+
checkExpressions(stringsIn({ if: rest.if }), where, [], true);
|
|
579
|
+
checkExpressions(stringsIn({ ...rest, if: undefined }), where, [], false);
|
|
580
|
+
if (stringsIn(job).some(({ text: value }) => /\bsecrets\./u.test(value)) && !job.environment)
|
|
581
|
+
problems.push(`${where} reads a secret outside an environment`);
|
|
582
|
+
if (!Array.isArray(steps) || !steps.length) {
|
|
583
|
+
problems.push(`${where} has no steps`);
|
|
584
|
+
continue;
|
|
585
|
+
}
|
|
586
|
+
const ids = [];
|
|
587
|
+
steps.forEach((step, i) => {
|
|
588
|
+
const at = `${where}, step ${i + 1}${step?.name ? ` (${step.name})` : ''}`;
|
|
589
|
+
if (!isObject(step)) {
|
|
590
|
+
problems.push(`${at} isn't a mapping`);
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
if ('uses' in step === 'run' in step) problems.push(`${at} has one of uses or run`);
|
|
594
|
+
if ('uses' in step && !/^[\w.-]+\/[\w./-]+@[0-9a-f]{40}$/u.test(String(step.uses)))
|
|
595
|
+
problems.push(`${at} uses ${step.uses}, which isn't pinned to a commit`);
|
|
596
|
+
if (typeof step.run === 'string' && step.run.includes('${{'))
|
|
597
|
+
problems.push(`${at} puts \${{ }} in its script: pass it through env`);
|
|
598
|
+
checkExpressions(stringsIn(step), at, ids, false);
|
|
599
|
+
if (step.id !== undefined) {
|
|
600
|
+
if (ids.includes(step.id)) problems.push(`${at} repeats the id ${step.id}`);
|
|
601
|
+
ids.push(String(step.id));
|
|
602
|
+
}
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
return problems;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/** The run scripts of a parsed workflow, `[{ where, script }]`, for a shell's syntax check. */
|
|
609
|
+
export function runScripts(text) {
|
|
610
|
+
const doc = parseYaml(text);
|
|
611
|
+
return Object.entries(doc.jobs ?? {}).flatMap(([job, { steps = [] }]) =>
|
|
612
|
+
steps
|
|
613
|
+
.filter((s) => typeof s.run === 'string')
|
|
614
|
+
.map((s, i) => ({ where: `${job}: ${s.name ?? `step ${i + 1}`}`, script: s.run })),
|
|
615
|
+
);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// ---- the command ---------------------------------------------------------------------------
|
|
619
|
+
|
|
620
|
+
const TEMPLATES = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'template', 'pipeline');
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* What `pipeline init` does with each rendered file, given what the repository has there (`readTarget`, null when
|
|
624
|
+
* nothing): write it, leave it (already the same), or refuse it (a file of the repository's own, or one rendered
|
|
625
|
+
* before when `update` isn't set).
|
|
626
|
+
* @param {Array<{ path: string, text: string }>} files
|
|
627
|
+
* @param {(path: string) => string | null} readTarget
|
|
628
|
+
* @param {{ update?: boolean }} [options]
|
|
629
|
+
*/
|
|
630
|
+
export function initPlan(files, readTarget, { update = false } = {}) {
|
|
631
|
+
const write = [];
|
|
632
|
+
const same = [];
|
|
633
|
+
const refused = [];
|
|
634
|
+
for (const file of files) {
|
|
635
|
+
const there = readTarget(file.path);
|
|
636
|
+
if (there === null) write.push(file);
|
|
637
|
+
else if (there === file.text) same.push(file.path);
|
|
638
|
+
else if (!update)
|
|
639
|
+
refused.push({
|
|
640
|
+
path: file.path,
|
|
641
|
+
reason: 'it differs from what the config renders: run pipeline init --update to replace it',
|
|
642
|
+
});
|
|
643
|
+
else if (file.path === DEPLOY_PATHS || there.startsWith(`# ${HEADER}`)) write.push(file);
|
|
644
|
+
else
|
|
645
|
+
refused.push({
|
|
646
|
+
path: file.path,
|
|
647
|
+
reason:
|
|
648
|
+
"it's the repository's own, not rendered: move what it does into the config, or rename it, then run pipeline init again",
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
return { write, same, refused };
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/** The checkout's top folder, or the folder the command runs in. */
|
|
655
|
+
function topOf(cwd) {
|
|
656
|
+
try {
|
|
657
|
+
return execFileSync('git', ['rev-parse', '--show-toplevel'], {
|
|
658
|
+
cwd,
|
|
659
|
+
encoding: 'utf8',
|
|
660
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
661
|
+
}).trim();
|
|
662
|
+
} catch {
|
|
663
|
+
return cwd;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/** The repository's config, checked, and the files it renders. */
|
|
668
|
+
export function loadPipeline(root, templates = TEMPLATES) {
|
|
669
|
+
const at = join(root, CONFIG_PATH);
|
|
670
|
+
if (!existsSync(at))
|
|
671
|
+
bad(
|
|
672
|
+
`${CONFIG_PATH} isn't here. Write it, like this example (workers, package, or both), then run this again:\n${JSON.stringify(EXAMPLE, null, 2)}`,
|
|
673
|
+
);
|
|
674
|
+
let raw;
|
|
675
|
+
try {
|
|
676
|
+
raw = JSON.parse(readFileSync(at, 'utf8'));
|
|
677
|
+
} catch (error) {
|
|
678
|
+
bad(`${CONFIG_PATH} isn't JSON: ${error.message}`);
|
|
679
|
+
}
|
|
680
|
+
const config = checkConfig(raw, {
|
|
681
|
+
packageJson: (dir) => {
|
|
682
|
+
try {
|
|
683
|
+
return JSON.parse(readFileSync(join(root, dir, 'package.json'), 'utf8'));
|
|
684
|
+
} catch {
|
|
685
|
+
return null;
|
|
686
|
+
}
|
|
687
|
+
},
|
|
688
|
+
});
|
|
689
|
+
return { config, files: renderPipeline(config, (name) => readFileSync(join(templates, name), 'utf8')) };
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
const readIn = (root) => (path) => (existsSync(join(root, path)) ? readFileSync(join(root, path), 'utf8') : null);
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* `npx breakaway pipeline init [--update] [--dry-run]` and `pipeline check`, in the checkout the command runs in.
|
|
696
|
+
* @param {string[]} args
|
|
697
|
+
* @param {Record<string, any>} opts
|
|
698
|
+
* @param {{ cwd?: string, log?: (line: string) => void, error?: (line: string) => void }} [io]
|
|
699
|
+
* @returns {number} the exit code
|
|
700
|
+
*/
|
|
701
|
+
export function run(args, opts, { cwd = process.cwd(), log = console.log, error = console.error } = {}) {
|
|
702
|
+
const [step] = args;
|
|
703
|
+
const root = topOf(cwd);
|
|
704
|
+
try {
|
|
705
|
+
if (step !== 'init' && step !== 'check')
|
|
706
|
+
bad('pipeline has init and check. npx breakaway help says what each does.');
|
|
707
|
+
const { config, files } = loadPipeline(root);
|
|
708
|
+
const read = readIn(root);
|
|
709
|
+
const problems = files
|
|
710
|
+
.filter((f) => f.path.endsWith('.yml'))
|
|
711
|
+
.flatMap((f) => lintWorkflow(f.text).map((p) => `${f.path}: ${p}`));
|
|
712
|
+
if (problems.length) bad(`The rendered workflows have problems, so nothing was written:\n${problems.join('\n')}`);
|
|
713
|
+
const missing = helpersFor(config).filter((path) => read(path) === null);
|
|
714
|
+
const helpersLine = missing.length
|
|
715
|
+
? `The workflows run ${missing.join(', ')}, which this repository doesn't have yet: npx breakaway repos init <slug> --update copies them.`
|
|
716
|
+
: null;
|
|
717
|
+
if (step === 'check') {
|
|
718
|
+
const stale = files.filter((f) => read(f.path) !== f.text).map((f) => f.path);
|
|
719
|
+
const orphan = !config.package && read(WORKFLOWS.release)?.startsWith(`# ${HEADER}`) ? [WORKFLOWS.release] : [];
|
|
720
|
+
if (stale.length || orphan.length || missing.length) {
|
|
721
|
+
for (const path of stale)
|
|
722
|
+
error(`${path} isn't what ${CONFIG_PATH} renders: run npx breakaway pipeline init --update.`);
|
|
723
|
+
for (const path of orphan) error(`${path} was rendered for a package the config no longer names: delete it.`);
|
|
724
|
+
if (helpersLine) error(helpersLine);
|
|
725
|
+
return 1;
|
|
726
|
+
}
|
|
727
|
+
log(`${CONFIG_PATH} is sound, and ${files.map((f) => f.path).join(', ')} are what it renders.`);
|
|
728
|
+
return 0;
|
|
729
|
+
}
|
|
730
|
+
const plan = initPlan(files, read, { update: Boolean(opts.update) });
|
|
731
|
+
if (!opts['dry-run'])
|
|
732
|
+
for (const file of plan.write) {
|
|
733
|
+
mkdirSync(dirname(join(root, file.path)), { recursive: true });
|
|
734
|
+
writeFileSync(join(root, file.path), file.text);
|
|
735
|
+
}
|
|
736
|
+
const verb = opts['dry-run'] ? 'Would write' : 'Wrote';
|
|
737
|
+
for (const file of plan.write) log(`${verb} ${file.path}`);
|
|
738
|
+
for (const path of plan.same) log(`${path} is already current`);
|
|
739
|
+
for (const { path, reason } of plan.refused) error(`Left ${path}: ${reason}.`);
|
|
740
|
+
if (helpersLine) log(helpersLine);
|
|
741
|
+
return plan.refused.length ? 1 : 0;
|
|
742
|
+
} catch (e) {
|
|
743
|
+
if (!(e instanceof PipelineError)) throw e;
|
|
744
|
+
error(`pipeline: ${e.message}`);
|
|
745
|
+
return 1;
|
|
746
|
+
}
|
|
747
|
+
}
|