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

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 (64) hide show
  1. package/README.md +163 -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 +103 -38
  25. package/src/ctx/links.ts +30 -0
  26. package/src/ctx/sh.ts +104 -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 +234 -98
  45. package/src/harness/result.ts +45 -24
  46. package/src/harness/validate.ts +82 -15
  47. package/src/index.ts +139 -10
  48. package/src/library/checks.ts +11 -6
  49. package/src/library/compose-deploy.ts +115 -0
  50. package/src/library/github-source.ts +100 -47
  51. package/src/library/helm-deploy-ssh.ts +426 -112
  52. package/src/library/helm-deploy.ts +529 -0
  53. package/src/library/http-health.ts +7 -5
  54. package/src/library/image-build.ts +510 -162
  55. package/src/library/index.ts +48 -14
  56. package/src/library/models.ts +35 -14
  57. package/src/library/ssh.ts +44 -18
  58. package/src/testing/index.ts +100 -35
  59. package/src/util/canonical.ts +7 -4
  60. package/src/util/code-hash.ts +17 -0
  61. package/src/util/hash.ts +5 -5
  62. package/src/util/tar.ts +83 -38
  63. package/src/library/script.ts +0 -85
  64. package/src/util/handler-hash.ts +0 -22
@@ -1,16 +1,50 @@
1
- import { gitHubSource } from "./github-source.ts";
2
- import { helmDeployOverSsh } from "./helm-deploy-ssh.ts";
3
- import { imageBuild } from "./image-build.ts";
4
- import { script } from "./script.ts";
1
+ import { gitHubSource } from './github-source.ts';
2
+ import { composeDeploy } from './compose-deploy.ts';
3
+ import { helmDeployInCluster } from './helm-deploy.ts';
4
+ import { helmDeployOverSsh } from './helm-deploy-ssh.ts';
5
+ import { imageBuild } from './image-build.ts';
5
6
 
6
- export { Source, BuiltImage, PushedImage, HelmRelease, FailureContext } from "./models.ts";
7
- export { inputEnvName, type RunValue, type ScriptDef } from "./script.ts";
8
- export type { GitHubSourceDef } from "./github-source.ts";
9
- export type { ImageBuildDef, ImageBuildTarballDef, ImageBuildPushDef } from "./image-build.ts";
10
- export type { HelmDeployOverSshDef } from "./helm-deploy-ssh.ts";
11
- export { sshSession, shq, type SshSession } from "./ssh.ts";
12
- export { serviceUrl, checkSpa } from "./checks.ts";
13
- export { httpHealthCheck, HTTP_HEALTH_DEFAULTS, type HttpHealthCheckOptions } from "./http-health.ts";
7
+ export {
8
+ Source,
9
+ BuiltImage,
10
+ PushedImage,
11
+ HelmRelease,
12
+ FailureContext,
13
+ } from './models.ts';
14
+ export type { GitHubSourceDef } from './github-source.ts';
15
+ export type {
16
+ ImageBuildDef,
17
+ ImageBuildTarballDef,
18
+ ImageBuildPushDef,
19
+ RegistryAuth,
20
+ RegistryAuthConfig,
21
+ } from './image-build.ts';
22
+ export type { HelmDeployOverSshDef } from './helm-deploy-ssh.ts';
23
+ export {
24
+ type ComposeDeployDef,
25
+ type ComposeTargetValue,
26
+ type ComposeDeployResult,
27
+ } from './compose-deploy.ts';
28
+ export {
29
+ helmTarget,
30
+ deployTarget,
31
+ type DeployTargetOptions,
32
+ type HelmDeployInClusterDef,
33
+ type HelmDeploySshTargetDef,
34
+ type HelmTargetDeployDef,
35
+ type HelmTargetValue,
36
+ type InClusterTargetValue,
37
+ type SshTargetValue,
38
+ } from './helm-deploy.ts';
39
+ export { sshSession, type SshSession } from './ssh.ts';
40
+ export { serviceUrl, checkSpa } from './checks.ts';
41
+ export { httpHealthCheck, type HttpHealthCheckOptions } from './http-health.ts';
14
42
 
15
- /** SDK-provided steps (step + handler pairs with typed config). */
16
- export const steps = { script, gitHubSource, imageBuild, helmDeployOverSsh };
43
+ /** SDK-provided steps: each is `implement(step(…), …)` with typed settings, staged directly. */
44
+ export const steps = {
45
+ gitHubSource,
46
+ imageBuild,
47
+ helmDeployOverSsh,
48
+ helmDeployInCluster,
49
+ composeDeploy,
50
+ };
@@ -1,8 +1,11 @@
1
- import { z } from "zod";
2
- import { fileRef, model } from "../core/models.ts";
1
+ import { z } from 'zod';
2
+ import { fileRef, model } from '../core/models.ts';
3
3
 
4
4
  /** A source tree at a commit. `tree` references a directory (`path` ends with `/`). */
5
- export const Source = model("Source", z.object({ repo: z.string(), sha: z.string(), tree: fileRef() }));
5
+ export const Source = model(
6
+ 'Source',
7
+ z.object({ repo: z.string(), sha: z.string(), tree: fileRef() }),
8
+ );
6
9
 
7
10
  /**
8
11
  * A container image built by a step, as an image tarball (`docker save` format) in the step's files.
@@ -10,30 +13,48 @@ export const Source = model("Source", z.object({ repo: z.string(), sha: z.string
10
13
  * (`olve-pipelines-v2` → `docker.io/library/olve-pipelines-v2`).
11
14
  */
12
15
  export const BuiltImage = model(
13
- "BuiltImage",
14
- z.object({ repository: z.string(), tag: z.string(), digest: z.string(), tar: fileRef() }),
16
+ 'BuiltImage',
17
+ z.object({
18
+ repository: z.string(),
19
+ tag: z.string(),
20
+ digest: z.string(),
21
+ tar: fileRef(),
22
+ }),
15
23
  );
16
24
 
17
25
  /**
18
26
  * A container image pushed to a registry by a step (`imageBuild` with `push: true`), pulled from there
19
- * by the deploy. `repository` includes the registry host.
27
+ * by the deploy. `repository` includes the registry host. `cached` is true when `contentTag` found the
28
+ * image already in the registry and the build was skipped.
20
29
  */
21
30
  export const PushedImage = model(
22
- "PushedImage",
23
- z.object({ repository: z.string(), tag: z.string(), digest: z.string() }),
31
+ 'PushedImage',
32
+ z.object({
33
+ repository: z.string(),
34
+ tag: z.string(),
35
+ digest: z.string(),
36
+ cached: z.boolean().optional(),
37
+ }),
24
38
  );
25
39
 
26
40
  /** A Helm release that was installed or upgraded. */
27
41
  export const HelmRelease = model(
28
- "HelmRelease",
29
- z.object({ release: z.string(), namespace: z.string(), image: z.string() }),
42
+ 'HelmRelease',
43
+ z.object({
44
+ release: z.string(),
45
+ namespace: z.string(),
46
+ /** The release's main image; absent for a chart deployed without images. */
47
+ image: z.string().optional(),
48
+ /** Where step pods reach the release (the target's `url`); absent = its in-cluster Service URL (`serviceUrl`). */
49
+ url: z.string().optional(),
50
+ }),
30
51
  );
31
52
 
32
53
  /** One failed step of a run, as seen by a failure handler. */
33
54
  export const FailedStep = z.object({
34
55
  step: z.string(),
35
56
  attempt: z.number().int(),
36
- /** `<pipeline id>#<run>/<step>@<attempt>` (spec §5.5). */
57
+ /** `<pipeline id>#<run>/<step>@<attempt>`. */
37
58
  address: z.string(),
38
59
  /** The attempt's failure reason (handler error, exit code, timeout, …). */
39
60
  reason: z.string(),
@@ -47,17 +68,17 @@ export const FailedStep = z.object({
47
68
  * the run's failed steps the handler is scoped to (all of them for an unscoped handler); never empty.
48
69
  */
49
70
  export const FailureContext = model(
50
- "FailureContext",
71
+ 'FailureContext',
51
72
  z.object({
52
73
  pipeline: z.object({ id: z.string(), name: z.string() }),
53
74
  run: z.object({
54
75
  id: z.string(),
55
76
  number: z.number().int(),
56
- /** `<pipeline id>#<run>` (spec §5.5). */
77
+ /** `<pipeline id>#<run>`. */
57
78
  address: z.string(),
58
79
  /** The run's derived version, `yyyy.MM.dd.<run>`. */
59
80
  versionStamp: z.string(),
60
- trigger: z.enum(["push", "poll", "manual"]),
81
+ trigger: z.enum(['push', 'poll', 'manual', 'webhook']),
61
82
  /** The run (`GET …/api/pipelines/<id>/runs/<n>`). */
62
83
  url: z.string(),
63
84
  }),
@@ -1,7 +1,7 @@
1
- // sshSession — run commands on a host over SSH from a handler (alpine image: installs openssh-client).
2
- import { chmodSync, mkdirSync, writeFileSync } from "node:fs";
3
- import { join } from "node:path";
4
- import type { Ctx } from "../ctx/types.ts";
1
+ // sshSession — run commands on a host over SSH from a handler (installs openssh-client when the image lacks ssh).
2
+ import { chmodSync, mkdirSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import type { Ctx } from '../ctx/types.ts';
5
5
 
6
6
  const SAFE = /^[A-Za-z0-9_\/.:=@%+,-]+$/;
7
7
  /** POSIX shell quoting (safe words are left bare, so logs stay readable). */
@@ -9,10 +9,18 @@ export function shq(s: string): string {
9
9
  return SAFE.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`;
10
10
  }
11
11
 
12
+ /** Installs the ssh client unless the image has one: apk (Alpine) or apt-get (Debian, Ubuntu). */
13
+ export const SSH_CLIENT_INSTALL =
14
+ 'command -v ssh >/dev/null 2>&1 || if command -v apk >/dev/null 2>&1; then apk add --no-cache openssh-client; ' +
15
+ 'else apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends openssh-client; fi';
16
+
12
17
  export interface SshSession {
13
18
  readonly host: string;
14
- /** Runs a remote command; a non-zero exit throws. */
15
- run(remote: string): Promise<void>;
19
+ /**
20
+ * Runs a remote command; a non-zero exit throws. With `tty`, the command gets a terminal, so a client that
21
+ * dies (a cancelled step) hangs it up: without one, the command gets no signal and carries on.
22
+ */
23
+ run(remote: string, options?: { tty?: boolean }): Promise<void>;
16
24
  /** Runs a remote command and returns its stdout. */
17
25
  text(remote: string): Promise<string>;
18
26
  /** `scp -r` a local file or directory to a remote path. */
@@ -22,24 +30,42 @@ export interface SshSession {
22
30
  }
23
31
 
24
32
  /**
25
- * Opens an SSH "session" to `host` (`user@host`) with a private key: installs openssh-client (apk),
33
+ * Opens an SSH "session" to `host` (`user@host`) with a private key: installs openssh-client if needed,
26
34
  * writes the key to `<tmpDir>/ssh/id`. The host key is not verified: hosts are reached over the tailnet.
27
35
  */
28
- export async function sshSession(ctx: Pick<Ctx, "exec" | "sh" | "tmpDir">, opts: { host: string; key: string }): Promise<SshSession> {
29
- await ctx.exec(["apk", "add", "--no-cache", "openssh-client"]);
30
- const dir = join(ctx.tmpDir, "ssh");
36
+ export async function sshSession(
37
+ ctx: Pick<Ctx, 'exec' | 'sh' | 'tmpDir'>,
38
+ opts: { host: string; key: string },
39
+ ): Promise<SshSession> {
40
+ await ctx.sh(SSH_CLIENT_INSTALL);
41
+ const dir = join(ctx.tmpDir, 'ssh');
31
42
  mkdirSync(dir, { recursive: true, mode: 0o700 });
32
- const keyFile = join(dir, "id");
43
+ const keyFile = join(dir, 'id');
33
44
  // OpenSSH rejects a key without a trailing newline.
34
- writeFileSync(keyFile, opts.key.endsWith("\n") ? opts.key : opts.key + "\n", { mode: 0o600 });
45
+ writeFileSync(keyFile, opts.key.endsWith('\n') ? opts.key : opts.key + '\n', {
46
+ mode: 0o600,
47
+ });
35
48
  chmodSync(keyFile, 0o600);
36
- const sshOpts = ["-i", keyFile, "-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null", "-o", "LogLevel=ERROR", "-o", "BatchMode=yes"];
37
- const argv = (remote: string) => ["ssh", ...sshOpts, opts.host, remote];
49
+ const sshOpts = [
50
+ '-i',
51
+ keyFile,
52
+ '-o',
53
+ 'StrictHostKeyChecking=no',
54
+ '-o',
55
+ 'UserKnownHostsFile=/dev/null',
56
+ '-o',
57
+ 'LogLevel=ERROR',
58
+ '-o',
59
+ 'BatchMode=yes',
60
+ ];
61
+ const argv = (remote: string) => ['ssh', ...sshOpts, opts.host, remote];
38
62
  return {
39
63
  host: opts.host,
40
- run: (remote) => ctx.exec(argv(remote)),
41
- text: (remote) => ctx.sh.text(argv(remote).map(shq).join(" ")),
42
- copy: (local, remote) => ctx.exec(["scp", ...sshOpts, "-r", local, `${opts.host}:${remote}`]),
43
- shell: (remote) => argv(remote).map(shq).join(" "),
64
+ run: (remote, options) =>
65
+ ctx.exec(options?.tty ? ['ssh', '-tt', ...argv(remote).slice(1)] : argv(remote)),
66
+ text: (remote) => ctx.sh.text(argv(remote).map(shq).join(' ')),
67
+ copy: (local, remote) =>
68
+ ctx.exec(['scp', ...sshOpts, '-r', local, `${opts.host}:${remote}`]),
69
+ shell: (remote) => argv(remote).map(shq).join(' '),
44
70
  };
45
71
  }
@@ -1,26 +1,42 @@
1
1
  // @olivervea/pipelines/testing — run a handler under `bun test` through the real harness path
2
2
  // (validate input → handler → strict output validation), with a real ctx you can override.
3
- import { mkdtempSync } from "node:fs";
4
- import { tmpdir } from "node:os";
5
- import { join } from "node:path";
6
- import { isImplementation, type Implementation } from "../core/handlers.ts";
7
- import type { FileRef } from "../core/models.ts";
8
- import { isStep, type AnyStep, type LibraryStep, type OutputValue, type SecretsOf, type StepInput } from "../core/steps.ts";
9
- import { createCtx } from "../ctx/index.ts";
10
- import type { Ctx, RunInfo } from "../ctx/types.ts";
11
- import { executeStep, type FailureKind, type StepFunction } from "../harness/execute.ts";
12
- import { sha256Hex } from "../util/hash.ts";
3
+ import { mkdtempSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { isImplementation, type Implementation } from '../core/handlers.ts';
7
+ import { cellName, parseCell } from '../core/matrix.ts';
8
+ import type { FileRef } from '../core/models.ts';
9
+ import type {
10
+ AnyStep,
11
+ CellOf,
12
+ ConfigOf,
13
+ OutputValue,
14
+ SecretsOf,
15
+ StepInput,
16
+ } from '../core/steps.ts';
17
+ import { createCtx } from '../ctx/index.ts';
18
+ import type { Ctx, RunInfo } from '../ctx/types.ts';
19
+ import type { StepLink } from '../ctx/links.ts';
20
+ import { EXIT } from '../harness/exit.ts';
21
+ import { executeStep, type FailureKind, type StepFunction } from '../harness/execute.ts';
22
+ import { sha256Hex } from '../util/hash.ts';
13
23
 
14
24
  export interface TestHandlerOptions<S extends AnyStep> {
15
25
  /** Replace parts of ctx (e.g. `sh`, `http`, `files`). The rest is the real implementation. */
16
- ctx?: Partial<Ctx<SecretsOf<S>>>;
26
+ ctx?: Partial<Ctx<SecretsOf<S>, ConfigOf<S>>>;
27
+ /** The step's config entries (z.input view: defaults are applied), like `$config` in input.json. */
28
+ config?: Record<string, unknown>;
17
29
  /** Secret values (instead of reading the environment). */
18
30
  secrets?: Partial<Record<SecretsOf<S>, string>>;
19
- run?: Partial<Omit<RunInfo, "step">>;
31
+ run?: Partial<Omit<RunInfo, 'step'>>;
20
32
  /** Directory used as $OLVE_DIR (default: a fresh temp dir). */
21
33
  dir?: string;
22
34
  /** Abort signal handed to ctx.signal. */
23
35
  signal?: AbortSignal;
36
+ /** `<step>/<input>` → URL returned by `ctx.inputUrl`. */
37
+ callbackUrls?: Record<string, string>;
38
+ /** The cell to run a matrix step in (`ctx.cell`), like `OLVE_CELL`. */
39
+ cell?: CellOf<S>;
24
40
  }
25
41
 
26
42
  export type TestResult<S extends AnyStep> = {
@@ -29,58 +45,107 @@ export type TestResult<S extends AnyStep> = {
29
45
  filesDir: string;
30
46
  logs: string[];
31
47
  exitCode: number;
48
+ /** What the handler passed to `ctx.link`; absent when nothing. */
49
+ links?: StepLink[];
32
50
  } & (
33
- | { status: "succeeded"; output: OutputValue<S> }
34
- | { status: "skipped"; reason: string }
35
- | { status: "failed"; error: { kind: FailureKind; message: string } }
51
+ | { status: 'succeeded'; output: OutputValue<S> }
52
+ | { status: 'skipped'; reason: string }
53
+ | { status: 'failed'; error: { kind: FailureKind; message: string } }
36
54
  );
37
55
 
38
56
  /**
39
- * Runs a handler (the default export of `handlers/<step>.ts`) or a library step with `input`.
57
+ * Runs a step that carries its implementation with `input`.
40
58
  * Input is validated like `input.json`, the result like a real run: an invalid output gives
41
59
  * `{ status: "failed", error: { kind: "output" } }`, a throwing handler `kind: "handler"`.
42
60
  */
43
61
  export async function testHandler<S extends AnyStep>(
44
- target: Implementation<S> | (S & LibraryStep),
45
- input: StepInput<S["input"]>,
62
+ target: Implementation<S>,
63
+ input: StepInput<S['input']>,
46
64
  opts: TestHandlerOptions<S> = {},
47
65
  ): Promise<TestResult<S>> {
48
- let step: AnyStep;
49
- let fn: StepFunction;
50
- if (isImplementation(target)) {
51
- step = target.step;
52
- fn = target.handler as unknown as StepFunction;
53
- } else if (isStep(target) && target.builtin) {
54
- step = target;
55
- fn = target.builtin.run as StepFunction;
56
- } else throw new TypeError("testHandler expects implement(...) (a handler module's default export) or a library step");
66
+ if (!isImplementation(target))
67
+ throw new TypeError(
68
+ 'testHandler expects implement(…): a step that carries its implementation',
69
+ );
70
+ const step: AnyStep = target.step;
71
+ const fn = target.handler as unknown as StepFunction;
57
72
 
73
+ const cell = step.matrix
74
+ ? parseCell(
75
+ step.name,
76
+ step.matrix,
77
+ opts.cell === undefined ? undefined : JSON.stringify(opts.cell),
78
+ )
79
+ : undefined;
58
80
  const dir = opts.dir ?? mkdtempSync(join(tmpdir(), `olve-test-${step.name}-`));
59
- const filesDir = join(dir, "work", "files");
81
+ const filesDir = join(dir, 'work', 'files');
60
82
  const logs: string[] = [];
61
- const run: RunInfo = { pipeline: "test", id: "test", number: 1, attempt: 1, version: "0.0.0-test", ...opts.run, step: step.name };
83
+ if (cell && !cell.ok) {
84
+ logs.push(cell.message);
85
+ return {
86
+ dir,
87
+ filesDir,
88
+ logs,
89
+ exitCode: EXIT.inputInvalid,
90
+ status: 'failed',
91
+ error: { kind: 'input', message: cell.message },
92
+ };
93
+ }
94
+ const run: RunInfo = {
95
+ pipeline: 'test',
96
+ id: 'test',
97
+ number: 1,
98
+ attempt: 1,
99
+ version: '0.0.0-test',
100
+ ...opts.run,
101
+ step: cell?.ok ? cellName(step.name, cell.cell) : step.name,
102
+ };
62
103
  const real = createCtx({
63
104
  run,
64
105
  secrets: step.secrets,
65
106
  secretValues: opts.secrets as Record<string, string> | undefined,
66
- paths: { root: dir, workFiles: filesDir, tmp: join(dir, "tmp") },
107
+ paths: { root: dir, workFiles: filesDir, tmp: join(dir, 'tmp') },
67
108
  signal: opts.signal ?? new AbortController().signal,
68
109
  logSink: (l) => logs.push(l),
110
+ callbackUrls: opts.callbackUrls,
111
+ ...(cell?.ok ? { cell: cell.cell } : {}),
69
112
  });
70
113
  const ctx = { ...real, ...opts.ctx } as Ctx;
71
114
  // Round-trip through JSON like input.json does (drops undefined, rejects non-JSON values early).
72
- const raw = JSON.parse(JSON.stringify(input ?? {}));
73
- const exec = await executeStep({ step, fn, rawInput: raw, ctx, workFiles: filesDir, log: (l) => logs.push(l) });
74
- return { dir, filesDir, logs, exitCode: exec.exitCode, ...exec.envelope } as TestResult<S>;
115
+ const raw = JSON.parse(
116
+ JSON.stringify({ ...input, ...(opts.config ? { $config: opts.config } : {}) }),
117
+ );
118
+ const exec = await executeStep({
119
+ step,
120
+ fn,
121
+ rawInput: raw,
122
+ ctx,
123
+ workFiles: filesDir,
124
+ log: (l) => logs.push(l),
125
+ });
126
+ return {
127
+ dir,
128
+ filesDir,
129
+ logs,
130
+ exitCode: exec.exitCode,
131
+ ...exec.envelope,
132
+ } as TestResult<S>;
75
133
  }
76
134
 
77
135
  /** A file reference for test inputs (`sha256` defaults to the hash of `content`). */
78
- export function testFileRef(ref: { step: string; path: string; run?: number; attempt?: number; sha256?: string; content?: string | Uint8Array }): FileRef {
136
+ export function testFileRef(ref: {
137
+ step: string;
138
+ path: string;
139
+ run?: number;
140
+ attempt?: number;
141
+ sha256?: string;
142
+ content?: string | Uint8Array;
143
+ }): FileRef {
79
144
  return {
80
145
  run: ref.run ?? 1,
81
146
  step: ref.step,
82
147
  attempt: ref.attempt ?? 1,
83
148
  path: ref.path,
84
- sha256: ref.sha256 ?? sha256Hex(ref.content ?? ""),
149
+ sha256: ref.sha256 ?? sha256Hex(ref.content ?? ''),
85
150
  } as FileRef;
86
151
  }
@@ -2,7 +2,7 @@
2
2
  // (order is meaningful: stages, steps, enum values), `undefined` members dropped. Stable for hashing.
3
3
  export function canonicalize(v: unknown): unknown {
4
4
  if (Array.isArray(v)) return v.map(canonicalize);
5
- if (v !== null && typeof v === "object") {
5
+ if (v !== null && typeof v === 'object') {
6
6
  const out: Record<string, unknown> = {};
7
7
  for (const k of Object.keys(v).sort()) {
8
8
  const x = (v as Record<string, unknown>)[k];
@@ -10,10 +10,13 @@ export function canonicalize(v: unknown): unknown {
10
10
  }
11
11
  return out;
12
12
  }
13
- if (typeof v === "number" && !Number.isFinite(v)) throw new Error(`non-finite number in canonical JSON: ${v}`);
14
- if (typeof v === "bigint" || typeof v === "function" || typeof v === "symbol") throw new Error(`${typeof v} is not JSON`);
13
+ if (typeof v === 'number' && !Number.isFinite(v))
14
+ throw new Error(`non-finite number in canonical JSON: ${v}`);
15
+ if (typeof v === 'bigint' || typeof v === 'function' || typeof v === 'symbol')
16
+ throw new Error(`${typeof v} is not JSON`);
15
17
  return v;
16
18
  }
17
19
 
18
20
  /** Canonical JSON text, 2-space indented, with a trailing newline. */
19
- export const canonicalJson = (v: unknown): string => JSON.stringify(canonicalize(v), null, 2) + "\n";
21
+ export const canonicalJson = (v: unknown): string =>
22
+ JSON.stringify(canonicalize(v), null, 2) + '\n';
@@ -0,0 +1,17 @@
1
+ import { canonicalJson } from './canonical.ts';
2
+ import { sha256Hex } from './hash.ts';
3
+
4
+ /** Identity of the harness compiled into a binary. */
5
+ export interface HarnessInfo {
6
+ /** Bun version (runtime compiled into the binary). */
7
+ bun: string;
8
+ /** @olivervea/pipelines package version. */
9
+ sdk: string;
10
+ /** `sha256:` hash over the SDK runtime sources and the zod version. */
11
+ sdkHash: string;
12
+ }
13
+
14
+ /** The hash of a whole build: its code bundled with the SDK external, combined with the harness. */
15
+ export function composeCodeHash(bundleHash: string, harness: HarnessInfo): string {
16
+ return 'sha256:' + sha256Hex(canonicalJson({ bundle: bundleHash, harness }));
17
+ }
package/src/util/hash.ts CHANGED
@@ -1,13 +1,13 @@
1
- import { openSync, readSync, closeSync } from "node:fs";
1
+ import { openSync, readSync, closeSync } from 'node:fs';
2
2
 
3
3
  export function sha256Hex(data: string | Uint8Array | ArrayBuffer): string {
4
- return new Bun.CryptoHasher("sha256").update(data).digest("hex");
4
+ return new Bun.CryptoHasher('sha256').update(data).digest('hex');
5
5
  }
6
6
 
7
7
  /** Streaming SHA-256 of a file (constant memory). */
8
8
  export function sha256File(path: string): string {
9
- const h = new Bun.CryptoHasher("sha256");
10
- const fd = openSync(path, "r");
9
+ const h = new Bun.CryptoHasher('sha256');
10
+ const fd = openSync(path, 'r');
11
11
  try {
12
12
  const buf = new Uint8Array(1 << 20);
13
13
  for (;;) {
@@ -18,5 +18,5 @@ export function sha256File(path: string): string {
18
18
  } finally {
19
19
  closeSync(fd);
20
20
  }
21
- return h.digest("hex");
21
+ return h.digest('hex');
22
22
  }