@olivervea/pipelines 2.0.0-beta.2 → 2.0.0-beta.20

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 (54) hide show
  1. package/README.md +125 -62
  2. package/manifest.schema.json +208 -11
  3. package/package.json +6 -4
  4. package/src/assets.d.ts +2 -2
  5. package/src/build/build.ts +121 -51
  6. package/src/build/cli.ts +29 -22
  7. package/src/build/elf.ts +8 -7
  8. package/src/build/hashes.ts +82 -20
  9. package/src/build/project.ts +95 -28
  10. package/src/builder/pipeline.ts +344 -88
  11. package/src/builder/refs.ts +15 -5
  12. package/src/core/brands.ts +7 -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 +61 -15
  17. package/src/core/models.ts +32 -15
  18. package/src/core/origin.ts +23 -0
  19. package/src/core/secrets.ts +2 -2
  20. package/src/core/steps.ts +89 -38
  21. package/src/core/types.ts +5 -1
  22. package/src/core/webhooks.ts +21 -7
  23. package/src/ctx/files.ts +111 -40
  24. package/src/ctx/index.ts +84 -31
  25. package/src/ctx/sh.ts +105 -44
  26. package/src/ctx/types.ts +12 -4
  27. package/src/describe/describe.ts +701 -133
  28. package/src/describe/manifest-schema.ts +258 -61
  29. package/src/describe/mapper-source.ts +249 -104
  30. package/src/describe/record.ts +67 -37
  31. package/src/dev/cli.ts +72 -35
  32. package/src/harness/env.ts +45 -25
  33. package/src/harness/execute.ts +58 -29
  34. package/src/harness/index.ts +22 -9
  35. package/src/harness/main.ts +239 -78
  36. package/src/harness/result.ts +38 -22
  37. package/src/harness/validate.ts +77 -15
  38. package/src/index.ts +124 -11
  39. package/src/library/checks.ts +28 -0
  40. package/src/library/compose-deploy.ts +113 -0
  41. package/src/library/github-source.ts +100 -47
  42. package/src/library/helm-deploy-ssh.ts +452 -108
  43. package/src/library/helm-deploy.ts +449 -0
  44. package/src/library/http-health.ts +7 -5
  45. package/src/library/image-build.ts +499 -105
  46. package/src/library/index.ts +48 -12
  47. package/src/library/models.ts +33 -9
  48. package/src/library/ssh.ts +67 -0
  49. package/src/testing/index.ts +74 -36
  50. package/src/util/canonical.ts +7 -4
  51. package/src/util/handler-hash.ts +17 -7
  52. package/src/util/hash.ts +5 -5
  53. package/src/util/tar.ts +86 -36
  54. package/src/library/script.ts +0 -85
@@ -2,29 +2,47 @@
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";
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 type { WebhookEvent } from '../core/webhooks.ts';
11
+ import { pipelineData, type PipelineData, type StageNode } from '../builder/pipeline.ts';
12
+ import { createCtx, formatLog } from '../ctx/index.ts';
13
+ import { DescribeError, describePipeline, manifestText } from '../describe/describe.ts';
14
+ import type { HarnessInfo } from '../util/handler-hash.ts';
15
+ import { callbackUrlsFromEnv, ENV, paths, runInfoFromEnv } from './env.ts';
16
+ import { EXIT } from './exit.ts';
17
+ import {
18
+ executeStep,
19
+ type Envelope,
20
+ type Execution,
21
+ type StepFunction,
22
+ } from './execute.ts';
23
+ import {
24
+ MANIFEST_BEGIN,
25
+ MANIFEST_END,
26
+ packResult,
27
+ removeStale,
28
+ sentinelBlock,
29
+ uploadFile,
30
+ writeEnvelope,
31
+ } from './result.ts';
32
+ import { formatIssues, parseStrict } from './validate.ts';
19
33
 
20
34
  /** 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 }>>>;
35
+ export type HandlerRegistry = Readonly<
36
+ Record<string, () => Promise<{ default: unknown }>>
37
+ >;
22
38
 
23
39
  export interface HarnessConfig {
24
40
  /** Default export of pipeline.ts. */
25
41
  pipeline: unknown;
26
42
  handlers: HandlerRegistry;
27
43
  handlerHashes: Readonly<Record<string, string>>;
44
+ /** Kind → hash of the code of the steps of that kind that carry their implementation. */
45
+ kindHashes?: Readonly<Record<string, string>>;
28
46
  harness: HarnessInfo;
29
47
  }
30
48
 
@@ -38,56 +56,99 @@ export const defaultIO: HarnessIO = {
38
56
  await Bun.write(Bun.stdout, text);
39
57
  },
40
58
  stderr: (line) => {
41
- process.stderr.write(line.endsWith("\n") ? line : line + "\n");
59
+ process.stderr.write(line.endsWith('\n') ? line : line + '\n');
42
60
  },
43
61
  };
44
62
 
45
- const USAGE = "usage: step describe [--sentinels] | step run <step> | step map-trigger <trigger>";
63
+ const USAGE =
64
+ 'usage: step describe [--sentinels] | step run <step> | step map-trigger <trigger>';
46
65
 
47
66
  export function allSteps(data: PipelineData): AnyStep[] {
48
67
  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);
68
+ for (const { step } of stepsWithCases(data)) if (!out.includes(step)) out.push(step);
69
+ return out;
70
+ }
71
+
72
+ /** Every step with the `select` case conditions (`<ref>.<discriminator>=<value>`) it sits in. */
73
+ export function stepsWithCases(data: PipelineData): { step: AnyStep; cases: string[] }[] {
74
+ const out: { step: AnyStep; cases: string[] }[] = [];
75
+ const walk = (nodes: readonly StageNode[], cases: string[]) => {
76
+ for (const n of nodes) {
77
+ if (n.kind === 'stage') out.push(...n.steps.map((s) => ({ step: s.step, cases })));
78
+ else if (n.kind === 'when') walk(n.nodes, cases);
79
+ else {
80
+ const union = isConfigRef(n.on) ? unionAt(n.on) : undefined;
81
+ for (const [value, inner] of Object.entries(n.cases))
82
+ walk(
83
+ inner,
84
+ union
85
+ ? [...cases, `${configRefPath(n.on)}.${union.discriminator}=${value}`]
86
+ : cases,
87
+ );
88
+ }
89
+ }
51
90
  };
52
- walk(data.nodes);
53
- for (const fh of data.failureHandlers) if (!out.includes(fh.step)) out.push(fh.step);
91
+ walk(data.nodes, []);
92
+ for (const fh of data.failureHandlers) out.push({ step: fh.step, cases: [] });
54
93
  return out;
55
94
  }
56
95
 
96
+ /** The step `run <name>` executes: the only one so named, or the one in the cases the service selected (`OLVE_CASES`). */
97
+ export function findStep(
98
+ data: PipelineData,
99
+ name: string,
100
+ selected: readonly string[],
101
+ ): AnyStep | string {
102
+ const candidates = stepsWithCases(data).filter((c) => c.step.name === name);
103
+ const distinct = candidates.filter(
104
+ (c, i) => candidates.findIndex((d) => d.step === c.step) === i,
105
+ );
106
+ if (distinct.length === 0) return `unknown step '${name}'`;
107
+ if (distinct.length === 1) return distinct[0]!.step;
108
+ const chosen = distinct.filter((c) => c.cases.every((k) => selected.includes(k)));
109
+ if (chosen.length === 1) return chosen[0]!.step;
110
+ return `step '${name}' is in several select cases; ${ENV_CASES} must name the selected one (got ${JSON.stringify(selected)})`;
111
+ }
112
+
113
+ /** The select case conditions of the step being run (JSON array), set by the service for steps inside a select. */
114
+ export const ENV_CASES = 'OLVE_CASES';
115
+
57
116
  /** Installs SIGTERM/SIGINT handlers that abort the returned signal (PID 1 ignores signals without them). */
58
117
  export function cancellation(): { signal: AbortSignal; dispose(): void } {
59
118
  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);
119
+ const onTerm = () => ac.abort('SIGTERM');
120
+ const onInt = () =>
121
+ ac.signal.aborted ? process.exit(EXIT.sigint) : ac.abort('SIGINT');
122
+ process.on('SIGTERM', onTerm);
123
+ process.on('SIGINT', onInt);
64
124
  return {
65
125
  signal: ac.signal,
66
126
  dispose() {
67
- process.off("SIGTERM", onTerm);
68
- process.off("SIGINT", onInt);
127
+ process.off('SIGTERM', onTerm);
128
+ process.off('SIGINT', onInt);
69
129
  },
70
130
  };
71
131
  }
72
132
 
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
- }
133
+ /** Resolves the function that implements `step` (carried by the step, or a handler module). */
134
+ export async function resolveStepFunction(
135
+ step: AnyStep,
136
+ handlers: HandlerRegistry,
137
+ ): Promise<StepFunction | string> {
138
+ if (isImplementation(step)) return step.handler as unknown as StepFunction;
79
139
  const load = handlers[step.name];
80
- if (!load) return `step '${step.name}' has no handler (expected handlers/${step.name}.ts)`;
140
+ if (!load) return `step '${step.name}' has no handler`;
81
141
  let mod: { default: unknown };
82
142
  try {
83
143
  mod = await load();
84
144
  } catch (e) {
85
- return `handlers/${step.name}.ts failed to load: ${(e as Error)?.stack ?? String(e)}`;
145
+ return `the handler of step '${step.name}' failed to load: ${(e as Error)?.stack ?? String(e)}`;
86
146
  }
87
147
  const impl = mod.default;
88
- if (!isImplementation(impl)) return `handlers/${step.name}.ts must default-export implement(<step '${step.name}'>, …)`;
148
+ if (!isImplementation(impl))
149
+ return `the handler of step '${step.name}' must default-export implement(<step '${step.name}'>, …)`;
89
150
  if (impl.step !== step)
90
- return `handlers/${step.name}.ts implements step '${(impl.step as AnyStep)?.name}', not '${step.name}' (the default export must be implement(<the '${step.name}' step from pipeline.ts>, …))`;
151
+ return `the handler of step '${step.name}' implements a different step object (it must be implement(<the '${step.name}' step from pipeline.ts>, …))`;
91
152
  return impl.handler as unknown as StepFunction;
92
153
  }
93
154
 
@@ -100,7 +161,7 @@ export async function finish(exec: Execution, io: HarnessIO): Promise<number> {
100
161
  if (!url) return exec.exitCode;
101
162
  try {
102
163
  mkdirSync(p.tmp, { recursive: true });
103
- const archive = join(p.tmp, "result.tar.gz");
164
+ const archive = join(p.tmp, 'result.tar.gz');
104
165
  await packResult(p.work, archive);
105
166
  await uploadFile(url, archive, io.stderr);
106
167
  } catch (e) {
@@ -110,18 +171,31 @@ export async function finish(exec: Execution, io: HarnessIO): Promise<number> {
110
171
  return exec.exitCode;
111
172
  }
112
173
 
113
- function readJson(path: string): { ok: true; value: unknown } | { ok: false; message: string } {
174
+ function readJson(
175
+ path: string,
176
+ ): { ok: true; value: unknown } | { ok: false; message: string } {
114
177
  try {
115
- return { ok: true, value: JSON.parse(readFileSync(path, "utf8")) };
178
+ return { ok: true, value: JSON.parse(readFileSync(path, 'utf8')) };
116
179
  } catch (e) {
117
180
  return { ok: false, message: `cannot read ${path}: ${(e as Error).message}` };
118
181
  }
119
182
  }
120
183
 
121
- async function describeMode(cfg: HarnessConfig, args: string[], io: HarnessIO): Promise<number> {
122
- const sentinels = args.includes("--sentinels");
184
+ async function describeMode(
185
+ cfg: HarnessConfig,
186
+ args: string[],
187
+ io: HarnessIO,
188
+ ): Promise<number> {
189
+ const sentinels = args.includes('--sentinels');
123
190
  try {
124
- const text = manifestText(describePipeline(cfg.pipeline, { handlerNames: Object.keys(cfg.handlers), handlerHashes: cfg.handlerHashes, harness: cfg.harness }));
191
+ const text = manifestText(
192
+ describePipeline(cfg.pipeline, {
193
+ handlerNames: Object.keys(cfg.handlers),
194
+ handlerHashes: cfg.handlerHashes,
195
+ kindHashes: cfg.kindHashes,
196
+ harness: cfg.harness,
197
+ }),
198
+ );
125
199
  await io.stdout(sentinels ? `${MANIFEST_BEGIN}\n${text}${MANIFEST_END}\n` : text);
126
200
  return EXIT.ok;
127
201
  } catch (e) {
@@ -133,13 +207,17 @@ async function describeMode(cfg: HarnessConfig, args: string[], io: HarnessIO):
133
207
  }
134
208
  }
135
209
 
136
- async function runMode(cfg: HarnessConfig, name: string | undefined, io: HarnessIO): Promise<number> {
137
- if (!name) return io.stderr(USAGE), EXIT.usage;
210
+ async function runMode(
211
+ cfg: HarnessConfig,
212
+ name: string | undefined,
213
+ io: HarnessIO,
214
+ ): Promise<number> {
215
+ if (!name) return (io.stderr(USAGE), EXIT.usage);
138
216
  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;
217
+ const step = findStep(data, name, selectedCases());
218
+ if (typeof step === 'string') return (io.stderr(step), EXIT.usage);
141
219
  const fn = await resolveStepFunction(step, cfg.handlers);
142
- if (typeof fn === "string") return io.stderr(fn), EXIT.configInvalid;
220
+ if (typeof fn === 'string') return (io.stderr(fn), EXIT.configInvalid);
143
221
 
144
222
  const p = paths();
145
223
  removeStale(p.work);
@@ -151,11 +229,28 @@ async function runMode(cfg: HarnessConfig, name: string | undefined, io: Harness
151
229
  let exec: Execution;
152
230
  if (!raw.ok) {
153
231
  io.stderr(raw.message);
154
- exec = { envelope: { status: "failed", error: { kind: "input", message: raw.message } }, exitCode: EXIT.inputInvalid };
232
+ exec = {
233
+ envelope: { status: 'failed', error: { kind: 'input', message: raw.message } },
234
+ exitCode: EXIT.inputInvalid,
235
+ };
155
236
  } else {
156
237
  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 });
238
+ const ctx = createCtx({
239
+ run,
240
+ secrets: step.secrets,
241
+ paths: { root: p.root, workFiles: p.files, tmp: p.tmp },
242
+ signal: cancel.signal,
243
+ logSink: io.stderr,
244
+ callbackUrls: callbackUrlsFromEnv(),
245
+ });
246
+ exec = await executeStep({
247
+ step,
248
+ fn,
249
+ rawInput: raw.value,
250
+ ctx,
251
+ workFiles: p.files,
252
+ log: io.stderr,
253
+ });
159
254
  }
160
255
  return await finish(exec, io);
161
256
  } finally {
@@ -164,64 +259,121 @@ async function runMode(cfg: HarnessConfig, name: string | undefined, io: Harness
164
259
  }
165
260
 
166
261
  function isWebhookEvent(v: unknown): boolean {
167
- if (typeof v !== "object" || v === null || Array.isArray(v) || !("body" in v)) return false;
262
+ if (typeof v !== 'object' || v === null || Array.isArray(v) || !('body' in v))
263
+ return false;
168
264
  const headers = (v as { headers?: unknown }).headers;
169
- return typeof headers === "object" && headers !== null && !Array.isArray(headers);
265
+ return typeof headers === 'object' && headers !== null && !Array.isArray(headers);
170
266
  }
171
267
 
172
- async function mapTriggerMode(cfg: HarnessConfig, name: string | undefined, io: HarnessIO): Promise<number> {
173
- if (!name) return io.stderr(USAGE), EXIT.usage;
268
+ async function mapTriggerMode(
269
+ cfg: HarnessConfig,
270
+ name: string | undefined,
271
+ io: HarnessIO,
272
+ ): Promise<number> {
273
+ if (!name) return (io.stderr(USAGE), EXIT.usage);
174
274
  const data = pipelineData(cfg.pipeline);
175
275
  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;
276
+ if (!trigger)
277
+ return (
278
+ io.stderr(
279
+ `unknown trigger '${name}' (known: ${data.triggers.map((t) => t.name).join(', ') || 'none'})`,
280
+ ),
281
+ EXIT.usage
282
+ );
177
283
  const p = paths();
178
284
  removeStale(p.work);
179
285
  mkdirSync(p.work, { recursive: true });
180
286
  const raw = readJson(p.input);
181
- const fail = (kind: "input" | "output" | "handler", message: string, exitCode: number): Execution => {
287
+ const fail = (
288
+ kind: 'input' | 'output' | 'handler',
289
+ message: string,
290
+ exitCode: number,
291
+ ): Execution => {
182
292
  io.stderr(message);
183
- return { envelope: { status: "failed", error: { kind, message } }, exitCode };
293
+ return { envelope: { status: 'failed', error: { kind, message } }, exitCode };
184
294
  };
185
295
  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);
296
+ if (!raw.ok) exec = fail('input', raw.message, EXIT.inputInvalid);
297
+ else if (
298
+ trigger.kind === 'poll' &&
299
+ (typeof raw.value !== 'object' ||
300
+ raw.value === null ||
301
+ typeof (raw.value as { value?: unknown }).value !== 'string')
302
+ )
303
+ exec = fail(
304
+ 'input',
305
+ `poll trigger '${name}': the event must be {"value": "<string>"}`,
306
+ EXIT.inputInvalid,
307
+ );
308
+ else if (
309
+ trigger.kind === 'push' &&
310
+ (typeof raw.value !== 'object' || raw.value === null || Array.isArray(raw.value))
311
+ )
312
+ exec = fail(
313
+ 'input',
314
+ `push trigger: the event must be the webhook payload object`,
315
+ EXIT.inputInvalid,
316
+ );
317
+ else if (trigger.kind === 'webhook' && !isWebhookEvent(raw.value))
318
+ exec = fail(
319
+ 'input',
320
+ `webhook trigger '${name}': the event must be {"headers": {...}, "body": ...}`,
321
+ EXIT.inputInvalid,
322
+ );
193
323
  else {
194
324
  let mapped: unknown;
195
325
  let threw: unknown = undefined;
196
326
  try {
197
327
  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);
328
+ trigger.kind === 'poll'
329
+ ? await trigger.map((raw.value as { value: string }).value)
330
+ : trigger.kind === 'webhook'
331
+ ? await trigger.map(raw.value as WebhookEvent)
332
+ : await trigger.map(raw.value);
201
333
  } catch (e) {
202
- threw = e ?? new Error("undefined");
334
+ threw = e ?? new Error('undefined');
203
335
  }
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 };
336
+ if (threw !== undefined)
337
+ exec = fail(
338
+ 'handler',
339
+ `trigger '${name}' mapper threw: ${(threw as Error)?.message ?? String(threw)}`,
340
+ EXIT.handlerFailed,
341
+ );
342
+ else if (mapped === null && trigger.kind === 'webhook')
343
+ exec = {
344
+ envelope: { status: 'skipped', reason: `trigger '${name}' ignored the delivery` },
345
+ exitCode: EXIT.ok,
346
+ };
206
347
  else {
207
- const r = parseStrict(data.input.schema, mapped, "input");
348
+ const r = parseStrict(data.input.schema, mapped, 'input');
208
349
  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);
350
+ ? {
351
+ envelope: { status: 'succeeded', output: r.value } satisfies Envelope,
352
+ exitCode: EXIT.ok,
353
+ }
354
+ : fail(
355
+ 'output',
356
+ `trigger '${name}': mapped input does not match ${data.input.name}:\n ${r.issues.join('\n ')}`,
357
+ EXIT.outputInvalid,
358
+ );
211
359
  }
212
360
  }
213
361
  return finish(exec, io);
214
362
  }
215
363
 
216
364
  /** 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> {
365
+ export async function runHarness(
366
+ cfg: HarnessConfig,
367
+ argv: readonly string[],
368
+ io: HarnessIO = defaultIO,
369
+ ): Promise<number> {
218
370
  const [mode, ...rest] = argv;
219
371
  switch (mode) {
220
- case "describe":
372
+ case 'describe':
221
373
  return describeMode(cfg, rest, io);
222
- case "run":
374
+ case 'run':
223
375
  return runMode(cfg, rest[0], io);
224
- case "map-trigger":
376
+ case 'map-trigger':
225
377
  return mapTriggerMode(cfg, rest[0], io);
226
378
  default:
227
379
  io.stderr(USAGE);
@@ -230,3 +382,12 @@ export async function runHarness(cfg: HarnessConfig, argv: readonly string[], io
230
382
  }
231
383
 
232
384
  export { formatIssues, formatLog };
385
+
386
+ function selectedCases(): string[] {
387
+ try {
388
+ const v = JSON.parse(process.env[ENV_CASES] ?? '[]') as unknown;
389
+ return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [];
390
+ } catch {
391
+ return [];
392
+ }
393
+ }
@@ -1,22 +1,29 @@
1
1
  // Writing, printing and uploading the result of `run` / `map-trigger`.
2
- import { existsSync, mkdirSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
- import { join } from "node:path";
4
- import { canonicalJson } from "../util/canonical.ts";
5
- import { dirEntries, writeTarGz, type TarEntry } from "../util/tar.ts";
6
- import { redactUrl } from "../ctx/files.ts";
7
- import type { Envelope } from "./execute.ts";
2
+ import {
3
+ existsSync,
4
+ mkdirSync,
5
+ readdirSync,
6
+ renameSync,
7
+ rmSync,
8
+ writeFileSync,
9
+ } from 'node:fs';
10
+ import { join } from 'node:path';
11
+ import { canonicalJson } from '../util/canonical.ts';
12
+ import { dirEntries, writeTarGz, type TarEntry } from '../util/tar.ts';
13
+ import { redactUrl } from '../ctx/files.ts';
14
+ import type { Envelope } from './execute.ts';
8
15
 
9
- export const RESULT_BEGIN = "---OLVE-RESULT-BEGIN---";
10
- export const RESULT_END = "---OLVE-RESULT-END---";
11
- export const MANIFEST_BEGIN = "---OLVE-MANIFEST-BEGIN---";
12
- export const MANIFEST_END = "---OLVE-MANIFEST-END---";
16
+ export const RESULT_BEGIN = '---OLVE-RESULT-BEGIN---';
17
+ export const RESULT_END = '---OLVE-RESULT-END---';
18
+ export const MANIFEST_BEGIN = '---OLVE-MANIFEST-BEGIN---';
19
+ export const MANIFEST_END = '---OLVE-MANIFEST-END---';
13
20
 
14
21
  /** Atomically writes the envelope to `<work>/output.json`. */
15
22
  export function writeEnvelope(work: string, envelope: Envelope): string {
16
23
  mkdirSync(work, { recursive: true });
17
- const path = join(work, "output.json");
18
- writeFileSync(path + ".partial", canonicalJson(envelope));
19
- renameSync(path + ".partial", path);
24
+ const path = join(work, 'output.json');
25
+ writeFileSync(path + '.partial', canonicalJson(envelope));
26
+ renameSync(path + '.partial', path);
20
27
  return path;
21
28
  }
22
29
 
@@ -27,9 +34,15 @@ export function sentinelBlock(envelope: Envelope): string {
27
34
 
28
35
  /** Packs output.json + files/ into result.tar.gz (deterministic). */
29
36
  export async function packResult(work: string, out: string): Promise<void> {
30
- const entries: TarEntry[] = [{ path: "output.json", type: "file", mode: 0o644, source: join(work, "output.json") }];
31
- const files = join(work, "files");
32
- if (existsSync(files)) entries.push({ path: "files", type: "dir", mode: 0o755 }, ...dirEntries(files, "files"));
37
+ const entries: TarEntry[] = [
38
+ { path: 'output.json', type: 'file', mode: 0o644, source: join(work, 'output.json') },
39
+ ];
40
+ const files = join(work, 'files');
41
+ if (existsSync(files))
42
+ entries.push(
43
+ { path: 'files', type: 'dir', mode: 0o755 },
44
+ ...dirEntries(files, 'files'),
45
+ );
33
46
  await writeTarGz(out, entries);
34
47
  }
35
48
 
@@ -48,7 +61,7 @@ export async function uploadFile(
48
61
  let status = 0;
49
62
  let detail: string;
50
63
  try {
51
- const res = await fetch(url, { method: "PUT", body: Bun.file(file) });
64
+ const res = await fetch(url, { method: 'PUT', body: Bun.file(file) });
52
65
  status = res.status;
53
66
  if (res.ok) return;
54
67
  detail = `HTTP ${res.status} ${res.statusText}`;
@@ -57,7 +70,8 @@ export async function uploadFile(
57
70
  detail = (e as Error).message;
58
71
  }
59
72
  const transient = status === 0 || TRANSIENT(status);
60
- if (!transient || i >= attempts) throw new Error(`upload to ${redactUrl(url)} failed: ${detail}`);
73
+ if (!transient || i >= attempts)
74
+ throw new Error(`upload to ${redactUrl(url)} failed: ${detail}`);
61
75
  log(`upload to ${redactUrl(url)} failed (${detail}); retry ${i}/${attempts - 1}`);
62
76
  await Bun.sleep(base * 2 ** (i - 1));
63
77
  }
@@ -68,8 +82,10 @@ export async function uploadFile(
68
82
  * *contents* of files/. The directories themselves are kept (olve-init creates them 1777).
69
83
  */
70
84
  export function removeStale(work: string): void {
71
- rmSync(join(work, "output.json"), { force: true });
72
- rmSync(join(work, "output.json.partial"), { force: true });
73
- const files = join(work, "files");
74
- if (existsSync(files)) for (const e of readdirSync(files)) rmSync(join(files, e), { recursive: true, force: true });
85
+ rmSync(join(work, 'output.json'), { force: true });
86
+ rmSync(join(work, 'output.json.partial'), { force: true });
87
+ const files = join(work, 'files');
88
+ if (existsSync(files))
89
+ for (const e of readdirSync(files))
90
+ rmSync(join(files, e), { recursive: true, force: true });
75
91
  }