@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
package/README.md CHANGED
@@ -4,18 +4,18 @@ The [Olve.Pipelines](https://github.com/OliverVea/Olve.Pipelines) v2 SDK. It has
4
4
 
5
5
  - the **authoring API**: models, steps, handlers and the typed pipeline builder;
6
6
  - the **step harness**: the runtime compiled into each pipeline's single executable (`describe`, `run`, `map-trigger`);
7
- - the **build tool** (`olve-build`), which turns a pipeline directory into `step.tar.gz` and `manifest.json`;
7
+ - the **build tool** (`olve-build`), which turns a pipeline directory into `step.tar.zst` and `manifest.json`;
8
8
  - the **dev runner** (`olve-dev`), which runs the same harness path from source (used by `pl dev`).
9
9
 
10
- Design: [`docs/design/pipelines-v2.md`](https://github.com/OliverVea/Olve.Pipelines/blob/v2/docs/design/pipelines-v2.md). Spike findings: [`pipelines-v2-spikes.md`](https://github.com/OliverVea/Olve.Pipelines/blob/v2/docs/design/pipelines-v2-spikes.md).
10
+ Design: [`docs/design/pipelines-v2.md`](https://github.com/OliverVea/Olve.Pipelines/blob/main/docs/design/pipelines-v2.md). Spike findings: [`pipelines-v2-spikes.md`](https://github.com/OliverVea/Olve.Pipelines/blob/main/docs/design/pipelines-v2-spikes.md).
11
11
  The manifest format is in [`manifest.schema.json`](manifest.schema.json), generated from [`src/describe/manifest-schema.ts`](src/describe/manifest-schema.ts).
12
12
 
13
13
  ```
14
- sdk/
14
+ src/sdk/
15
15
  src/index.ts authoring API (`@olivervea/pipelines`)
16
- src/core/ models, secrets, steps, implement()
16
+ src/core/ models, secrets, config, steps, implement()
17
17
  src/builder/ pipeline builder (types + runtime records), Ref<T>
18
- src/library/ library steps (script, gitHubSource, imageBuild, helmDeployOverSsh), their exported helpers (httpHealthCheck) and SDK models
18
+ src/library/ library steps (gitHubSource, imageBuild, helmDeployOverSsh, helmDeployInCluster, composeDeploy), their exported helpers (httpHealthCheck, sshSession, checkSpa) and SDK models; public API only
19
19
  src/describe/ describe: manifest, checks, mapper recording + source analysis, manifest schema
20
20
  src/harness/ harness CLI modes, env contract, envelope, result upload (`@olivervea/pipelines/harness`)
21
21
  src/ctx/ the ctx handed to handlers
@@ -47,8 +47,8 @@ bunx olve-build . --out .olve/out
47
47
  ```
48
48
 
49
49
  - The package ships TypeScript sources (Bun runs them directly; `harness.sdkHash` hashes them), so the
50
- same version gives the same `sdkHash` and handler hashes from npm or from `file:../sdk`.
51
- - Inside this repo, `.pipelines/` and `.pipelines-test/` use `"@olivervea/pipelines": "file:../sdk"`.
50
+ same version gives the same `sdkHash` and `codeHash` from npm or from `file:../src/sdk`.
51
+ - Inside this repo, `.pipelines/` and `.pipelines-test/` use `"@olivervea/pipelines": "file:../src/sdk"`.
52
52
 
53
53
  ### Releases
54
54
 
@@ -59,22 +59,14 @@ skips when the version exists, or when the pipeline secret `NPM_TOKEN` is unset.
59
59
 
60
60
  ## Authoring
61
61
 
62
- A pipeline directory (the binding path, `.pipelines/` by default) looks like this:
62
+ A pipeline directory (the binding path, `.pipelines/` by default) is an ordinary Bun package. `main` in `package.json` is required and names the entry point, which default-exports the pipeline; every other name is yours. A step carries its implementation, `implement(step({ … }), fn)`, and is imported and staged like any value.
63
63
 
64
- ```
65
- models.ts data models (contract file)
66
- pipeline.ts steps + `export default pipeline(…)` (contract file)
67
- handlers/*.ts one per custom step: `export default implement(step, fn)` (pairs by the step, not the file name)
68
- handlers/lib/… shared helpers (not handlers)
69
- scripts/*.sh embedded with `import s from "../scripts/x.sh" with { type: "file" }`
70
- ```
71
-
72
- Contract files hold contracts only. Describe evaluates them without loading handlers, and they are kept out of every handler's hash.
64
+ Describe evaluates the entry point and everything it imports, so top-level code must be deterministic: no env, time or network. Scripts are embedded with `import s from "./x.sh" with { type: "file" }`.
73
65
 
74
66
  ### Models, secrets, steps
75
67
 
76
68
  ```ts
77
- import { model, z, fileRef, optional, secret, step, steps, Source } from "@olivervea/pipelines";
69
+ import { model, z, fileRef, optional, external, secret, step, steps, Source } from "@olivervea/pipelines";
78
70
 
79
71
  export const BuildArtifact = model("BuildArtifact", z.object({
80
72
  version: z.string(),
@@ -87,53 +79,77 @@ export const deployBeta = step({
87
79
  name: "deploy-beta", // identity: lower-case kebab, ≤ 63 chars
88
80
  description: "Deploys to beta.", // required (may be ""); shown in the UI, not versioned
89
81
  image: "alpine:latest", // any image; the harness brings its own runtime
90
- input: { artifact: BuildArtifact }, // model | optional(model) | plain zod schema
82
+ input: { artifact: BuildArtifact }, // model | optional(model) | cells(model) | plain zod schema | external(model, …)
91
83
  output: BetaDeployment, // a model, or omitted (no output)
92
84
  secrets: [SSH_PRIVATE_KEY], // only these are mounted; ctx.secrets is typed to them
93
85
  timeoutSeconds: 900, // optional
94
86
  retries: 1, // optional: a failed attempt is retried (fresh pod) up to n times
95
87
  });
96
-
97
- import testSh from "./scripts/test.sh" with { type: "file" };
98
- export const test = steps.script({ name: "test", description: "Runs the tests.", image: "…", script: testSh, input: { source: Source }, output: TestReport,
99
- env: { TAG: ({ source }, run) => `${run.version}-${source.sha.slice(0, 7)}` } }); // env values may be functions of the inputs
100
88
  ```
101
89
 
102
90
  Rules:
103
91
 
92
+ - `step()` is the contract. A pipeline stages `implement(step, fn)`; a staged step without an implementation is a describe error.
104
93
  - Models are **nominal**: two models with the same shape are not interchangeable.
105
94
  - There is **one schema per model name**. Two different schemas registered under the same name is a describe error.
106
95
  - **Outputs are validated strictly**: unknown keys are rejected at any depth, not stripped.
107
96
  - **Trigger mappers** produce `z.input`, so fields with defaults may be left out. **Handlers, mapper refs and `when`** see `z.output`.
108
97
  - A **plain-schema input** has no model to match on, so the step needs a mapper.
109
98
  - **`optional(M)`** means "maybe skipped": `M` must still have an earlier producer.
99
+ - **`cells(M)`** reads every cell of a matrix step whose output is `M` (see "Matrix steps").
100
+ - **`external(M, { title, description?, from? })`** is answered from outside the run; `from` lists `callback()` (another system POSTs to the URL an earlier step gets from `ctx.inputUrl(step, input)`), `poll({ url, valuePath, intervalSeconds? })` and `person()` (the default). It needs no producer and no mapper entry; the step waits until it is answered, then the value (validated against `M`) is in its input like any other. Failure handlers can't have one.
110
101
 
111
- **`steps.script` contract** (for the script):
102
+ ### Config
112
103
 
113
- | Name | What it holds |
114
- |---|---|
115
- | `$OLVE_INPUT` | Path to the validated input as JSON. |
116
- | `INPUT_<KEY>` | One variable per input key. `deployTo` becomes `INPUT_DEPLOY_TO`. Strings are passed raw; everything else is JSON. |
117
- | `$OLVE_OUTPUT` | Set only when the step has an output model. Write the output JSON to this path. |
104
+ Values that differ per deployment (hosts, namespaces, which identity provider) are **config**: typed entries the pipeline declares, whose values live on the instance (one YAML document per pipeline, edited with `pl config` or the dashboard), not in the repo.
105
+
106
+ ```ts
107
+ import { config, secret, secretRef, step, z } from "@olivervea/pipelines";
108
+
109
+ export const DEX_CLIENT_SECRET = secret("DEX_CLIENT_SECRET", "Dex client secret.");
110
+ export const AUTH = config("auth", "How users authenticate.", z.discriminatedUnion("mode", [
111
+ z.object({ mode: z.literal("none") }),
112
+ z.object({ mode: z.literal("dex"), issuer: z.string().url(), clientSecret: secretRef(DEX_CLIENT_SECRET) }),
113
+ ]));
114
+ export const TARGET = config("target", "Where the release is deployed.",
115
+ z.object({ host: z.string(), namespace: z.string().default("olve-pipelines") }));
116
+
117
+ export const deploy = step({ name: "deploy", description: "Deploys the release.", image: "alpine:3.22",
118
+ config: [AUTH, TARGET], // ctx.config is typed to exactly these
119
+ secrets: [DEX_CLIENT_SECRET] }); // a secretRef's secret must be declared too
120
+ ```
118
121
 
119
- In the output JSON, an object of the form `{"$file": "<path>"}` or `{"$dir": "<path>"}` becomes a file reference. The step's declared secrets are environment variables named after the secrets.
122
+ ```yaml
123
+ # the instance's config document for this pipeline
124
+ auth: { mode: dex, issuer: https://dex.example.com, clientSecret: { secret: DEX_CLIENT_SECRET } }
125
+ target: { host: deploy@box }
126
+ ```
127
+
128
+ - `config(name, description, schema)` mirrors `secret(...)`. Names match `^[a-z][a-zA-Z0-9]*$` (they are YAML keys).
129
+ - An entry whose schema accepts `undefined` (`.optional()`, `.default(…)`) is optional; every other entry is required. Defaults are applied when config is read, never written into the document.
130
+ - Handlers see `ctx.config` with the output view (defaults applied); a discriminated union narrows as usual.
131
+ - `secretRef(S)` is a field whose value is `{ secret: "<S.name>" }`: it names a secret, never holds its value. The handler reads the value from `ctx.secrets`. A step that receives an entry containing a `secretRef` must declare that secret.
132
+ - Config is per **run**: the run pins a config snapshot at start; continues and step reruns reuse it. Secrets are per attempt.
133
+ - Library steps (and any step's `settings`) take **config references** in place of values: `host: TARGET.host`, `target: TARGET.beta`. A reference is a typed proxy (`ConfigRef<T>`, `T` = the field's output type; `Configurable<T> = T | ConfigRef<T>`), so a `number` field or an optional field can't go where a `string` is required. Fields named `kind`, `name`, `description` or `schema` (the entry's own properties) are reached through `$` (`TARGET.$.name`); `TARGET.$` and `TARGET` itself reference the whole entry. The step's `config` gets every entry referenced in its `settings` (`configEntries` in the manifest); the manifest records each reference as `{"$config": "<entry>.<path>"}` (field names must not contain `.`; literal keys starting with `$` are reserved); the handler resolves them with `resolveConfigRefs(value, ctx.config)` (defaults applied). `describe` and reconcile reject a reference to an entry the step doesn't receive or a field the entry doesn't have.
134
+ - `clusterAccess: true` on a step asks for the instance's step ServiceAccount token (`Kubernetes:StepServiceAccount`; manifest `clusterAccess: true`, omitted otherwise). Without the instance setting the pod gets no token, like every step.
120
135
 
121
136
  ### Library steps
122
137
 
123
- Library steps carry their own implementation and image; they take typed config and use the SDK models `Source`, `BuiltImage`, `PushedImage` and `HelmRelease`. The set grows from real use: a step or an option exists because a pipeline uses it.
138
+ Library steps carry their own implementation and image; they take typed settings and use the SDK models `Source`, `BuiltImage`, `PushedImage` and `HelmRelease`. Each is a function returning `implement(step({ kind, settings, … }), handler)`, built on the public API only (a test enforces it), so a step package outside the SDK is written the same way (see [Reusable steps](#reusable-steps)). The set grows from real use: a step or an option exists because several pipelines use it; what one pipeline needs stays a custom step there.
124
139
 
125
140
  | Step | Image | Input → output | Config |
126
141
  |---|---|---|---|
127
- | `steps.script` | yours | yours | `script` (embedded), `env` (see above) |
128
- | `steps.gitHubSource` | `alpine:3.22` (work happens in the harness) | `{ repo: "owner/name", sha }` (plain schemas: needs a mapper) → `Source` | `token?: Secret` (sent as `Authorization: Bearer`; becomes the step's secret) |
129
- | `steps.imageBuild` | `gcr.io/kaniko-project/executor:debug` | `{ source: Source }` → `BuiltImage { repository, tag, digest, tar }`; with `push`: `PushedImage { repository, tag, digest }` | `repository` (image name; tag = run version, `-attempt<n>` on reruns), `context?` (dir in the source tree; default the root), `dockerfile?` (relative to the context; default `Dockerfile`), `push?` (push to `repository` instead of a tarball), `contentTag?` (with `push`: tag `src-<context hash>`, skip when the registry has it), `cacheRepo?` (kaniko layer cache), `registryMap?` (kaniko `--registry-map`: registry → mirror prefix; default: the homelab pull-through caches, `{}` = off), `stallTimeoutSeconds?` (default 300, 0 = off), `retries?` (default 2) |
130
- | `steps.helmDeployOverSsh` | `alpine:3.22` (+ `apk add openssh-client` at run time) | `{ source: Source, <key>: BuiltImage }` per `images` (`PushedImage` with `fromRegistry`) → `HelmRelease { release, namespace, image }` (`image`: the first image) | `host` (`user@host`), `sshKey: Secret`, `release`, `namespace`, `chart` (dir in the source), `values?` (chart-relative `-f`), `set?`, `images?` (input key → values prefix; default `{ image: "image" }`), `fromRegistry?`, `deployments?` (default `[release]`), `rolloutTimeoutSeconds?` (default 120), `healthUrl?` (URL or `{ url, attempts?, delaySeconds? }`) |
131
-
132
- - `gitHubSource` downloads `https://api.github.com/repos/<repo>/tarball/<sha>` (retrying 5xx), strips the top-level directory and outputs it as `tree`.
133
- - `imageBuild` copies the tree to `/kaniko/build-context` (kaniko wipes other directories between multi-stage builds), builds `--context=/kaniko/build-context[/<context>] --dockerfile=<that>/<dockerfile>` with `--no-push --tar-path --digest-file`, and outputs the tarball. A missing context or Dockerfile fails the step before kaniko starts. `repository` in the output is fully qualified the way a runtime names a loaded image (`x` → `docker.io/library/x`). A **stall watchdog** kills kaniko when it prints nothing for `stallTimeoutSeconds` (a base-image pull can stall on an idle connection at "Unpacking rootfs", and kaniko has no read timeout), failing the attempt; `retries` then starts a fresh attempt. The retry is deliberately a new pod, never a second kaniko in the same container: kaniko unpacks the base image over the container root and does not clean up when killed.
134
- - **Caches never block a build.** `registryMap` defaults to the homelab pull-through caches (`DEFAULT_REGISTRY_MAP`: `mcr.microsoft.com` → `mcr-cache.ovea.pro`, `index.docker.io` → `hub-cache.ovea.pro`; kaniko under gVisor stalls pulling from mcr.microsoft.com directly). A map you pass replaces the default; `{}` turns mapping off. Before kaniko starts, every mirror host and the `cacheRepo` registry gets a 5 s `GET https://<host>/v2/` (200 or 401 = up); one that doesn't answer is dropped with a log line (`mcr-cache.ovea.pro unreachable; pulling from mcr.microsoft.com directly`, or no `--cache` for the layer cache). After that, kaniko itself copes: it falls back to the original registry when a mirror can't serve an image's manifest (`RetrieveRemoteImage`; `--skip-default-registry-fallback` is never set), a layer-cache lookup error is a cache miss, a cache push error is a warning, and `--skip-push-permission-check` stops its startup check of the cache repo from exiting. A cache that dies mid-download (after the manifest came from it) fails or stalls the attempt; `retries` starts a fresh one, which probes again.
135
- - **Registry mode** (`push: true`, for images too big for the step's ~1 GiB scratch space and Garage): kaniko runs without `--no-push`/`--tar-path`, pushes to `repository` (it must start with a registry host; pushes are anonymous) and the output is a `PushedImage` with the digest from `--digest-file`. With `contentTag`, the tag is `src-` + the first 12 hex of the sha256 of the context directory's deterministic tar (the bytes `ctx.output.dir` would write), computed from the fetched tree; an anonymous `HEAD https://<host>/v2/<path>/manifests/<tag>` that answers a `Docker-Content-Digest` skips kaniko and outputs that digest.
136
- - `helmDeployOverSsh` gets each image into the host's **k3s** containerd: a `BuiltImage` tar is streamed in (`nerdctl --address /run/k3s/containerd/containerd.sock --namespace k8s.io load`), a `PushedImage` (`fromRegistry`) is pulled there (`… pull -q`, so the rollout doesn't wait on the pull). It copies the chart to `/tmp/<release>-helm-<namespace>`, runs `helm upgrade --install` from inside it (values, then `set`, then per image `<prefix>.repository`/`.tag`/`.pullPolicy` — `Never` for tarballs, `IfNotPresent` for registry images — which always win), waits for `kubectl rollout status` of each of `deployments` in order (`deploy/<name>` for a bare name), and with `healthUrl` polls it (5 × 5 s unless given) from the harness. Several images (one chart, two Deployments):
142
+ | `steps.gitHubSource` | `alpine:3.22` (work happens in the harness) | `{ repo: "owner/name", sha }` (plain schemas: needs a mapper) → `Source` | `token?: Secret` (sent as `Authorization: Bearer` when set; unset = anonymous; becomes the step's secret), `apiUrl?` (default `https://api.github.com`) |
143
+ | `steps.imageBuild` | `gcr.io/kaniko-project/executor:v1.24.0-debug`, pinned by digest | `{ source: Source }` → `BuiltImage { repository, tag, digest, tar }`; with `push`: `PushedImage { repository, tag, digest, cached }` | `repository` (image name; tag = run version, `-attempt<n>` on reruns), `context?` (dir in the source tree with the `Dockerfile`; default the root), `push?` (push to `repository` instead of a tarball), `contentTag?` (with `push`: tag `src-<hash>` of the context and the build recipe; skip when the registry has it, with `cached: true`), `registryMap?` (kaniko `--registry-map`: registry → mirror prefix; default none), `cacheRepo?` (kaniko's layer cache repository; default `<repository>/cache` with `push`, none for a tarball; `false` = off), `stallTimeoutSeconds?` (default 300, 0 = off), `retries?` (default 2), `registryAuth?` (`{ username, password: Secret }`: log in to the `repository` registry; or a config reference to `{ username, password: { secret: NAME } }`, with `NAME` in `secrets`), `insecureRegistry?` (that registry is plain HTTP), `secrets?` (declared secrets a config `registryAuth` may name), `enabled?` (`false` skips the step) |
144
+ | `steps.helmDeployOverSsh` | `alpine:3.22` (+ `apk add openssh-client` at run time) | `{ source: Source, <key>: BuiltImage }` per `images` (`PushedImage` with `fromRegistry`) → `HelmRelease { release, namespace, image? }` (`image`: the first image; absent without images) | `host` (`user@host`), `sshKey: Secret`, `release`, `namespace`, `chart` (dir in the source), `values?` (chart-relative `-f`), `set?`, `images?` (input key → values prefix; default `{ image: "image" }`; `{}` = the chart alone), `fromRegistry?`, `imageRefs?` (`PushedImage` input key → values prefix: set as `<prefix>.repository`/`.tag`/`.digest`, not pulled; works next to tarball `images`), `deployments?` (default `[release]`, none without images), `rolloutTimeoutSeconds?` (default 120), `dependencies?` (pull `Chart.yaml`'s dependencies on the host), `helmWait?` / `atomic?` (`helm --wait` / `--atomic`), `healthUrl?` (URL or `{ url, attempts?, delaySeconds? }`) |
145
+ | `steps.helmDeployInCluster` (and the `target` form of `helmDeployOverSsh`) | `alpine/helm:3.16.2` (ssh: `alpine:3.22`) | `{ source: Source, <key>: PushedImage }` per `images`, `<key>: optional(PushedImage)` per `imageRefs` → `HelmRelease` (`image`: `repository:tag@digest` of the first image) | `target` (a `select` case's reference to a `helmTarget()` variant: `{ kind: "inCluster", namespace, release, values, set, url? }` for helmDeployInCluster, the same plus `host` with `kind: "ssh"` for helmDeployOverSsh; `url` is where step pods reach the release, default its in-cluster Service URL, and is passed on in `HelmRelease.url`), `sshKey: Secret` (ssh only), `chart`, `inlineValues?` (objects written as values files after the target's; a resolved `secretRef`, `{ secret: NAME }`, becomes that secret's value, and the file holding it is deleted after the upgrade), `secrets?` (the secrets those `secretRef`s name; the step declares them), `images?`, `imageRefs?`, `deployments?` (ssh), `rolloutTimeoutSeconds?` (default 120), `healthPath?`; helmDeployInCluster declares `clusterAccess` |
146
+ | `steps.composeDeploy` | `alpine:3.22` (work happens in the harness) | `{ image: PushedImage }` → `HelmRelease { release: <service>, namespace: "compose", image: <repository>@<digest> }` | `target` (a `select` case's reference to the compose variant of `deployTarget()`: `{ kind: "compose", deployerUrl, service, token: { secret }, healthUrl? }`), `token: Secret` (must be the secret the target names; the step's only secret), `timeoutSeconds?` |
147
+
148
+ - `gitHubSource` downloads `<apiUrl>/repos/<repo>/tarball/<sha>` (retrying 5xx), strips the top-level directory and outputs it as `tree`.
149
+ - `imageBuild` copies the tree to `/kaniko/build-context` (kaniko wipes other directories between multi-stage builds), builds `--context=/kaniko/build-context[/<context>] --dockerfile=<that>/Dockerfile` with `--no-push --tar-path --digest-file`, and outputs the tarball. A missing context or Dockerfile fails the step before kaniko starts. `repository` in the output is fully qualified the way a runtime names a loaded image (`x` → `docker.io/library/x`). A **stall watchdog** kills kaniko when it prints nothing for `stallTimeoutSeconds` (a base-image pull can stall on an idle connection at "Unpacking rootfs", and kaniko has no read timeout), failing the attempt; `retries` then starts a fresh attempt. The retry is deliberately a new pod, never a second kaniko in the same container: kaniko unpacks the base image over the container root and does not clean up when killed.
150
+ - **Caches never block a build.** `registryMap` maps registries to pull-through caches (e.g. `{ "mcr.microsoft.com": "mcr-cache.example.com" }`; kaniko under gVisor can stall pulling from mcr.microsoft.com directly, a nearby cache doesn't). There is none by default. Before kaniko starts, every mirror host gets a 5 s `GET https://<host>/v2/` (200 or 401 = up); one that doesn't answer is dropped with a log line (`<cache> unreachable; pulling from <registry> directly`). After that, kaniko itself copes: it falls back to the original registry when a mirror can't serve an image's manifest (`RetrieveRemoteImage`; `--skip-default-registry-fallback` is never set). A cache that dies mid-download (after the manifest came from it) fails or stalls the attempt; `retries` starts a fresh one, which probes again. The layer cache (`cacheRepo`) is probed the same way and left out when its registry doesn't answer (`<host> unreachable; building without the layer cache`); kaniko treats a cache lookup error as a miss and a failed cache push as a warning.
151
+ - **Registry mode** (`push: true`, for images too big for the step's ~1 GiB scratch space and Garage): kaniko runs without `--no-push`/`--tar-path`, pushes to `repository` (it must start with a registry host; anonymous unless `registryAuth`) and the output is a `PushedImage` with the digest from `--digest-file`. With `contentTag`, the tag is `src-` + the first 12 hex of the sha256 of the context directory's deterministic tar (the bytes `ctx.output.dir` would write), computed from the fetched tree; a `HEAD https://<host>/v2/<path>/manifests/<tag>` (answering a Basic or Bearer challenge: anonymous token, or the `registryAuth` login; an anonymous lookup that is denied counts as "not there") that answers a `Docker-Content-Digest` skips kaniko and outputs that digest. With `registryAuth`, the step declares the password secret and writes `$DOCKER_CONFIG/config.json` (`/kaniko/.docker` in the kaniko image) for that registry before kaniko runs, so pushes and base-image pulls from it are logged in. `insecureRegistry` makes the lookups plain HTTP and adds kaniko `--insecure --insecure-registry=<host>`.
152
+ - `helmDeployOverSsh` gets each image into the host's **k3s** containerd: a `BuiltImage` tar is streamed in (`nerdctl --address /run/k3s/containerd/containerd.sock --namespace k8s.io load`), a `PushedImage` (`fromRegistry`) is pulled there (`… pull -q`, so the rollout doesn't wait on the pull). It copies the chart to `/tmp/<release>-helm-<namespace>`, runs `helm upgrade --install` from inside it (values, then `set`, then per image `<prefix>.repository`/`.tag`/`.pullPolicy` — `Never` for tarballs, `IfNotPresent` for registry images — which always win), waits for `kubectl rollout status` of each of `deployments` in order (`deploy/<name>` for a bare name), and with `healthUrl` polls it (5 × 5 s unless given) from the harness. `images: {}` deploys the chart alone: the step's only input is `source`, no image is imported and no image flag set, and nothing is waited on unless `deployments` names it. `dependencies: true` runs `helm pull <name> --repo <repository> --version <version>` (or the `oci://` reference) into the copied chart's `charts/` for each dependency in `Chart.yaml`; `helmWait` / `atomic` add `--wait` / `--atomic` with `--timeout=<rolloutTimeoutSeconds>s` and make `deployments` default to none. Several images (one chart, two Deployments):
137
153
 
138
154
  ```ts
139
155
  steps.helmDeployOverSsh({ name: "deploy-beta", description: "", host: "oliver@bulwark-m2", sshKey: SSH_PRIVATE_KEY,
@@ -141,31 +157,83 @@ Library steps carry their own implementation and image; they take typed config a
141
157
  images: { server: "image", web: "web.image" }, deployments: ["olve-slimerepublics-server", "olve-slimerepublics-web"] });
142
158
  ```
143
159
 
160
+ - **Deploy targets.** `helmTarget(defaults?, extend?)` is the `inCluster | ssh` union. `deployTarget({ namespace?, release?, extend?, composeToken, optional? })` adds `compose` (`{ deployerUrl, service, token: secretRef(composeToken), healthUrl?, …extend }`) and, with `optional: true`, `none` (`{ kind: "none" }`, no other fields; its `select` case usually has no steps). A library step that references a target narrowed to one case needs only that case's secrets: the helm steps don't declare the compose `token`.
161
+ - `composeDeploy` resolves its target, checks `kind` is `compose` and that `token.secret` is the declared secret, then `POST <deployerUrl>/v1/deploy` `{ service, image: "<repository>@<digest>" }` with `Authorization: Bearer <token>`. Each line of the answer's `log` is logged as `deployer: …`; anything but HTTP 2xx with `status: "deployed"` fails the step with the deployer's status and message. With `healthUrl` it then runs `httpHealthCheck` (12 × 5 s). The deployer itself (`compose/deployer`) and its API: [docs/setup/compose-deploy.md](../docs/setup/compose-deploy.md).
162
+
144
163
  **Helpers.** General functions the library steps are built on, exported for custom handlers:
145
164
 
146
165
  | Helper | What it does |
147
166
  |---|---|
148
- | `httpHealthCheck(ctx, url, { attempts?, delaySeconds?, headers? })` | GETs `url` until it returns 2xx (redirects followed), retrying network errors and non-2xx statuses; defaults 5 attempts, 5 s apart (v1's deploy-beta loop). Throws the last error when the attempts run out. Runs in the harness, so the image needs no curl. `helmDeployOverSsh`'s `healthUrl` uses it. |
167
+ | `httpHealthCheck(ctx, url, { attempts?, delaySeconds?, headers? })` | GETs `url` until it returns 2xx (redirects followed), retrying network errors and non-2xx statuses; defaults 5 attempts, 5 s apart. Throws the last error when the attempts run out. Runs in the harness, so the image needs no curl. `helmDeployOverSsh`'s `healthUrl` uses it. |
149
168
  | `serviceUrl(release)` | `http://<release>.<namespace>.svc.cluster.local` for a `HelmRelease` (charts that name the Service after the release). |
150
169
  | `checkSpa(ctx, url)` | `GET <url>/` must reference an `/assets/*.js` bundle (Vite's layout) and the bundle must be served as JavaScript; returns `{ html, bundle }` for app-specific checks. |
151
- | `sshSession(ctx, { host, key })` | Installs openssh-client (apk), writes the key to `<tmpDir>/ssh/id` (trailing newline, mode 600; host key not verified) and returns `{ run(remote), text(remote), copy(local, remote), shell(remote) }`; `shell` is the quoted ssh command for pipes (`ctx.sh(`printf '%s' "$V" \| ${s.shell("kubectl apply -f -")}`)`). `helmDeployOverSsh` uses it. `shq(s)` is the POSIX quoting it uses. |
170
+ | `sshSession(ctx, { host, key })` | Installs openssh-client when the image has no ssh (apk or apt-get), writes the key to `<tmpDir>/ssh/id` (trailing newline, mode 600; host key not verified) and returns `{ run(remote, { tty? }), text(remote), copy(local, remote), shell(remote) }`; `shell` is the quoted ssh command for pipes (`ctx.sh(`printf '%s' "$V" \| ${s.shell("kubectl apply -f -")}`)`). `helmDeployOverSsh` uses it. |
152
171
 
153
172
  ```ts
154
- import { httpHealthCheck, implement } from "@olivervea/pipelines";
155
- export default implement(smoke, async (_, ctx) => { await httpHealthCheck(ctx, "http://app.ns.svc.cluster.local/api/health", { attempts: 10 }); });
173
+ import { httpHealthCheck, implement, step } from "@olivervea/pipelines";
174
+ export const smoke = implement(step({ name: "smoke", description: "", image: "alpine:3.22" }), async (_, ctx) => { await httpHealthCheck(ctx, "http://app.ns.svc.cluster.local/api/health", { attempts: 10 }); });
156
175
  ```
157
176
 
177
+ ### Reusable steps
178
+
179
+ A step several pipelines use is a function returning `implement(step(…), handler)`, staged directly. The library steps are written this way, so a module in a repo or a package of its own works the same.
180
+
181
+ ```ts
182
+ import { implement, resolveConfigRefs, serviceUrl, step, HelmRelease, type Configurable, type LiteralName } from "@olivervea/pipelines";
183
+
184
+ export function smokeTest<const N extends string>(def: { name: LiteralName<N>; description: string; path: Configurable<string> }) {
185
+ return implement(
186
+ step({ name: def.name, description: def.description, kind: "smokeTest", image: "alpine:3.22", input: { release: HelmRelease }, settings: { path: def.path } }),
187
+ async ({ release }, ctx) => void (await ctx.http.expectOk(`${release.url ?? serviceUrl(release)}${resolveConfigRefs(def.path, ctx.config)}`)),
188
+ );
189
+ }
190
+ ```
191
+
192
+ - **`kind`**: a label, `custom` unless the step names one. Prefix a package's kinds (`acme/smokeTest`).
193
+ - **`settings`**: what the step was set up with, recorded as the step's manifest `config` (so part of its version). JSON values and config references; the step receives every entry its settings reference.
194
+ - `implement` refuses a step that already carries an implementation.
195
+
196
+ ### Matrix steps
197
+
198
+ ```ts
199
+ import { cascadeFailure, cells, implement, step } from "@olivervea/pipelines";
200
+
201
+ export const buildCli = implement(step({
202
+ name: "build-cli", description: "", image: "oven/bun:1.4.3-slim",
203
+ input: { source: Source }, output: BuiltBinary,
204
+ matrix: {
205
+ axes: { os: ["linux", "windows", "macos"], arch: ["x64", "arm64"] }, // key order is the axis order
206
+ exclude: [{ os: "windows", arch: "arm64" }], // names one or more axes
207
+ failure: cascadeFailure(), // default keepRunning()
208
+ maxParallel: 2, // optional
209
+ },
210
+ }), async ({ source }, ctx) => {
211
+ const { os, arch } = ctx.cell; // { os: "linux" | "windows" | "macos"; arch: "x64" | "arm64" }
212
+ // …
213
+ });
214
+
215
+ export const publish = implement(step({
216
+ name: "publish", description: "", image: "alpine", input: { binaries: cells(BuiltBinary) },
217
+ }), async ({ binaries }) => { for (const { cell, output } of binaries) { /* … */ } });
218
+ ```
219
+
220
+ - **Cells.** The step runs once per cell: the cross product of the axes (first axis outermost) minus the excludes. 1 to 4 axes, 1 to 64 cells. An axis name matches `^[a-z][a-zA-Z0-9]{0,30}$`, a value `^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$`, values unique per axis. Exclude keys and values are type-checked against the axes.
221
+ - **`ctx.cell`** is the cell, typed from the axes' literals. It exists only on a matrix step's ctx.
222
+ - **A cell's name** is `build-cli[os=linux,arch=x64]`: `ctx.run.step` and the `step` of the cell's file references hold it.
223
+ - **Failure.** `keepRunning()` lets every cell run to the end; `cascadeFailure()` cancels the cells still running or waiting once a cell has failed its last allowed attempt.
224
+ - **Reading a matrix step.** A later step reads it only through `cells(M)`, matched by model like other inputs or wired whole with a mapper (`[publish, from => ({ binaries: from["build-cli"] })]`; a field of a matrix step is a type error). The value is `{ cell, output }[]` in cell order, one entry per cell with an output; `cell` is `Readonly<Record<string, string>>`. A matrix step can't be read as one output, and a matrix step inside a `when` can't be read by `cells` (it may be skipped).
225
+ - **Failure handlers** can't be matrix steps.
226
+
158
227
  ### Handlers
159
228
 
160
229
  ```ts
161
- // handlers/deploy-beta.ts
162
- import { implement } from "@olivervea/pipelines";
163
- import { deployBeta } from "../pipeline";
164
- import deployScript from "../scripts/deploy.sh" with { type: "file" };
230
+ import { implement, step } from "@olivervea/pipelines";
231
+ import deployScript from "./scripts/deploy.sh" with { type: "file" };
165
232
 
166
- export default implement(deployBeta, async ({ artifact }, ctx) => {
233
+ export const deployBeta = implement(step({ name: "deploy-beta", /* … */ }), async ({ artifact }, ctx) => {
167
234
  await ctx.sh(deployScript, { env: { IMAGE_TAR: await ctx.files.fetch(artifact.image), CHART_DIR: await ctx.files.fetch(artifact.chart) } });
168
235
  await ctx.retry({ attempts: 5, delaySeconds: 5 }, () => ctx.http.expectOk("https://pipelines-beta.ovea.pro/api/health"));
236
+ ctx.link("https://pipelines-beta.ovea.pro", "beta"); // shown on the step card and in pl run get
169
237
  return { release: "olve-pipelines", version: artifact.version, url: "https://pipelines-beta.ovea.pro" }; // or ctx.skip("reason")
170
238
  });
171
239
  ```
@@ -180,9 +248,11 @@ export default implement(deployBeta, async ({ artifact }, ctx) => {
180
248
  | `http.fetch`, `http.getJson(url, { headers })`, `http.expectOk(url, { method, headers })` | Bound to `ctx.signal`. A non-2xx status throws a readable error, with the URL redacted after `?`. |
181
249
  | `retry({ attempts, delaySeconds, backoff? }, fn)` | Retries `fn`. Can be cancelled. |
182
250
  | `secrets.NAME` | Typed to the step's declared secrets. Read from the env var `NAME`. Throws if the variable is missing. |
251
+ | `config.<entry>` | Typed to the step's declared config entries (output view, defaults applied). From input.json's `$config`. An optional entry that is not set is `undefined`. |
183
252
  | `log(message, fields?)` | Writes a `message k=v …` line to stderr. |
184
253
  | `signal` | Aborted on SIGTERM/SIGINT. Running commands are sent SIGTERM. |
185
- | `run` | `{ pipeline, id, number, attempt, step, version }` (from the env, see below). |
254
+ | `run` | `{ pipeline, id, number, attempt, step, version }` (from the env, see below). For a matrix step `step` is the cell's name. |
255
+ | `cell` | Matrix steps only: the cell being run, in axis order (from `OLVE_CELL`). |
186
256
  | `tmpDir` | A writable scratch directory (`$OLVE_DIR/tmp`). Kaniko has no `/tmp`. |
187
257
  | `skip(reason)` | Produce no output (prefer `when`). |
188
258
 
@@ -206,19 +276,23 @@ export default pipeline({ name: "Olve.Pipelines", description: "…", input: M.D
206
276
  - **The `from` argument.** It is keyed by step name, plus `input` for the pipeline input. It holds `Ref<T>` values, so only property access and literals compile. Use `from["deploy-beta"].url` for names that contain hyphens.
207
277
  - **Describe checks the mapper twice.** It records the mapper with proxies, and it statically analyses the mapper's source. The source must be an object literal of member chains, literals, constant arrays and nested object literals. Ternaries, `&&`, `||`, `??`, `!`, comparisons, arithmetic, template strings, calls, spread and optional chaining are all rejected. Free identifiers such as module constants are allowed; they are recorded as `{const}`.
208
278
  - **Triggers.** A pipeline may call `onPush` at most once. Each `onPoll` and `onWebhook` needs a distinct `name` (`onPoll` defaults to `"poll"`). `onWebhook({ name, verify }, ({ headers, body }) => input | null)` maps a delivery to `/api/hooks/<pipeline id>/<name>`; `verify` is `hmacSha256({ secret, header, prefix? })`, `githubSignature(secret)` or `bearer(secret)`, and `null` ignores the delivery.
279
+ - **`select` (branch on config).** `.select(TARGET.prod, { ssh: (p, prod) => p.stage(…), inCluster: (p, prod) => p.stage(…) })`: the first argument references a `z.discriminatedUnion` in a config entry (`DiscriminatorOf<U>` is its one literal field; a union with no or several literal fields is a type error). Cases are exhaustive and checked at compile time; each gets the reference narrowed to its variant (`ConfigNode<VariantOf<U, D, V>>`), recorded as `{"$config": "target.prod", "$case": "target.prod.kind=ssh"}` and usable only inside that case. The manifest records `{select, discriminator, cases}`; the service resolves it per run from the pinned config snapshot (`when` is per run input). The same step name may appear in several cases (same output model; not two different custom steps, since handlers pair by name); a step every case has is definitely available afterwards, one only some have is maybe-available. The harness picks the variant of a same-named step from `OLVE_CASES` (a JSON array of the case conditions the step sits in, set by the service).
280
+ - **Retention.** `pipeline({ …, retention: { keepLatestRuns?, minRunAgeDays?, buildGraceDays? } })` overrides the instance's retention thresholds for this pipeline's runs and binaries, field by field (non-negative integers; unset = the instance's value). Recorded on the manifest root as `retention`; the service applies the active version's.
209
281
  - **Failure handlers.** `.onFailure(handler, { steps? })` takes a step whose inputs are all `FailureContext`. When a run fails, the service runs each handler whose scope (default: any step) contains a failed step, once per failure: `run <handler>` in the handler's image with its declared secrets, from the run's pinned binary. It is untracked and best-effort — not an attempt of the run, never retried, and its own failure triggers nothing; its log is kept at `runs/<p>/<run>/_failure/<handler>/<id>/log`. `FailureContext` carries `pipeline {id, name}`, `run {id, number, address, versionStamp, trigger, url}`, `version {number, commit}`, the run's `input`, a one-line `reason`, and `failedSteps[]` (`step, attempt, address, reason, logUrl`) — only the steps in the handler's scope, never empty.
210
282
 
211
283
  ### Testing handlers
212
284
 
213
285
  ```ts
214
286
  import { testHandler, testFileRef } from "@olivervea/pipelines/testing";
215
- import deployBeta from "../handlers/deploy-beta";
287
+ import { deployBeta } from "../steps/deploy-beta";
216
288
 
217
- const r = await testHandler(deployBeta, { artifact }, { secrets: { SSH_PRIVATE_KEY: "k" }, ctx: { http: fakeHttp }, run: { number: 412 } });
289
+ const r = await testHandler(deployBeta, { artifact }, { secrets: { SSH_PRIVATE_KEY: "k" }, config: { target: { host: "h" } }, ctx: { http: fakeHttp }, run: { number: 412 } });
218
290
  expect(r).toMatchObject({ status: "succeeded", output: { release: "olve-pipelines" } }); // r.filesDir, r.logs, r.exitCode
219
291
  ```
220
292
 
221
- `testHandler` uses the same validation path as a real run. Invalid input gives `status: "failed"` with `kind: "input"`; an invalid return value gives `kind: "output"`. Any part of `ctx` can be replaced; whatever you don't replace is the real implementation. `testHandler` also accepts library steps.
293
+ A matrix step takes the cell to run: `testHandler(buildCli, { source }, { cell: { os: "linux", arch: "x64" } })`; without one it fails with `kind: "input"` and exit code 65, like a pod without `OLVE_CELL`.
294
+
295
+ `testHandler` uses the same validation path as a real run. Invalid input gives `status: "failed"` with `kind: "input"`; an invalid return value gives `kind: "output"`. Any part of `ctx` can be replaced; whatever you don't replace is the real implementation. `testHandler` takes any implementation: a custom step, a library step or a reusable step.
222
296
 
223
297
  ## Harness contract (service ⇄ step binary)
224
298
 
@@ -233,7 +307,7 @@ This section is everything the C# side needs in order to run the binary.
233
307
  | Mode | Reads | Writes | Notes |
234
308
  |---|---|---|---|
235
309
  | `describe [--sentinels]` | the binary itself | stdout: the manifest (canonical JSON) | With `--sentinels`, stdout gets the manifest between `---OLVE-MANIFEST-BEGIN---` and `---OLVE-MANIFEST-END---`. Needs no env and no files. |
236
- | `run <step>` | `$OLVE_DIR/input.json` | `$OLVE_DIR/work/output.json`, `$OLVE_DIR/work/files/…` | Steps in stages and failure-handler steps. |
310
+ | `run <step>` | `$OLVE_DIR/input.json` | `$OLVE_DIR/work/output.json`, `$OLVE_DIR/work/files/…` | Steps in stages and failure-handler steps. A matrix step's cell runs as `run <step>` (the step's own name) with `OLVE_CELL`. |
237
311
  | `map-trigger <trigger>` | `$OLVE_DIR/input.json` (the event) | `$OLVE_DIR/work/output.json` | `<trigger>` is the trigger's `name` from the manifest. |
238
312
 
239
313
  `run` and `map-trigger` also print the envelope on stdout, as one compact JSON line between `---OLVE-RESULT-BEGIN---` and `---OLVE-RESULT-END---`. The block always comes last. Use it as a fallback channel from the pod log. User output and stderr logs also go to the pod log, so never parse anything outside the sentinels.
@@ -242,7 +316,7 @@ This section is everything the C# side needs in order to run the binary.
242
316
 
243
317
  | Path | Who | What |
244
318
  |---|---|---|
245
- | `input.json` | service (via olve-init) | **run**: a JSON object with exactly the step's input keys, built from the manifest's `input` mappings. Leave out a key whose ref root step was skipped; this is allowed only for `optional` inputs. Unknown keys → exit 65. **map-trigger push**: `{"ref": "refs/heads/<branch>", "after": "<sha>", "repository": {"full_name": "<owner>/<repo>"}}` (`PushEvent`). **map-trigger poll**: `{"value": "<extracted value as string>"}`. **map-trigger webhook**: `{"headers": {…}, "body": …}` (`WebhookEvent`). |
319
+ | `input.json` | service (via olve-init) | **run**: a JSON object with exactly the step's input keys, built from the manifest's `input` mappings. Leave out a key whose ref root step was skipped; this is allowed only for `optional` inputs. Unknown keys → exit 65. For a step with `configEntries`, the reserved key `"$config"` holds `{ <entry>: <value> }` from the run's config (entries that are not set are left out); each entry is parsed with its schema, and a missing required entry or an invalid value → exit 65 (`config.<entry>…`). Other steps ignore `$config`. A `cells` input is a list in cell order, one entry per cell whose latest successful attempt has an output: `[{"cell": {"os": "linux", "arch": "x64"}, "output": {…}}, …]`. **map-trigger push**: `{"ref": "refs/heads/<branch>", "after": "<sha>", "repository": {"full_name": "<owner>/<repo>"}}` (`PushEvent`). **map-trigger poll**: `{"value": "<extracted value as string>"}`. **map-trigger webhook**: `{"headers": {…}, "body": …}` (`WebhookEvent`). |
246
320
  | `work/output.json` | harness | The envelope. Any stale copy is deleted at start, and the new file is written atomically. |
247
321
  | `work/files/<object path>` | harness | Output files and directory tars (see "File references"). |
248
322
  | `tmp/` | harness | Scratch space. Point `TMPDIR` here. |
@@ -259,13 +333,18 @@ This section is everything the C# side needs in order to run the binary.
259
333
  | `OLVE_RUN` | run | Run number (integer ≥ 0). Written into produced file refs. Default 0. |
260
334
  | `OLVE_ATTEMPT` | run | Attempt number (integer ≥ 1). Written into produced file refs. Default 1. |
261
335
  | `OLVE_VERSION_STAMP` | run | Derived version, e.g. `2026.10.21.412` → `ctx.run.version`. |
336
+ | `OLVE_CELL` | run | Only for a matrix step: the cell, as compact JSON in axis order (`{"os":"linux","arch":"x64"}`) → `ctx.cell`. Missing, or not one of the step's cells → exit 65. `ctx.run.step` and produced file refs carry the cell's name, `build-cli[os=linux,arch=x64]`. |
337
+ | `OLVE_CASES` | run | Only for a step inside a `select`: a JSON array of the case conditions it sits in (`["target.prod.kind=ssh"]`); `run <step>` uses it to pick the variant of a name several cases use. |
262
338
  | `<SECRET_NAME>` | per step | Each declared secret as an env var of the **same name**: `secretKeyRef` key = name. Mount only the step's `secrets` (for poll triggers, the poll's `secrets`). Child processes inherit them. The mount is optional: an unset secret leaves the variable unset and `ctx.secrets.<NAME>` throws on access, so a handler can check `process.env.<NAME>` first and skip. |
263
339
  | `OLVE_RESULT_URL` | optional | Pre-signed **PUT** for `result.tar.gz` (see below). Unset means no upload. |
340
+ | `OLVE_CALLBACK_URLS` | optional | JSON object mapping `<step>/<input>` → this run's callback URL of that external input (`ctx.inputUrl`). Set when the pipeline has `callback()` inputs and the instance has a public base URL. |
264
341
  | `OLVE_FILE_URLS` | optional | JSON object mapping file key → pre-signed **GET** URL, one entry per file ref in the input. The key is `<run>/<step>/<attempt>/<path>` (`path` exactly as in the ref, including a trailing `/` for directories). The URL points at the stored object (see the storage rule). |
265
342
  | `OLVE_FILES_BASE_URL` (+ `OLVE_FILES_TOKEN`) | optional | Fallback: `GET <base>/<run>/<step>/<attempt>/<object path>` with an optional `Authorization: Bearer <token>`. |
266
343
  | `OLVE_FILES_DIR` | dev | Local pool laid out as `<run>/<step>/<attempt>/files/<object path>`. |
267
344
 
268
- `OLVE_RESULT_URL`, `OLVE_FILE_URLS` and `OLVE_FILES_TOKEN` are removed from the environment of child processes. URLs are never logged past `?`.
345
+ `OLVE_RESULT_URL`, `OLVE_FILE_URLS`, `OLVE_FILES_TOKEN` and `OLVE_CALLBACK_URLS` are removed from the environment of child processes. URLs are never logged past `?`.
346
+
347
+ **Secret masking** (`run`): every value of the step's declared secrets is replaced with `***` in what the step writes: `ctx.log`, `console`, anything written to stdout or stderr, the output of `ctx.exec` and `ctx.sh` commands (piped through the harness when the step has a secret; inherited otherwise), and the envelope's failure message or skip reason. Output is masked a line at a time; a multi-line value is masked line by line, and values (or lines) shorter than 4 characters are left alone. Outputs and files are data and are not masked. A process that writes to the pod's stdout around the harness (a file descriptor it opened itself) is not masked.
269
348
 
270
349
  `ctx.files.fetch` looks for a file in this order:
271
350
 
@@ -284,6 +363,8 @@ It retries network errors, 5xx, 408 and 429. It always verifies `sha256`.
284
363
  { "status": "failed", "error": { "kind": "handler" | "input" | "output", "message": "…" } }
285
364
  ```
286
365
 
366
+ Any of the three may carry `"links": [{ "url": "https://…", "label": "…" }]`, what the handler passed to `ctx.link` (absolute http(s) URLs of at most 2048 characters, labels of at most 100, at most 10). The key is left out when there are none. The server refuses an envelope whose links break these limits.
367
+
287
368
  For `map-trigger`, `output` is the pipeline input after it has been parsed by the input model, so defaults are applied. Validate it again server-side.
288
369
 
289
370
  ### Exit codes
@@ -293,10 +374,10 @@ For `map-trigger`, `output` is the pipeline input after it has been parsed by th
293
374
  | 0 | ok (succeeded or skipped) | yes |
294
375
  | 1 | the handler or trigger mapper threw | failed / `handler` |
295
376
  | 64 | usage: unknown mode, step or trigger | none |
296
- | 65 | `input.json` missing, not JSON, or invalid for the step (or event) | failed / `input` |
377
+ | 65 | `input.json` missing, not JSON, or invalid for the step (or event), including its `$config`; a matrix step's `OLVE_CELL` missing or not one of its cells | failed / `input` |
297
378
  | 66 | the return value failed strict validation, or an own file ref is missing or changed | failed / `output` |
298
379
  | 74 | the `result.tar.gz` upload failed after retries. Used only when the step itself succeeded; otherwise the step's own code wins. | yes (printed) |
299
- | 78 | the pipeline is invalid (describe problems, or a missing or wrong handler module) | none |
380
+ | 78 | the pipeline is invalid (describe problems, or a step without an implementation) | none |
300
381
  | 130 / 143 | the handler failed after SIGINT / SIGTERM (cancellation, `activeDeadlineSeconds`) | failed / `handler` |
301
382
 
302
383
  ### Result upload (`OLVE_RESULT_URL`)
@@ -314,6 +395,8 @@ The service unpacks the archive into `runs/<p>/<run>/<step>/<attempt>/`.
314
395
  { "run": 412, "step": "build", "attempt": 1, "path": "chart/", "sha256": "<64 hex>" }
315
396
  ```
316
397
 
398
+ - `step` is the step's name, or a matrix cell's name (`build-cli[os=linux,arch=x64]`).
399
+
317
400
  - In JSON Schema, a reference is marked `"x-olve-kind": "fileRef"`.
318
401
  - `path` is relative, with no `.`/`..` segments, no `\`, no NUL and no leading `/`. The pattern is in the schema. A **trailing `/` means a directory**.
319
402
  - **Storage rule:** the object path is `path` for files. For directories, it is `path` without the trailing `/`, plus `.tar`: `chart/` → `chart.tar`, an uncompressed deterministic tar.
@@ -322,11 +405,12 @@ The service unpacks the archive into `runs/<p>/<run>/<step>/<attempt>/`.
322
405
 
323
406
  ## Manifest
324
407
 
325
- Exact format: [`manifest.schema.json`](manifest.schema.json) (JSON Schema 2020-12, `$defs` `Manifest`, `Step`, `Stage`, `Mapping`, `Trigger`, `InputType`, `ModelSchemas`). The text is canonical JSON: sorted keys, 2-space indent, a trailing newline, and no `$schema` in the model schemas. Hash the text as printed.
408
+ Exact format: [`manifest.schema.json`](manifest.schema.json) (JSON Schema 2020-12, `$defs` `Manifest`, `Step`, `Stage`, `Mapping`, `Trigger`, `InputType`, `ModelSchemas`, `Matrix`). The text is canonical JSON: sorted keys, 2-space indent, a trailing newline, and no `$schema` in the model schemas. Hash the text as printed.
326
409
 
327
410
  - **`apiVersion`** is `"2.0"`.
328
411
  - **`harness`** is `{ bun, sdk, sdkHash }`.
329
412
  - **`models[name]`** is `{ output, input? }`.
413
+ - **Step `kind`** is `custom` by default, or what a reusable step names (`imageBuild`, `composeDeploy`, a package's `acme/…`). To the service it is a label: any non-empty kind is accepted, so a new library or package step needs no service change.
330
414
  - `output` is the `z.output` view, with `additionalProperties: false` on every object. It validates outputs, the pool and trigger results.
331
415
  - `input` is the `z.input` view (defaults optional). Use it for trigger forms and manual input. It is emitted only when it differs from `output` beyond `additionalProperties`.
332
416
  - **Step `input`** maps each input key to a mapping:
@@ -335,45 +419,49 @@ Exact format: [`manifest.schema.json`](manifest.schema.json) (JSON Schema 2020-1
335
419
  - a nested object of mappings.
336
420
 
337
421
  An object whose only key is `ref` (with a string value) or `const` is a tag. Describe never emits a nested object with exactly that key set. Model-matched inputs are emitted already resolved (`{"ref": "build"}`).
338
- - **`inputTypes`** declares each input key: `{model, optional}`, or `{schema: {output, input?}}` for a plain schema.
339
- - **`handlerHash`** is the step's implementation identity. A step version can be computed from image + `inputTypes`/model versions + `output` model version + `secrets` + `input` mappings + `handlerHash`. Stage placement, `when` and the step's `description` are not part of it.
340
- - Custom steps: `sha256(canonical {bundle, harness})`. `bundle` hashes the handler bundled on its own, with `pipeline.ts`, `models.ts` and `@olivervea/pipelines` external. It is tree-shaken and includes embedded scripts, so it changes only when that handler's code (or code it uses) changes.
341
- - Library steps: `sha256(canonical {library: kind, config, harness})`.
342
- - Because `harness` is part of both hashes, a Bun or SDK bump changes every handler.
422
+ - **Step `externalInputs`** (when present) maps each external input key to `{model, title, description, from}`, `from` being `{kind: "person"}`, `{kind: "callback"}` or `{kind: "poll", url, valuePath, intervalSeconds}`. Those keys are not in `input` or `inputTypes`; the service merges the answers into `input.json`.
423
+ - **`inputTypes`** declares each input key: `{model, optional}`, `{model, optional: false, cells: true}` for a `cells` input (mapped with a whole-step ref to a matrix step whose output is that model), or `{schema: {output, input?}}` for a plain schema.
424
+ - **Step `matrix`** (matrix steps only, never a failure handler) is `{axes: [{name, values}…], exclude?: [{axis: value…}…], failure: {kind: "keepRunning" | "cascadeFailure"}, maxParallel?}`. `axes` is a list because canonical JSON sorts keys; `exclude` is omitted when empty; `failure.kind` is always written. All of it is part of what the step declares.
425
+ - **`codeHash`** is the build's identity: `sha256(canonical {bundle, harness})`. `bundle` hashes the entry point bundled with everything it imports, with `@olivervea/pipelines` external. It is tree-shaken and includes embedded scripts and dependencies, so any change to code in use changes it, and with it the manifest, which makes a new pipeline version. A step version covers what the step declares: image, `inputTypes`/model versions, `output` model version, `secrets`, `configEntries` (and those entries' schemas), `input` mappings and `config`.
343
426
  - **`stages`** is an array whose items are either `{steps: [Step…]}` (parallel, with a barrier) or `{when: {field: [values]}, stages: [...]}`.
344
427
  - **`triggers`**:
345
428
  - `{kind: "push", name: "push"}`;
346
429
  - `{kind: "poll", name, url, headers, secrets, valuePath, intervalSeconds}`, where header values may contain `$SECRET:NAME`;
347
430
  - `{kind: "webhook", name, verify: {scheme: "hmac-sha256", secret, header, prefix} | {scheme: "bearer", secret}}`. The `map-trigger` event is `{headers, body}`; a `null` mapping yields a `skipped` envelope (the delivery is ignored).
348
431
  - **`failureHandlers`** is a list of `{step: Step, steps: [names] | null}`. Each handler's inputs are all FailureContext (`{"ref": "failure"}`).
432
+ - **`retention`** (when present) is `{keepLatestRuns?, minRunAgeDays?, buildGraceDays?}`, non-negative integers that replace the instance's thresholds for this pipeline. Omitted when the definition sets none.
349
433
  - **`secrets`** lists every secret used by steps, polls and webhooks, sorted, as `{name, description}`.
434
+ - **`config`** lists every config entry steps declare, sorted by name, as `{name, description, required, schema}`. `schema` is the `z.input` view (defaults listed); it validates the entry's value in the config document. `required` is false when the schema accepts a missing value. A `secretRef` field is marked `"x-olve-kind": "secretRef"`, with the secret's name as `properties.secret.const`. Omitted when there are no entries.
435
+ - **Step `configEntries`** names the entries the step receives (sorted; omitted when none). It is part of the step's content, like `secrets`. The step key `config` is the step's `settings`.
350
436
 
351
437
  Describe runs these checks; reconcile should re-run the manifest-level ones:
352
438
 
353
439
  - step name format and reserved names (`input`, `failure`), and duplicate step names;
354
440
  - one schema per model name, and JSON-representable models;
355
- - missing handler file, and a handler file without a step (or for a library step);
441
+ - a staged step without an implementation, and an empty step kind;
356
442
  - availability, ambiguity, maybe-available as required, `optional` without a producer, plain schema without a mapper;
357
443
  - mapper source analysis and recording, unknown fields in refs, mapper keys that are missing or extra;
358
444
  - `when` on an unknown or non-literal field, or with an invalid value;
359
445
  - a poll header referencing an undeclared secret, duplicate triggers;
360
446
  - conflicting secret descriptions;
361
- - an `onFailure` scope naming an unknown step, and failure-handler inputs.
447
+ - config names, one description and schema per config name, a step receiving a `secretRef` without declaring its secret, and input keys starting with `$` (reserved);
448
+ - an `onFailure` scope naming an unknown step, and failure-handler inputs;
449
+ - a matrix's axes, values, excludes, cell count, failure strategy and `maxParallel`, a matrix failure handler, and reading a matrix step other than whole through a `cells` input (or a `cells` input reading anything else).
362
450
 
363
451
  ## Build tool
364
452
 
365
453
  ```
366
454
  olve-build <pipelineDir> --out <dir> [--target bun-linux-x64|bun-linux-x64-musl] [--upload-url <presigned PUT>] [--sentinels]
367
- olve-build <pipelineDir> --hash-only # print handler hashes (diagnostics)
455
+ olve-build <pipelineDir> --hash-only # print the code hash (diagnostics)
368
456
  ```
369
457
 
370
- 1. Pair each `handlers/*.ts` with the step its `implement(step, …)` names, and hash it (see `handlerHash`).
371
- 2. Generate `<pipelineDir>/.olve/gen/handlers.ts` (a registry of literal `import()` paths, plus hashes and harness info) and `entry.ts`. Add `.olve/` to `.gitignore`.
458
+ 1. Read the entry point from `main` in `package.json` (a missing `main`, or a `main` whose file is missing, is an error) and hash the build (see `codeHash`).
459
+ 2. Generate `<pipelineDir>/.olve/gen/entry.ts`, which imports the pipeline and calls `runHarness` with it, the code hash and the harness info. Add `.olve/` to `.gitignore`.
372
460
  3. Run `bun build --compile --target=bun-linux-x64 --no-compile-autoload-dotenv --no-compile-autoload-bunfig --bytecode --format=esm` → `<out>/step`. The output is never minified, because mapper source analysis needs real source.
373
461
  4. Check that `PT_INTERP` is `/lib64/ld-linux-x86-64.so.2`. On alpine, Bun silently produces a musl binary; the check fails it.
374
- 5. Run `<out>/step describe` → `<out>/manifest.json`. A describe problem is a build failure with exit 78. **A custom step without a handler file is an error.**
375
- 6. Write `<out>/step.tar.gz` (deterministic, `step` at the root, mode 0755) and `<out>/build.json` (diagnostics).
376
- 7. With `--upload-url`, PUT `step.tar.gz`, using the same retry rules as the result upload. With `--sentinels`, print the manifest between the manifest sentinels.
462
+ 5. Run `<out>/step describe` → `<out>/manifest.json`. A describe problem is a build failure with exit 78. **A step with no implementation is an error.**
463
+ 6. Write `<out>/step.tar.zst` (a deterministic tar, `step` at the root, mode 0755, zstd level 3: it unpacks in the step pod far faster than gzip; harness-base also still unpacks the gzip archives of older SDKs; from 2.0.0-beta.7 the SDK needs an instance whose harness-base unpacks zstd, i.e. one deployed on or after 2026-10-25) and `<out>/build.json` (diagnostics).
464
+ 7. With `--upload-url`, PUT `step.tar.zst`, using the same retry rules as the result upload. With `--sentinels`, print the manifest between the manifest sentinels.
377
465
 
378
466
  Exit codes: 0 ok, 1 build failure, 64 usage, 78 pipeline invalid.
379
467
 
@@ -385,11 +473,11 @@ bun install --frozen-lockfile
385
473
  bunx olve-build .pipelines --out /work/out --upload-url "$STEP_PUT_URL" --sentinels
386
474
  ```
387
475
 
388
- From npm, nothing else is needed. A local `file:`/`link:` dependency (`"@olivervea/pipelines": "file:../sdk"`, as Olve.Pipelines' own `.pipelines/` does) resolves to its **source directory**, not the copy in `node_modules`, so its own dependencies must be installed there too: the build Job runs `bun install --frozen-lockfile --production` in every local dependency of the binding's `package.json` that is inside the repo, before building.
476
+ From npm, nothing else is needed. A local `file:`/`link:` dependency (`"@olivervea/pipelines": "file:../src/sdk"`, as Olve.Pipelines' own `.pipelines/` does) resolves to its **source directory**, not the copy in `node_modules`, so its own dependencies must be installed there too: the build Job runs `bun install --frozen-lockfile --production` in every local dependency of the binding's `package.json` that is inside the repo, before building.
389
477
 
390
478
  The repo depends on the SDK as a normal package. It resolves through `exports` (`@olivervea/pipelines`, `@olivervea/pipelines/harness`, `@olivervea/pipelines/testing`), and the tests cover this with a `file:` install. `bun` must be on `PATH`, because the bins use `#!/usr/bin/env bun`. A consumer `tsconfig.json` has to include the dot-directory explicitly (`"include": [".pipelines/**/*.ts"]`), and should exclude `.pipelines/.olve`.
391
479
 
392
- How the service runs it (reconcile, `BuildJobBuilder`): one Job per (pipeline, config commit), named `olve-build-<pipeline12>-<sha12>`. The repo tarball comes from `codeload.github.com/<repo>/tar.gz/<sha>` anonymously (public repos), or from the GitHub API tarball endpoint with the binding's credentials key from the pipeline Secret. The binary is PUT to `versions/<pipeline>/builds/<sha>/step.tar.gz`, and a version added from that build keeps this key as its `BinaryKey`. The in-repo fixture `.pipelines-test/` (bound on v2 beta) uses exactly this layout.
480
+ How the service runs it (reconcile, `BuildJobBuilder`): one Job per (pipeline, config commit), named `olve-build-<pipeline12>-<sha12>`. The repo tarball comes from `codeload.github.com/<repo>/tar.gz/<sha>` anonymously (public repos), or from the GitHub API tarball endpoint with the binding's credentials key from the pipeline Secret. The archive is PUT to `versions/<pipeline>/builds/<sha>/step.tar.gz` (the key predates zstd; the init container tells the formats apart by content), and a version added from that build keeps this key as its `BinaryKey`. The in-repo fixture `.pipelines-test/` (bound on v2 beta) uses exactly this layout.
393
481
 
394
482
  The manifest is the text between the sentinels in the pod log. The manifest text is identical across checkouts. The binary and archive bytes are identical for the same checkout layout.
395
483
 
@@ -397,11 +485,11 @@ The manifest is the text between the sentinels in the pod log. The manifest text
397
485
 
398
486
  ```
399
487
  olve-dev describe <dir> [--sentinels] # same bytes as the compiled binary's describe
400
- olve-dev run <dir> <step> [--input-file f] [--olve-dir d] [--secret NAME=value]...
488
+ olve-dev run <dir> <step> [--input-file f] [--config-file f] [--olve-dir d] [--secret NAME=value]...
401
489
  olve-dev map-trigger <dir> <trigger> --event-file f [--olve-dir d]
402
490
  ```
403
491
 
404
- `olve-dev` runs handlers from source, through the same validation path as the binary. `$OLVE_DIR` defaults to `<dir>/.olve`, and outputs land in `<dir>/.olve/work/`. Set `OLVE_FILES_DIR` to a local pool to fetch file refs.
492
+ `olve-dev` runs handlers from source, through the same validation path as the binary. `$OLVE_DIR` defaults to `<dir>/.olve`, and outputs land in `<dir>/.olve/work/`. Set `OLVE_FILES_DIR` to a local pool to fetch file refs. `--config-file` (a JSON object of config entries) becomes the input's `$config`. A matrix step's cell comes from `OLVE_CELL` in the environment, as in the pod.
405
493
 
406
494
  ## Development
407
495
 
@@ -409,7 +497,6 @@ olve-dev map-trigger <dir> <trigger> --event-file f [--olve-dir d]
409
497
  bun install
410
498
  bunx tsc --noEmit -p . # type-check (TS 7)
411
499
  bun scripts/check-compile-fail.ts # compile-fail cases (also run by bun test)
412
- TSC=/path/to/tsc-5.9/bin/tsc bun scripts/check-compile-fail.ts # the other TS line
413
500
  bun test # runtime + build/e2e tests
414
501
  bun scripts/gen-manifest-schema.ts # after changing src/describe/manifest-schema.ts
415
502
  ```