breakaway 2.0.0-main.98 → 2.0.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,180 @@
1
+ /**
2
+ * Rendering and checking a workflow from a template (BRK-90, BRK-307): `fill` puts a template's values in, and
3
+ * `lintWorkflow` finds what GitHub (or actionlint) would refuse in the result. Pure and Node-safe, so `pipeline init`,
4
+ * `infra init`, and the Worker (the board's change brings the apply workflow, src/infra-runner-render.js) render and
5
+ * check the same way.
6
+ */
7
+ import { parseYaml } from './yaml.js';
8
+
9
+ /** The secrets a rendered workflow may read: each lives in a GitHub environment only the default branch can use. */
10
+ const SECRETS = new Set(['CLOUDFLARE_API_TOKEN', 'NPM_TOKEN']);
11
+
12
+ const isObject = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
13
+
14
+ /**
15
+ * A template with its values: `{{key}}` is replaced inline, a line that is only `{{@key}}` becomes the list's lines at
16
+ * that indentation (none for an empty list), and lines between `{{#if key}}` and `{{/if}}` stay only when the value is
17
+ * truthy.
18
+ * @param {string} template
19
+ * @param {Record<string, any>} values
20
+ */
21
+ export function fill(template, values) {
22
+ const out = [];
23
+ const keep = [true];
24
+ for (const line of template.split('\n')) {
25
+ const cond = /^\s*\{\{#if (\w+)\}\}\s*$/u.exec(line);
26
+ if (cond) {
27
+ keep.push(keep.at(-1) && Boolean(values[cond[1]]));
28
+ continue;
29
+ }
30
+ if (/^\s*\{\{\/if\}\}\s*$/u.test(line)) {
31
+ if (keep.length === 1) throw new Error('{{/if}} without {{#if}}');
32
+ keep.pop();
33
+ continue;
34
+ }
35
+ if (!keep.at(-1)) continue;
36
+ const list = /^(\s*)\{\{@(\w+)\}\}\s*$/u.exec(line);
37
+ if (list) {
38
+ const items = values[list[2]];
39
+ if (!Array.isArray(items)) throw new Error(`{{@${list[2]}}} needs a list`);
40
+ for (const item of items) out.push(`${list[1]}${item}`);
41
+ continue;
42
+ }
43
+ out.push(
44
+ line.replace(/(?<!\$)\{\{(\w+)\}\}/gu, (_, key) => {
45
+ if (!(key in values)) throw new Error(`the template's {{${key}}} has no value`);
46
+ return String(values[key]);
47
+ }),
48
+ );
49
+ }
50
+ if (keep.length !== 1) throw new Error('{{#if}} without {{/if}}');
51
+ return out.join('\n');
52
+ }
53
+
54
+ const CONTEXTS = new Set([
55
+ 'github',
56
+ 'inputs',
57
+ 'secrets',
58
+ 'vars',
59
+ 'steps',
60
+ 'env',
61
+ 'job',
62
+ 'needs',
63
+ 'runner',
64
+ 'matrix',
65
+ 'strategy',
66
+ ]);
67
+
68
+ /** The `${{ }}` expressions in a value, and the value itself when it's an `if:` (an expression without the braces). */
69
+ function expressionsIn(text, isIf) {
70
+ const found = [...String(text).matchAll(/\$\{\{([\s\S]*?)\}\}/gu)].map((m) => m[1]);
71
+ if (isIf && !found.length) found.push(String(text));
72
+ return found;
73
+ }
74
+
75
+ /** Every string under a value, with whether it's an `if:`. */
76
+ function stringsIn(value, key = '') {
77
+ if (typeof value === 'string') return [{ text: value, isIf: key === 'if' }];
78
+ if (Array.isArray(value)) return value.flatMap((item) => stringsIn(item));
79
+ if (isObject(value)) return Object.entries(value).flatMap(([k, v]) => stringsIn(v, k));
80
+ return [];
81
+ }
82
+
83
+ /**
84
+ * The problems GitHub (or actionlint) would find in a rendered workflow, as messages; none when it's sound. It parses
85
+ * the YAML, then checks the shape (on, permissions, jobs with runs-on, a timeout, and steps), that every action is
86
+ * pinned to a commit, that every expression names a context Actions has, a step that ran before, an input the workflow
87
+ * takes, or a secret the pipeline uses, that secrets are read only in a job with an environment, that run scripts take
88
+ * no `${{ }}` (they read env instead), and that nothing of the template is left over.
89
+ * @param {string} text
90
+ * @returns {string[]}
91
+ */
92
+ export function lintWorkflow(text) {
93
+ const problems = [];
94
+ const leftover = text.replace(/\$\{\{[\s\S]*?\}\}/gu, '').match(/\{\{[#/@]?\w+\}\}/u);
95
+ if (leftover) problems.push(`the template's ${leftover[0]} is left over`);
96
+ let doc;
97
+ try {
98
+ doc = parseYaml(text);
99
+ } catch (error) {
100
+ return [...problems, error.message];
101
+ }
102
+ if (typeof doc.name !== 'string' || !doc.name) problems.push('it has no name');
103
+ if (!isObject(doc.on)) problems.push('it has no on: triggers');
104
+ else if ('pull_request_target' in doc.on) problems.push('pull_request_target runs untrusted code with secrets');
105
+ if (!isObject(doc.permissions) || Object.keys(doc.permissions).length)
106
+ problems.push('the workflow sets permissions: {}, and each job asks for what it needs');
107
+ if (!isObject(doc.jobs) || !Object.keys(doc.jobs).length) return [...problems, 'it has no jobs'];
108
+ const inputs = Object.keys(doc.on?.workflow_dispatch?.inputs ?? {});
109
+ for (const [name, input] of Object.entries(doc.on?.workflow_dispatch?.inputs ?? {}))
110
+ if (!['string', 'boolean', 'choice', 'number', 'environment'].includes(input?.type))
111
+ problems.push(`input ${name} has no type`);
112
+
113
+ const checkExpressions = (strings, where, steps, jobLevel) => {
114
+ for (const { text: value, isIf } of strings)
115
+ for (const expression of expressionsIn(value, isIf)) {
116
+ const code = expression.replace(/'(?:[^']|'')*'/gu, "''");
117
+ for (const m of code.matchAll(/(?<![\w.-])([A-Za-z_][\w-]*)((?:\.[\w-]+)*)/gu)) {
118
+ const [, context, path] = m;
119
+ if (!path) {
120
+ if (!/^\s*\(/u.test(code.slice(m.index + context.length)) && !['true', 'false', 'null'].includes(context))
121
+ problems.push(`${where}: "${context}" isn't a context or a function`);
122
+ continue;
123
+ }
124
+ const [first] = path.slice(1).split('.');
125
+ if (!CONTEXTS.has(context)) problems.push(`${where}: no context "${context}"`);
126
+ else if (context === 'secrets' && !SECRETS.has(first))
127
+ problems.push(`${where}: the pipeline uses no secret ${first}`);
128
+ else if (context === 'inputs' && !inputs.includes(first)) problems.push(`${where}: no input ${first}`);
129
+ else if (context === 'steps' && !steps.includes(first))
130
+ problems.push(`${where}: no step ${first} before this one`);
131
+ else if (context === 'env' && jobLevel) problems.push(`${where}: env isn't available here`);
132
+ }
133
+ }
134
+ };
135
+ checkExpressions(stringsIn({ on: doc.on, env: doc.env, concurrency: doc.concurrency }), 'the workflow', [], false);
136
+
137
+ for (const [jobName, job] of Object.entries(doc.jobs)) {
138
+ const where = `job ${jobName}`;
139
+ if (!isObject(job)) {
140
+ problems.push(`${where} isn't a mapping`);
141
+ continue;
142
+ }
143
+ if (!job['runs-on']) problems.push(`${where} has no runs-on`);
144
+ if (typeof job['timeout-minutes'] !== 'number') problems.push(`${where} has no timeout-minutes`);
145
+ if (!isObject(job.permissions)) problems.push(`${where} doesn't say its permissions`);
146
+ for (const need of [job.needs ?? []].flat())
147
+ if (!(need in doc.jobs)) problems.push(`${where} needs ${need}, which isn't a job`);
148
+ const { steps = [], ...rest } = job;
149
+ checkExpressions(stringsIn({ if: rest.if }), where, [], true);
150
+ checkExpressions(stringsIn({ ...rest, if: undefined, outputs: undefined }), where, [], false);
151
+ // A job's outputs read its steps once they've all run.
152
+ const stepIds = Array.isArray(steps) ? steps.map((s) => s?.id).filter(Boolean) : [];
153
+ checkExpressions(stringsIn({ outputs: rest.outputs }), `${where}'s outputs`, stepIds, false);
154
+ if (stringsIn(job).some(({ text: value }) => /\bsecrets\./u.test(value)) && !job.environment)
155
+ problems.push(`${where} reads a secret outside an environment`);
156
+ if (!Array.isArray(steps) || !steps.length) {
157
+ problems.push(`${where} has no steps`);
158
+ continue;
159
+ }
160
+ const ids = [];
161
+ steps.forEach((step, i) => {
162
+ const at = `${where}, step ${i + 1}${step?.name ? ` (${step.name})` : ''}`;
163
+ if (!isObject(step)) {
164
+ problems.push(`${at} isn't a mapping`);
165
+ return;
166
+ }
167
+ if ('uses' in step === 'run' in step) problems.push(`${at} has one of uses or run`);
168
+ if ('uses' in step && !/^[\w.-]+\/[\w./-]+@[0-9a-f]{40}$/u.test(String(step.uses)))
169
+ problems.push(`${at} uses ${step.uses}, which isn't pinned to a commit`);
170
+ if (typeof step.run === 'string' && step.run.includes('${{'))
171
+ problems.push(`${at} puts \${{ }} in its script: pass it through env`);
172
+ checkExpressions(stringsIn(step), at, ids, false);
173
+ if (step.id !== undefined) {
174
+ if (ids.includes(step.id)) problems.push(`${at} repeats the id ${step.id}`);
175
+ ids.push(String(step.id));
176
+ }
177
+ });
178
+ }
179
+ return problems;
180
+ }
@@ -26,7 +26,7 @@ The board answers at {{address}}.
26
26
  ## How it updates
27
27
 
28
28
  - **`stable`** (the default): `breakaway.json` pins a release. The update workflow opens a pull request that bumps it, with the release notes. Merging it deploys it. Nothing deploys until you merge.
29
- - **`main`**: the board follows the latest pre-release, one per merge to breakaway. The update workflow starts a deploy when there is a newer one.
29
+ - **`main`**: the board follows the latest pre-release, which breakaway's owner publishes from `main` when they choose. The update workflow starts a deploy when there is a newer one.
30
30
 
31
31
  Both workflows run breakaway's CLI from the release's own source on GitHub, with Node. Apart from `wrangler`, nothing comes from npm.
32
32
 
@@ -7,6 +7,10 @@
7
7
  # {{branchName}} may use. It checks out none of this repository's code: it runs breakaway's runner, pinned to
8
8
  # {{version}} and installed once, before any step holds the token, from the package's own lockfile with no install
9
9
  # scripts, and reports each step to the board. Agents never run this workflow.
10
+ #
11
+ # Don't use GitHub's Re-run on it: a plan runs once per start, so the board refuses a re-run and the log says so. When
12
+ # a run fails before it applies anything, fix what stopped it and press Start the run again on the plan's page on the
13
+ # board, which starts a new run.
10
14
  name: Apply infrastructure
11
15
  run-name: Apply plan ${{ inputs.plan }} to ${{ inputs.environment }}
12
16