@olivervea/pipelines 2.0.0-beta.3 → 2.0.0-beta.31

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.
Files changed (65) hide show
  1. package/README.md +165 -78
  2. package/manifest.schema.json +300 -16
  3. package/package.json +6 -4
  4. package/src/assets.d.ts +2 -2
  5. package/src/build/build.ts +98 -64
  6. package/src/build/cli.ts +33 -23
  7. package/src/build/elf.ts +16 -7
  8. package/src/build/hashes.ts +40 -51
  9. package/src/build/project.ts +55 -52
  10. package/src/builder/pipeline.ts +348 -89
  11. package/src/builder/refs.ts +15 -5
  12. package/src/core/brands.ts +6 -5
  13. package/src/core/config-refs.ts +103 -0
  14. package/src/core/config.ts +224 -0
  15. package/src/core/external.ts +86 -0
  16. package/src/core/handlers.ts +62 -15
  17. package/src/core/matrix.ts +150 -0
  18. package/src/core/models.ts +37 -15
  19. package/src/core/secrets.ts +2 -2
  20. package/src/core/steps.ts +113 -38
  21. package/src/core/types.ts +5 -1
  22. package/src/core/webhooks.ts +21 -7
  23. package/src/ctx/files.ts +119 -42
  24. package/src/ctx/index.ts +110 -39
  25. package/src/ctx/links.ts +30 -0
  26. package/src/ctx/sh.ts +106 -44
  27. package/src/ctx/types.ts +24 -4
  28. package/src/describe/config-schema.ts +98 -0
  29. package/src/describe/context.ts +187 -0
  30. package/src/describe/describe.ts +63 -355
  31. package/src/describe/failure-handlers.ts +34 -0
  32. package/src/describe/manifest-schema.ts +304 -61
  33. package/src/describe/mapper-source.ts +252 -105
  34. package/src/describe/record.ts +67 -37
  35. package/src/describe/schemas.ts +71 -0
  36. package/src/describe/stages.ts +166 -0
  37. package/src/describe/step.ts +544 -0
  38. package/src/describe/triggers.ts +72 -0
  39. package/src/dev/cli.ts +62 -35
  40. package/src/harness/env.ts +47 -25
  41. package/src/harness/execute.ts +74 -30
  42. package/src/harness/exit.ts +1 -1
  43. package/src/harness/index.ts +19 -8
  44. package/src/harness/main.ts +245 -99
  45. package/src/harness/mask.ts +98 -0
  46. package/src/harness/result.ts +45 -24
  47. package/src/harness/validate.ts +82 -15
  48. package/src/index.ts +139 -10
  49. package/src/library/checks.ts +11 -6
  50. package/src/library/compose-deploy.ts +115 -0
  51. package/src/library/github-source.ts +100 -47
  52. package/src/library/helm-deploy-ssh.ts +426 -112
  53. package/src/library/helm-deploy.ts +529 -0
  54. package/src/library/http-health.ts +7 -5
  55. package/src/library/image-build.ts +510 -162
  56. package/src/library/index.ts +48 -14
  57. package/src/library/models.ts +35 -14
  58. package/src/library/ssh.ts +44 -18
  59. package/src/testing/index.ts +100 -35
  60. package/src/util/canonical.ts +7 -4
  61. package/src/util/code-hash.ts +17 -0
  62. package/src/util/hash.ts +5 -5
  63. package/src/util/tar.ts +83 -38
  64. package/src/library/script.ts +0 -85
  65. package/src/util/handler-hash.ts +0 -22
@@ -2,29 +2,42 @@
2
2
  // describe [--sentinels] → manifest (canonical JSON) on stdout
3
3
  // run <step> → $OLVE_DIR/input.json → handler → $OLVE_DIR/work/output.json (+ result upload)
4
4
  // map-trigger <trigger> → $OLVE_DIR/input.json (event) → pipeline input → $OLVE_DIR/work/output.json
5
- import { mkdirSync, readFileSync } from "node:fs";
6
- import { join } from "node:path";
7
- import { isImplementation } from "../core/handlers.ts";
8
- import type { AnyStep } from "../core/steps.ts";
9
- import type { WebhookEvent } from "../core/webhooks.ts";
10
- import { pipelineData, type PipelineData, type StageNode } from "../builder/pipeline.ts";
11
- import { createCtx, formatLog } from "../ctx/index.ts";
12
- import { DescribeError, describePipeline, manifestText } from "../describe/describe.ts";
13
- import type { HarnessInfo } from "../util/handler-hash.ts";
14
- import { ENV, paths, runInfoFromEnv } from "./env.ts";
15
- import { EXIT } from "./exit.ts";
16
- import { executeStep, type Envelope, type Execution, type StepFunction } from "./execute.ts";
17
- import { MANIFEST_BEGIN, MANIFEST_END, packResult, removeStale, sentinelBlock, uploadFile, writeEnvelope } from "./result.ts";
18
- import { formatIssues, parseStrict } from "./validate.ts";
19
-
20
- /** step name → lazy import of `handlers/<name>.ts`. Literal import() paths so Bun bundles them. */
21
- export type HandlerRegistry = Readonly<Record<string, () => Promise<{ default: unknown }>>>;
5
+ import { mkdirSync, readFileSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ import { isImplementation } from '../core/handlers.ts';
8
+ import { configRefPath, isConfigRef, unionAt } from '../core/config.ts';
9
+ import type { AnyStep } from '../core/steps.ts';
10
+ import { cellName, parseCell } from '../core/matrix.ts';
11
+ import { installMasking, maskEnvelope, maskPatterns, maskText } from './mask.ts';
12
+ import type { WebhookEvent } from '../core/webhooks.ts';
13
+ import { pipelineData, type PipelineData, type StageNode } from '../builder/pipeline.ts';
14
+ import { createCtx, formatLog } from '../ctx/index.ts';
15
+ import { DescribeError, describePipeline, manifestText } from '../describe/describe.ts';
16
+ import type { HarnessInfo } from '../util/code-hash.ts';
17
+ import { callbackUrlsFromEnv, ENV, paths, runInfoFromEnv } from './env.ts';
18
+ import { EXIT } from './exit.ts';
19
+ import {
20
+ executeStep,
21
+ type Envelope,
22
+ type Execution,
23
+ type StepFunction,
24
+ } from './execute.ts';
25
+ import {
26
+ MANIFEST_BEGIN,
27
+ MANIFEST_END,
28
+ packResult,
29
+ removeStale,
30
+ sentinelBlock,
31
+ uploadFile,
32
+ writeEnvelope,
33
+ } from './result.ts';
34
+ import { formatIssues, parseStrict } from './validate.ts';
22
35
 
23
36
  export interface HarnessConfig {
24
- /** Default export of pipeline.ts. */
37
+ /** Default export of the entry point. */
25
38
  pipeline: unknown;
26
- handlers: HandlerRegistry;
27
- handlerHashes: Readonly<Record<string, string>>;
39
+ /** Hash of the build (from the build; includes the harness identity). */
40
+ codeHash?: string;
28
41
  harness: HarnessInfo;
29
42
  }
30
43
 
@@ -38,57 +51,78 @@ export const defaultIO: HarnessIO = {
38
51
  await Bun.write(Bun.stdout, text);
39
52
  },
40
53
  stderr: (line) => {
41
- process.stderr.write(line.endsWith("\n") ? line : line + "\n");
54
+ process.stderr.write(line.endsWith('\n') ? line : line + '\n');
42
55
  },
43
56
  };
44
57
 
45
- const USAGE = "usage: step describe [--sentinels] | step run <step> | step map-trigger <trigger>";
58
+ const USAGE =
59
+ 'usage: step describe [--sentinels] | step run <step> | step map-trigger <trigger>';
46
60
 
47
- export function allSteps(data: PipelineData): AnyStep[] {
48
- const out: AnyStep[] = [];
49
- const walk = (nodes: readonly StageNode[]) => {
50
- for (const n of nodes) n.kind === "stage" ? out.push(...n.steps.map((s) => s.step)) : walk(n.nodes);
61
+ /** Every step with the `select` case conditions (`<ref>.<discriminator>=<value>`) it sits in. */
62
+ export function stepsWithCases(data: PipelineData): { step: AnyStep; cases: string[] }[] {
63
+ const out: { step: AnyStep; cases: string[] }[] = [];
64
+ const walk = (nodes: readonly StageNode[], cases: string[]) => {
65
+ for (const n of nodes) {
66
+ if (n.kind === 'stage') out.push(...n.steps.map((s) => ({ step: s.step, cases })));
67
+ else if (n.kind === 'when') walk(n.nodes, cases);
68
+ else {
69
+ const union = isConfigRef(n.on) ? unionAt(n.on) : undefined;
70
+ for (const [value, inner] of Object.entries(n.cases))
71
+ walk(
72
+ inner,
73
+ union
74
+ ? [...cases, `${configRefPath(n.on)}.${union.discriminator}=${value}`]
75
+ : cases,
76
+ );
77
+ }
78
+ }
51
79
  };
52
- walk(data.nodes);
53
- for (const fh of data.failureHandlers) if (!out.includes(fh.step)) out.push(fh.step);
80
+ walk(data.nodes, []);
81
+ for (const fh of data.failureHandlers) out.push({ step: fh.step, cases: [] });
54
82
  return out;
55
83
  }
56
84
 
85
+ /** The step `run <name>` executes: the only one so named, or the one in the cases the service selected (`OLVE_CASES`). */
86
+ export function findStep(
87
+ data: PipelineData,
88
+ name: string,
89
+ selected: readonly string[],
90
+ ): AnyStep | string {
91
+ const candidates = stepsWithCases(data).filter((c) => c.step.name === name);
92
+ const distinct = candidates.filter(
93
+ (c, i) => candidates.findIndex((d) => d.step === c.step) === i,
94
+ );
95
+ if (distinct.length === 0) return `unknown step '${name}'`;
96
+ if (distinct.length === 1) return distinct[0]!.step;
97
+ const chosen = distinct.filter((c) => c.cases.every((k) => selected.includes(k)));
98
+ if (chosen.length === 1) return chosen[0]!.step;
99
+ return `step '${name}' is in several select cases; ${ENV_CASES} must name the selected one (got ${JSON.stringify(selected)})`;
100
+ }
101
+
102
+ /** The select case conditions of the step being run (JSON array), set by the service for steps inside a select. */
103
+ export const ENV_CASES = 'OLVE_CASES';
104
+
57
105
  /** Installs SIGTERM/SIGINT handlers that abort the returned signal (PID 1 ignores signals without them). */
58
106
  export function cancellation(): { signal: AbortSignal; dispose(): void } {
59
107
  const ac = new AbortController();
60
- const onTerm = () => ac.abort("SIGTERM");
61
- const onInt = () => (ac.signal.aborted ? process.exit(EXIT.sigint) : ac.abort("SIGINT"));
62
- process.on("SIGTERM", onTerm);
63
- process.on("SIGINT", onInt);
108
+ const onTerm = () => ac.abort('SIGTERM');
109
+ const onInt = () =>
110
+ ac.signal.aborted ? process.exit(EXIT.sigint) : ac.abort('SIGINT');
111
+ process.on('SIGTERM', onTerm);
112
+ process.on('SIGINT', onInt);
64
113
  return {
65
114
  signal: ac.signal,
66
115
  dispose() {
67
- process.off("SIGTERM", onTerm);
68
- process.off("SIGINT", onInt);
116
+ process.off('SIGTERM', onTerm);
117
+ process.off('SIGINT', onInt);
69
118
  },
70
119
  };
71
120
  }
72
121
 
73
- /** Resolves the function that implements `step` (library or handler module). */
74
- export async function resolveStepFunction(step: AnyStep, handlers: HandlerRegistry): Promise<StepFunction | string> {
75
- if (step.kind !== "custom") {
76
- if (!step.builtin) return `library step '${step.name}' (${step.kind}) has no implementation`;
77
- return step.builtin.run as StepFunction;
78
- }
79
- const load = handlers[step.name];
80
- if (!load) return `step '${step.name}' has no handler`;
81
- let mod: { default: unknown };
82
- try {
83
- mod = await load();
84
- } catch (e) {
85
- return `the handler of step '${step.name}' failed to load: ${(e as Error)?.stack ?? String(e)}`;
86
- }
87
- const impl = mod.default;
88
- if (!isImplementation(impl)) return `the handler of step '${step.name}' must default-export implement(<step '${step.name}'>, …)`;
89
- if (impl.step !== step)
90
- return `the handler of step '${step.name}' implements a different step object (it must be implement(<the '${step.name}' step from pipeline.ts>, …))`;
91
- return impl.handler as unknown as StepFunction;
122
+ /** The function `step` carries, or what is wrong. */
123
+ export function resolveStepFunction(step: AnyStep): StepFunction | string {
124
+ if (isImplementation(step)) return step.handler as unknown as StepFunction;
125
+ return `step '${step.name}' has no implementation (stage implement(step({...}), fn))`;
92
126
  }
93
127
 
94
128
  /** Writes output.json, prints the sentinel block and uploads result.tar.gz when OLVE_RESULT_URL is set. */
@@ -100,7 +134,7 @@ export async function finish(exec: Execution, io: HarnessIO): Promise<number> {
100
134
  if (!url) return exec.exitCode;
101
135
  try {
102
136
  mkdirSync(p.tmp, { recursive: true });
103
- const archive = join(p.tmp, "result.tar.gz");
137
+ const archive = join(p.tmp, 'result.tar.gz');
104
138
  await packResult(p.work, archive);
105
139
  await uploadFile(url, archive, io.stderr);
106
140
  } catch (e) {
@@ -110,18 +144,29 @@ export async function finish(exec: Execution, io: HarnessIO): Promise<number> {
110
144
  return exec.exitCode;
111
145
  }
112
146
 
113
- function readJson(path: string): { ok: true; value: unknown } | { ok: false; message: string } {
147
+ function readJson(
148
+ path: string,
149
+ ): { ok: true; value: unknown } | { ok: false; message: string } {
114
150
  try {
115
- return { ok: true, value: JSON.parse(readFileSync(path, "utf8")) };
151
+ return { ok: true, value: JSON.parse(readFileSync(path, 'utf8')) };
116
152
  } catch (e) {
117
153
  return { ok: false, message: `cannot read ${path}: ${(e as Error).message}` };
118
154
  }
119
155
  }
120
156
 
121
- async function describeMode(cfg: HarnessConfig, args: string[], io: HarnessIO): Promise<number> {
122
- const sentinels = args.includes("--sentinels");
157
+ async function describeMode(
158
+ cfg: HarnessConfig,
159
+ args: string[],
160
+ io: HarnessIO,
161
+ ): Promise<number> {
162
+ const sentinels = args.includes('--sentinels');
123
163
  try {
124
- const text = manifestText(describePipeline(cfg.pipeline, { handlerNames: Object.keys(cfg.handlers), handlerHashes: cfg.handlerHashes, harness: cfg.harness }));
164
+ const text = manifestText(
165
+ describePipeline(cfg.pipeline, {
166
+ codeHash: cfg.codeHash,
167
+ harness: cfg.harness,
168
+ }),
169
+ );
125
170
  await io.stdout(sentinels ? `${MANIFEST_BEGIN}\n${text}${MANIFEST_END}\n` : text);
126
171
  return EXIT.ok;
127
172
  } catch (e) {
@@ -133,95 +178,187 @@ async function describeMode(cfg: HarnessConfig, args: string[], io: HarnessIO):
133
178
  }
134
179
  }
135
180
 
136
- async function runMode(cfg: HarnessConfig, name: string | undefined, io: HarnessIO): Promise<number> {
137
- if (!name) return io.stderr(USAGE), EXIT.usage;
181
+ async function runMode(
182
+ cfg: HarnessConfig,
183
+ name: string | undefined,
184
+ io: HarnessIO,
185
+ ): Promise<number> {
186
+ if (!name) return (io.stderr(USAGE), EXIT.usage);
138
187
  const data = pipelineData(cfg.pipeline);
139
- const step = allSteps(data).find((s) => s.name === name);
140
- if (!step) return io.stderr(`unknown step '${name}'`), EXIT.usage;
141
- const fn = await resolveStepFunction(step, cfg.handlers);
142
- if (typeof fn === "string") return io.stderr(fn), EXIT.configInvalid;
188
+ const step = findStep(data, name, selectedCases());
189
+ if (typeof step === 'string') return (io.stderr(step), EXIT.usage);
190
+ const fn = resolveStepFunction(step);
191
+ if (typeof fn === 'string') return (io.stderr(fn), EXIT.configInvalid);
143
192
 
144
193
  const p = paths();
145
194
  removeStale(p.work);
146
195
  mkdirSync(p.files, { recursive: true });
147
196
  mkdirSync(p.tmp, { recursive: true });
148
197
  const raw = readJson(p.input);
198
+ const cell = step.matrix
199
+ ? parseCell(step.name, step.matrix, process.env[ENV.cell])
200
+ : undefined;
149
201
  const cancel = cancellation();
202
+ const masks = maskPatterns(step.secrets.map((s) => process.env[s.name]));
203
+ const unmask = masks.length > 0 ? installMasking(masks) : () => {};
204
+ if (masks.length > 0) {
205
+ const stderr = io.stderr;
206
+ io = { ...io, stderr: (line) => stderr(maskText(line, masks)) };
207
+ }
150
208
  try {
151
209
  let exec: Execution;
152
- if (!raw.ok) {
153
- io.stderr(raw.message);
154
- exec = { envelope: { status: "failed", error: { kind: "input", message: raw.message } }, exitCode: EXIT.inputInvalid };
210
+ if (!raw.ok || (cell && !cell.ok)) {
211
+ const message = !raw.ok ? raw.message : cell && !cell.ok ? cell.message : '';
212
+ io.stderr(message);
213
+ exec = {
214
+ envelope: { status: 'failed', error: { kind: 'input', message } },
215
+ exitCode: EXIT.inputInvalid,
216
+ };
155
217
  } else {
156
- const run = runInfoFromEnv(step.name);
157
- const ctx = createCtx({ run, secrets: step.secrets, paths: { root: p.root, workFiles: p.files, tmp: p.tmp }, signal: cancel.signal, logSink: io.stderr });
158
- exec = await executeStep({ step, fn, rawInput: raw.value, ctx, workFiles: p.files, log: io.stderr });
218
+ const run = runInfoFromEnv(cell?.ok ? cellName(step.name, cell.cell) : step.name);
219
+ const ctx = createCtx({
220
+ run,
221
+ secrets: step.secrets,
222
+ paths: { root: p.root, workFiles: p.files, tmp: p.tmp },
223
+ signal: cancel.signal,
224
+ logSink: io.stderr,
225
+ callbackUrls: callbackUrlsFromEnv(),
226
+ ...(cell?.ok ? { cell: cell.cell } : {}),
227
+ pipeOutput: masks.length > 0,
228
+ });
229
+ exec = await executeStep({
230
+ step,
231
+ fn,
232
+ rawInput: raw.value,
233
+ ctx,
234
+ workFiles: p.files,
235
+ log: io.stderr,
236
+ });
159
237
  }
160
- return await finish(exec, io);
238
+ unmask();
239
+ return await finish({ ...exec, envelope: maskEnvelope(exec.envelope, masks) }, io);
161
240
  } finally {
241
+ unmask();
162
242
  cancel.dispose();
163
243
  }
164
244
  }
165
245
 
166
246
  function isWebhookEvent(v: unknown): boolean {
167
- if (typeof v !== "object" || v === null || Array.isArray(v) || !("body" in v)) return false;
247
+ if (typeof v !== 'object' || v === null || Array.isArray(v) || !('body' in v))
248
+ return false;
168
249
  const headers = (v as { headers?: unknown }).headers;
169
- return typeof headers === "object" && headers !== null && !Array.isArray(headers);
250
+ return typeof headers === 'object' && headers !== null && !Array.isArray(headers);
170
251
  }
171
252
 
172
- async function mapTriggerMode(cfg: HarnessConfig, name: string | undefined, io: HarnessIO): Promise<number> {
173
- if (!name) return io.stderr(USAGE), EXIT.usage;
253
+ async function mapTriggerMode(
254
+ cfg: HarnessConfig,
255
+ name: string | undefined,
256
+ io: HarnessIO,
257
+ ): Promise<number> {
258
+ if (!name) return (io.stderr(USAGE), EXIT.usage);
174
259
  const data = pipelineData(cfg.pipeline);
175
260
  const trigger = data.triggers.find((t) => t.name === name);
176
- if (!trigger) return io.stderr(`unknown trigger '${name}' (known: ${data.triggers.map((t) => t.name).join(", ") || "none"})`), EXIT.usage;
261
+ if (!trigger)
262
+ return (
263
+ io.stderr(
264
+ `unknown trigger '${name}' (known: ${data.triggers.map((t) => t.name).join(', ') || 'none'})`,
265
+ ),
266
+ EXIT.usage
267
+ );
177
268
  const p = paths();
178
269
  removeStale(p.work);
179
270
  mkdirSync(p.work, { recursive: true });
180
271
  const raw = readJson(p.input);
181
- const fail = (kind: "input" | "output" | "handler", message: string, exitCode: number): Execution => {
272
+ const fail = (
273
+ kind: 'input' | 'output' | 'handler',
274
+ message: string,
275
+ exitCode: number,
276
+ ): Execution => {
182
277
  io.stderr(message);
183
- return { envelope: { status: "failed", error: { kind, message } }, exitCode };
278
+ return { envelope: { status: 'failed', error: { kind, message } }, exitCode };
184
279
  };
185
280
  let exec: Execution;
186
- if (!raw.ok) exec = fail("input", raw.message, EXIT.inputInvalid);
187
- else if (trigger.kind === "poll" && (typeof raw.value !== "object" || raw.value === null || typeof (raw.value as { value?: unknown }).value !== "string"))
188
- exec = fail("input", `poll trigger '${name}': the event must be {"value": "<string>"}`, EXIT.inputInvalid);
189
- else if (trigger.kind === "push" && (typeof raw.value !== "object" || raw.value === null || Array.isArray(raw.value)))
190
- exec = fail("input", `push trigger: the event must be the webhook payload object`, EXIT.inputInvalid);
191
- else if (trigger.kind === "webhook" && !isWebhookEvent(raw.value))
192
- exec = fail("input", `webhook trigger '${name}': the event must be {"headers": {...}, "body": ...}`, EXIT.inputInvalid);
281
+ if (!raw.ok) exec = fail('input', raw.message, EXIT.inputInvalid);
282
+ else if (
283
+ trigger.kind === 'poll' &&
284
+ (typeof raw.value !== 'object' ||
285
+ raw.value === null ||
286
+ typeof (raw.value as { value?: unknown }).value !== 'string')
287
+ )
288
+ exec = fail(
289
+ 'input',
290
+ `poll trigger '${name}': the event must be {"value": "<string>"}`,
291
+ EXIT.inputInvalid,
292
+ );
293
+ else if (
294
+ trigger.kind === 'push' &&
295
+ (typeof raw.value !== 'object' || raw.value === null || Array.isArray(raw.value))
296
+ )
297
+ exec = fail(
298
+ 'input',
299
+ `push trigger: the event must be the webhook payload object`,
300
+ EXIT.inputInvalid,
301
+ );
302
+ else if (trigger.kind === 'webhook' && !isWebhookEvent(raw.value))
303
+ exec = fail(
304
+ 'input',
305
+ `webhook trigger '${name}': the event must be {"headers": {...}, "body": ...}`,
306
+ EXIT.inputInvalid,
307
+ );
193
308
  else {
194
309
  let mapped: unknown;
195
310
  let threw: unknown = undefined;
196
311
  try {
197
312
  mapped =
198
- trigger.kind === "poll" ? await trigger.map((raw.value as { value: string }).value)
199
- : trigger.kind === "webhook" ? await trigger.map(raw.value as WebhookEvent)
200
- : await trigger.map(raw.value);
313
+ trigger.kind === 'poll'
314
+ ? await trigger.map((raw.value as { value: string }).value)
315
+ : trigger.kind === 'webhook'
316
+ ? await trigger.map(raw.value as WebhookEvent)
317
+ : await trigger.map(raw.value);
201
318
  } catch (e) {
202
- threw = e ?? new Error("undefined");
319
+ threw = e ?? new Error('undefined');
203
320
  }
204
- if (threw !== undefined) exec = fail("handler", `trigger '${name}' mapper threw: ${(threw as Error)?.message ?? String(threw)}`, EXIT.handlerFailed);
205
- else if (mapped === null && trigger.kind === "webhook") exec = { envelope: { status: "skipped", reason: `trigger '${name}' ignored the delivery` }, exitCode: EXIT.ok };
321
+ if (threw !== undefined)
322
+ exec = fail(
323
+ 'handler',
324
+ `trigger '${name}' mapper threw: ${(threw as Error)?.message ?? String(threw)}`,
325
+ EXIT.handlerFailed,
326
+ );
327
+ else if (mapped === null && trigger.kind === 'webhook')
328
+ exec = {
329
+ envelope: { status: 'skipped', reason: `trigger '${name}' ignored the delivery` },
330
+ exitCode: EXIT.ok,
331
+ };
206
332
  else {
207
- const r = parseStrict(data.input.schema, mapped, "input");
333
+ const r = parseStrict(data.input.schema, mapped, 'input');
208
334
  exec = r.ok
209
- ? { envelope: { status: "succeeded", output: r.value } satisfies Envelope, exitCode: EXIT.ok }
210
- : fail("output", `trigger '${name}': mapped input does not match ${data.input.name}:\n ${r.issues.join("\n ")}`, EXIT.outputInvalid);
335
+ ? {
336
+ envelope: { status: 'succeeded', output: r.value } satisfies Envelope,
337
+ exitCode: EXIT.ok,
338
+ }
339
+ : fail(
340
+ 'output',
341
+ `trigger '${name}': mapped input does not match ${data.input.name}:\n ${r.issues.join('\n ')}`,
342
+ EXIT.outputInvalid,
343
+ );
211
344
  }
212
345
  }
213
346
  return finish(exec, io);
214
347
  }
215
348
 
216
349
  /** Entry point of the compiled binary (and the dev runner). Returns the exit code. */
217
- export async function runHarness(cfg: HarnessConfig, argv: readonly string[], io: HarnessIO = defaultIO): Promise<number> {
350
+ export async function runHarness(
351
+ cfg: HarnessConfig,
352
+ argv: readonly string[],
353
+ io: HarnessIO = defaultIO,
354
+ ): Promise<number> {
218
355
  const [mode, ...rest] = argv;
219
356
  switch (mode) {
220
- case "describe":
357
+ case 'describe':
221
358
  return describeMode(cfg, rest, io);
222
- case "run":
359
+ case 'run':
223
360
  return runMode(cfg, rest[0], io);
224
- case "map-trigger":
361
+ case 'map-trigger':
225
362
  return mapTriggerMode(cfg, rest[0], io);
226
363
  default:
227
364
  io.stderr(USAGE);
@@ -230,3 +367,12 @@ export async function runHarness(cfg: HarnessConfig, argv: readonly string[], io
230
367
  }
231
368
 
232
369
  export { formatIssues, formatLog };
370
+
371
+ function selectedCases(): string[] {
372
+ try {
373
+ const v = JSON.parse(process.env[ENV_CASES] ?? '[]') as unknown;
374
+ return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [];
375
+ } catch {
376
+ return [];
377
+ }
378
+ }
@@ -0,0 +1,98 @@
1
+ import { format } from 'node:util';
2
+ import type { Envelope } from './execute.ts';
3
+
4
+ /**
5
+ * Masks the step's declared secret values in what it writes to stdout and stderr. Output is masked a
6
+ * line at a time, so a value split across two writes is still found; a multi-line value (a key file)
7
+ * is masked line by line.
8
+ */
9
+
10
+ export const MASK = '***';
11
+ /** Shorter values (and lines of multi-line values) are left alone: they would mask ordinary text. */
12
+ export const MIN_MASKED_LENGTH = 4;
13
+ /** A line longer than this is masked and written without waiting for its end. */
14
+ const MAX_PENDING = 64 * 1024;
15
+
16
+ /** The strings to mask for these secret values, longest first. */
17
+ export function maskPatterns(values: readonly (string | undefined)[]): string[] {
18
+ const patterns = new Set<string>();
19
+ for (const value of values)
20
+ for (const line of (value ?? '').split(/\r?\n/))
21
+ if (line.trim().length >= MIN_MASKED_LENGTH) patterns.add(line.trim());
22
+ return [...patterns].sort((a, b) => b.length - a.length);
23
+ }
24
+
25
+ export function maskText(text: string, patterns: readonly string[]): string {
26
+ let out = text;
27
+ for (const p of patterns) if (out.includes(p)) out = out.split(p).join(MASK);
28
+ return out;
29
+ }
30
+
31
+ /** A writer that masks complete lines and holds back the unfinished one until it ends or `flush`. */
32
+ export function lineMasker(patterns: readonly string[], write: (text: string) => void) {
33
+ const decoder = new TextDecoder();
34
+ let pending = '';
35
+ return {
36
+ write(chunk: string | Uint8Array) {
37
+ pending +=
38
+ typeof chunk === 'string' ? chunk : decoder.decode(chunk, { stream: true });
39
+ const end = Math.max(pending.lastIndexOf('\n'), pending.lastIndexOf('\r'));
40
+ if (end >= 0) {
41
+ write(maskText(pending.slice(0, end + 1), patterns));
42
+ pending = pending.slice(end + 1);
43
+ }
44
+ if (pending.length > MAX_PENDING) this.flush();
45
+ },
46
+ flush() {
47
+ pending += decoder.decode();
48
+ if (pending) write(maskText(pending, patterns));
49
+ pending = '';
50
+ },
51
+ };
52
+ }
53
+
54
+ /** The envelope with its failure message or skip reason masked; outputs are data and stay as they are. */
55
+ export function maskEnvelope(envelope: Envelope, patterns: readonly string[]): Envelope {
56
+ if (envelope.status === 'failed')
57
+ return {
58
+ ...envelope,
59
+ error: { ...envelope.error, message: maskText(envelope.error.message, patterns) },
60
+ };
61
+ if (envelope.status === 'skipped')
62
+ return { ...envelope, reason: maskText(envelope.reason, patterns) };
63
+ return envelope;
64
+ }
65
+
66
+ type Write = typeof process.stdout.write;
67
+
68
+ /**
69
+ * Routes process.stdout and process.stderr through maskers until the returned function is called,
70
+ * which flushes them and restores the streams.
71
+ */
72
+ export function installMasking(patterns: readonly string[]): () => void {
73
+ const streams = [process.stdout, process.stderr];
74
+ const originals = streams.map((s) => s.write.bind(s) as Write);
75
+ const maskers = originals.map((original) =>
76
+ lineMasker(patterns, (text) => void original(text)),
77
+ );
78
+ streams.forEach((s, i) => {
79
+ s.write = ((chunk: string | Uint8Array, a?: unknown, b?: unknown) => {
80
+ maskers[i]!.write(chunk);
81
+ const callback = typeof a === 'function' ? a : typeof b === 'function' ? b : null;
82
+ if (callback) queueMicrotask(() => (callback as () => void)());
83
+ return true;
84
+ }) as Write;
85
+ });
86
+ // Bun's console writes natively, past process.stdout.write.
87
+ const methods = ['log', 'info', 'debug', 'warn', 'error'] as const;
88
+ const consoleOriginals = methods.map((m) => console[m]);
89
+ methods.forEach((m) => {
90
+ const stream = m === 'warn' || m === 'error' ? process.stderr : process.stdout;
91
+ console[m] = (...args: unknown[]) => void stream.write(format(...args) + '\n');
92
+ });
93
+ return () => {
94
+ maskers.forEach((m) => m.flush());
95
+ streams.forEach((s, i) => (s.write = originals[i]!));
96
+ methods.forEach((m, i) => (console[m] = consoleOriginals[i]!));
97
+ };
98
+ }