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

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
@@ -15,7 +15,7 @@ sdk/
15
15
  src/index.ts authoring API (`@olivervea/pipelines`)
16
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, helmDeployInCluster, composeDeploy), their exported helpers (httpHealthCheck, registryTag, npmDistTag) 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
@@ -93,10 +93,6 @@ export const deployBeta = step({
93
93
  timeoutSeconds: 900, // optional
94
94
  retries: 1, // optional: a failed attempt is retried (fresh pod) up to n times
95
95
  });
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
96
  ```
101
97
 
102
98
  Rules:
@@ -139,37 +135,26 @@ target: { host: deploy@box }
139
135
  - Handlers see `ctx.config` with the output view (defaults applied); a discriminated union narrows as usual.
140
136
  - `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
137
  - 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.
138
+ - 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.
143
139
  - `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
140
 
145
- **`steps.script` contract** (for the script):
146
-
147
- | Name | What it holds |
148
- |---|---|
149
- | `$OLVE_INPUT` | Path to the validated input as JSON. |
150
- | `INPUT_<KEY>` | One variable per input key. `deployTo` becomes `INPUT_DEPLOY_TO`. Strings are passed raw; everything else is JSON. |
151
- | `$OLVE_OUTPUT` | Set only when the step has an output model. Write the output JSON to this path. |
152
-
153
- 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.
154
-
155
141
  ### Library steps
156
142
 
157
- 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.
143
+ 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.
158
144
 
159
145
  | Step | Image | Input → output | Config |
160
146
  |---|---|---|---|
161
- | `steps.script` | yours | yours | `script` (embedded), `env` (see above) |
162
147
  | `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? }`) |
148
+ | `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 with the `Dockerfile`; default the root), `push?` (push to `repository` instead of a tarball), `contentTag?` (with `push`: tag `src-<context hash>`, skip when the registry has it), `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) |
149
+ | `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? }`) |
165
150
  | `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
151
  | `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?` |
167
152
 
168
153
  - `gitHubSource` downloads `<apiUrl>/repos/<repo>/tarball/<sha>` (retrying 5xx), strips the top-level directory and outputs it as `tree`.
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.
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.
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>`.
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):
154
+ - `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.
155
+ - **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 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`). 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.
156
+ - **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>`.
157
+ - `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):
173
158
 
174
159
  ```ts
175
160
  steps.helmDeployOverSsh({ name: "deploy-beta", description: "", host: "oliver@bulwark-m2", sshKey: SSH_PRIVATE_KEY,
@@ -187,13 +172,33 @@ Library steps carry their own implementation and image; they take typed config a
187
172
  | `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. |
188
173
  | `serviceUrl(release)` | `http://<release>.<namespace>.svc.cluster.local` for a `HelmRelease` (charts that name the Service after the release). |
189
174
  | `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. |
190
- | `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. |
175
+ | `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. |
191
176
 
192
177
  ```ts
193
178
  import { httpHealthCheck, implement } from "@olivervea/pipelines";
194
179
  export default implement(smoke, async (_, ctx) => { await httpHealthCheck(ctx, "http://app.ns.svc.cluster.local/api/health", { attempts: 10 }); });
195
180
  ```
196
181
 
182
+ ### Reusable steps
183
+
184
+ A step several pipelines use is a function returning `implement(step(…), handler)`, staged directly: it carries its handler, so it has no `handlers/` file. The library steps are written this way, so a module in a repo or a package of its own works the same.
185
+
186
+ ```ts
187
+ import { implement, resolveConfigRefs, serviceUrl, step, HelmRelease, type Configurable, type LiteralName } from "@olivervea/pipelines";
188
+
189
+ export function smokeTest<const N extends string>(def: { name: LiteralName<N>; description: string; path: Configurable<string> }) {
190
+ return implement(
191
+ step({ name: def.name, description: def.description, kind: "smokeTest", image: "alpine:3.22", input: { release: HelmRelease }, settings: { path: def.path } }),
192
+ async ({ release }, ctx) => void (await ctx.http.expectOk(`${release.url ?? serviceUrl(release)}${resolveConfigRefs(def.path, ctx.config)}`)),
193
+ );
194
+ }
195
+ ```
196
+
197
+ - **`kind`**: a label, required for a step that carries its implementation (`custom` is a step with a handler file). Prefix a package's kinds (`acme/smokeTest`).
198
+ - **`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.
199
+ - **Identity**: `implement` and `step` record the module that called them (from the stack; a tail call can name a module further up, which only adds to what is hashed). The build bundles those modules, minus `pipeline.ts` and `models.ts`, like a handler, and the kind's hash (`kindHashes` in the generated `handlers.ts`) goes into each such step's `handlerHash`. A step that carries its implementation and has no other module (defined and implemented in a contract file) is a build error.
200
+ - `implement` refuses a step that already carries an implementation; a `handlers/*.ts` file for such a step is a describe error.
201
+
197
202
  ### Handlers
198
203
 
199
204
  ```ts
@@ -259,7 +264,7 @@ const r = await testHandler(deployBeta, { artifact }, { secrets: { SSH_PRIVATE_K
259
264
  expect(r).toMatchObject({ status: "succeeded", output: { release: "olve-pipelines" } }); // r.filesDir, r.logs, r.exitCode
260
265
  ```
261
266
 
262
- `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.
267
+ `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 handler module's default export, a library step or a reusable step.
263
268
 
264
269
  ## Harness contract (service ⇄ step binary)
265
270
 
@@ -369,7 +374,7 @@ Exact format: [`manifest.schema.json`](manifest.schema.json) (JSON Schema 2020-1
369
374
  - **`apiVersion`** is `"2.0"`.
370
375
  - **`harness`** is `{ bun, sdk, sdkHash }`.
371
376
  - **`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.
377
+ - **Step `kind`** is `custom` (a handler file) or the kind of a step that carries its implementation (`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.
373
378
  - `output` is the `z.output` view, with `additionalProperties: false` on every object. It validates outputs, the pool and trigger results.
374
379
  - `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`.
375
380
  - **Step `input`** maps each input key to a mapping:
@@ -381,7 +386,7 @@ Exact format: [`manifest.schema.json`](manifest.schema.json) (JSON Schema 2020-1
381
386
  - **`inputTypes`** declares each input key: `{model, optional}`, or `{schema: {output, input?}}` for a plain schema.
382
387
  - **`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.
383
388
  - 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.
384
- - Library steps: `sha256(canonical {library: kind, config, harness})`.
389
+ - Steps that carry their implementation: `sha256(canonical {library: kind, config, harness, code})`, `code` being the hash of the modules that define and implement steps of that kind (see [Reusable steps](#reusable-steps)).
385
390
  - Because `harness` is part of both hashes, a Bun or SDK bump changes every handler.
386
391
  - **`stages`** is an array whose items are either `{steps: [Step…]}` (parallel, with a barrier) or `{when: {field: [values]}, stages: [...]}`.
387
392
  - **`triggers`**:
@@ -391,13 +396,13 @@ Exact format: [`manifest.schema.json`](manifest.schema.json) (JSON Schema 2020-1
391
396
  - **`failureHandlers`** is a list of `{step: Step, steps: [names] | null}`. Each handler's inputs are all FailureContext (`{"ref": "failure"}`).
392
397
  - **`secrets`** lists every secret used by steps, polls and webhooks, sorted, as `{name, description}`.
393
398
  - **`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.
399
+ - **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`.
395
400
 
396
401
  Describe runs these checks; reconcile should re-run the manifest-level ones:
397
402
 
398
403
  - step name format and reserved names (`input`, `failure`), and duplicate step names;
399
404
  - one schema per model name, and JSON-representable models;
400
- - missing handler file, and a handler file without a step (or for a library step);
405
+ - missing handler file, a handler file without a step (or for a step that carries its implementation), and a step that carries its implementation without a kind;
401
406
  - availability, ambiguity, maybe-available as required, `optional` without a producer, plain schema without a mapper;
402
407
  - mapper source analysis and recording, unknown fields in refs, mapper keys that are missing or extra;
403
408
  - `when` on an unknown or non-literal field, or with an invalid value;
@@ -413,7 +418,7 @@ olve-build <pipelineDir> --out <dir> [--target bun-linux-x64|bun-linux-x64-musl]
413
418
  olve-build <pipelineDir> --hash-only # print handler hashes (diagnostics)
414
419
  ```
415
420
 
416
- 1. Pair each `handlers/*.ts` with the step its `implement(step, …)` names, and hash it (see `handlerHash`).
421
+ 1. Pair each `handlers/*.ts` with the step its `implement(step, …)` names, and hash it (see `handlerHash`); hash the modules of each kind of step that carries its implementation.
417
422
  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`.
418
423
  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.
419
424
  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.
@@ -382,7 +382,7 @@
382
382
  "additionalProperties": {
383
383
  "$ref": "#/$defs/Json"
384
384
  },
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.",
385
+ "description": "The step's settings (display only; already covered by handlerHash). A config reference is recorded as {\"$config\": \"<entry>.<field>…\"}; the entry is in configEntries.",
386
386
  "propertyNames": {
387
387
  "type": "string"
388
388
  },
@@ -402,7 +402,7 @@
402
402
  "type": "string"
403
403
  },
404
404
  "handlerHash": {
405
- "description": "Implementation identity: handler bundle (contract files excluded) + harness (bun + sdkHash); for library steps kind + config + harness.",
405
+ "description": "Implementation identity: handler bundle (contract files excluded) + harness (bun + sdkHash); for a step that carries its implementation kind + config + harness + the hash of the code of its kind.",
406
406
  "pattern": "^sha256:[0-9a-f]{64}$",
407
407
  "type": "string"
408
408
  },
@@ -432,7 +432,7 @@
432
432
  "type": "object"
433
433
  },
434
434
  "kind": {
435
- "description": "'custom' = handlers/<name>.ts; others are SDK library steps. A label: the service accepts any kind.",
435
+ "description": "'custom' = the handler is a handlers/*.ts file; any other kind is a step that carries its implementation (an SDK or package step). A label: the service accepts any kind.",
436
436
  "minLength": 1,
437
437
  "type": "string"
438
438
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olivervea/pipelines",
3
- "version": "2.0.0-beta.7",
3
+ "version": "2.0.0-beta.9",
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",
@@ -7,8 +7,8 @@ import type { HarnessInfo } from "../util/handler-hash.ts";
7
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
- import { handlerHashes, type HandlerHash } from "./hashes.ts";
11
- import { harnessInfo, loadProject, relPosix, type Project } from "./project.ts";
10
+ import { handlerHashes, kindHashes, type HandlerHash } from "./hashes.ts";
11
+ import { harnessInfo, loadPipeline, loadProject, relPosix, type Project } from "./project.ts";
12
12
 
13
13
  export const BUILD_FLAGS = ["--no-compile-autoload-dotenv", "--no-compile-autoload-bunfig", "--bytecode", "--format=esm"] as const;
14
14
  export type Target = "bun-linux-x64" | "bun-linux-x64-musl";
@@ -41,7 +41,7 @@ export interface BuildResult {
41
41
  }
42
42
 
43
43
  /** Writes `<dir>/.olve/gen/{handlers,entry}.ts`: the literal-import handler registry + binary entry. */
44
- export function generateEntry(project: Project, hashes: Record<string, string>, harness: HarnessInfo): string {
44
+ export function generateEntry(project: Project, hashes: Record<string, string>, harness: HarnessInfo, kinds: Record<string, string> = {}): string {
45
45
  const gen = join(project.dir, ".olve", "gen");
46
46
  mkdirSync(gen, { recursive: true });
47
47
  const registry = project.handlers.map(([name, file]) => ` ${JSON.stringify(name)}: () => import(${JSON.stringify(relPosix(gen, file))}),`).join("\n");
@@ -51,6 +51,7 @@ export function generateEntry(project: Project, hashes: Record<string, string>,
51
51
  `import type { HandlerRegistry, HarnessInfo } from "@olivervea/pipelines/harness";\n\n` +
52
52
  `export const handlers: HandlerRegistry = {\n${registry}\n};\n\n` +
53
53
  `export const handlerHashes: Record<string, string> = ${JSON.stringify(hashes, null, 2)};\n\n` +
54
+ `export const kindHashes: Record<string, string> = ${JSON.stringify(kinds, null, 2)};\n\n` +
54
55
  `export const harness: HarnessInfo = ${JSON.stringify(harness, null, 2)};\n`,
55
56
  );
56
57
  const entry = join(gen, "entry.ts");
@@ -59,8 +60,8 @@ export function generateEntry(project: Project, hashes: Record<string, string>,
59
60
  `// GENERATED by olve-build — do not edit. Entry point of the step binary.\n` +
60
61
  `import pipeline from ${JSON.stringify(relPosix(gen, project.pipelineFile))};\n` +
61
62
  `import { runHarness } from "@olivervea/pipelines/harness";\n` +
62
- `import { handlers, handlerHashes, harness } from "./handlers.ts";\n\n` +
63
- `process.exit(await runHarness({ pipeline, handlers, handlerHashes, harness }, process.argv.slice(2)));\n`,
63
+ `import { handlers, handlerHashes, kindHashes, harness } from "./handlers.ts";\n\n` +
64
+ `process.exit(await runHarness({ pipeline, handlers, handlerHashes, kindHashes, harness }, process.argv.slice(2)));\n`,
64
65
  );
65
66
  return entry;
66
67
  }
@@ -76,7 +77,8 @@ export async function build(dir: string, opts: BuildOptions): Promise<BuildResul
76
77
 
77
78
  const t0 = performance.now();
78
79
  const hashes = await handlerHashes(project, harness);
79
- const entry = generateEntry(project, Object.fromEntries(Object.entries(hashes).map(([k, v]) => [k, v.handlerHash])), harness);
80
+ const kinds = await kindHashes(project, await loadPipeline(project));
81
+ const entry = generateEntry(project, Object.fromEntries(Object.entries(hashes).map(([k, v]) => [k, v.handlerHash])), harness, kinds);
80
82
  log(`olve-build: ${project.handlers.length} handler(s) hashed in ${((performance.now() - t0) / 1000).toFixed(2)}s`);
81
83
 
82
84
  // 1. compile
@@ -112,7 +114,7 @@ export async function build(dir: string, opts: BuildOptions): Promise<BuildResul
112
114
  await writeTarZst(archive, [{ path: "step", type: "file", mode: 0o755, source: binary }]);
113
115
  writeFileSync(
114
116
  join(out, "build.json"),
115
- canonicalJson({ target, flags: BUILD_FLAGS, harness, handlers: hashes, binarySha256: sha256File(binary), archiveSha256: sha256File(archive) }),
117
+ canonicalJson({ target, flags: BUILD_FLAGS, harness, handlers: hashes, kinds, binarySha256: sha256File(binary), archiveSha256: sha256File(archive) }),
116
118
  );
117
119
  log(`olve-build: wrote ${archive}`);
118
120
 
@@ -2,8 +2,12 @@
2
2
  // (tree-shaken → function-level granularity; embedded scripts are bundle outputs too), then hash the
3
3
  // outputs. Contract changes are versioned through the manifest's model/step entries, SDK changes
4
4
  // through `harness`; both are combined in composeHandlerHash.
5
- import { dirname, join, resolve } from "node:path";
5
+ import { dirname, join, relative, resolve } from "node:path";
6
6
  import type { BunPlugin } from "bun";
7
+ import { isImplementation } from "../core/handlers.ts";
8
+ import { originsOf } from "../core/origin.ts";
9
+ import { pipelineData } from "../builder/pipeline.ts";
10
+ import { allSteps } from "../harness/main.ts";
7
11
  import { composeHandlerHash, type HarnessInfo } from "../util/handler-hash.ts";
8
12
  import { sha256Hex } from "../util/hash.ts";
9
13
  import type { Project } from "./project.ts";
@@ -33,11 +37,12 @@ function externals(project: Project): BunPlugin {
33
37
  }
34
38
 
35
39
  export async function bundleHash(project: Project, entry: string): Promise<{ hash: string; outputs: string[] }> {
40
+ const inside = !relative(project.dir, entry).startsWith("..");
36
41
  const r = await Bun.build({
37
42
  entrypoints: [entry],
38
43
  target: "bun",
39
44
  format: "esm",
40
- root: project.dir,
45
+ root: inside ? project.dir : dirname(entry),
41
46
  naming: { entry: "[dir]/[name].[ext]", chunk: "[dir]/[name].[ext]", asset: "[dir]/[name].[ext]" },
42
47
  plugins: [externals(project)],
43
48
  throw: false,
@@ -65,3 +70,27 @@ export async function handlerHashes(project: Project, harness: HarnessInfo): Pro
65
70
  }
66
71
  return out;
67
72
  }
73
+
74
+ /**
75
+ * Kind → hash of the modules that implement the steps of that kind which carry their implementation
76
+ * (each module bundled like a handler: the SDK and contract files external).
77
+ */
78
+ export async function kindHashes(project: Project, pipeline: unknown): Promise<Record<string, string>> {
79
+ const origins = new Map<string, Set<string>>();
80
+ for (const s of allSteps(pipelineData(pipeline))) {
81
+ if (!isImplementation(s)) continue;
82
+ const files = originsOf(s).filter((f) => !project.contractFiles.includes(f));
83
+ if (files.length === 0)
84
+ throw new Error(`step '${s.name}' carries its implementation but is defined in ${project.contractFiles.map((f) => relative(project.dir, f)).join(" or ")}; define and implement it in a module of its own (or a package)`);
85
+ const set = origins.get(s.kind) ?? new Set<string>();
86
+ for (const f of files) set.add(f);
87
+ origins.set(s.kind, set);
88
+ }
89
+ const out: Record<string, string> = {};
90
+ for (const [kind, files] of [...origins].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) {
91
+ const parts: string[] = [];
92
+ for (const f of [...files].sort()) parts.push((await bundleHash(project, f)).hash);
93
+ out[kind] = "sha256:" + sha256Hex(parts.join("\n"));
94
+ }
95
+ return out;
96
+ }
@@ -35,6 +35,13 @@ export async function loadProject(dirArg: string): Promise<Project> {
35
35
  return { dir, pipelineFile, contractFiles, handlers: await resolveHandlers(hdir, files) };
36
36
  }
37
37
 
38
+ /** The default export of pipeline.ts. */
39
+ export async function loadPipeline(project: Project): Promise<unknown> {
40
+ const mod = (await import(project.pipelineFile)) as { default?: unknown };
41
+ if (!mod.default) throw new ProjectError(`${project.pipelineFile} has no default export (export default pipeline({...})...)`);
42
+ return mod.default;
43
+ }
44
+
38
45
  /** Pairs each handler file with the step its default export implements (file names don't matter). */
39
46
  async function resolveHandlers(hdir: string, files: string[]): Promise<[string, string][]> {
40
47
  const byStep = new Map<string, string>();
@@ -2,6 +2,7 @@
2
2
  // a dev runner and the user's installed package — still recognise each other's objects.
3
3
  export const STEP = Symbol.for("olve.pipelines.step");
4
4
  export const IMPLEMENTATION = Symbol.for("olve.pipelines.implementation");
5
+ export const ORIGIN = Symbol.for("olve.pipelines.origin");
5
6
  export const SKIPPED = Symbol.for("olve.pipelines.skipped");
6
7
  export const PIPELINE = Symbol.for("olve.pipelines.pipeline");
7
8
  export const REF_PATH = Symbol.for("olve.pipelines.refPath");
@@ -11,3 +12,4 @@ export declare const ModelBrand: unique symbol;
11
12
  export declare const RefBrand: unique symbol;
12
13
  export declare const StepBrand: unique symbol;
13
14
  export declare const SkippedBrand: unique symbol;
15
+ export declare const ImplementationBrand: unique symbol;
@@ -12,7 +12,7 @@ interface RefTarget {
12
12
  }
13
13
 
14
14
  /**
15
- * A typed reference to a config value (`TARGET.beta.host`), accepted by library steps in place of a
15
+ * A typed reference to a config value (`TARGET.beta.host`), accepted in a step's settings in place of a
16
16
  * literal. It is recorded in the manifest as `{"$config": "target.beta.host"}` and resolved from the
17
17
  * run's config when the step runs; the step receives the entry automatically.
18
18
  */
@@ -1,6 +1,7 @@
1
- import { IMPLEMENTATION, SKIPPED, type SkippedBrand } from "./brands.ts";
1
+ import { IMPLEMENTATION, ORIGIN, SKIPPED, type ImplementationBrand, type SkippedBrand } from "./brands.ts";
2
2
  import type { Ctx } from "../ctx/types.ts";
3
- import type { AnyStep, ConfigOf, LibraryStep, OutputValue, SecretsOf, StepInput } from "./steps.ts";
3
+ import { callerFile, originsOf } from "./origin.ts";
4
+ import type { AnyStep, ConfigOf, OutputValue, SecretsOf, StepInput } from "./steps.ts";
4
5
  import type { Awaitable } from "./types.ts";
5
6
 
6
7
  /** Returned by `ctx.skip(reason)`: the step produced no output on purpose. */
@@ -18,10 +19,13 @@ export function isSkipped(v: unknown): v is Skipped {
18
19
  export type HandlerResult<S extends AnyStep> = OutputValue<S> | Skipped;
19
20
  export type Handler<S extends AnyStep> = (input: StepInput<S["input"]>, ctx: Ctx<SecretsOf<S>, ConfigOf<S>>) => Awaitable<HandlerResult<S>>;
20
21
 
21
- export interface Implementation<S extends AnyStep = AnyStep> {
22
+ /** A step with its handler. It is itself a step: stage it directly, or default-export it from `handlers/*.ts`. */
23
+ export type Implementation<S extends AnyStep = AnyStep> = S & {
24
+ readonly [ImplementationBrand]: true;
25
+ /** The step as defined. */
22
26
  readonly step: S;
23
27
  readonly handler: Handler<S>;
24
- }
28
+ };
25
29
 
26
30
  /** Block-bodied/async returns get no excess-property check; this restores it for handler outputs. */
27
31
  type NoExtraFields<R, T> = R extends Skipped | void
@@ -29,14 +33,16 @@ type NoExtraFields<R, T> = R extends Skipped | void
29
33
  : { [K in Exclude<keyof R, keyof T>]: `❌ '${K & string}' is not a field of the output model` };
30
34
 
31
35
  /**
32
- * Implement a custom step. Input, ctx and return types come from the step definition alone.
33
- * Default-export the result from `handlers/<step-name>.ts`.
36
+ * Implement a step. Input, ctx and return types come from the step definition alone. Default-export
37
+ * the result from a `handlers/*.ts` file, or stage it directly (a reusable step: give it a `kind`).
34
38
  */
35
39
  export function implement<S extends AnyStep, R extends HandlerResult<S>>(
36
- step: S & (S extends LibraryStep ? "❌ library steps already carry their implementation" : unknown),
40
+ step: S & (S extends { readonly [ImplementationBrand]: true } ? "❌ this step already carries its implementation" : unknown),
37
41
  handler: (input: StepInput<S["input"]>, ctx: Ctx<SecretsOf<S>, ConfigOf<S>>) => Awaitable<R & NoExtraFields<R, OutputValue<S>>>,
38
42
  ): Implementation<S> {
39
- return Object.freeze({ [IMPLEMENTATION]: true, step, handler: handler as unknown as Handler<S> }) as Implementation<S>;
43
+ if (isImplementation(step)) throw new TypeError(`step '${(step as AnyStep).name}' already carries its implementation`);
44
+ const origins = [...new Set([...originsOf(step), callerFile()].filter((f): f is string => f !== undefined))];
45
+ return Object.freeze({ ...step, [IMPLEMENTATION]: true, [ORIGIN]: origins, step, handler: handler as unknown as Handler<S> }) as unknown as Implementation<S>;
40
46
  }
41
47
 
42
48
  export function isImplementation(v: unknown): v is Implementation {
@@ -0,0 +1,20 @@
1
+ import { ORIGIN } from "./brands.ts";
2
+
3
+ /**
4
+ * The file that called the function calling this one, from the stack. A tail call drops its caller's
5
+ * frame, so this can name a module further up; the build only uses it to choose what to hash.
6
+ */
7
+ export function callerFile(): string | undefined {
8
+ const files = (new Error().stack ?? "").split("\n").slice(1).map((f) => f.match(FRAME)?.[1]);
9
+ const own = files[0];
10
+ const callee = files[1];
11
+ const file = files.slice(2).find((f) => f && f !== own && f !== callee) ?? undefined;
12
+ return file?.startsWith("file://") ? new URL(file).pathname : file;
13
+ }
14
+ const FRAME = /\(?((?:file:\/\/)?\/[^()]*?):\d+(?::\d+)?\)?\s*$/;
15
+
16
+ /** The modules that defined and implemented a step (where `step` and `implement` were called). */
17
+ export function originsOf(v: unknown): string[] {
18
+ const o = (v as Record<symbol, unknown>)[ORIGIN];
19
+ return Array.isArray(o) ? o.filter((x): x is string => typeof x === "string") : [];
20
+ }
package/src/core/steps.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { z } from "zod";
2
- import { STEP, type StepBrand } from "./brands.ts";
2
+ import { ORIGIN, STEP, type StepBrand } from "./brands.ts";
3
+ import { callerFile } from "./origin.ts";
3
4
  import type { AnyModel, Infer, Optional } from "./models.ts";
4
5
  import type { AnyConfigEntry } from "./config.ts";
5
6
  import { configRefsIn } from "./config-refs.ts";
@@ -18,8 +19,12 @@ export type InferEntry<E> =
18
19
  /** What a handler receives: the output view of every input entry. */
19
20
  export type StepInput<I> = Simplify<{ -readonly [K in keyof I]: InferEntry<I[K]> }>;
20
21
 
21
- /** `custom` steps have a handler file; the others are library steps implemented by the SDK. */
22
- export type StepKind = "custom" | "script" | "gitHubSource" | "imageBuild" | "helmDeployOverSsh" | "helmDeployInCluster" | "composeDeploy";
22
+ /**
23
+ * A label for what a step is, shown in the UI. `custom` (the default) is a step whose handler is in
24
+ * `handlers/`; a step that carries its implementation (`implement(step, …)` staged directly) names its
25
+ * kind, e.g. `gitHubSource`. Packages prefix theirs (`acme/deploy`).
26
+ */
27
+ export type StepKind = string;
23
28
 
24
29
  export interface Step<
25
30
  N extends string = string,
@@ -42,17 +47,9 @@ export interface Step<
42
47
  readonly retries?: number;
43
48
  /** The step pod gets the instance's step ServiceAccount token (`Kubernetes:StepServiceAccount`). */
44
49
  readonly clusterAccess?: boolean;
45
- /** Library steps carry their own implementation and config. */
46
- readonly builtin?: LibraryImplementation;
50
+ /** How the step is set up, as written in the definition (may hold config references). */
51
+ readonly settings?: Readonly<Record<string, unknown>>;
47
52
  }
48
- /** Runtime part of a library step (not part of the public contract). */
49
- export interface LibraryImplementation {
50
- /** Config as emitted in the manifest (JSON only; must not contain paths). */
51
- readonly manifestConfig: () => Record<string, unknown>;
52
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
53
- readonly run: (input: any, ctx: any) => Promise<unknown>;
54
- }
55
- export type LibraryStep = { readonly builtin: LibraryImplementation };
56
53
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
57
54
  export type AnyStep = Step<string, any, AnyModel | null, any, any>;
58
55
 
@@ -89,16 +86,23 @@ export interface StepDef<N extends string, I extends InputSpec, O extends AnyMod
89
86
  * (`Kubernetes:StepServiceAccount`), if it grants one. Default false (no token).
90
87
  */
91
88
  clusterAccess?: boolean;
89
+ /** A label for the step's kind (default `custom`). */
90
+ kind?: StepKind;
91
+ /**
92
+ * How the step is set up, shown in the manifest and part of the step's identity: the parameters of
93
+ * a reusable step. JSON values and config references (`target.prod.host`); every config entry it
94
+ * references is delivered to the step, and `resolveConfigRefs(value, ctx.config)` resolves them.
95
+ */
96
+ settings?: Readonly<Record<string, unknown>>;
92
97
  }
93
98
 
94
- /** `configFrom`: a library step's definition; the config entries it references are added to the step's `config`. */
95
- export function makeStep(kind: StepKind, def: Record<string, unknown>, builtin?: LibraryImplementation, configFrom?: unknown): AnyStep {
99
+ function makeStep(def: Record<string, unknown>, origin: string | undefined): AnyStep {
96
100
  const config = new Map<string, AnyConfigEntry>();
97
101
  for (const e of (def.config as readonly AnyConfigEntry[] | undefined) ?? []) if (!config.has(e.name)) config.set(e.name, e);
98
- configRefsIn(configFrom, config);
102
+ configRefsIn(def.settings, config);
99
103
  const s = {
100
104
  [STEP]: true,
101
- kind,
105
+ kind: def.kind ?? "custom",
102
106
  name: def.name,
103
107
  description: def.description,
104
108
  image: def.image,
@@ -109,7 +113,8 @@ export function makeStep(kind: StepKind, def: Record<string, unknown>, builtin?:
109
113
  ...(def.timeoutSeconds !== undefined ? { timeoutSeconds: def.timeoutSeconds } : {}),
110
114
  ...(def.retries !== undefined ? { retries: def.retries } : {}),
111
115
  ...(def.clusterAccess === true ? { clusterAccess: true } : {}),
112
- ...(builtin ? { builtin } : {}),
116
+ ...(def.settings !== undefined ? { settings: def.settings } : {}),
117
+ [ORIGIN]: origin === undefined ? [] : [origin],
113
118
  };
114
119
  return Object.freeze(s) as unknown as AnyStep;
115
120
  }
@@ -118,7 +123,10 @@ export function isStep(v: unknown): v is AnyStep {
118
123
  return typeof v === "object" && v !== null && (v as Record<symbol, unknown>)[STEP] === true;
119
124
  }
120
125
 
121
- /** Define a custom step: a contract (name, image, inputs, output, secrets). Its handler lives in `handlers/<name>.ts`. */
126
+ /**
127
+ * Define a step: a contract (name, image, inputs, output, secrets). Its handler is a `handlers/*.ts`
128
+ * default-exporting `implement(step, …)`, or, for a reusable step, `implement(step, …)` staged directly.
129
+ */
122
130
  export function step<
123
131
  const N extends string,
124
132
  const I extends InputSpec = {},
@@ -126,5 +134,5 @@ export function step<
126
134
  const Sec extends readonly Secret[] = [],
127
135
  const Cfg extends readonly AnyConfigEntry[] = [],
128
136
  >(def: StepDef<N, I, O, Sec, Cfg>): Step<N, I, O, Sec[number]["name"], Cfg[number]> {
129
- return makeStep("custom", def as never) as never;
137
+ return makeStep(def as never, callerFile()) as never;
130
138
  }