@olivervea/pipelines 2.0.0-beta.1 → 2.0.0-beta.3
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 +41 -32
- package/manifest.schema.json +73 -1
- package/package.json +1 -1
- package/src/build/build.ts +3 -3
- package/src/build/cli.ts +1 -1
- package/src/build/hashes.ts +1 -1
- package/src/build/project.ts +26 -5
- package/src/builder/pipeline.ts +37 -8
- package/src/core/webhooks.ts +30 -0
- package/src/ctx/sh.ts +2 -1
- package/src/describe/describe.ts +13 -6
- package/src/describe/manifest-schema.ts +16 -1
- package/src/dev/cli.ts +1 -1
- package/src/harness/index.ts +1 -1
- package/src/harness/main.ts +18 -5
- package/src/index.ts +3 -2
- package/src/library/checks.ts +23 -0
- package/src/library/helm-deploy-ssh.ts +107 -62
- package/src/library/image-build.ts +176 -20
- package/src/library/index.ts +4 -2
- package/src/library/models.ts +9 -0
- package/src/library/ssh.ts +45 -0
- package/src/testing/index.ts +1 -1
- package/src/util/handler-hash.ts +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @olivervea/pipelines
|
|
2
2
|
|
|
3
|
-
The [Olve.Pipelines](https://github.com/OliverVea/Olve.Pipelines) v2 SDK
|
|
3
|
+
The [Olve.Pipelines](https://github.com/OliverVea/Olve.Pipelines) v2 SDK. It has four parts:
|
|
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`);
|
|
@@ -12,14 +12,14 @@ The manifest format is in [`manifest.schema.json`](manifest.schema.json), genera
|
|
|
12
12
|
|
|
13
13
|
```
|
|
14
14
|
sdk/
|
|
15
|
-
src/index.ts authoring API (`@
|
|
15
|
+
src/index.ts authoring API (`@olivervea/pipelines`)
|
|
16
16
|
src/core/ models, secrets, steps, implement()
|
|
17
17
|
src/builder/ pipeline builder (types + runtime records), Ref<T>
|
|
18
18
|
src/library/ library steps (script, gitHubSource, imageBuild, helmDeployOverSsh), their exported helpers (httpHealthCheck) and SDK models
|
|
19
19
|
src/describe/ describe: manifest, checks, mapper recording + source analysis, manifest schema
|
|
20
|
-
src/harness/ harness CLI modes, env contract, envelope, result upload (`@
|
|
20
|
+
src/harness/ harness CLI modes, env contract, envelope, result upload (`@olivervea/pipelines/harness`)
|
|
21
21
|
src/ctx/ the ctx handed to handlers
|
|
22
|
-
src/testing/ testHandler (`@
|
|
22
|
+
src/testing/ testHandler (`@olivervea/pipelines/testing`)
|
|
23
23
|
src/build/ olve-build
|
|
24
24
|
src/dev/ olve-dev
|
|
25
25
|
examples/self-deploy/ fixture pipeline (the self-deploy shape) used by the tests
|
|
@@ -30,13 +30,12 @@ sdk/
|
|
|
30
30
|
|
|
31
31
|
## Install
|
|
32
32
|
|
|
33
|
-
The package is `@olivervea/pipelines` on npm;
|
|
34
|
-
in the pipeline directory's `package.json`:
|
|
33
|
+
The package is `@olivervea/pipelines` on npm; a pipeline directory's `package.json` depends on it by that name:
|
|
35
34
|
|
|
36
35
|
```json
|
|
37
36
|
{
|
|
38
37
|
"type": "module",
|
|
39
|
-
"dependencies": { "@
|
|
38
|
+
"dependencies": { "@olivervea/pipelines": "2.0.0-beta.3" },
|
|
40
39
|
"devDependencies": { "@types/bun": "1.4.3", "typescript": "7.0.2" }
|
|
41
40
|
}
|
|
42
41
|
```
|
|
@@ -47,18 +46,16 @@ bunx olve-dev describe .
|
|
|
47
46
|
bunx olve-build . --out .olve/out
|
|
48
47
|
```
|
|
49
48
|
|
|
50
|
-
- Keep the alias name: `olve-build` generates imports of `@olve/pipelines/harness`, so an unaliased
|
|
51
|
-
`@olivervea/pipelines` dependency does not build.
|
|
52
49
|
- The package ships TypeScript sources (Bun runs them directly; `harness.sdkHash` hashes them), so the
|
|
53
50
|
same version gives the same `sdkHash` and handler hashes from npm or from `file:../sdk`.
|
|
54
|
-
- Inside this repo, `.pipelines/` and `.pipelines-test/` use `"@
|
|
51
|
+
- Inside this repo, `.pipelines/` and `.pipelines-test/` use `"@olivervea/pipelines": "file:../sdk"`.
|
|
55
52
|
|
|
56
53
|
### Releases
|
|
57
54
|
|
|
58
55
|
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
|
|
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.
|
|
56
|
+
`latest`. To release, bump `version` in `package.json`, push to `v2`: once the run reaches prod, the
|
|
57
|
+
self-deploy's `publish-sdk` step runs `bun run check` and `bun publish` when that version is not on npm yet. It
|
|
58
|
+
skips when the version exists, or when the pipeline secret `NPM_TOKEN` is unset. Published versions: `npm view @olivervea/pipelines versions`.
|
|
62
59
|
|
|
63
60
|
## Authoring
|
|
64
61
|
|
|
@@ -67,7 +64,7 @@ A pipeline directory (the binding path, `.pipelines/` by default) looks like thi
|
|
|
67
64
|
```
|
|
68
65
|
models.ts data models (contract file)
|
|
69
66
|
pipeline.ts steps + `export default pipeline(…)` (contract file)
|
|
70
|
-
handlers
|
|
67
|
+
handlers/*.ts one per custom step: `export default implement(step, fn)` (pairs by the step, not the file name)
|
|
71
68
|
handlers/lib/… shared helpers (not handlers)
|
|
72
69
|
scripts/*.sh embedded with `import s from "../scripts/x.sh" with { type: "file" }`
|
|
73
70
|
```
|
|
@@ -77,7 +74,7 @@ Contract files hold contracts only. Describe evaluates them without loading hand
|
|
|
77
74
|
### Models, secrets, steps
|
|
78
75
|
|
|
79
76
|
```ts
|
|
80
|
-
import { model, z, fileRef, optional, secret, step, steps, Source } from "@
|
|
77
|
+
import { model, z, fileRef, optional, secret, step, steps, Source } from "@olivervea/pipelines";
|
|
81
78
|
|
|
82
79
|
export const BuildArtifact = model("BuildArtifact", z.object({
|
|
83
80
|
version: z.string(),
|
|
@@ -123,27 +120,38 @@ In the output JSON, an object of the form `{"$file": "<path>"}` or `{"$dir": "<p
|
|
|
123
120
|
|
|
124
121
|
### Library steps
|
|
125
122
|
|
|
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.
|
|
123
|
+
Library steps carry their own implementation and image; they take typed config and use the SDK models `Source`, `BuiltImage`, `PushedImage` and `HelmRelease`. The set grows from real use: a step or an option exists because a pipeline uses it.
|
|
127
124
|
|
|
128
125
|
| Step | Image | Input → output | Config |
|
|
129
126
|
|---|---|---|---|
|
|
130
127
|
| `steps.script` | yours | yours | `script` (embedded), `env` (see above) |
|
|
131
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) |
|
|
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,
|
|
133
|
-
| `steps.helmDeployOverSsh` | `alpine:3.22` (+ `apk add openssh-client` at run time) | `{ source: Source,
|
|
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? }`) |
|
|
134
131
|
|
|
135
132
|
- `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
|
|
137
|
-
- `
|
|
133
|
+
- `imageBuild` copies the tree to `/kaniko/build-context` (kaniko wipes other directories between multi-stage builds), builds `--context=/kaniko/build-context[/<context>] --dockerfile=<that>/<dockerfile>` with `--no-push --tar-path --digest-file`, and outputs the tarball. A missing context or Dockerfile fails the step before kaniko starts. `repository` in the output is fully qualified the way a runtime names a loaded image (`x` → `docker.io/library/x`). A **stall watchdog** kills kaniko when it prints nothing for `stallTimeoutSeconds` (a base-image pull can stall on an idle connection at "Unpacking rootfs", and kaniko has no read timeout), failing the attempt; `retries` then starts a fresh attempt. The retry is deliberately a new pod, never a second kaniko in the same container: kaniko unpacks the base image over the container root and does not clean up when killed.
|
|
134
|
+
- **Caches never block a build.** `registryMap` defaults to the homelab pull-through caches (`DEFAULT_REGISTRY_MAP`: `mcr.microsoft.com` → `mcr-cache.ovea.pro`, `index.docker.io` → `hub-cache.ovea.pro`; kaniko under gVisor stalls pulling from mcr.microsoft.com directly). A map you pass replaces the default; `{}` turns mapping off. Before kaniko starts, every mirror host and the `cacheRepo` registry gets a 5 s `GET https://<host>/v2/` (200 or 401 = up); one that doesn't answer is dropped with a log line (`mcr-cache.ovea.pro unreachable; pulling from mcr.microsoft.com directly`, or no `--cache` for the layer cache). After that, kaniko itself copes: it falls back to the original registry when a mirror can't serve an image's manifest (`RetrieveRemoteImage`; `--skip-default-registry-fallback` is never set), a layer-cache lookup error is a cache miss, a cache push error is a warning, and `--skip-push-permission-check` stops its startup check of the cache repo from exiting. A cache that dies mid-download (after the manifest came from it) fails or stalls the attempt; `retries` starts a fresh one, which probes again.
|
|
135
|
+
- **Registry mode** (`push: true`, for images too big for the step's ~1 GiB scratch space and Garage): kaniko runs without `--no-push`/`--tar-path`, pushes to `repository` (it must start with a registry host; pushes are anonymous) and the output is a `PushedImage` with the digest from `--digest-file`. With `contentTag`, the tag is `src-` + the first 12 hex of the sha256 of the context directory's deterministic tar (the bytes `ctx.output.dir` would write), computed from the fetched tree; an anonymous `HEAD https://<host>/v2/<path>/manifests/<tag>` that answers a `Docker-Content-Digest` skips kaniko and outputs that digest.
|
|
136
|
+
- `helmDeployOverSsh` gets each image into the host's **k3s** containerd: a `BuiltImage` tar is streamed in (`nerdctl --address /run/k3s/containerd/containerd.sock --namespace k8s.io load`), a `PushedImage` (`fromRegistry`) is pulled there (`… pull -q`, so the rollout doesn't wait on the pull). It copies the chart to `/tmp/<release>-helm-<namespace>`, runs `helm upgrade --install` from inside it (values, then `set`, then per image `<prefix>.repository`/`.tag`/`.pullPolicy` — `Never` for tarballs, `IfNotPresent` for registry images — which always win), waits for `kubectl rollout status` of each of `deployments` in order (`deploy/<name>` for a bare name), and with `healthUrl` polls it (5 × 5 s unless given) from the harness. Several images (one chart, two Deployments):
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
steps.helmDeployOverSsh({ name: "deploy-beta", description: "", host: "oliver@bulwark-m2", sshKey: SSH_PRIVATE_KEY,
|
|
140
|
+
release: "olve-slimerepublics", namespace: "apps-beta", chart: "helm",
|
|
141
|
+
images: { server: "image", web: "web.image" }, deployments: ["olve-slimerepublics-server", "olve-slimerepublics-web"] });
|
|
142
|
+
```
|
|
138
143
|
|
|
139
144
|
**Helpers.** General functions the library steps are built on, exported for custom handlers:
|
|
140
145
|
|
|
141
146
|
| Helper | What it does |
|
|
142
147
|
|---|---|
|
|
143
148
|
| `httpHealthCheck(ctx, url, { attempts?, delaySeconds?, headers? })` | GETs `url` until it returns 2xx (redirects followed), retrying network errors and non-2xx statuses; defaults 5 attempts, 5 s apart (v1's deploy-beta loop). Throws the last error when the attempts run out. Runs in the harness, so the image needs no curl. `helmDeployOverSsh`'s `healthUrl` uses it. |
|
|
149
|
+
| `serviceUrl(release)` | `http://<release>.<namespace>.svc.cluster.local` for a `HelmRelease` (charts that name the Service after the release). |
|
|
150
|
+
| `checkSpa(ctx, url)` | `GET <url>/` must reference an `/assets/*.js` bundle (Vite's layout) and the bundle must be served as JavaScript; returns `{ html, bundle }` for app-specific checks. |
|
|
151
|
+
| `sshSession(ctx, { host, key })` | Installs openssh-client (apk), writes the key to `<tmpDir>/ssh/id` (trailing newline, mode 600; host key not verified) and returns `{ run(remote), text(remote), copy(local, remote), shell(remote) }`; `shell` is the quoted ssh command for pipes (`ctx.sh(`printf '%s' "$V" \| ${s.shell("kubectl apply -f -")}`)`). `helmDeployOverSsh` uses it. `shq(s)` is the POSIX quoting it uses. |
|
|
144
152
|
|
|
145
153
|
```ts
|
|
146
|
-
import { httpHealthCheck, implement } from "@
|
|
154
|
+
import { httpHealthCheck, implement } from "@olivervea/pipelines";
|
|
147
155
|
export default implement(smoke, async (_, ctx) => { await httpHealthCheck(ctx, "http://app.ns.svc.cluster.local/api/health", { attempts: 10 }); });
|
|
148
156
|
```
|
|
149
157
|
|
|
@@ -151,7 +159,7 @@ export default implement(smoke, async (_, ctx) => { await httpHealthCheck(ctx, "
|
|
|
151
159
|
|
|
152
160
|
```ts
|
|
153
161
|
// handlers/deploy-beta.ts
|
|
154
|
-
import { implement } from "@
|
|
162
|
+
import { implement } from "@olivervea/pipelines";
|
|
155
163
|
import { deployBeta } from "../pipeline";
|
|
156
164
|
import deployScript from "../scripts/deploy.sh" with { type: "file" };
|
|
157
165
|
|
|
@@ -164,7 +172,7 @@ export default implement(deployBeta, async ({ artifact }, ctx) => {
|
|
|
164
172
|
|
|
165
173
|
| `ctx.` | |
|
|
166
174
|
|---|---|
|
|
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`. |
|
|
175
|
+
| `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. `args` are the script's arguments, or `$1`, `$2`… of a command string. A non-zero exit throws a `CommandError` that carries `exitCode`. |
|
|
168
176
|
| `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
177
|
| `readText(path)`, `readJson(path)` | Paths are relative to the cwd. |
|
|
170
178
|
| `files.fetch(ref)` | Downloads a file reference and verifies its sha256. Directory refs are extracted. Returns a local path. |
|
|
@@ -197,13 +205,13 @@ export default pipeline({ name: "Olve.Pipelines", description: "…", input: M.D
|
|
|
197
205
|
- **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
206
|
- **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
207
|
- **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` (
|
|
208
|
+
- **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.
|
|
201
209
|
- **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
210
|
|
|
203
211
|
### Testing handlers
|
|
204
212
|
|
|
205
213
|
```ts
|
|
206
|
-
import { testHandler, testFileRef } from "@
|
|
214
|
+
import { testHandler, testFileRef } from "@olivervea/pipelines/testing";
|
|
207
215
|
import deployBeta from "../handlers/deploy-beta";
|
|
208
216
|
|
|
209
217
|
const r = await testHandler(deployBeta, { artifact }, { secrets: { SSH_PRIVATE_KEY: "k" }, ctx: { http: fakeHttp }, run: { number: 412 } });
|
|
@@ -234,7 +242,7 @@ This section is everything the C# side needs in order to run the binary.
|
|
|
234
242
|
|
|
235
243
|
| Path | Who | What |
|
|
236
244
|
|---|---|---|
|
|
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**:
|
|
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`). |
|
|
238
246
|
| `work/output.json` | harness | The envelope. Any stale copy is deleted at start, and the new file is written atomically. |
|
|
239
247
|
| `work/files/<object path>` | harness | Output files and directory tars (see "File references"). |
|
|
240
248
|
| `tmp/` | harness | Scratch space. Point `TMPDIR` here. |
|
|
@@ -329,15 +337,16 @@ Exact format: [`manifest.schema.json`](manifest.schema.json) (JSON Schema 2020-1
|
|
|
329
337
|
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
338
|
- **`inputTypes`** declares each input key: `{model, optional}`, or `{schema: {output, input?}}` for a plain schema.
|
|
331
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.
|
|
332
|
-
- Custom steps: `sha256(canonical {bundle, harness})`. `bundle` hashes the handler bundled on its own, with `pipeline.ts`, `models.ts` and `@
|
|
340
|
+
- Custom steps: `sha256(canonical {bundle, harness})`. `bundle` hashes the handler bundled on its own, with `pipeline.ts`, `models.ts` and `@olivervea/pipelines` external. It is tree-shaken and includes embedded scripts, so it changes only when that handler's code (or code it uses) changes.
|
|
333
341
|
- Library steps: `sha256(canonical {library: kind, config, harness})`.
|
|
334
342
|
- Because `harness` is part of both hashes, a Bun or SDK bump changes every handler.
|
|
335
343
|
- **`stages`** is an array whose items are either `{steps: [Step…]}` (parallel, with a barrier) or `{when: {field: [values]}, stages: [...]}`.
|
|
336
344
|
- **`triggers`**:
|
|
337
345
|
- `{kind: "push", name: "push"}`;
|
|
338
|
-
- `{kind: "poll", name, url, headers, secrets, valuePath, intervalSeconds}`, where header values may contain `$SECRET:NAME
|
|
346
|
+
- `{kind: "poll", name, url, headers, secrets, valuePath, intervalSeconds}`, where header values may contain `$SECRET:NAME`;
|
|
347
|
+
- `{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).
|
|
339
348
|
- **`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
|
|
349
|
+
- **`secrets`** lists every secret used by steps, polls and webhooks, sorted, as `{name, description}`.
|
|
341
350
|
|
|
342
351
|
Describe runs these checks; reconcile should re-run the manifest-level ones:
|
|
343
352
|
|
|
@@ -358,7 +367,7 @@ olve-build <pipelineDir> --out <dir> [--target bun-linux-x64|bun-linux-x64-musl]
|
|
|
358
367
|
olve-build <pipelineDir> --hash-only # print handler hashes (diagnostics)
|
|
359
368
|
```
|
|
360
369
|
|
|
361
|
-
1.
|
|
370
|
+
1. Pair each `handlers/*.ts` with the step its `implement(step, …)` names, and hash it (see `handlerHash`).
|
|
362
371
|
2. Generate `<pipelineDir>/.olve/gen/handlers.ts` (a registry of literal `import()` paths, plus hashes and harness info) and `entry.ts`. Add `.olve/` to `.gitignore`.
|
|
363
372
|
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
373
|
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.
|
|
@@ -376,9 +385,9 @@ bun install --frozen-lockfile
|
|
|
376
385
|
bunx olve-build .pipelines --out /work/out --upload-url "$STEP_PUT_URL" --sentinels
|
|
377
386
|
```
|
|
378
387
|
|
|
379
|
-
From npm
|
|
388
|
+
From npm, nothing else is needed. A local `file:`/`link:` dependency (`"@olivervea/pipelines": "file:../sdk"`, as Olve.Pipelines' own `.pipelines/` does) resolves to its **source directory**, not the copy in `node_modules`, so its own dependencies must be installed there too: the build Job runs `bun install --frozen-lockfile --production` in every local dependency of the binding's `package.json` that is inside the repo, before building.
|
|
380
389
|
|
|
381
|
-
The repo depends on the SDK as a normal package. It resolves through `exports` (`@
|
|
390
|
+
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`.
|
|
382
391
|
|
|
383
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.
|
|
384
393
|
|
package/manifest.schema.json
CHANGED
|
@@ -92,7 +92,7 @@
|
|
|
92
92
|
"type": "string"
|
|
93
93
|
},
|
|
94
94
|
"sdk": {
|
|
95
|
-
"description": "@
|
|
95
|
+
"description": "@olivervea/pipelines package version.",
|
|
96
96
|
"type": "string"
|
|
97
97
|
},
|
|
98
98
|
"sdkHash": {
|
|
@@ -483,6 +483,78 @@
|
|
|
483
483
|
"intervalSeconds"
|
|
484
484
|
],
|
|
485
485
|
"type": "object"
|
|
486
|
+
},
|
|
487
|
+
{
|
|
488
|
+
"additionalProperties": false,
|
|
489
|
+
"description": "Inbound HTTP delivery to /api/hooks/<pipeline id>/<name>. Event = {headers, body}; a null mapping ignores it.",
|
|
490
|
+
"properties": {
|
|
491
|
+
"kind": {
|
|
492
|
+
"const": "webhook",
|
|
493
|
+
"type": "string"
|
|
494
|
+
},
|
|
495
|
+
"name": {
|
|
496
|
+
"pattern": "^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$",
|
|
497
|
+
"type": "string"
|
|
498
|
+
},
|
|
499
|
+
"verify": {
|
|
500
|
+
"oneOf": [
|
|
501
|
+
{
|
|
502
|
+
"additionalProperties": false,
|
|
503
|
+
"properties": {
|
|
504
|
+
"header": {
|
|
505
|
+
"minLength": 1,
|
|
506
|
+
"type": "string"
|
|
507
|
+
},
|
|
508
|
+
"prefix": {
|
|
509
|
+
"description": "Text before the hex digest in the header, e.g. 'sha256='.",
|
|
510
|
+
"type": "string"
|
|
511
|
+
},
|
|
512
|
+
"scheme": {
|
|
513
|
+
"const": "hmac-sha256",
|
|
514
|
+
"type": "string"
|
|
515
|
+
},
|
|
516
|
+
"secret": {
|
|
517
|
+
"description": "Secret name; also the env var name in the step container.",
|
|
518
|
+
"pattern": "^[A-Z_][A-Z0-9_]*$",
|
|
519
|
+
"type": "string"
|
|
520
|
+
}
|
|
521
|
+
},
|
|
522
|
+
"required": [
|
|
523
|
+
"scheme",
|
|
524
|
+
"secret",
|
|
525
|
+
"header",
|
|
526
|
+
"prefix"
|
|
527
|
+
],
|
|
528
|
+
"type": "object"
|
|
529
|
+
},
|
|
530
|
+
{
|
|
531
|
+
"additionalProperties": false,
|
|
532
|
+
"properties": {
|
|
533
|
+
"scheme": {
|
|
534
|
+
"const": "bearer",
|
|
535
|
+
"type": "string"
|
|
536
|
+
},
|
|
537
|
+
"secret": {
|
|
538
|
+
"description": "Secret name; also the env var name in the step container.",
|
|
539
|
+
"pattern": "^[A-Z_][A-Z0-9_]*$",
|
|
540
|
+
"type": "string"
|
|
541
|
+
}
|
|
542
|
+
},
|
|
543
|
+
"required": [
|
|
544
|
+
"scheme",
|
|
545
|
+
"secret"
|
|
546
|
+
],
|
|
547
|
+
"type": "object"
|
|
548
|
+
}
|
|
549
|
+
]
|
|
550
|
+
}
|
|
551
|
+
},
|
|
552
|
+
"required": [
|
|
553
|
+
"kind",
|
|
554
|
+
"name",
|
|
555
|
+
"verify"
|
|
556
|
+
],
|
|
557
|
+
"type": "object"
|
|
486
558
|
}
|
|
487
559
|
]
|
|
488
560
|
},
|
package/package.json
CHANGED
package/src/build/build.ts
CHANGED
|
@@ -48,7 +48,7 @@ export function generateEntry(project: Project, hashes: Record<string, string>,
|
|
|
48
48
|
writeFileSync(
|
|
49
49
|
join(gen, "handlers.ts"),
|
|
50
50
|
`// GENERATED by olve-build — do not edit.\n` +
|
|
51
|
-
`import type { HandlerRegistry, HarnessInfo } from "@
|
|
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
54
|
`export const harness: HarnessInfo = ${JSON.stringify(harness, null, 2)};\n`,
|
|
@@ -58,7 +58,7 @@ export function generateEntry(project: Project, hashes: Record<string, string>,
|
|
|
58
58
|
entry,
|
|
59
59
|
`// GENERATED by olve-build — do not edit. Entry point of the step binary.\n` +
|
|
60
60
|
`import pipeline from ${JSON.stringify(relPosix(gen, project.pipelineFile))};\n` +
|
|
61
|
-
`import { runHarness } from "@
|
|
61
|
+
`import { runHarness } from "@olivervea/pipelines/harness";\n` +
|
|
62
62
|
`import { handlers, handlerHashes, harness } from "./handlers.ts";\n\n` +
|
|
63
63
|
`process.exit(await runHarness({ pipeline, handlers, handlerHashes, harness }, process.argv.slice(2)));\n`,
|
|
64
64
|
);
|
|
@@ -69,7 +69,7 @@ export async function build(dir: string, opts: BuildOptions): Promise<BuildResul
|
|
|
69
69
|
const log = opts.log ?? ((l: string) => process.stderr.write(l + "\n"));
|
|
70
70
|
const stdout = opts.stdout ?? (async (t: string) => void (await Bun.write(Bun.stdout, t)));
|
|
71
71
|
const target = opts.target ?? "bun-linux-x64";
|
|
72
|
-
const project = loadProject(dir);
|
|
72
|
+
const project = await loadProject(dir);
|
|
73
73
|
const out = resolve(opts.out);
|
|
74
74
|
mkdirSync(out, { recursive: true });
|
|
75
75
|
const harness = harnessInfo();
|
package/src/build/cli.ts
CHANGED
|
@@ -32,7 +32,7 @@ async function main(): Promise<number> {
|
|
|
32
32
|
const dir = positionals[0]!;
|
|
33
33
|
try {
|
|
34
34
|
if (values["hash-only"]) {
|
|
35
|
-
const h = await handlerHashes(loadProject(dir), harnessInfo());
|
|
35
|
+
const h = await handlerHashes(await loadProject(dir), harnessInfo());
|
|
36
36
|
await Bun.write(Bun.stdout, canonicalJson({ harness: harnessInfo(), handlers: h }));
|
|
37
37
|
return 0;
|
|
38
38
|
}
|
package/src/build/hashes.ts
CHANGED
|
@@ -22,7 +22,7 @@ function externals(project: Project): BunPlugin {
|
|
|
22
22
|
return {
|
|
23
23
|
name: "olve-handler-hash-externals",
|
|
24
24
|
setup(b) {
|
|
25
|
-
b.onResolve({ filter: /^@
|
|
25
|
+
b.onResolve({ filter: /^@olivervea\/pipelines(\/.*)?$/ }, (args) => ({ path: args.path, external: true }));
|
|
26
26
|
b.onResolve({ filter: /^\.{1,2}\// }, (args) => {
|
|
27
27
|
const base = resolve(dirname(args.importer), args.path);
|
|
28
28
|
for (const cand of [base, `${base}.ts`, join(base, "index.ts")]) if (contracts.has(cand)) return { path: args.path, external: true };
|
package/src/build/project.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Layout of a pipeline directory and the identity of the SDK building it.
|
|
2
2
|
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
3
3
|
import { dirname, join, relative, resolve } from "node:path";
|
|
4
|
+
import { isImplementation } from "../core/handlers.ts";
|
|
4
5
|
import { sha256Hex } from "../util/hash.ts";
|
|
5
6
|
import type { HarnessInfo } from "../util/handler-hash.ts";
|
|
6
7
|
|
|
@@ -11,7 +12,7 @@ export interface Project {
|
|
|
11
12
|
pipelineFile: string;
|
|
12
13
|
/** Contract files (excluded from handler bundles): pipeline.ts, models.ts. */
|
|
13
14
|
contractFiles: string[];
|
|
14
|
-
/** step name → absolute handler file, sorted by name. */
|
|
15
|
+
/** step name → absolute handler file, sorted by step name. The step is the one the file's `implement(step, …)` names. */
|
|
15
16
|
handlers: [string, string][];
|
|
16
17
|
}
|
|
17
18
|
|
|
@@ -19,20 +20,40 @@ export class ProjectError extends Error {
|
|
|
19
20
|
override name = "ProjectError";
|
|
20
21
|
}
|
|
21
22
|
|
|
22
|
-
export function loadProject(dirArg: string): Project {
|
|
23
|
+
export async function loadProject(dirArg: string): Promise<Project> {
|
|
23
24
|
const dir = resolve(dirArg);
|
|
24
25
|
if (!existsSync(dir) || !statSync(dir).isDirectory()) throw new ProjectError(`${dirArg} is not a directory`);
|
|
25
26
|
const pipelineFile = join(dir, "pipeline.ts");
|
|
26
27
|
if (!existsSync(pipelineFile)) throw new ProjectError(`${join(dirArg, "pipeline.ts")} not found (it must default-export pipeline({...}))`);
|
|
27
28
|
const contractFiles = ["pipeline.ts", "models.ts"].map((f) => join(dir, f)).filter((f) => existsSync(f));
|
|
28
29
|
const hdir = join(dir, "handlers");
|
|
29
|
-
const
|
|
30
|
+
const files = existsSync(hdir)
|
|
30
31
|
? readdirSync(hdir)
|
|
31
32
|
.filter((f) => f.endsWith(".ts") && !f.endsWith(".test.ts") && !f.endsWith(".d.ts") && statSync(join(hdir, f)).isFile())
|
|
32
33
|
.sort()
|
|
33
|
-
.map((f) => [f.slice(0, -3), join(hdir, f)])
|
|
34
34
|
: [];
|
|
35
|
-
return { dir, pipelineFile, contractFiles, handlers };
|
|
35
|
+
return { dir, pipelineFile, contractFiles, handlers: await resolveHandlers(hdir, files) };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Pairs each handler file with the step its default export implements (file names don't matter). */
|
|
39
|
+
async function resolveHandlers(hdir: string, files: string[]): Promise<[string, string][]> {
|
|
40
|
+
const byStep = new Map<string, string>();
|
|
41
|
+
for (const f of files) {
|
|
42
|
+
const file = join(hdir, f);
|
|
43
|
+
let impl: unknown;
|
|
44
|
+
try {
|
|
45
|
+
impl = ((await import(file)) as { default?: unknown }).default;
|
|
46
|
+
} catch (e) {
|
|
47
|
+
throw new ProjectError(`handlers/${f} failed to load: ${(e as Error)?.message ?? String(e)}`);
|
|
48
|
+
}
|
|
49
|
+
if (!isImplementation(impl))
|
|
50
|
+
throw new ProjectError(`handlers/${f} must default-export implement(<step>, …); put shared code in a subfolder such as handlers/lib/`);
|
|
51
|
+
const step = (impl.step as { name: string }).name;
|
|
52
|
+
const other = byStep.get(step);
|
|
53
|
+
if (other) throw new ProjectError(`handlers/${other.slice(hdir.length + 1)} and handlers/${f} both implement step '${step}'`);
|
|
54
|
+
byStep.set(step, file);
|
|
55
|
+
}
|
|
56
|
+
return [...byStep].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
36
57
|
}
|
|
37
58
|
|
|
38
59
|
function listFiles(dir: string): string[] {
|
package/src/builder/pipeline.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { AnyModel, Infer, InferInput, Plain } from "../core/models.ts";
|
|
|
3
3
|
import type { Secret } from "../core/secrets.ts";
|
|
4
4
|
import type { AnyStep, OutputValue, Step } from "../core/steps.ts";
|
|
5
5
|
import { isStep } from "../core/steps.ts";
|
|
6
|
+
import type { WebhookEvent, WebhookVerify } from "../core/webhooks.ts";
|
|
6
7
|
import type { IsUnion, OrError, Simplify } from "../core/types.ts";
|
|
7
8
|
import type { Optional } from "../core/models.ts";
|
|
8
9
|
import type { FailureContext } from "../library/models.ts";
|
|
@@ -70,7 +71,7 @@ type SiblingNames<E extends readonly unknown[], K> = {
|
|
|
70
71
|
}[number];
|
|
71
72
|
|
|
72
73
|
/**
|
|
73
|
-
* Per-entry check. Inference constraints
|
|
74
|
+
* Per-entry check. Inference constraints:
|
|
74
75
|
* - the [step, mapper] branch must be exactly `readonly [S, (from) => …]` — wrapping S or the whole
|
|
75
76
|
* mapper type in a conditional/intersection breaks inference of E or contextual typing of `from`;
|
|
76
77
|
* so name errors of tuple entries are reported on the mapper's return type instead;
|
|
@@ -146,7 +147,8 @@ export type TriggerNode =
|
|
|
146
147
|
readonly valuePath: string;
|
|
147
148
|
readonly intervalSeconds: number;
|
|
148
149
|
readonly map: (value: string) => unknown;
|
|
149
|
-
}
|
|
150
|
+
}
|
|
151
|
+
| { readonly kind: "webhook"; readonly name: string; readonly verify: WebhookVerify; readonly map: (event: WebhookEvent) => unknown };
|
|
150
152
|
|
|
151
153
|
export interface FailureHandlerNode {
|
|
152
154
|
readonly step: AnyStep;
|
|
@@ -176,6 +178,11 @@ export function isPipeline(p: unknown): boolean {
|
|
|
176
178
|
|
|
177
179
|
// ─── Pipeline builder ────────────────────────────────────────────────────────────────────────
|
|
178
180
|
|
|
181
|
+
// Builder calls record into shared data and hand back a fresh handle; a handle that was already
|
|
182
|
+
// extended no longer matches its type (it would claim fewer stages than the data has), so reuse is a problem.
|
|
183
|
+
const extended = new WeakSet<object>();
|
|
184
|
+
const ReusedProblem = "a pipeline value was extended twice: chain the calls (pipeline({...}).stage(a).stage(b)) instead of reusing an earlier value";
|
|
185
|
+
|
|
179
186
|
export interface PipelineDef<In extends AnyModel> {
|
|
180
187
|
/** Display name. */
|
|
181
188
|
name: string;
|
|
@@ -201,10 +208,19 @@ export class Pipeline<In extends AnyModel, A extends AvailMap> {
|
|
|
201
208
|
};
|
|
202
209
|
}
|
|
203
210
|
|
|
211
|
+
private next(): never {
|
|
212
|
+
const data = this[PIPELINE];
|
|
213
|
+
if (extended.has(this) && !data.problems.includes(ReusedProblem)) data.problems.push(ReusedProblem);
|
|
214
|
+
extended.add(this);
|
|
215
|
+
const handle = Object.create(Object.getPrototypeOf(this)) as Pipeline<In, A>;
|
|
216
|
+
(handle as { [PIPELINE]: PipelineData })[PIPELINE] = data;
|
|
217
|
+
return handle as never;
|
|
218
|
+
}
|
|
219
|
+
|
|
204
220
|
/** Map a GitHub push to the pipeline input (`z.input` view). Runs as code in a `map-trigger` Job per event. */
|
|
205
221
|
onPush<R extends InferInput<In>>(map: (event: PushEvent) => Exact<R, InferInput<In>>): this {
|
|
206
222
|
this[PIPELINE].triggers.push({ kind: "push", name: "push", map: map as (event: unknown) => unknown });
|
|
207
|
-
return this;
|
|
223
|
+
return this.next();
|
|
208
224
|
}
|
|
209
225
|
|
|
210
226
|
/**
|
|
@@ -233,7 +249,19 @@ export class Pipeline<In extends AnyModel, A extends AvailMap> {
|
|
|
233
249
|
intervalSeconds: cfg.intervalSeconds,
|
|
234
250
|
map: map as (value: string) => unknown,
|
|
235
251
|
});
|
|
236
|
-
return this;
|
|
252
|
+
return this.next();
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Start a run from a delivery to `/api/hooks/<pipeline id>/<name>`, authenticated by `verify`. Map it to the
|
|
257
|
+
* pipeline input (`z.input` view), or return `null` to ignore it. Runs as code in a `map-trigger` Job per delivery.
|
|
258
|
+
*/
|
|
259
|
+
onWebhook<R extends InferInput<In>>(
|
|
260
|
+
cfg: { name: string; verify: WebhookVerify },
|
|
261
|
+
map: (event: WebhookEvent) => Exact<R, InferInput<In>> | null,
|
|
262
|
+
): this {
|
|
263
|
+
this[PIPELINE].triggers.push({ kind: "webhook", name: cfg.name, verify: cfg.verify, map: map as (event: WebhookEvent) => unknown });
|
|
264
|
+
return this.next();
|
|
237
265
|
}
|
|
238
266
|
|
|
239
267
|
/**
|
|
@@ -258,7 +286,7 @@ export class Pipeline<In extends AnyModel, A extends AvailMap> {
|
|
|
258
286
|
});
|
|
259
287
|
if (entries.length === 0) data.problems.push("empty stage");
|
|
260
288
|
data.nodes.push({ kind: "stage", steps });
|
|
261
|
-
return this;
|
|
289
|
+
return this.next();
|
|
262
290
|
}
|
|
263
291
|
|
|
264
292
|
/** Declarative condition on literal-like pipeline-input fields. Outputs produced inside become maybe-available after. */
|
|
@@ -266,9 +294,10 @@ export class Pipeline<In extends AnyModel, A extends AvailMap> {
|
|
|
266
294
|
const data = this[PIPELINE];
|
|
267
295
|
const inner = new Pipeline<In, A>({ name: data.name, input: data.input as In }, data);
|
|
268
296
|
const returned = block(inner);
|
|
269
|
-
if (returned
|
|
297
|
+
if (!isPipeline(returned) || pipelineData(returned) !== inner[PIPELINE])
|
|
298
|
+
data.problems.push("a when block must return the pipeline it was given (p => p.stage(...))");
|
|
270
299
|
data.nodes.push({ kind: "when", cond: { ...(cond as object) }, nodes: inner[PIPELINE].nodes });
|
|
271
|
-
return this
|
|
300
|
+
return this.next();
|
|
272
301
|
}
|
|
273
302
|
|
|
274
303
|
/** Run `handler` (a step whose inputs are all FailureContext) when one of `steps` (default: any) fails. Untracked, best-effort. */
|
|
@@ -281,7 +310,7 @@ export class Pipeline<In extends AnyModel, A extends AvailMap> {
|
|
|
281
310
|
opts?: { steps?: readonly Step<StepNames<A>, any, any, any>[] },
|
|
282
311
|
): this {
|
|
283
312
|
this[PIPELINE].failureHandlers.push({ step: handler as AnyStep, steps: opts?.steps ? [...(opts.steps as readonly AnyStep[])] : null });
|
|
284
|
-
return this;
|
|
313
|
+
return this.next();
|
|
285
314
|
}
|
|
286
315
|
}
|
|
287
316
|
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Secret } from "./secrets.ts";
|
|
2
|
+
|
|
3
|
+
/** How a webhook trigger authenticates deliveries. Build one with `hmacSha256`, `githubSignature` or `bearer`. */
|
|
4
|
+
export type WebhookVerify =
|
|
5
|
+
| { readonly scheme: "hmac-sha256"; readonly secret: Secret; readonly header: string; readonly prefix: string }
|
|
6
|
+
| { readonly scheme: "bearer"; readonly secret: Secret };
|
|
7
|
+
|
|
8
|
+
/** HMAC-SHA256 of the raw body keyed by `secret`, hex, in `header` after `prefix`. */
|
|
9
|
+
export function hmacSha256(o: { secret: Secret; header: string; prefix?: string }): WebhookVerify {
|
|
10
|
+
return Object.freeze({ scheme: "hmac-sha256", secret: o.secret, header: o.header, prefix: o.prefix ?? "" });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** GitHub's delivery signature: `X-Hub-Signature-256: sha256=<hex>`. */
|
|
14
|
+
export function githubSignature(secret: Secret): WebhookVerify {
|
|
15
|
+
return hmacSha256({ secret, header: "X-Hub-Signature-256", prefix: "sha256=" });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** `Authorization: Bearer <secret>`. */
|
|
19
|
+
export function bearer(secret: Secret): WebhookVerify {
|
|
20
|
+
return Object.freeze({ scheme: "bearer", secret });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* What a webhook trigger mapper receives: the request headers (lower-case names, without `authorization`
|
|
25
|
+
* and the signature header) and the body (parsed JSON, or the raw text when it isn't JSON).
|
|
26
|
+
*/
|
|
27
|
+
export interface WebhookEvent {
|
|
28
|
+
readonly headers: Readonly<Record<string, string>>;
|
|
29
|
+
readonly body: unknown;
|
|
30
|
+
}
|
package/src/ctx/sh.ts
CHANGED
|
@@ -85,7 +85,8 @@ export function createSh(deps: ShDeps): { sh: Sh; exec: (argv: readonly string[]
|
|
|
85
85
|
const scriptPath = typeof cmd === "object" ? cmd.script : isEmbeddedScript(cmd) ? cmd : undefined;
|
|
86
86
|
if (scriptPath === undefined) {
|
|
87
87
|
const shell = detectShell(deps.shellCandidates);
|
|
88
|
-
|
|
88
|
+
// `sh -c cmd sh a b`: the args are $1, $2… of the command.
|
|
89
|
+
return { argv: [shell, "-c", cmd as string, "sh", ...args], what: `command \`${truncate(cmd as string)}\`` };
|
|
89
90
|
}
|
|
90
91
|
const file = materialize(scriptPath);
|
|
91
92
|
const first = readFileSync(file, "utf8").split("\n", 1)[0] ?? "";
|