mandrel-platform 1.2.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.
@@ -36,6 +36,7 @@ import {
36
36
  collectPinnedRefs,
37
37
  resolveManifest,
38
38
  manifestsMatch,
39
+ diffSubpathAtSha,
39
40
  runCheck,
40
41
  runCli,
41
42
  } from "./check-first-party-pin-freshness.mjs";
@@ -255,6 +256,140 @@ test("clean: two call sites pinning the same fresh SHA both pass", () => {
255
256
  assert.equal(result.scanned, 2);
256
257
  });
257
258
 
259
+ // ---------------------------------------------------------------------------
260
+ // Story #379 — the comparison is the whole action DIRECTORY, not the manifest
261
+ //
262
+ // A composite action whose behaviour lives in a sibling script is the majority
263
+ // of this repo's action surface (`osv-scan`, `osv-track-issue`), and a
264
+ // manifest-only comparison is structurally unable to see a change there. That
265
+ // blind spot shipped live: Story #365 rewrote `osv-scan/osv-report-gate.mjs`
266
+ // (+189/-12) without touching `action.yml`, so both call sites read as fresh
267
+ // while executing the old gate.
268
+ // ---------------------------------------------------------------------------
269
+
270
+ const SIBLING_MANIFEST = [
271
+ "name: demo",
272
+ "description: fixture composite action whose behaviour lives in a sibling script",
273
+ "runs:",
274
+ " using: composite",
275
+ " steps:",
276
+ " - shell: bash",
277
+ " run: node ./gate.mjs",
278
+ "",
279
+ ].join("\n");
280
+
281
+ /**
282
+ * Create a repo whose action manifest NEVER changes — only the sibling
283
+ * `gate.mjs` does. The `before` commit therefore carries a byte-identical
284
+ * `action.yml` alongside a stale `gate.mjs`.
285
+ */
286
+ function makeSiblingScriptRepo(label) {
287
+ const root = mkdtempSync(join(tmpdir(), `pinfresh-${label}-`));
288
+ git(root, "init", "-b", "main");
289
+ git(root, "config", "user.email", "fixture@example.invalid");
290
+ git(root, "config", "user.name", "Pin Freshness Fixture");
291
+ git(root, "config", "commit.gpgsign", "false");
292
+ git(root, "config", "core.hooksPath", join(root, ".no-hooks"));
293
+
294
+ put(root, `${SUBPATH}/action.yml`, SIBLING_MANIFEST);
295
+ put(root, `${SUBPATH}/gate.mjs`, "process.exit(0);\n");
296
+ git(root, "add", "-A");
297
+ git(root, "commit", "-m", "initial action");
298
+ const before = git(root, "rev-parse", "HEAD");
299
+
300
+ // Behaviour change, manifest untouched.
301
+ put(root, `${SUBPATH}/gate.mjs`, "process.exit(process.env.FAIL ? 1 : 0);\n");
302
+ git(root, "add", "-A");
303
+ git(root, "commit", "-m", "harden the gate");
304
+ const after = git(root, "rev-parse", "HEAD");
305
+
306
+ return { root, before, after };
307
+ }
308
+
309
+ /** `git show <sha>:<path>` without the trimming the `git` helper applies. */
310
+ function showRaw(root, sha, relPath) {
311
+ return execFileSync("git", ["show", `${sha}:${relPath}`], { cwd: root, encoding: "utf8" });
312
+ }
313
+
314
+ test("stale: a sibling script that differs at the pinned SHA is stale even when action.yml is byte-identical", () => {
315
+ const { root, before } = makeSiblingScriptRepo("sibling-stale");
316
+ track(root);
317
+ put(root, ".github/workflows/fixture.yml", workflow(before));
318
+
319
+ // The premise, asserted rather than assumed: a manifest-only comparison
320
+ // would have called this pin fresh.
321
+ assert.equal(
322
+ showRaw(root, before, `${SUBPATH}/action.yml`),
323
+ readFileSync(join(root, SUBPATH, "action.yml"), "utf8"),
324
+ "premise: action.yml is byte-identical at the pinned SHA"
325
+ );
326
+
327
+ const result = runCheck({ cwd: root, firstPartyOwner: OWNER });
328
+
329
+ assert.equal(result.ok, false);
330
+ assert.equal(result.stale.length, 1);
331
+ assert.equal(result.unreachable.length, 0);
332
+ assert.match(result.stale[0].reason, /gate\.mjs/, "the report names the drifting sibling");
333
+
334
+ const { code, stderr } = capture(["--cwd", root, "--first-party-owner", OWNER]);
335
+ assert.equal(code, 1);
336
+ assert.match(stderr, /gate\.mjs/);
337
+ });
338
+
339
+ test("clean: a pin carrying the current sibling script exits 0", () => {
340
+ const { root, after } = makeSiblingScriptRepo("sibling-clean");
341
+ track(root);
342
+ put(root, ".github/workflows/fixture.yml", workflow(after));
343
+
344
+ const result = runCheck({ cwd: root, firstPartyOwner: OWNER });
345
+ assert.equal(result.ok, true);
346
+ });
347
+
348
+ test("stale: a file added to the action directory since the pinned SHA is stale", () => {
349
+ const { root, after } = makeSiblingScriptRepo("sibling-added");
350
+ track(root);
351
+ put(root, `${SUBPATH}/helper.mjs`, "export const help = () => 1;\n");
352
+ git(root, "add", "-A");
353
+ put(root, ".github/workflows/fixture.yml", workflow(after));
354
+
355
+ const result = runCheck({ cwd: root, firstPartyOwner: OWNER });
356
+
357
+ assert.equal(result.ok, false);
358
+ assert.match(result.stale[0].reason, /helper\.mjs/);
359
+ });
360
+
361
+ test("stale: a file removed from the action directory since the pinned SHA is stale", () => {
362
+ const { root, after } = makeSiblingScriptRepo("sibling-removed");
363
+ track(root);
364
+ git(root, "rm", "-q", `${SUBPATH}/gate.mjs`);
365
+ put(root, ".github/workflows/fixture.yml", workflow(after));
366
+
367
+ const result = runCheck({ cwd: root, firstPartyOwner: OWNER });
368
+
369
+ assert.equal(result.ok, false);
370
+ assert.match(result.stale[0].reason, /gate\.mjs/);
371
+ });
372
+
373
+ test("diffSubpathAtSha: a tracked file missing from disk is drift, not a silent skip", () => {
374
+ const path = `${SUBPATH}/gone.mjs`;
375
+ const fake = { lsTree: () => [path], lsFiles: () => [path], show: () => "body\n" };
376
+
377
+ const drift = diffSubpathAtSha(fake, "/no-such-root", "0".repeat(40), SUBPATH);
378
+
379
+ assert.deepEqual(drift, [{ path, kind: "unreadable" }]);
380
+ });
381
+
382
+ test("diffSubpathAtSha: a blob git cannot resolve at the pinned SHA is drift, not a pass", () => {
383
+ const { root } = makeSiblingScriptRepo("sibling-unresolvable");
384
+ track(root);
385
+ const path = `${SUBPATH}/gate.mjs`;
386
+ const fake = { lsTree: () => [path], lsFiles: () => [path], show: () => null };
387
+
388
+ const drift = diffSubpathAtSha(fake, root, "0".repeat(40), SUBPATH);
389
+
390
+ assert.deepEqual(drift, [{ path, kind: "differs" }]);
391
+ });
392
+
258
393
  // ---------------------------------------------------------------------------
259
394
  // AC-7 — third-party / local / docker references are never classified
260
395
  // ---------------------------------------------------------------------------
@@ -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
- // Minimal indentation-based extraction (dependency-free, mirrors
44
- // check-affected-mode.test.mjs / check-ci-required-aggregator.test.mjs).
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