mandrel-platform 1.1.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +69 -12
- package/config/stryker.base.json +7 -2
- package/package.json +1 -1
- package/scripts/audit-check.mjs +331 -6
- package/scripts/audit-check.test.mjs +382 -1
- package/scripts/check-action-pins.mjs +87 -15
- package/scripts/check-action-pins.test.mjs +103 -4
- package/scripts/check-affected-mode.test.mjs +5 -51
- package/scripts/check-codeql-gating.test.mjs +649 -0
- package/scripts/check-destructive-migration.mjs +277 -11
- package/scripts/check-destructive-migration.test.mjs +334 -0
- package/scripts/check-environments-isolation-audit.test.mjs +212 -0
- package/scripts/check-fail-fast-attribution.test.mjs +90 -4
- package/scripts/check-first-party-pin-freshness.mjs +648 -0
- package/scripts/check-first-party-pin-freshness.test.mjs +624 -0
- package/scripts/check-gitleaks-allowlist.test.mjs +312 -0
- package/scripts/check-osv-scan-mode.test.mjs +5 -50
- package/scripts/check-release-type.mjs +591 -0
- package/scripts/check-release-type.test.mjs +678 -0
- package/scripts/check-setup-toolchain-store.test.mjs +139 -0
- package/scripts/check-toolchain-cache-default.test.mjs +308 -0
- package/scripts/lib/yaml-step.mjs +109 -0
- package/scripts/lib/yaml-step.test.mjs +156 -0
- package/scripts/osv-report-gate.test.mjs +289 -0
- package/scripts/runner-env-drift.test.mjs +554 -0
- package/scripts/stryker-base-config.test.mjs +256 -0
- package/templates/runbooks/runner-provisioning.md +50 -6
- package/templates/runner/check-runner-env-drift.sh +248 -0
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* check-gitleaks-allowlist.test.mjs — the secret-scan tier's escape hatch
|
|
4
|
+
* (Story #365).
|
|
5
|
+
*
|
|
6
|
+
* The failure this closes: the secret scan exposed no seam for suppressing a
|
|
7
|
+
* known false positive, so ordinary prose matching the generic-secret
|
|
8
|
+
* heuristic blocked a docs-only pull request with no option but rewording the
|
|
9
|
+
* source or turning the tier off. A required check needs an escape that is not
|
|
10
|
+
* disabling it.
|
|
11
|
+
*
|
|
12
|
+
* Reading the YAML is not enough: what decides the outcome is a shell branch,
|
|
13
|
+
* and the failure mode is silent — a caller-supplied config that quietly
|
|
14
|
+
* REPLACES the default ruleset would turn one suppression into a tier-wide
|
|
15
|
+
* opt-out that still reports green. So this extracts the real `run:` body and
|
|
16
|
+
* executes it against a stub gitleaks that echoes its argv, the same
|
|
17
|
+
* read-then-execute approach as check-setup-toolchain-store.test.mjs.
|
|
18
|
+
*
|
|
19
|
+
* Run: node --test scripts/check-gitleaks-allowlist.test.mjs
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import assert from "node:assert/strict";
|
|
23
|
+
import { test } from "node:test";
|
|
24
|
+
import { execFileSync } from "node:child_process";
|
|
25
|
+
import { readFileSync, mkdtempSync, writeFileSync, chmodSync, rmSync } from "node:fs";
|
|
26
|
+
import { tmpdir } from "node:os";
|
|
27
|
+
import path from "node:path";
|
|
28
|
+
|
|
29
|
+
import { stepByName, runScript } from "./lib/yaml-step.mjs";
|
|
30
|
+
|
|
31
|
+
const ACTION = ".github/actions/gitleaks-scan/action.yml";
|
|
32
|
+
const WORKFLOW = ".github/workflows/pr-quality.yml";
|
|
33
|
+
|
|
34
|
+
const actionText = readFileSync(ACTION, "utf8");
|
|
35
|
+
const workflowText = readFileSync(WORKFLOW, "utf8");
|
|
36
|
+
const scanScript = runScript(stepByName(actionText, "Run gitleaks scan"));
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The definition block of a single `workflow_call` input, keyed by name: every
|
|
40
|
+
* line indented under the ` <name>:` key. Asserts rather than returning a
|
|
41
|
+
* sentinel, so a renamed input fails at the point of extraction instead of
|
|
42
|
+
* silently passing an empty block to every downstream matcher.
|
|
43
|
+
*/
|
|
44
|
+
function workflowInput(name) {
|
|
45
|
+
const lines = workflowText.split("\n");
|
|
46
|
+
const start = lines.indexOf(` ${name}:`);
|
|
47
|
+
assert.notEqual(start, -1, `workflow_call input "${name}" is not declared`);
|
|
48
|
+
const body = [];
|
|
49
|
+
for (let i = start + 1; i < lines.length && /^ {8,}\S/.test(lines[i]); i++) {
|
|
50
|
+
body.push(lines[i]);
|
|
51
|
+
}
|
|
52
|
+
return body.join("\n");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Run the extracted scan body against a stub gitleaks that echoes its argv and
|
|
57
|
+
* exits `leakExit` (non-zero = a finding). Returns `{ status, output }` rather
|
|
58
|
+
* than throwing, because a non-zero exit IS the assertion in the blocking
|
|
59
|
+
* cases.
|
|
60
|
+
*/
|
|
61
|
+
function runScan({ files = {}, leakExit = 0, ...env } = {}) {
|
|
62
|
+
const dir = mkdtempSync(path.join(tmpdir(), "gitleaks-allowlist-"));
|
|
63
|
+
try {
|
|
64
|
+
for (const [name, contents] of Object.entries(files)) {
|
|
65
|
+
writeFileSync(path.join(dir, name), contents);
|
|
66
|
+
}
|
|
67
|
+
const stub = path.join(dir, "gitleaks-stub");
|
|
68
|
+
writeFileSync(stub, `#!/bin/sh\necho "GITLEAKS_ARGV: $*"\nexit ${leakExit}\n`);
|
|
69
|
+
chmodSync(stub, 0o755);
|
|
70
|
+
const script = path.join(dir, "scan.sh");
|
|
71
|
+
writeFileSync(script, scanScript);
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
const output = execFileSync("bash", [script], {
|
|
75
|
+
cwd: dir,
|
|
76
|
+
encoding: "utf8",
|
|
77
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
78
|
+
env: {
|
|
79
|
+
PATH: process.env.PATH,
|
|
80
|
+
GITLEAKS_BIN: stub,
|
|
81
|
+
SCAN_MODE: "dir",
|
|
82
|
+
LOG_OPTS: "",
|
|
83
|
+
REDACT: "100",
|
|
84
|
+
VERBOSE: "false",
|
|
85
|
+
REPORT_FORMAT: "",
|
|
86
|
+
REPORT_PATH: "",
|
|
87
|
+
NON_BLOCKING: "false",
|
|
88
|
+
CONFIG_PATH: "",
|
|
89
|
+
ALLOW_RULE_REPLACEMENT: "false",
|
|
90
|
+
...env,
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
return { status: 0, output };
|
|
94
|
+
} catch (err) {
|
|
95
|
+
return {
|
|
96
|
+
status: err.status ?? 1,
|
|
97
|
+
output: `${err.stdout ?? ""}${err.stderr ?? ""}`,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
} finally {
|
|
101
|
+
rmSync(dir, { recursive: true, force: true });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const EXTENDING_CONFIG = ['[extend]', 'useDefault = true', '', '[[rules]]', 'id = "x"'].join("\n");
|
|
106
|
+
|
|
107
|
+
test("no config-path leaves the scan byte-for-byte as it was", () => {
|
|
108
|
+
const { status, output } = runScan();
|
|
109
|
+
assert.equal(status, 0);
|
|
110
|
+
assert.doesNotMatch(output, /--config/);
|
|
111
|
+
assert.match(output, /--redact=100/);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("AC-7: a caller-supplied allowlist config is passed to gitleaks as --config", () => {
|
|
115
|
+
const { status, output } = runScan({
|
|
116
|
+
files: { ".gitleaks.toml": EXTENDING_CONFIG },
|
|
117
|
+
CONFIG_PATH: ".gitleaks.toml",
|
|
118
|
+
});
|
|
119
|
+
assert.equal(status, 0);
|
|
120
|
+
assert.match(output, /--config \.gitleaks\.toml/);
|
|
121
|
+
// The tier is still on: redaction and the scan itself are unchanged.
|
|
122
|
+
assert.match(output, /--redact=100/);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("AC-7: the suppression seam works without disabling the tier or rewording the source", () => {
|
|
126
|
+
// The docs-only PR case: a config that allowlists the one false positive,
|
|
127
|
+
// scanned in the same blocking mode, with the source untouched.
|
|
128
|
+
const { status, output } = runScan({
|
|
129
|
+
files: {
|
|
130
|
+
".gitleaks.toml": [
|
|
131
|
+
"[extend]",
|
|
132
|
+
"useDefault = true",
|
|
133
|
+
"",
|
|
134
|
+
"[[allowlists]]",
|
|
135
|
+
'description = "prose in docs/reusable-workflows.md"',
|
|
136
|
+
'paths = ["docs/reusable-workflows\\\\.md"]',
|
|
137
|
+
].join("\n"),
|
|
138
|
+
},
|
|
139
|
+
CONFIG_PATH: ".gitleaks.toml",
|
|
140
|
+
});
|
|
141
|
+
assert.equal(status, 0);
|
|
142
|
+
assert.match(output, /--config \.gitleaks\.toml/);
|
|
143
|
+
assert.doesNotMatch(output, /non-blocking/);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("AC-8: with a config in place, any other finding still fails the step", () => {
|
|
147
|
+
// The load-bearing half. An allowlist narrows one rule; it must not turn the
|
|
148
|
+
// gate into a reporter. A leak (stub exit 1) fails the step exactly as it
|
|
149
|
+
// does with no config at all.
|
|
150
|
+
const withConfig = runScan({
|
|
151
|
+
files: { ".gitleaks.toml": EXTENDING_CONFIG },
|
|
152
|
+
CONFIG_PATH: ".gitleaks.toml",
|
|
153
|
+
leakExit: 1,
|
|
154
|
+
});
|
|
155
|
+
assert.notEqual(withConfig.status, 0, "a finding must still fail the step");
|
|
156
|
+
|
|
157
|
+
const withoutConfig = runScan({ leakExit: 1 });
|
|
158
|
+
assert.equal(withConfig.status, withoutConfig.status);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("AC-8: a config that replaces the default ruleset is rejected", () => {
|
|
162
|
+
// Without this guard, `--config` is a supported way to delete every rule and
|
|
163
|
+
// still report green — a tier-wide opt-out wearing an allowlist's clothes.
|
|
164
|
+
const { status, output } = runScan({
|
|
165
|
+
files: { ".gitleaks.toml": '[[rules]]\nid = "only-mine"\n' },
|
|
166
|
+
CONFIG_PATH: ".gitleaks.toml",
|
|
167
|
+
});
|
|
168
|
+
assert.equal(status, 1);
|
|
169
|
+
assert.match(output, /must extend the default ruleset/);
|
|
170
|
+
assert.match(output, /useDefault = true/);
|
|
171
|
+
assert.doesNotMatch(output, /GITLEAKS_ARGV/, "gitleaks must not run on a rejected config");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("AC-8: useDefault must sit in [extend], not merely appear in the file", () => {
|
|
175
|
+
// The guard's one remaining bypass if it were a bare grep: gitleaks reads
|
|
176
|
+
// useDefault only from the [extend] table, so the same line under [[rules]]
|
|
177
|
+
// is inert — a rule-replacing config that reads as if it extended.
|
|
178
|
+
const { status, output } = runScan({
|
|
179
|
+
files: {
|
|
180
|
+
".gitleaks.toml": ["[[rules]]", 'id = "only-mine"', "useDefault = true"].join("\n"),
|
|
181
|
+
},
|
|
182
|
+
CONFIG_PATH: ".gitleaks.toml",
|
|
183
|
+
});
|
|
184
|
+
assert.equal(status, 1);
|
|
185
|
+
assert.match(output, /must extend the default ruleset/);
|
|
186
|
+
assert.doesNotMatch(output, /GITLEAKS_ARGV/, "gitleaks must not run on a rejected config");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("AC-7: [extend] is still honoured when other tables follow it", () => {
|
|
190
|
+
const { status, output } = runScan({
|
|
191
|
+
files: {
|
|
192
|
+
".gitleaks.toml": [
|
|
193
|
+
"[extend]",
|
|
194
|
+
"useDefault = true",
|
|
195
|
+
"",
|
|
196
|
+
"[[allowlists]]",
|
|
197
|
+
'description = "prose"',
|
|
198
|
+
].join("\n"),
|
|
199
|
+
},
|
|
200
|
+
CONFIG_PATH: ".gitleaks.toml",
|
|
201
|
+
});
|
|
202
|
+
assert.equal(status, 0);
|
|
203
|
+
assert.match(output, /--config \.gitleaks\.toml/);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("a deliberate full rule-set replacement is possible, but only explicitly", () => {
|
|
207
|
+
const { status, output } = runScan({
|
|
208
|
+
files: { ".gitleaks.toml": '[[rules]]\nid = "only-mine"\n' },
|
|
209
|
+
CONFIG_PATH: ".gitleaks.toml",
|
|
210
|
+
ALLOW_RULE_REPLACEMENT: "true",
|
|
211
|
+
});
|
|
212
|
+
assert.equal(status, 0);
|
|
213
|
+
assert.match(output, /--config \.gitleaks\.toml/);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("a config-path that does not exist is a hard error, not a silently-skipped flag", () => {
|
|
217
|
+
const { status, output } = runScan({ CONFIG_PATH: "nope.toml" });
|
|
218
|
+
assert.equal(status, 1);
|
|
219
|
+
assert.match(output, /does not exist in the checkout/);
|
|
220
|
+
assert.doesNotMatch(output, /GITLEAKS_ARGV/);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("AC-3: a repo-root .gitleaks.toml is validated instead of silently auto-discovered", () => {
|
|
224
|
+
// gitleaks reads a repo-root .gitleaks.toml on its own whenever --config is
|
|
225
|
+
// absent. Left alone, that is a second, UNVALIDATED way into the scan — the
|
|
226
|
+
// useDefault guard never runs. The file is adopted as config-path instead.
|
|
227
|
+
const { status, output } = runScan({
|
|
228
|
+
files: { ".gitleaks.toml": EXTENDING_CONFIG },
|
|
229
|
+
});
|
|
230
|
+
assert.equal(status, 0);
|
|
231
|
+
assert.match(output, /--config \.gitleaks\.toml/);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("AC-3: a rule-replacing repo-root .gitleaks.toml is rejected, naming config-path", () => {
|
|
235
|
+
// The bypass this closes. Auto-discovery would have applied this config —
|
|
236
|
+
// which deletes every default rule — and reported green.
|
|
237
|
+
const { status, output } = runScan({
|
|
238
|
+
files: { ".gitleaks.toml": '[[rules]]\nid = "only-mine"\n' },
|
|
239
|
+
});
|
|
240
|
+
assert.equal(status, 1);
|
|
241
|
+
assert.match(output, /must extend the default ruleset/);
|
|
242
|
+
assert.match(output, /config-path/);
|
|
243
|
+
assert.doesNotMatch(output, /GITLEAKS_ARGV/, "gitleaks must not run on a rejected config");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// Not AC-3 evidence: this passes with the discovery branch reverted, because
|
|
247
|
+
// nothing ever routed a root file when config-path was set. It is a regression
|
|
248
|
+
// guard for one plausible mis-write of that branch — omitting its `[ -z
|
|
249
|
+
// "$CONFIG_PATH" ]` condition — and should not be read as proving AC-3.
|
|
250
|
+
test("an explicit config-path still wins over a repo-root .gitleaks.toml", () => {
|
|
251
|
+
const { status, output } = runScan({
|
|
252
|
+
files: { ".gitleaks.toml": EXTENDING_CONFIG, "custom.toml": EXTENDING_CONFIG },
|
|
253
|
+
CONFIG_PATH: "custom.toml",
|
|
254
|
+
});
|
|
255
|
+
assert.equal(status, 0);
|
|
256
|
+
assert.match(output, /--config custom\.toml/);
|
|
257
|
+
assert.doesNotMatch(output, /--config \.gitleaks\.toml/);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("AC-3: a deliberate rule-replacing root config still opts in explicitly", () => {
|
|
261
|
+
const { status, output } = runScan({
|
|
262
|
+
files: { ".gitleaks.toml": '[[rules]]\nid = "only-mine"\n' },
|
|
263
|
+
ALLOW_RULE_REPLACEMENT: "true",
|
|
264
|
+
});
|
|
265
|
+
assert.equal(status, 0);
|
|
266
|
+
assert.match(output, /--config \.gitleaks\.toml/);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("AC-1: pr-quality.yml declares both allowlist inputs with unchanged-behaviour defaults", () => {
|
|
270
|
+
// pr-quality.yml is compile-time resolved for every consumer, so a caller
|
|
271
|
+
// that passes neither input must get byte-identical behaviour: '' and
|
|
272
|
+
// 'false' are exactly the composite's own defaults.
|
|
273
|
+
const configPath = workflowInput("secret-scan-config-path");
|
|
274
|
+
assert.match(configPath, /^ {8}type: string$/m);
|
|
275
|
+
assert.match(configPath, /^ {8}default: ''$/m);
|
|
276
|
+
|
|
277
|
+
const allowReplacement = workflowInput("secret-scan-allow-default-rule-replacement");
|
|
278
|
+
assert.match(allowReplacement, /^ {8}type: string$/m);
|
|
279
|
+
assert.match(allowReplacement, /^ {8}default: 'false'$/m);
|
|
280
|
+
|
|
281
|
+
// Cross-repo portability contract (scripts/check-workflow-portability.mjs):
|
|
282
|
+
// no `${{ }}` in a workflow_call input description or default — GitHub
|
|
283
|
+
// resolves those during interface validation, before any context exists.
|
|
284
|
+
assert.doesNotMatch(configPath, /\$\{\{/);
|
|
285
|
+
assert.doesNotMatch(allowReplacement, /\$\{\{/);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("AC-2: both caller inputs reach the gitleaks composite's own inputs", () => {
|
|
289
|
+
// The whole point of the Story: an input a consumer can set has to arrive at
|
|
290
|
+
// the composite, or the documented escape from a false positive is
|
|
291
|
+
// unreachable and `enable-security: false` stays the only exit.
|
|
292
|
+
const step = stepByName(workflowText, "Secret scan (pinned gitleaks, blocking)");
|
|
293
|
+
assert.match(step, /^\s+config-path: \$\{\{ inputs\.secret-scan-config-path \}\}$/m);
|
|
294
|
+
assert.match(
|
|
295
|
+
step,
|
|
296
|
+
/^\s+allow-default-rule-replacement: \$\{\{ inputs\.secret-scan-allow-default-rule-replacement \}\}$/m,
|
|
297
|
+
);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test("the action declares both inputs with the non-breaking empty/false defaults", () => {
|
|
301
|
+
// A consumer that passes neither input must be unaffected — the whole reason
|
|
302
|
+
// the defaults are '' and 'false'.
|
|
303
|
+
assert.match(actionText, /^ {2}config-path:$/m);
|
|
304
|
+
assert.match(actionText, /^ {2}allow-default-rule-replacement:$/m);
|
|
305
|
+
// Cross-repo portability contract: no `${{ }}` in input descriptions or
|
|
306
|
+
// defaults (scripts/check-workflow-portability.mjs).
|
|
307
|
+
const inputsBlock = actionText.slice(
|
|
308
|
+
actionText.indexOf("\ninputs:"),
|
|
309
|
+
actionText.indexOf("\nruns:"),
|
|
310
|
+
);
|
|
311
|
+
assert.doesNotMatch(inputsBlock, /\$\{\{/);
|
|
312
|
+
});
|
|
@@ -34,64 +34,19 @@ import { join, resolve, dirname } from "node:path";
|
|
|
34
34
|
import { fileURLToPath } from "node:url";
|
|
35
35
|
import { tmpdir } from "node:os";
|
|
36
36
|
|
|
37
|
+
import { stepByName, runScript } from "./lib/yaml-step.mjs";
|
|
38
|
+
|
|
37
39
|
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
38
40
|
const prQuality = readFileSync(join(repoRoot, ".github/workflows/pr-quality.yml"), "utf8");
|
|
39
41
|
const advisoryScan = readFileSync(join(repoRoot, ".github/workflows/advisory-scan.yml"), "utf8");
|
|
40
42
|
const composite = readFileSync(join(repoRoot, ".github/actions/osv-scan/action.yml"), "utf8");
|
|
41
43
|
|
|
42
44
|
// ---------------------------------------------------------------------------
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
+
// Local extraction helpers. Step-block and `run:` extraction are shared via
|
|
46
|
+
// scripts/lib/yaml-step.mjs; the two below read mapping keys and a job block,
|
|
47
|
+
// which no other suite needs.
|
|
45
48
|
// ---------------------------------------------------------------------------
|
|
46
49
|
|
|
47
|
-
function stepByName(text, name) {
|
|
48
|
-
const lines = text.split("\n");
|
|
49
|
-
const nameIdx = lines.findIndex((l) => /^\s+(- )?name:\s/.test(l) && l.includes(name));
|
|
50
|
-
assert.notEqual(nameIdx, -1, `step "${name}" not found`);
|
|
51
|
-
let start = -1;
|
|
52
|
-
for (let i = nameIdx; i >= 0; i--) {
|
|
53
|
-
if (/^\s*-\s/.test(lines[i])) {
|
|
54
|
-
start = i;
|
|
55
|
-
break;
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
assert.notEqual(start, -1, `opening bullet for step "${name}" not found`);
|
|
59
|
-
const bulletIndent = lines[start].match(/^(\s*)/)[1].length;
|
|
60
|
-
let end = lines.length;
|
|
61
|
-
for (let i = start + 1; i < lines.length; i++) {
|
|
62
|
-
if (/^\s*$/.test(lines[i])) continue;
|
|
63
|
-
const indent = lines[i].match(/^(\s*)/)[1].length;
|
|
64
|
-
if (indent < bulletIndent) {
|
|
65
|
-
end = i;
|
|
66
|
-
break;
|
|
67
|
-
}
|
|
68
|
-
if (indent === bulletIndent && /^\s*-\s/.test(lines[i])) {
|
|
69
|
-
end = i;
|
|
70
|
-
break;
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
return lines.slice(start, end).join("\n");
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/** The dedented body of a step's `run: |` block scalar. */
|
|
77
|
-
function runScript(stepBlock) {
|
|
78
|
-
const lines = stepBlock.split("\n");
|
|
79
|
-
const start = lines.findIndex((l) => /^\s+run:\s*\|\s*$/.test(l));
|
|
80
|
-
assert.notEqual(start, -1, "`run: |` block not found");
|
|
81
|
-
const runIndent = lines[start].match(/^(\s*)/)[1].length;
|
|
82
|
-
const body = [];
|
|
83
|
-
for (let i = start + 1; i < lines.length; i++) {
|
|
84
|
-
if (/^\s*$/.test(lines[i])) {
|
|
85
|
-
body.push("");
|
|
86
|
-
continue;
|
|
87
|
-
}
|
|
88
|
-
const indent = lines[i].match(/^(\s*)/)[1].length;
|
|
89
|
-
if (indent <= runIndent) break;
|
|
90
|
-
body.push(lines[i].slice(runIndent + 2));
|
|
91
|
-
}
|
|
92
|
-
return body.join("\n");
|
|
93
|
-
}
|
|
94
|
-
|
|
95
50
|
/**
|
|
96
51
|
* The body of a mapping key at a given indent, up to the next sibling key or
|
|
97
52
|
* end of input. Terminating on end-of-input matters: the LAST declared input
|