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

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.
package/README.md CHANGED
@@ -4,7 +4,7 @@ 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
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).
@@ -13,9 +13,9 @@ The manifest format is in [`manifest.schema.json`](manifest.schema.json), genera
13
13
  ```
14
14
  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 (script, gitHubSource, imageBuild, helmDeployOverSsh, helmDeployInCluster, composeDeploy), their exported helpers (httpHealthCheck, registryTag, npmDistTag) and SDK models
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
@@ -108,6 +108,40 @@ Rules:
108
108
  - A **plain-schema input** has no model to match on, so the step needs a mapper.
109
109
  - **`optional(M)`** means "maybe skipped": `M` must still have an earlier producer.
110
110
 
111
+ ### Config
112
+
113
+ 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.
114
+
115
+ ```ts
116
+ import { config, secret, secretRef, step, z } from "@olivervea/pipelines";
117
+
118
+ export const DEX_CLIENT_SECRET = secret("DEX_CLIENT_SECRET", "Dex client secret.");
119
+ export const AUTH = config("auth", "How users authenticate.", z.discriminatedUnion("mode", [
120
+ z.object({ mode: z.literal("none") }),
121
+ z.object({ mode: z.literal("dex"), issuer: z.string().url(), clientSecret: secretRef(DEX_CLIENT_SECRET) }),
122
+ ]));
123
+ export const TARGET = config("target", "Where the release is deployed.",
124
+ z.object({ host: z.string(), namespace: z.string().default("olve-pipelines") }));
125
+
126
+ export const deploy = step({ name: "deploy", description: "Deploys the release.", image: "alpine:3.22",
127
+ config: [AUTH, TARGET], // ctx.config is typed to exactly these
128
+ secrets: [DEX_CLIENT_SECRET] }); // a secretRef's secret must be declared too
129
+ ```
130
+
131
+ ```yaml
132
+ # the instance's config document for this pipeline
133
+ auth: { mode: dex, issuer: https://dex.example.com, clientSecret: { secret: DEX_CLIENT_SECRET } }
134
+ target: { host: deploy@box }
135
+ ```
136
+
137
+ - `config(name, description, schema)` mirrors `secret(...)`. Names match `^[a-z][a-zA-Z0-9]*$` (they are YAML keys).
138
+ - 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.
139
+ - Handlers see `ctx.config` with the output view (defaults applied); a discriminated union narrows as usual.
140
+ - `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.
141
+ - Config is per **run**: the run pins a config snapshot at start; continues and step reruns reuse it. Secrets are per attempt.
142
+ - Library steps 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 referenced entry (`configEntries` in the manifest); `manifestConfig` records each reference as `{"$config": "<entry>.<path>"}` (field names must not contain `.`; literal keys starting with `$` are reserved); `run()` resolves them from `ctx.config` (defaults applied) before doing anything. `describe` and reconcile reject a reference to an entry the step doesn't receive or a field the entry doesn't have.
143
+ - `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.
144
+
111
145
  **`steps.script` contract** (for the script):
112
146
 
113
147
  | Name | What it holds |
@@ -125,14 +159,16 @@ Library steps carry their own implementation and image; they take typed config a
125
159
  | Step | Image | Input → output | Config |
126
160
  |---|---|---|---|
127
161
  | `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? }`) |
162
+ | `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`) |
163
+ | `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), `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) |
164
+ | `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?`, `imageRefs?` (`PushedImage` input key → values prefix: set as `<prefix>.repository`/`.tag`/`.digest`, not pulled; works next to tarball `images`), `deployments?` (default `[release]`), `rolloutTimeoutSeconds?` (default 120), `healthUrl?` (URL or `{ url, attempts?, delaySeconds? }`) |
165
+ | `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), `images?`, `imageRefs?`, `deployments?` (ssh), `rolloutTimeoutSeconds?` (default 120), `healthPath?`; helmDeployInCluster declares `clusterAccess` |
166
+ | `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?` |
131
167
 
132
- - `gitHubSource` downloads `https://api.github.com/repos/<repo>/tarball/<sha>` (retrying 5xx), strips the top-level directory and outputs it as `tree`.
168
+ - `gitHubSource` downloads `<apiUrl>/repos/<repo>/tarball/<sha>` (retrying 5xx), strips the top-level directory and outputs it as `tree`.
133
169
  - `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
170
  - **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.
171
+ - **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, base-image and cache pulls from it are logged in. `insecureRegistry` makes the lookups plain HTTP and adds kaniko `--insecure --insecure-registry=<host>`.
136
172
  - `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):
137
173
 
138
174
  ```ts
@@ -141,6 +177,9 @@ Library steps carry their own implementation and image; they take typed config a
141
177
  images: { server: "image", web: "web.image" }, deployments: ["olve-slimerepublics-server", "olve-slimerepublics-web"] });
142
178
  ```
143
179
 
180
+ - **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`.
181
+ - `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).
182
+
144
183
  **Helpers.** General functions the library steps are built on, exported for custom handlers:
145
184
 
146
185
  | Helper | What it does |
@@ -180,6 +219,7 @@ export default implement(deployBeta, async ({ artifact }, ctx) => {
180
219
  | `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
220
  | `retry({ attempts, delaySeconds, backoff? }, fn)` | Retries `fn`. Can be cancelled. |
182
221
  | `secrets.NAME` | Typed to the step's declared secrets. Read from the env var `NAME`. Throws if the variable is missing. |
222
+ | `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
223
  | `log(message, fields?)` | Writes a `message k=v …` line to stderr. |
184
224
  | `signal` | Aborted on SIGTERM/SIGINT. Running commands are sent SIGTERM. |
185
225
  | `run` | `{ pipeline, id, number, attempt, step, version }` (from the env, see below). |
@@ -206,6 +246,7 @@ export default pipeline({ name: "Olve.Pipelines", description: "…", input: M.D
206
246
  - **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
247
  - **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
248
  - **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.
249
+ - **`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).
209
250
  - **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
251
 
211
252
  ### Testing handlers
@@ -214,7 +255,7 @@ export default pipeline({ name: "Olve.Pipelines", description: "…", input: M.D
214
255
  import { testHandler, testFileRef } from "@olivervea/pipelines/testing";
215
256
  import deployBeta from "../handlers/deploy-beta";
216
257
 
217
- const r = await testHandler(deployBeta, { artifact }, { secrets: { SSH_PRIVATE_KEY: "k" }, ctx: { http: fakeHttp }, run: { number: 412 } });
258
+ const r = await testHandler(deployBeta, { artifact }, { secrets: { SSH_PRIVATE_KEY: "k" }, config: { target: { host: "h" } }, ctx: { http: fakeHttp }, run: { number: 412 } });
218
259
  expect(r).toMatchObject({ status: "succeeded", output: { release: "olve-pipelines" } }); // r.filesDir, r.logs, r.exitCode
219
260
  ```
220
261
 
@@ -242,7 +283,7 @@ This section is everything the C# side needs in order to run the binary.
242
283
 
243
284
  | Path | Who | What |
244
285
  |---|---|---|
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`). |
286
+ | `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`. **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
287
  | `work/output.json` | harness | The envelope. Any stale copy is deleted at start, and the new file is written atomically. |
247
288
  | `work/files/<object path>` | harness | Output files and directory tars (see "File references"). |
248
289
  | `tmp/` | harness | Scratch space. Point `TMPDIR` here. |
@@ -259,6 +300,7 @@ This section is everything the C# side needs in order to run the binary.
259
300
  | `OLVE_RUN` | run | Run number (integer ≥ 0). Written into produced file refs. Default 0. |
260
301
  | `OLVE_ATTEMPT` | run | Attempt number (integer ≥ 1). Written into produced file refs. Default 1. |
261
302
  | `OLVE_VERSION_STAMP` | run | Derived version, e.g. `2026.10.21.412` → `ctx.run.version`. |
303
+ | `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
304
  | `<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
305
  | `OLVE_RESULT_URL` | optional | Pre-signed **PUT** for `result.tar.gz` (see below). Unset means no upload. |
264
306
  | `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). |
@@ -293,7 +335,7 @@ For `map-trigger`, `output` is the pipeline input after it has been parsed by th
293
335
  | 0 | ok (succeeded or skipped) | yes |
294
336
  | 1 | the handler or trigger mapper threw | failed / `handler` |
295
337
  | 64 | usage: unknown mode, step or trigger | none |
296
- | 65 | `input.json` missing, not JSON, or invalid for the step (or event) | failed / `input` |
338
+ | 65 | `input.json` missing, not JSON, or invalid for the step (or event), including its `$config` | failed / `input` |
297
339
  | 66 | the return value failed strict validation, or an own file ref is missing or changed | failed / `output` |
298
340
  | 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
341
  | 78 | the pipeline is invalid (describe problems, or a missing or wrong handler module) | none |
@@ -327,6 +369,7 @@ Exact format: [`manifest.schema.json`](manifest.schema.json) (JSON Schema 2020-1
327
369
  - **`apiVersion`** is `"2.0"`.
328
370
  - **`harness`** is `{ bun, sdk, sdkHash }`.
329
371
  - **`models[name]`** is `{ output, input? }`.
372
+ - **Step `kind`** is `custom` or the library step's name (`imageBuild`, `composeDeploy`, …). To the service it is a label: any non-empty kind is accepted, so a new library step needs no service change.
330
373
  - `output` is the `z.output` view, with `additionalProperties: false` on every object. It validates outputs, the pool and trigger results.
331
374
  - `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
375
  - **Step `input`** maps each input key to a mapping:
@@ -336,7 +379,7 @@ Exact format: [`manifest.schema.json`](manifest.schema.json) (JSON Schema 2020-1
336
379
 
337
380
  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
381
  - **`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.
382
+ - **`handlerHash`** is the step's implementation identity. A step version can be computed from image + `inputTypes`/model versions + `output` model version + `secrets` + `configEntries` (and those entries' schemas) + `input` mappings + `handlerHash`. Stage placement, `when` and the step's `description` are not part of it.
340
383
  - 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
384
  - Library steps: `sha256(canonical {library: kind, config, harness})`.
342
385
  - Because `harness` is part of both hashes, a Bun or SDK bump changes every handler.
@@ -347,6 +390,8 @@ Exact format: [`manifest.schema.json`](manifest.schema.json) (JSON Schema 2020-1
347
390
  - `{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
391
  - **`failureHandlers`** is a list of `{step: Step, steps: [names] | null}`. Each handler's inputs are all FailureContext (`{"ref": "failure"}`).
349
392
  - **`secrets`** lists every secret used by steps, polls and webhooks, sorted, as `{name, description}`.
393
+ - **`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.
394
+ - **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 library-step configuration.
350
395
 
351
396
  Describe runs these checks; reconcile should re-run the manifest-level ones:
352
397
 
@@ -358,6 +403,7 @@ Describe runs these checks; reconcile should re-run the manifest-level ones:
358
403
  - `when` on an unknown or non-literal field, or with an invalid value;
359
404
  - a poll header referencing an undeclared secret, duplicate triggers;
360
405
  - conflicting secret descriptions;
406
+ - config names, one description and schema per config name, a step receiving a `secretRef` without declaring its secret, and input keys starting with `$` (reserved);
361
407
  - an `onFailure` scope naming an unknown step, and failure-handler inputs.
362
408
 
363
409
  ## Build tool
@@ -372,8 +418,8 @@ olve-build <pipelineDir> --hash-only # print handler hashes (diagnostic
372
418
  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
419
  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
420
  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.
421
+ 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).
422
+ 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
423
 
378
424
  Exit codes: 0 ok, 1 build failure, 64 usage, 78 pipeline invalid.
379
425
 
@@ -389,7 +435,7 @@ From npm, nothing else is needed. A local `file:`/`link:` dependency (`"@oliverv
389
435
 
390
436
  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
437
 
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.
438
+ 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
439
 
394
440
  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
441
 
@@ -397,11 +443,11 @@ The manifest is the text between the sentinels in the pod log. The manifest text
397
443
 
398
444
  ```
399
445
  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]...
446
+ olve-dev run <dir> <step> [--input-file f] [--config-file f] [--olve-dir d] [--secret NAME=value]...
401
447
  olve-dev map-trigger <dir> <trigger> --event-file f [--olve-dir d]
402
448
  ```
403
449
 
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.
450
+ `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`.
405
451
 
406
452
  ## Development
407
453
 
@@ -47,6 +47,42 @@
47
47
  "const": "2.0",
48
48
  "type": "string"
49
49
  },
50
+ "config": {
51
+ "description": "Config entries used by steps, sorted by name (omitted when none). Fields of kind secretRef ({secret: NAME}) name a secret.",
52
+ "items": {
53
+ "additionalProperties": false,
54
+ "properties": {
55
+ "description": {
56
+ "type": "string"
57
+ },
58
+ "name": {
59
+ "description": "Config entry name; a key of the pipeline's config document.",
60
+ "pattern": "^[a-z][a-zA-Z0-9]*$",
61
+ "type": "string"
62
+ },
63
+ "required": {
64
+ "description": "False when the schema accepts a missing value (optional, or a default).",
65
+ "type": "boolean"
66
+ },
67
+ "schema": {
68
+ "additionalProperties": {},
69
+ "description": "z.input view: validates the config document's value for this entry (defaults listed).",
70
+ "propertyNames": {
71
+ "type": "string"
72
+ },
73
+ "type": "object"
74
+ }
75
+ },
76
+ "required": [
77
+ "name",
78
+ "description",
79
+ "required",
80
+ "schema"
81
+ ],
82
+ "type": "object"
83
+ },
84
+ "type": "array"
85
+ },
50
86
  "description": {
51
87
  "type": "string"
52
88
  },
@@ -297,22 +333,70 @@
297
333
  "stages"
298
334
  ],
299
335
  "type": "object"
336
+ },
337
+ {
338
+ "additionalProperties": false,
339
+ "properties": {
340
+ "cases": {
341
+ "additionalProperties": {
342
+ "items": {
343
+ "$ref": "#/$defs/Stage"
344
+ },
345
+ "type": "array"
346
+ },
347
+ "description": "Discriminator value → stages. Exactly one case runs; a step name may repeat across cases, nowhere else.",
348
+ "propertyNames": {
349
+ "type": "string"
350
+ },
351
+ "type": "object"
352
+ },
353
+ "discriminator": {
354
+ "description": "The union's literal field whose value names the case.",
355
+ "minLength": 1,
356
+ "type": "string"
357
+ },
358
+ "select": {
359
+ "description": "Config reference (`<entry>.<field>…`) to a discriminated union; the run's config snapshot picks the case.",
360
+ "minLength": 1,
361
+ "type": "string"
362
+ }
363
+ },
364
+ "required": [
365
+ "select",
366
+ "discriminator",
367
+ "cases"
368
+ ],
369
+ "type": "object"
300
370
  }
301
371
  ]
302
372
  },
303
373
  "Step": {
304
374
  "additionalProperties": false,
305
375
  "properties": {
376
+ "clusterAccess": {
377
+ "const": true,
378
+ "description": "The step pod gets the instance's step ServiceAccount token (Kubernetes:StepServiceAccount), if the instance grants one. Omitted = no token.",
379
+ "type": "boolean"
380
+ },
306
381
  "config": {
307
382
  "additionalProperties": {
308
383
  "$ref": "#/$defs/Json"
309
384
  },
310
- "description": "Library-step configuration (display only; already covered by handlerHash).",
385
+ "description": "Library-step configuration (display only; already covered by handlerHash). A config reference is recorded as {\"$config\": \"<entry>.<field>…\"}; the entry is in configEntries.",
311
386
  "propertyNames": {
312
387
  "type": "string"
313
388
  },
314
389
  "type": "object"
315
390
  },
391
+ "configEntries": {
392
+ "description": "Config entries the step receives (sorted; omitted when none). The service delivers them in input.json under '$config'.",
393
+ "items": {
394
+ "description": "Config entry name; a key of the pipeline's config document.",
395
+ "pattern": "^[a-z][a-zA-Z0-9]*$",
396
+ "type": "string"
397
+ },
398
+ "type": "array"
399
+ },
316
400
  "description": {
317
401
  "description": "What the step does, shown in the UI (may be empty).",
318
402
  "type": "string"
@@ -348,14 +432,8 @@
348
432
  "type": "object"
349
433
  },
350
434
  "kind": {
351
- "description": "'custom' = handlers/<name>.ts; others are SDK library steps.",
352
- "enum": [
353
- "custom",
354
- "script",
355
- "gitHubSource",
356
- "imageBuild",
357
- "helmDeployOverSsh"
358
- ],
435
+ "description": "'custom' = handlers/<name>.ts; others are SDK library steps. A label: the service accepts any kind.",
436
+ "minLength": 1,
359
437
  "type": "string"
360
438
  },
361
439
  "name": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olivervea/pipelines",
3
- "version": "2.0.0-beta.3",
3
+ "version": "2.0.0-beta.7",
4
4
  "description": "Olve.Pipelines v2 SDK: typed pipeline authoring, the step harness, and the build tool.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,10 +1,10 @@
1
- // olve-build: pipeline dir → single executable + step.tar.gz + manifest.json.
1
+ // olve-build: pipeline dir → single executable + step.tar.zst + manifest.json.
2
2
  import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { join, resolve } from "node:path";
4
4
  import { canonicalJson } from "../util/canonical.ts";
5
5
  import { sha256File } from "../util/hash.ts";
6
6
  import type { HarnessInfo } from "../util/handler-hash.ts";
7
- import { writeTarGz } from "../util/tar.ts";
7
+ import { writeTarZst } from "../util/tar.ts";
8
8
  import { MANIFEST_BEGIN, MANIFEST_END, uploadFile } from "../harness/result.ts";
9
9
  import { GLIBC_INTERP, MUSL_INTERP, readInterpreter } from "./elf.ts";
10
10
  import { handlerHashes, type HandlerHash } from "./hashes.ts";
@@ -23,7 +23,7 @@ export class BuildError extends Error {
23
23
  export interface BuildOptions {
24
24
  out: string;
25
25
  target?: Target;
26
- /** Pre-signed PUT for step.tar.gz. */
26
+ /** Pre-signed PUT for the step archive. */
27
27
  uploadUrl?: string;
28
28
  /** Print the manifest between sentinels on stdout (for the build Job's pod log). */
29
29
  sentinels?: boolean;
@@ -107,9 +107,9 @@ export async function build(dir: string, opts: BuildOptions): Promise<BuildResul
107
107
  const manifestFile = join(out, "manifest.json");
108
108
  writeFileSync(manifestFile, manifest);
109
109
 
110
- // 4. step.tar.gz (deterministic: `step` at the root, 0755, mtime 0)
111
- const archive = join(out, "step.tar.gz");
112
- await writeTarGz(archive, [{ path: "step", type: "file", mode: 0o755, source: binary }]);
110
+ // 4. step.tar.zst (deterministic: `step` at the root, 0755, mtime 0)
111
+ const archive = join(out, "step.tar.zst");
112
+ await writeTarZst(archive, [{ path: "step", type: "file", mode: 0o755, source: binary }]);
113
113
  writeFileSync(
114
114
  join(out, "build.json"),
115
115
  canonicalJson({ target, flags: BUILD_FLAGS, harness, handlers: hashes, binarySha256: sha256File(binary), archiveSha256: sha256File(archive) }),
@@ -118,7 +118,7 @@ export async function build(dir: string, opts: BuildOptions): Promise<BuildResul
118
118
 
119
119
  if (opts.uploadUrl) {
120
120
  await uploadFile(opts.uploadUrl, archive, log);
121
- log("olve-build: uploaded step.tar.gz");
121
+ log("olve-build: uploaded step.tar.zst");
122
122
  }
123
123
  if (opts.sentinels) await stdout(`${MANIFEST_BEGIN}\n${manifest}${MANIFEST_END}\n`);
124
124
  return { binary, archive, manifestFile, manifest, hashes, harness };
@@ -8,6 +8,7 @@ import type { IsUnion, OrError, Simplify } from "../core/types.ts";
8
8
  import type { Optional } from "../core/models.ts";
9
9
  import type { FailureContext } from "../library/models.ts";
10
10
  import type { MapperResult, Ref } from "./refs.ts";
11
+ import { isConfigRef, narrowConfigRef, unionAt, type ConfigNode, type ConfigRef, type DiscriminatorOf, type VariantOf } from "../core/config.ts";
11
12
 
12
13
  // ─── Availability (builder state, type level) ────────────────────────────────────────────────
13
14
 
@@ -96,6 +97,29 @@ type OutputsOf<E extends readonly unknown[], Maybe extends boolean> = {
96
97
  };
97
98
  type MakeMaybe<X extends AvailMap> = { [K in keyof X]: Slot<X[K]["model"], X[K]["value"], true> };
98
99
 
100
+ // ─── select (branching on config) ────────────────────────────────────────────────────────────
101
+
102
+ type KeysOfUnion<T> = T extends unknown ? keyof T : never;
103
+ type SlotIn<B, K extends PropertyKey> = B extends { [P in K]: infer X extends Slot } ? X : never;
104
+ /** A case function: the pipeline so far, and the select's reference narrowed to the case's variant. */
105
+ export type SelectCase<In extends AnyModel, A extends AvailMap, R> = (p: Pipeline<In, A>, ref: R) => Pipeline<In, AvailMap>;
106
+ export type SelectCases<In extends AnyModel, A extends AvailMap, U, D extends string> = {
107
+ readonly [V in Extract<U[D & keyof U], string>]: SelectCase<In, A, ConfigNode<VariantOf<U, D, V>>>;
108
+ };
109
+ type CaseAvail<C> = { [K in keyof C]: C[K] extends (...args: never[]) => Pipeline<AnyModel, infer B> ? B : never }[keyof C];
110
+ /**
111
+ * After a select: a step every case has is as available as in its cases (one case always runs);
112
+ * a step only some cases have is maybe-available.
113
+ */
114
+ export type AfterSelect<A extends AvailMap, B> = Simplify<
115
+ A &
116
+ { [K in Exclude<keyof B, keyof A>]: Slot<SlotIn<B, K>["model"], SlotIn<B, K>["value"], SlotIn<B, K>["maybe"]> } &
117
+ { [K in Exclude<KeysOfUnion<B>, keyof B | keyof A>]: Slot<SlotIn<B, K>["model"], SlotIn<B, K>["value"], true> }
118
+ >;
119
+ type SelectOnError<U> = [DiscriminatorOf<U>] extends [never]
120
+ ? "❌ select needs a reference to a discriminated union in config (z.discriminatedUnion)"
121
+ : IsUnion<DiscriminatorOf<U>> extends true ? "❌ the union has more than one literal field; select can't tell which is the discriminator" : never;
122
+
99
123
  // ─── Conditions / triggers ───────────────────────────────────────────────────────────────────
100
124
 
101
125
  type IsLiteralLike<T> = [T] extends [string | number | boolean]
@@ -134,7 +158,8 @@ export interface StageStepNode {
134
158
  }
135
159
  export type StageNode =
136
160
  | { readonly kind: "stage"; readonly steps: readonly StageStepNode[] }
137
- | { readonly kind: "when"; readonly cond: Readonly<Record<string, unknown>>; readonly nodes: readonly StageNode[] };
161
+ | { readonly kind: "when"; readonly cond: Readonly<Record<string, unknown>>; readonly nodes: readonly StageNode[] }
162
+ | { readonly kind: "select"; readonly on: ConfigRef; readonly cases: Readonly<Record<string, readonly StageNode[]>> };
138
163
 
139
164
  export type TriggerNode =
140
165
  | { readonly kind: "push"; readonly name: string; readonly map: (event: unknown) => unknown }
@@ -300,6 +325,34 @@ export class Pipeline<In extends AnyModel, A extends AvailMap> {
300
325
  return this.next();
301
326
  }
302
327
 
328
+ /**
329
+ * Branch on config: `on` references a discriminated union in a config entry, and each case gets the
330
+ * pipeline and `on` narrowed to its variant. Exactly one case runs, chosen by the run's config
331
+ * snapshot (not by run input; that is `when`). The same step name may appear in several cases.
332
+ */
333
+ select<U, const C extends SelectCases<In, A, U, DiscriminatorOf<U>>>(
334
+ on: ConfigRef<U> & ([SelectOnError<U>] extends [never] ? unknown : SelectOnError<U>),
335
+ cases: C & { readonly [K in Exclude<keyof C, keyof SelectCases<In, A, U, DiscriminatorOf<U>>>]: `❌ '${K & string}' is not a case of this select` },
336
+ ): Pipeline<In, AfterSelect<A, CaseAvail<C>>> {
337
+ const data = this[PIPELINE];
338
+ const recorded: Record<string, StageNode[]> = {};
339
+ const union = isConfigRef(on) ? unionAt(on) : undefined;
340
+ if (!union) data.problems.push("select: the first argument must reference a z.discriminatedUnion in a config entry");
341
+ for (const [value, fn] of Object.entries(cases as Record<string, unknown>)) {
342
+ if (typeof fn !== "function") {
343
+ data.problems.push(`select: case '${value}' is not a function`);
344
+ continue;
345
+ }
346
+ const inner = new Pipeline<In, A>({ name: data.name, input: data.input as In }, data);
347
+ const returned = (fn as (p: unknown, ref: unknown) => unknown)(inner, union ? narrowConfigRef(on, union.discriminator, value) : on);
348
+ if (!isPipeline(returned) || pipelineData(returned) !== inner[PIPELINE])
349
+ data.problems.push(`select: case '${value}' must return the pipeline it was given ((p, ref) => p.stage(...))`);
350
+ recorded[value] = inner[PIPELINE].nodes;
351
+ }
352
+ data.nodes.push({ kind: "select", on, cases: recorded });
353
+ return this.next();
354
+ }
355
+
303
356
  /** Run `handler` (a step whose inputs are all FailureContext) when one of `steps` (default: any) fails. Untracked, best-effort. */
304
357
  onFailure<S extends AnyStep>(
305
358
  handler: S &
@@ -307,7 +360,7 @@ export class Pipeline<In extends AnyModel, A extends AvailMap> {
307
360
  ? unknown
308
361
  : "❌ a failure handler's inputs must all be FailureContext"),
309
362
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
310
- opts?: { steps?: readonly Step<StepNames<A>, any, any, any>[] },
363
+ opts?: { steps?: readonly Step<StepNames<A>, any, any, any, any>[] },
311
364
  ): this {
312
365
  this[PIPELINE].failureHandlers.push({ step: handler as AnyStep, steps: opts?.steps ? [...(opts.steps as readonly AnyStep[])] : null });
313
366
  return this.next();
@@ -0,0 +1,77 @@
1
+ // Config references inside library-step definitions: collected (the step receives their entries),
2
+ // recorded in the manifest as `{"$config": "<entry>.<path>"}`, and resolved from ctx.config at run time.
3
+ import { configRefPath, configRefTarget, isConfigRef, type AnyConfigEntry, type ConfigRef } from "./config.ts";
4
+
5
+ /** `T` with every config reference replaced by the type of its value. */
6
+ export type Resolved<T> = T extends ConfigRef<infer U>
7
+ ? U
8
+ : T extends readonly (infer E)[]
9
+ ? Resolved<E>[]
10
+ : T extends (...args: never[]) => unknown
11
+ ? T
12
+ : T extends object
13
+ ? { [K in keyof T]: Resolved<T[K]> }
14
+ : T;
15
+
16
+ /** Manifest key of a recorded config reference. */
17
+ export const CONFIG_REF_KEY = "$config";
18
+ /** Beside it on a reference narrowed by a `select` case: the case's condition, `<ref>.<discriminator>=<value>`. */
19
+ export const CONFIG_CASE_KEY = "$case";
20
+
21
+ function isPlain(v: unknown): v is Record<string, unknown> {
22
+ if (typeof v !== "object" || v === null || Array.isArray(v)) return false;
23
+ const proto = Object.getPrototypeOf(v);
24
+ return proto === Object.prototype || proto === null;
25
+ }
26
+
27
+ /** Every config entry referenced anywhere in `v` (plain objects and arrays only), by name. */
28
+ export function configRefsIn(v: unknown, into = new Map<string, AnyConfigEntry>()): AnyConfigEntry[] {
29
+ if (isConfigRef(v)) {
30
+ const { entry } = configRefTarget(v);
31
+ if (!into.has(entry.name)) into.set(entry.name, entry);
32
+ } else if (Array.isArray(v)) for (const x of v) configRefsIn(x, into);
33
+ else if (isPlain(v)) for (const x of Object.values(v)) configRefsIn(x, into);
34
+ return [...into.values()];
35
+ }
36
+
37
+ /** `v` as manifest JSON: references become `{"$config": "target.beta.host"}`; literal keys starting with `$` are reserved. */
38
+ export function recordConfigRefs(v: unknown): unknown {
39
+ if (isConfigRef(v)) {
40
+ const { path, case: kase } = configRefTarget(v);
41
+ for (const seg of path) if (seg === "" || seg.includes(".")) throw new Error(`config reference '${configRefPath(v)}': field names must not be empty or contain '.'`);
42
+ return { [CONFIG_REF_KEY]: configRefPath(v), ...(kase ? { [CONFIG_CASE_KEY]: kase } : {}) };
43
+ }
44
+ if (Array.isArray(v)) return v.map(recordConfigRefs);
45
+ if (isPlain(v)) {
46
+ const out: Record<string, unknown> = {};
47
+ for (const [k, x] of Object.entries(v)) {
48
+ if (k.startsWith("$")) throw new Error(`key '${k}': keys starting with '$' are reserved`);
49
+ out[k] = recordConfigRefs(x);
50
+ }
51
+ return out;
52
+ }
53
+ return v;
54
+ }
55
+
56
+ /** `v` with every reference replaced by its value in `config` (the step's parsed config entries). */
57
+ export function resolveConfigRefs<T>(v: T, config: Readonly<Record<string, unknown>>): Resolved<T> {
58
+ if (isConfigRef(v)) {
59
+ const { entry, path } = configRefTarget(v);
60
+ if (!Object.hasOwn(config, entry.name)) throw new Error(`config entry '${entry.name}' was not delivered to the step`);
61
+ let cur: unknown = config[entry.name];
62
+ for (const seg of path) cur = cur !== null && typeof cur === "object" ? (cur as Record<string, unknown>)[seg] : undefined;
63
+ return cur as Resolved<T>;
64
+ }
65
+ if (Array.isArray(v)) return v.map((x) => resolveConfigRefs(x, config)) as Resolved<T>;
66
+ if (isPlain(v)) {
67
+ const out: Record<string, unknown> = {};
68
+ for (const [k, x] of Object.entries(v)) out[k] = resolveConfigRefs(x, config);
69
+ return out as Resolved<T>;
70
+ }
71
+ return v as Resolved<T>;
72
+ }
73
+
74
+ /** Literal `v` (no reference inside), so define-time checks can run on it. */
75
+ export function isLiteral(v: unknown): boolean {
76
+ return configRefsIn(v).length === 0;
77
+ }