@olivervea/pipelines 0.0.0-stage → 2.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +407 -2
  3. package/manifest.schema.json +524 -0
  4. package/package.json +60 -4
  5. package/src/assets.d.ts +10 -0
  6. package/src/build/build.ts +125 -0
  7. package/src/build/cli.ts +52 -0
  8. package/src/build/elf.ts +35 -0
  9. package/src/build/hashes.ts +67 -0
  10. package/src/build/project.ts +79 -0
  11. package/src/builder/pipeline.ts +291 -0
  12. package/src/builder/refs.ts +26 -0
  13. package/src/core/brands.ts +13 -0
  14. package/src/core/handlers.ts +44 -0
  15. package/src/core/models.ts +69 -0
  16. package/src/core/secrets.ts +13 -0
  17. package/src/core/steps.ts +107 -0
  18. package/src/core/types.ts +6 -0
  19. package/src/ctx/files.ts +161 -0
  20. package/src/ctx/index.ts +119 -0
  21. package/src/ctx/sh.ts +196 -0
  22. package/src/ctx/types.ts +113 -0
  23. package/src/describe/describe.ts +379 -0
  24. package/src/describe/manifest-schema.ts +131 -0
  25. package/src/describe/mapper-source.ts +238 -0
  26. package/src/describe/record.ts +107 -0
  27. package/src/dev/cli.ts +80 -0
  28. package/src/harness/env.ts +58 -0
  29. package/src/harness/execute.ts +94 -0
  30. package/src/harness/exit.ts +19 -0
  31. package/src/harness/index.ts +9 -0
  32. package/src/harness/main.ts +219 -0
  33. package/src/harness/result.ts +75 -0
  34. package/src/harness/validate.ts +74 -0
  35. package/src/index.ts +11 -0
  36. package/src/library/github-source.ts +77 -0
  37. package/src/library/helm-deploy-ssh.ts +151 -0
  38. package/src/library/http-health.ts +34 -0
  39. package/src/library/image-build.ts +134 -0
  40. package/src/library/index.ts +14 -0
  41. package/src/library/models.ts +63 -0
  42. package/src/library/script.ts +85 -0
  43. package/src/testing/index.ts +86 -0
  44. package/src/util/canonical.ts +19 -0
  45. package/src/util/handler-hash.ts +22 -0
  46. package/src/util/hash.ts +22 -0
  47. package/src/util/tar.ts +154 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Oliver Vea
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,408 @@
1
- # Temporary Holding Version
1
+ # @olivervea/pipelines
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The [Olve.Pipelines](https://github.com/OliverVea/Olve.Pipelines) v2 SDK, imported as `@olve/pipelines`. It has four parts:
4
+
5
+ - the **authoring API**: models, steps, handlers and the typed pipeline builder;
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`;
8
+ - the **dev runner** (`olve-dev`), which runs the same harness path from source (used by `pl dev`).
9
+
10
+ Design: [`docs/design/pipelines-v2.md`](https://github.com/OliverVea/Olve.Pipelines/blob/v2/docs/design/pipelines-v2.md). Spike findings: [`pipelines-v2-spikes.md`](https://github.com/OliverVea/Olve.Pipelines/blob/v2/docs/design/pipelines-v2-spikes.md).
11
+ The manifest format is in [`manifest.schema.json`](manifest.schema.json), generated from [`src/describe/manifest-schema.ts`](src/describe/manifest-schema.ts).
12
+
13
+ ```
14
+ sdk/
15
+ src/index.ts authoring API (`@olve/pipelines`)
16
+ src/core/ models, secrets, steps, implement()
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
19
+ src/describe/ describe: manifest, checks, mapper recording + source analysis, manifest schema
20
+ src/harness/ harness CLI modes, env contract, envelope, result upload (`@olve/pipelines/harness`)
21
+ src/ctx/ the ctx handed to handlers
22
+ src/testing/ testHandler (`@olve/pipelines/testing`)
23
+ src/build/ olve-build
24
+ src/dev/ olve-dev
25
+ examples/self-deploy/ fixture pipeline (the self-deploy shape) used by the tests
26
+ tests/compile-fail/ type errors that must happen (checked by scripts/check-compile-fail.ts)
27
+ tests/compile-pass/ type-level assertions that must hold
28
+ tests/runtime/ bun tests
29
+ ```
30
+
31
+ ## Install
32
+
33
+ The package is `@olivervea/pipelines` on npm; pipelines import it as `@olve/pipelines` through an alias
34
+ in the pipeline directory's `package.json`:
35
+
36
+ ```json
37
+ {
38
+ "type": "module",
39
+ "dependencies": { "@olve/pipelines": "npm:@olivervea/pipelines@2.0.0-beta.1" },
40
+ "devDependencies": { "@types/bun": "1.4.3", "typescript": "7.0.2" }
41
+ }
42
+ ```
43
+
44
+ ```sh
45
+ bun install # commit bun.lock: the build Job installs with --frozen-lockfile
46
+ bunx olve-dev describe .
47
+ bunx olve-build . --out .olve/out
48
+ ```
49
+
50
+ - Keep the alias name: `olve-build` generates imports of `@olve/pipelines/harness`, so an unaliased
51
+ `@olivervea/pipelines` dependency does not build.
52
+ - The package ships TypeScript sources (Bun runs them directly; `harness.sdkHash` hashes them), so the
53
+ same version gives the same `sdkHash` and handler hashes from npm or from `file:../sdk`.
54
+ - Inside this repo, `.pipelines/` and `.pipelines-test/` use `"@olve/pipelines": "file:../sdk"`.
55
+
56
+ ### Releases
57
+
58
+ Versions are `2.0.0-beta.N` on the `beta` dist-tag (`publishConfig`) until v2 reaches prod, then
59
+ `latest`. To release, bump `version` in `package.json` and push to `v2`: the self-deploy's
60
+ `publish-sdk` step runs `bun run check` and `bun publish` when that version is not on npm yet. It
61
+ skips when the version exists, or when the pipeline secret `NPM_TOKEN` is unset.
62
+
63
+ ## Authoring
64
+
65
+ A pipeline directory (the binding path, `.pipelines/` by default) looks like this:
66
+
67
+ ```
68
+ models.ts data models (contract file)
69
+ pipeline.ts steps + `export default pipeline(…)` (contract file)
70
+ handlers/<step-name>.ts one per custom step: `export default implement(step, fn)`
71
+ handlers/lib/… shared helpers (not handlers)
72
+ scripts/*.sh embedded with `import s from "../scripts/x.sh" with { type: "file" }`
73
+ ```
74
+
75
+ Contract files hold contracts only. Describe evaluates them without loading handlers, and they are kept out of every handler's hash.
76
+
77
+ ### Models, secrets, steps
78
+
79
+ ```ts
80
+ import { model, z, fileRef, optional, secret, step, steps, Source } from "@olve/pipelines";
81
+
82
+ export const BuildArtifact = model("BuildArtifact", z.object({
83
+ version: z.string(),
84
+ image: fileRef(), // a file in some step attempt's output
85
+ chart: fileRef(), // a directory (path ends with "/")
86
+ }));
87
+ export const SSH_PRIVATE_KEY = secret("SSH_PRIVATE_KEY", "SSH key for the host.");
88
+
89
+ export const deployBeta = step({
90
+ name: "deploy-beta", // identity: lower-case kebab, ≤ 63 chars
91
+ description: "Deploys to beta.", // required (may be ""); shown in the UI, not versioned
92
+ image: "alpine:latest", // any image; the harness brings its own runtime
93
+ input: { artifact: BuildArtifact }, // model | optional(model) | plain zod schema
94
+ output: BetaDeployment, // a model, or omitted (no output)
95
+ secrets: [SSH_PRIVATE_KEY], // only these are mounted; ctx.secrets is typed to them
96
+ timeoutSeconds: 900, // optional
97
+ retries: 1, // optional: a failed attempt is retried (fresh pod) up to n times
98
+ });
99
+
100
+ import testSh from "./scripts/test.sh" with { type: "file" };
101
+ export const test = steps.script({ name: "test", description: "Runs the tests.", image: "…", script: testSh, input: { source: Source }, output: TestReport,
102
+ env: { TAG: ({ source }, run) => `${run.version}-${source.sha.slice(0, 7)}` } }); // env values may be functions of the inputs
103
+ ```
104
+
105
+ Rules:
106
+
107
+ - Models are **nominal**: two models with the same shape are not interchangeable.
108
+ - There is **one schema per model name**. Two different schemas registered under the same name is a describe error.
109
+ - **Outputs are validated strictly**: unknown keys are rejected at any depth, not stripped.
110
+ - **Trigger mappers** produce `z.input`, so fields with defaults may be left out. **Handlers, mapper refs and `when`** see `z.output`.
111
+ - A **plain-schema input** has no model to match on, so the step needs a mapper.
112
+ - **`optional(M)`** means "maybe skipped": `M` must still have an earlier producer.
113
+
114
+ **`steps.script` contract** (for the script):
115
+
116
+ | Name | What it holds |
117
+ |---|---|
118
+ | `$OLVE_INPUT` | Path to the validated input as JSON. |
119
+ | `INPUT_<KEY>` | One variable per input key. `deployTo` becomes `INPUT_DEPLOY_TO`. Strings are passed raw; everything else is JSON. |
120
+ | `$OLVE_OUTPUT` | Set only when the step has an output model. Write the output JSON to this path. |
121
+
122
+ 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.
123
+
124
+ ### Library steps
125
+
126
+ Library steps carry their own implementation and image; they take typed config and use the SDK models `Source`, `BuiltImage` and `HelmRelease`. The set grows from real use: a step or an option exists because a pipeline uses it.
127
+
128
+ | Step | Image | Input → output | Config |
129
+ |---|---|---|---|
130
+ | `steps.script` | yours | yours | `script` (embedded), `env` (see above) |
131
+ | `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) |
132
+ | `steps.imageBuild` | `gcr.io/kaniko-project/executor:debug` | `{ source: Source }` → `BuiltImage { repository, tag, digest, tar }` | `repository` (image name; tag = run version, `-attempt<n>` on reruns), `cacheRepo?` (kaniko layer cache), `registryMap?` (kaniko `--registry-map`: registry → mirror prefix, falls back upstream), `stallTimeoutSeconds?` (default 300, 0 = off), `retries?` (default 2) |
133
+ | `steps.helmDeployOverSsh` | `alpine:3.22` (+ `apk add openssh-client` at run time) | `{ source: Source, image: BuiltImage }` → `HelmRelease { release, namespace, image }` | `host` (`user@host`), `sshKey: Secret`, `release`, `namespace`, `chart` (dir in the source), `values?` (chart-relative `-f`), `set?`, `healthUrl?` |
134
+
135
+ - `gitHubSource` downloads `https://api.github.com/repos/<repo>/tarball/<sha>` (retrying 5xx), strips the top-level directory and outputs it as `tree`.
136
+ - `imageBuild` copies the tree to `/kaniko/build-context` (kaniko wipes other directories between multi-stage builds), builds `Dockerfile` at the tree root with `--no-push --tar-path --digest-file`, and outputs the tarball. `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.
137
+ - `helmDeployOverSsh` streams the image tar into the host's **k3s** containerd (`nerdctl --address /run/k3s/containerd/containerd.sock --namespace k8s.io load`), copies the chart to `/tmp/<release>-helm-<namespace>`, runs `helm upgrade --install` from inside it (values, then `set`, then `image.repository`/`image.tag`/`image.pullPolicy=Never`, which always win), waits for `kubectl rollout status deploy/<release>`, and with `healthUrl` polls it (5 × 5 s) from the harness.
138
+
139
+ **Helpers.** General functions the library steps are built on, exported for custom handlers:
140
+
141
+ | Helper | What it does |
142
+ |---|---|
143
+ | `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. |
144
+
145
+ ```ts
146
+ import { httpHealthCheck, implement } from "@olve/pipelines";
147
+ export default implement(smoke, async (_, ctx) => { await httpHealthCheck(ctx, "http://app.ns.svc.cluster.local/api/health", { attempts: 10 }); });
148
+ ```
149
+
150
+ ### Handlers
151
+
152
+ ```ts
153
+ // handlers/deploy-beta.ts
154
+ import { implement } from "@olve/pipelines";
155
+ import { deployBeta } from "../pipeline";
156
+ import deployScript from "../scripts/deploy.sh" with { type: "file" };
157
+
158
+ export default implement(deployBeta, async ({ artifact }, ctx) => {
159
+ await ctx.sh(deployScript, { env: { IMAGE_TAR: await ctx.files.fetch(artifact.image), CHART_DIR: await ctx.files.fetch(artifact.chart) } });
160
+ await ctx.retry({ attempts: 5, delaySeconds: 5 }, () => ctx.http.expectOk("https://pipelines-beta.ovea.pro/api/health"));
161
+ return { release: "olve-pipelines", version: artifact.version, url: "https://pipelines-beta.ovea.pro" }; // or ctx.skip("reason")
162
+ });
163
+ ```
164
+
165
+ | `ctx.` | |
166
+ |---|---|
167
+ | `sh(cmd \| script, { env, cwd, args })`, `sh.text(…)` | Runs a command string, or an embedded script, through the image's shell. The script is copied to `$OLVE_DIR/tmp/scripts/` first. The shell is the first of `/bin/sh`, `/busybox/sh`, `/bin/bash` that exists; a shell-less image gets a clear `ShellNotFoundError`. A script's shebang is honoured when its interpreter exists. A string counts as an embedded script when it is a `/$bunfs/…` path, or an absolute path to an existing `*.sh` or `*.bash` file. Pass `{ script }` to force script mode. A non-zero exit throws a `CommandError` that carries `exitCode`. |
168
+ | `exec([argv…], { env, cwd, idleTimeoutSeconds })` | Runs a binary directly, with no shell (works on distroless). `idleTimeoutSeconds` (also on `sh`) is an inactivity watchdog: no stdout/stderr output for that long → SIGTERM, SIGKILL 10 s later, and a `CommandStalledError`. |
169
+ | `readText(path)`, `readJson(path)` | Paths are relative to the cwd. |
170
+ | `files.fetch(ref)` | Downloads a file reference and verifies its sha256. Directory refs are extracted. Returns a local path. |
171
+ | `output.write(name, content)`, `output.file(path, { name })`, `output.dir(path, { name })` | Adds a file to the step's output and returns its `FileRef`. A directory becomes a deterministic tar. |
172
+ | `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 `?`. |
173
+ | `retry({ attempts, delaySeconds, backoff? }, fn)` | Retries `fn`. Can be cancelled. |
174
+ | `secrets.NAME` | Typed to the step's declared secrets. Read from the env var `NAME`. Throws if the variable is missing. |
175
+ | `log(message, fields?)` | Writes a `message k=v …` line to stderr. |
176
+ | `signal` | Aborted on SIGTERM/SIGINT. Running commands are sent SIGTERM. |
177
+ | `run` | `{ pipeline, id, number, attempt, step, version }` (from the env, see below). |
178
+ | `tmpDir` | A writable scratch directory (`$OLVE_DIR/tmp`). Kaniko has no `/tmp`. |
179
+ | `skip(reason)` | Produce no output (prefer `when`). |
180
+
181
+ ### Pipeline
182
+
183
+ ```ts
184
+ export default pipeline({ name: "Olve.Pipelines", description: "…", input: M.DeployRequest })
185
+ .onPush(e => ({ repo: e.repository.full_name, sha: e.after, deployTo: e.ref === "refs/heads/main" ? "prod" : "beta" }))
186
+ .onPoll({ url: "https://api.github.com/repos/o/r/commits/main", secrets: [GITHUB_TOKEN],
187
+ headers: { Authorization: "$SECRET:GITHUB_TOKEN" }, valuePath: "sha", intervalSeconds: 300 },
188
+ value => ({ sha: value }))
189
+ .stage([checkout, from => ({ repo: from.input.repo, sha: from.input.sha })]) // [step, mapper] tuple
190
+ .stage(build, test) // inputs matched by model
191
+ .stage(deployBeta)
192
+ .when({ deployTo: "prod" }, p => p.stage(deployProd).when({ publishCli: true }, p => p.stage(publishCli)))
193
+ .stage(summary) // optional(ProdDeployment) etc.
194
+ .onFailure(aoeTriage, { steps: [deployProd] });
195
+ ```
196
+
197
+ - **Stage forms.** `.stage(a)`, `.stage(a, b)`, `.stage([a, from => ({…})])` and `.stage([a, mapA], [b, mapB])` are accepted, and tuples can be mixed with plain steps. The old `.stage(step, mapper)` form is a type error and a describe error.
198
+ - **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.
199
+ - **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}`.
200
+ - **Triggers.** A pipeline may call `onPush` at most once. Each `onPoll` needs a distinct `name` (default `"poll"`).
201
+ - **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.
202
+
203
+ ### Testing handlers
204
+
205
+ ```ts
206
+ import { testHandler, testFileRef } from "@olve/pipelines/testing";
207
+ import deployBeta from "../handlers/deploy-beta";
208
+
209
+ const r = await testHandler(deployBeta, { artifact }, { secrets: { SSH_PRIVATE_KEY: "k" }, ctx: { http: fakeHttp }, run: { number: 412 } });
210
+ expect(r).toMatchObject({ status: "succeeded", output: { release: "olve-pipelines" } }); // r.filesDir, r.logs, r.exitCode
211
+ ```
212
+
213
+ `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.
214
+
215
+ ## Harness contract (service ⇄ step binary)
216
+
217
+ This section is everything the C# side needs in order to run the binary.
218
+
219
+ ### Invocation
220
+
221
+ ```
222
+ /olve/lib/ld-linux-x86-64.so.2 --library-path /olve/lib /olve/step <mode> [arg]
223
+ ```
224
+
225
+ | Mode | Reads | Writes | Notes |
226
+ |---|---|---|---|
227
+ | `describe [--sentinels]` | the binary itself | stdout: the manifest (canonical JSON) | With `--sentinels`, stdout gets the manifest between `---OLVE-MANIFEST-BEGIN---` and `---OLVE-MANIFEST-END---`. Needs no env and no files. |
228
+ | `run <step>` | `$OLVE_DIR/input.json` | `$OLVE_DIR/work/output.json`, `$OLVE_DIR/work/files/…` | Steps in stages and failure-handler steps. |
229
+ | `map-trigger <trigger>` | `$OLVE_DIR/input.json` (the event) | `$OLVE_DIR/work/output.json` | `<trigger>` is the trigger's `name` from the manifest. |
230
+
231
+ `run` and `map-trigger` also print the envelope on stdout, as one compact JSON line between `---OLVE-RESULT-BEGIN---` and `---OLVE-RESULT-END---`. The block always comes last. Use it as a fallback channel from the pod log. User output and stderr logs also go to the pod log, so never parse anything outside the sentinels.
232
+
233
+ ### Files (`$OLVE_DIR`, default `/olve`)
234
+
235
+ | Path | Who | What |
236
+ |---|---|---|
237
+ | `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**: the GitHub push payload. **map-trigger poll**: `{"value": "<extracted value as string>"}`. |
238
+ | `work/output.json` | harness | The envelope. Any stale copy is deleted at start, and the new file is written atomically. |
239
+ | `work/files/<object path>` | harness | Output files and directory tars (see "File references"). |
240
+ | `tmp/` | harness | Scratch space. Point `TMPDIR` here. |
241
+ | `files.json` | service, optional | Same format as `OLVE_FILE_URLS`, for when the URL map is too large for an env var. |
242
+
243
+ ### Environment
244
+
245
+ | Variable | Required | Meaning |
246
+ |---|---|---|
247
+ | `OLVE_DIR` | yes (default `/olve`) | IO root. |
248
+ | `TMPDIR` | recommended | `/olve/tmp`. |
249
+ | `OLVE_PIPELINE` | run | Pipeline id → `ctx.run.pipeline`. |
250
+ | `OLVE_RUN_ID` | run | Run id → `ctx.run.id`. |
251
+ | `OLVE_RUN` | run | Run number (integer ≥ 0). Written into produced file refs. Default 0. |
252
+ | `OLVE_ATTEMPT` | run | Attempt number (integer ≥ 1). Written into produced file refs. Default 1. |
253
+ | `OLVE_VERSION_STAMP` | run | Derived version, e.g. `2026.10.21.412` → `ctx.run.version`. |
254
+ | `<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. |
255
+ | `OLVE_RESULT_URL` | optional | Pre-signed **PUT** for `result.tar.gz` (see below). Unset means no upload. |
256
+ | `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). |
257
+ | `OLVE_FILES_BASE_URL` (+ `OLVE_FILES_TOKEN`) | optional | Fallback: `GET <base>/<run>/<step>/<attempt>/<object path>` with an optional `Authorization: Bearer <token>`. |
258
+ | `OLVE_FILES_DIR` | dev | Local pool laid out as `<run>/<step>/<attempt>/files/<object path>`. |
259
+
260
+ `OLVE_RESULT_URL`, `OLVE_FILE_URLS` and `OLVE_FILES_TOKEN` are removed from the environment of child processes. URLs are never logged past `?`.
261
+
262
+ `ctx.files.fetch` looks for a file in this order:
263
+
264
+ 1. the attempt's own `work/files`;
265
+ 2. `OLVE_FILE_URLS` / `files.json`;
266
+ 3. `OLVE_FILES_DIR`;
267
+ 4. `OLVE_FILES_BASE_URL`.
268
+
269
+ It retries network errors, 5xx, 408 and 429. It always verifies `sha256`.
270
+
271
+ ### Envelope (`work/output.json`)
272
+
273
+ ```json
274
+ { "status": "succeeded", "output": { … } } // output: the z.output value; null for steps without an output model
275
+ { "status": "skipped", "reason": "…" }
276
+ { "status": "failed", "error": { "kind": "handler" | "input" | "output", "message": "…" } }
277
+ ```
278
+
279
+ For `map-trigger`, `output` is the pipeline input after it has been parsed by the input model, so defaults are applied. Validate it again server-side.
280
+
281
+ ### Exit codes
282
+
283
+ | Code | Meaning | Envelope |
284
+ |---|---|---|
285
+ | 0 | ok (succeeded or skipped) | yes |
286
+ | 1 | the handler or trigger mapper threw | failed / `handler` |
287
+ | 64 | usage: unknown mode, step or trigger | none |
288
+ | 65 | `input.json` missing, not JSON, or invalid for the step (or event) | failed / `input` |
289
+ | 66 | the return value failed strict validation, or an own file ref is missing or changed | failed / `output` |
290
+ | 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) |
291
+ | 78 | the pipeline is invalid (describe problems, or a missing or wrong handler module) | none |
292
+ | 130 / 143 | the handler failed after SIGINT / SIGTERM (cancellation, `activeDeadlineSeconds`) | failed / `handler` |
293
+
294
+ ### Result upload (`OLVE_RESULT_URL`)
295
+
296
+ After writing the envelope, the harness packs `output.json` and `files/` into a deterministic `result.tar.gz`. It does this for **succeeded, skipped and failed** results alike. It then sends the archive with a single `PUT <OLVE_RESULT_URL>` and no extra headers.
297
+
298
+ - Network errors, 5xx, 408 and 429 are retried up to 5 times, with backoff of 1, 2, 4 and 8 s.
299
+ - Any other 4xx fails immediately.
300
+
301
+ The service unpacks the archive into `runs/<p>/<run>/<step>/<attempt>/`.
302
+
303
+ ### File references
304
+
305
+ ```json
306
+ { "run": 412, "step": "build", "attempt": 1, "path": "chart/", "sha256": "<64 hex>" }
307
+ ```
308
+
309
+ - In JSON Schema, a reference is marked `"x-olve-kind": "fileRef"`.
310
+ - `path` is relative, with no `.`/`..` segments, no `\`, no NUL and no leading `/`. The pattern is in the schema. A **trailing `/` means a directory**.
311
+ - **Storage rule:** the object path is `path` for files. For directories, it is `path` without the trailing `/`, plus `.tar`: `chart/` → `chart.tar`, an uncompressed deterministic tar.
312
+ - Objects live at `work/files/<object path>` in the pod and at `runs/<p>/<run>/<step>/<attempt>/files/<object path>` in Garage.
313
+ - `sha256` is the hash of the object (the tar, for directories).
314
+
315
+ ## Manifest
316
+
317
+ Exact format: [`manifest.schema.json`](manifest.schema.json) (JSON Schema 2020-12, `$defs` `Manifest`, `Step`, `Stage`, `Mapping`, `Trigger`, `InputType`, `ModelSchemas`). The text is canonical JSON: sorted keys, 2-space indent, a trailing newline, and no `$schema` in the model schemas. Hash the text as printed.
318
+
319
+ - **`apiVersion`** is `"2.0"`.
320
+ - **`harness`** is `{ bun, sdk, sdkHash }`.
321
+ - **`models[name]`** is `{ output, input? }`.
322
+ - `output` is the `z.output` view, with `additionalProperties: false` on every object. It validates outputs, the pool and trigger results.
323
+ - `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`.
324
+ - **Step `input`** maps each input key to a mapping:
325
+ - `{"ref": "<root>[.<field>…]"}`, where the root is `input`, a step name, or `failure` (failure handlers only);
326
+ - `{"const": <json>}`;
327
+ - a nested object of mappings.
328
+
329
+ 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"}`).
330
+ - **`inputTypes`** declares each input key: `{model, optional}`, or `{schema: {output, input?}}` for a plain schema.
331
+ - **`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.
332
+ - Custom steps: `sha256(canonical {bundle, harness})`. `bundle` hashes the handler bundled on its own, with `pipeline.ts`, `models.ts` and `@olve/pipelines` external. It is tree-shaken and includes embedded scripts, so it changes only when that handler's code (or code it uses) changes.
333
+ - Library steps: `sha256(canonical {library: kind, config, harness})`.
334
+ - Because `harness` is part of both hashes, a Bun or SDK bump changes every handler.
335
+ - **`stages`** is an array whose items are either `{steps: [Step…]}` (parallel, with a barrier) or `{when: {field: [values]}, stages: [...]}`.
336
+ - **`triggers`**:
337
+ - `{kind: "push", name: "push"}`;
338
+ - `{kind: "poll", name, url, headers, secrets, valuePath, intervalSeconds}`, where header values may contain `$SECRET:NAME`.
339
+ - **`failureHandlers`** is a list of `{step: Step, steps: [names] | null}`. Each handler's inputs are all FailureContext (`{"ref": "failure"}`).
340
+ - **`secrets`** lists every secret used by steps and polls, sorted, as `{name, description}`.
341
+
342
+ Describe runs these checks; reconcile should re-run the manifest-level ones:
343
+
344
+ - step name format and reserved names (`input`, `failure`), and duplicate step names;
345
+ - one schema per model name, and JSON-representable models;
346
+ - missing handler file, and a handler file without a step (or for a library step);
347
+ - availability, ambiguity, maybe-available as required, `optional` without a producer, plain schema without a mapper;
348
+ - mapper source analysis and recording, unknown fields in refs, mapper keys that are missing or extra;
349
+ - `when` on an unknown or non-literal field, or with an invalid value;
350
+ - a poll header referencing an undeclared secret, duplicate triggers;
351
+ - conflicting secret descriptions;
352
+ - an `onFailure` scope naming an unknown step, and failure-handler inputs.
353
+
354
+ ## Build tool
355
+
356
+ ```
357
+ olve-build <pipelineDir> --out <dir> [--target bun-linux-x64|bun-linux-x64-musl] [--upload-url <presigned PUT>] [--sentinels]
358
+ olve-build <pipelineDir> --hash-only # print handler hashes (diagnostics)
359
+ ```
360
+
361
+ 1. Hash every `handlers/*.ts` (see `handlerHash`).
362
+ 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`.
363
+ 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.
364
+ 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.
365
+ 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.**
366
+ 6. Write `<out>/step.tar.gz` (deterministic, `step` at the root, mode 0755) and `<out>/build.json` (diagnostics).
367
+ 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.
368
+
369
+ Exit codes: 0 ok, 1 build failure, 64 usage, 78 pipeline invalid.
370
+
371
+ **Build/describe Job recipe**, on `oven/bun:<pinned>-slim` (never alpine):
372
+
373
+ ```sh
374
+ # fetch the repo tarball, then in the binding path's package root:
375
+ bun install --frozen-lockfile
376
+ bunx olve-build .pipelines --out /work/out --upload-url "$STEP_PUT_URL" --sentinels
377
+ ```
378
+
379
+ From npm (the alias), nothing else is needed. A local `file:`/`link:` dependency (`"@olve/pipelines": "file:../sdk"`, as Olve.Pipelines' own `.pipelines/` does) resolves to its **source directory**, not the copy in `node_modules`, so its own dependencies must be installed there too: the build Job runs `bun install --frozen-lockfile --production` in every local dependency of the binding's `package.json` that is inside the repo, before building.
380
+
381
+ The repo depends on the SDK as a normal package. It resolves through `exports` (`@olve/pipelines`, `@olve/pipelines/harness`, `@olve/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`.
382
+
383
+ 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.
384
+
385
+ 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.
386
+
387
+ ## Dev runner
388
+
389
+ ```
390
+ olve-dev describe <dir> [--sentinels] # same bytes as the compiled binary's describe
391
+ olve-dev run <dir> <step> [--input-file f] [--olve-dir d] [--secret NAME=value]...
392
+ olve-dev map-trigger <dir> <trigger> --event-file f [--olve-dir d]
393
+ ```
394
+
395
+ `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.
396
+
397
+ ## Development
398
+
399
+ ```sh
400
+ bun install
401
+ bunx tsc --noEmit -p . # type-check (TS 7)
402
+ bun scripts/check-compile-fail.ts # compile-fail cases (also run by bun test)
403
+ TSC=/path/to/tsc-5.9/bin/tsc bun scripts/check-compile-fail.ts # the other TS line
404
+ bun test # runtime + build/e2e tests
405
+ bun scripts/gen-manifest-schema.ts # after changing src/describe/manifest-schema.ts
406
+ ```
407
+
408
+ The type-level builder's inference is fragile; see the comments in `src/builder/pipeline.ts`. Keep the compile-fail checker green on both TypeScript lines.