@olivervea/pipelines 2.0.0-beta.1 → 2.0.0-beta.11

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.
@@ -5,7 +5,9 @@
5
5
  import { mkdirSync, readFileSync } from "node:fs";
6
6
  import { join } from "node:path";
7
7
  import { isImplementation } from "../core/handlers.ts";
8
+ import { configRefPath, isConfigRef, unionAt } from "../core/config.ts";
8
9
  import type { AnyStep } from "../core/steps.ts";
10
+ import type { WebhookEvent } from "../core/webhooks.ts";
9
11
  import { pipelineData, type PipelineData, type StageNode } from "../builder/pipeline.ts";
10
12
  import { createCtx, formatLog } from "../ctx/index.ts";
11
13
  import { DescribeError, describePipeline, manifestText } from "../describe/describe.ts";
@@ -24,6 +26,8 @@ export interface HarnessConfig {
24
26
  pipeline: unknown;
25
27
  handlers: HandlerRegistry;
26
28
  handlerHashes: Readonly<Record<string, string>>;
29
+ /** Kind → hash of the code of the steps of that kind that carry their implementation. */
30
+ kindHashes?: Readonly<Record<string, string>>;
27
31
  harness: HarnessInfo;
28
32
  }
29
33
 
@@ -45,14 +49,43 @@ const USAGE = "usage: step describe [--sentinels] | step run <step> | step map-t
45
49
 
46
50
  export function allSteps(data: PipelineData): AnyStep[] {
47
51
  const out: AnyStep[] = [];
48
- const walk = (nodes: readonly StageNode[]) => {
49
- for (const n of nodes) n.kind === "stage" ? out.push(...n.steps.map((s) => s.step)) : walk(n.nodes);
52
+ for (const { step } of stepsWithCases(data)) if (!out.includes(step)) out.push(step);
53
+ return out;
54
+ }
55
+
56
+ /** Every step with the `select` case conditions (`<ref>.<discriminator>=<value>`) it sits in. */
57
+ export function stepsWithCases(data: PipelineData): { step: AnyStep; cases: string[] }[] {
58
+ const out: { step: AnyStep; cases: string[] }[] = [];
59
+ const walk = (nodes: readonly StageNode[], cases: string[]) => {
60
+ for (const n of nodes) {
61
+ if (n.kind === "stage") out.push(...n.steps.map((s) => ({ step: s.step, cases })));
62
+ else if (n.kind === "when") walk(n.nodes, cases);
63
+ else {
64
+ const union = isConfigRef(n.on) ? unionAt(n.on) : undefined;
65
+ for (const [value, inner] of Object.entries(n.cases))
66
+ walk(inner, union ? [...cases, `${configRefPath(n.on)}.${union.discriminator}=${value}`] : cases);
67
+ }
68
+ }
50
69
  };
51
- walk(data.nodes);
52
- for (const fh of data.failureHandlers) if (!out.includes(fh.step)) out.push(fh.step);
70
+ walk(data.nodes, []);
71
+ for (const fh of data.failureHandlers) out.push({ step: fh.step, cases: [] });
53
72
  return out;
54
73
  }
55
74
 
75
+ /** The step `run <name>` executes: the only one so named, or the one in the cases the service selected (`OLVE_CASES`). */
76
+ export function findStep(data: PipelineData, name: string, selected: readonly string[]): AnyStep | string {
77
+ const candidates = stepsWithCases(data).filter((c) => c.step.name === name);
78
+ const distinct = candidates.filter((c, i) => candidates.findIndex((d) => d.step === c.step) === i);
79
+ if (distinct.length === 0) return `unknown step '${name}'`;
80
+ if (distinct.length === 1) return distinct[0]!.step;
81
+ const chosen = distinct.filter((c) => c.cases.every((k) => selected.includes(k)));
82
+ if (chosen.length === 1) return chosen[0]!.step;
83
+ return `step '${name}' is in several select cases; ${ENV_CASES} must name the selected one (got ${JSON.stringify(selected)})`;
84
+ }
85
+
86
+ /** The select case conditions of the step being run (JSON array), set by the service for steps inside a select. */
87
+ export const ENV_CASES = "OLVE_CASES";
88
+
56
89
  /** Installs SIGTERM/SIGINT handlers that abort the returned signal (PID 1 ignores signals without them). */
57
90
  export function cancellation(): { signal: AbortSignal; dispose(): void } {
58
91
  const ac = new AbortController();
@@ -69,24 +102,21 @@ export function cancellation(): { signal: AbortSignal; dispose(): void } {
69
102
  };
70
103
  }
71
104
 
72
- /** Resolves the function that implements `step` (library or handler module). */
105
+ /** Resolves the function that implements `step` (carried by the step, or a handler module). */
73
106
  export async function resolveStepFunction(step: AnyStep, handlers: HandlerRegistry): Promise<StepFunction | string> {
74
- if (step.kind !== "custom") {
75
- if (!step.builtin) return `library step '${step.name}' (${step.kind}) has no implementation`;
76
- return step.builtin.run as StepFunction;
77
- }
107
+ if (isImplementation(step)) return step.handler as unknown as StepFunction;
78
108
  const load = handlers[step.name];
79
- if (!load) return `step '${step.name}' has no handler (expected handlers/${step.name}.ts)`;
109
+ if (!load) return `step '${step.name}' has no handler`;
80
110
  let mod: { default: unknown };
81
111
  try {
82
112
  mod = await load();
83
113
  } catch (e) {
84
- return `handlers/${step.name}.ts failed to load: ${(e as Error)?.stack ?? String(e)}`;
114
+ return `the handler of step '${step.name}' failed to load: ${(e as Error)?.stack ?? String(e)}`;
85
115
  }
86
116
  const impl = mod.default;
87
- if (!isImplementation(impl)) return `handlers/${step.name}.ts must default-export implement(<step '${step.name}'>, …)`;
117
+ if (!isImplementation(impl)) return `the handler of step '${step.name}' must default-export implement(<step '${step.name}'>, …)`;
88
118
  if (impl.step !== step)
89
- return `handlers/${step.name}.ts implements step '${(impl.step as AnyStep)?.name}', not '${step.name}' (the default export must be implement(<the '${step.name}' step from pipeline.ts>, …))`;
119
+ return `the handler of step '${step.name}' implements a different step object (it must be implement(<the '${step.name}' step from pipeline.ts>, …))`;
90
120
  return impl.handler as unknown as StepFunction;
91
121
  }
92
122
 
@@ -120,7 +150,7 @@ function readJson(path: string): { ok: true; value: unknown } | { ok: false; mes
120
150
  async function describeMode(cfg: HarnessConfig, args: string[], io: HarnessIO): Promise<number> {
121
151
  const sentinels = args.includes("--sentinels");
122
152
  try {
123
- const text = manifestText(describePipeline(cfg.pipeline, { handlerNames: Object.keys(cfg.handlers), handlerHashes: cfg.handlerHashes, harness: cfg.harness }));
153
+ const text = manifestText(describePipeline(cfg.pipeline, { handlerNames: Object.keys(cfg.handlers), handlerHashes: cfg.handlerHashes, kindHashes: cfg.kindHashes, harness: cfg.harness }));
124
154
  await io.stdout(sentinels ? `${MANIFEST_BEGIN}\n${text}${MANIFEST_END}\n` : text);
125
155
  return EXIT.ok;
126
156
  } catch (e) {
@@ -135,8 +165,8 @@ async function describeMode(cfg: HarnessConfig, args: string[], io: HarnessIO):
135
165
  async function runMode(cfg: HarnessConfig, name: string | undefined, io: HarnessIO): Promise<number> {
136
166
  if (!name) return io.stderr(USAGE), EXIT.usage;
137
167
  const data = pipelineData(cfg.pipeline);
138
- const step = allSteps(data).find((s) => s.name === name);
139
- if (!step) return io.stderr(`unknown step '${name}'`), EXIT.usage;
168
+ const step = findStep(data, name, selectedCases());
169
+ if (typeof step === "string") return io.stderr(step), EXIT.usage;
140
170
  const fn = await resolveStepFunction(step, cfg.handlers);
141
171
  if (typeof fn === "string") return io.stderr(fn), EXIT.configInvalid;
142
172
 
@@ -162,6 +192,12 @@ async function runMode(cfg: HarnessConfig, name: string | undefined, io: Harness
162
192
  }
163
193
  }
164
194
 
195
+ function isWebhookEvent(v: unknown): boolean {
196
+ if (typeof v !== "object" || v === null || Array.isArray(v) || !("body" in v)) return false;
197
+ const headers = (v as { headers?: unknown }).headers;
198
+ return typeof headers === "object" && headers !== null && !Array.isArray(headers);
199
+ }
200
+
165
201
  async function mapTriggerMode(cfg: HarnessConfig, name: string | undefined, io: HarnessIO): Promise<number> {
166
202
  if (!name) return io.stderr(USAGE), EXIT.usage;
167
203
  const data = pipelineData(cfg.pipeline);
@@ -181,15 +217,21 @@ async function mapTriggerMode(cfg: HarnessConfig, name: string | undefined, io:
181
217
  exec = fail("input", `poll trigger '${name}': the event must be {"value": "<string>"}`, EXIT.inputInvalid);
182
218
  else if (trigger.kind === "push" && (typeof raw.value !== "object" || raw.value === null || Array.isArray(raw.value)))
183
219
  exec = fail("input", `push trigger: the event must be the webhook payload object`, EXIT.inputInvalid);
220
+ else if (trigger.kind === "webhook" && !isWebhookEvent(raw.value))
221
+ exec = fail("input", `webhook trigger '${name}': the event must be {"headers": {...}, "body": ...}`, EXIT.inputInvalid);
184
222
  else {
185
223
  let mapped: unknown;
186
224
  let threw: unknown = undefined;
187
225
  try {
188
- mapped = trigger.kind === "poll" ? await trigger.map((raw.value as { value: string }).value) : await trigger.map(raw.value);
226
+ mapped =
227
+ trigger.kind === "poll" ? await trigger.map((raw.value as { value: string }).value)
228
+ : trigger.kind === "webhook" ? await trigger.map(raw.value as WebhookEvent)
229
+ : await trigger.map(raw.value);
189
230
  } catch (e) {
190
231
  threw = e ?? new Error("undefined");
191
232
  }
192
233
  if (threw !== undefined) exec = fail("handler", `trigger '${name}' mapper threw: ${(threw as Error)?.message ?? String(threw)}`, EXIT.handlerFailed);
234
+ else if (mapped === null && trigger.kind === "webhook") exec = { envelope: { status: "skipped", reason: `trigger '${name}' ignored the delivery` }, exitCode: EXIT.ok };
193
235
  else {
194
236
  const r = parseStrict(data.input.schema, mapped, "input");
195
237
  exec = r.ok
@@ -217,3 +259,12 @@ export async function runHarness(cfg: HarnessConfig, argv: readonly string[], io
217
259
  }
218
260
 
219
261
  export { formatIssues, formatLog };
262
+
263
+ function selectedCases(): string[] {
264
+ try {
265
+ const v = JSON.parse(process.env[ENV_CASES] ?? "[]") as unknown;
266
+ return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : [];
267
+ } catch {
268
+ return [];
269
+ }
270
+ }
@@ -44,13 +44,31 @@ function entrySchema(e: unknown): z.ZodType {
44
44
  return e as z.ZodType;
45
45
  }
46
46
 
47
+ /** Reserved input.json key holding the step's config entries (written by the service). */
48
+ export const CONFIG_KEY = "$config";
49
+
50
+ /** Parses `$config` with each declared entry's schema (defaults applied). Undeclared entries are ignored. */
51
+ export function validateConfig(step: AnyStep, raw: unknown): Validation<Record<string, unknown>> {
52
+ const given = isPlainObject(raw) ? raw[CONFIG_KEY] : undefined;
53
+ if (given !== undefined && !isPlainObject(given)) return { ok: false, issues: [`input.${CONFIG_KEY} must be an object keyed by config entry names`] };
54
+ const issues: string[] = [];
55
+ const out: Record<string, unknown> = {};
56
+ for (const e of step.config ?? []) {
57
+ const value = given?.[e.name];
58
+ const r = e.schema.safeParse(value);
59
+ if (!r.success) issues.push(...formatIssues(r.error, `config.${e.name}`).map((i) => (value === undefined ? `${i} (config entry '${e.name}' is not set)` : i)));
60
+ else if (r.data !== undefined) out[e.name] = r.data;
61
+ }
62
+ return issues.length ? { ok: false, issues } : { ok: true, value: out };
63
+ }
64
+
47
65
  /** Validates input.json against the step's input entries. Unknown top-level keys are rejected; nested values are parsed (unknown nested keys stripped). */
48
66
  export function validateInput(step: AnyStep, raw: unknown): Validation<Record<string, unknown>> {
49
67
  if (!isPlainObject(raw)) return { ok: false, issues: ["input.json must be a JSON object keyed by the step's input names"] };
50
68
  const issues: string[] = [];
51
69
  const out: Record<string, unknown> = {};
52
70
  const declared = step.input as Record<string, unknown>;
53
- for (const k of Object.keys(raw)) if (!Object.hasOwn(declared, k)) issues.push(`input.${k}: not an input of step '${step.name}'`);
71
+ for (const k of Object.keys(raw)) if (k !== CONFIG_KEY && !Object.hasOwn(declared, k)) issues.push(`input.${k}: not an input of step '${step.name}'`);
54
72
  for (const [k, e] of Object.entries(declared)) {
55
73
  if (!Object.hasOwn(raw, k)) {
56
74
  if (isOptional(e)) continue;
package/src/index.ts CHANGED
@@ -1,11 +1,15 @@
1
1
  /// <reference path="./assets.d.ts" />
2
- // @olve/pipelines — authoring API (contracts, handlers, pipeline builder).
2
+ // @olivervea/pipelines — authoring API (contracts, handlers, pipeline builder).
3
3
  export { z } from "zod";
4
4
  export { model, fileRef, optional, type Model, type AnyModel, type Infer, type InferInput, type Plain, type FileRef, type Optional } from "./core/models.ts";
5
5
  export { secret, type Secret, type DeclaredSecrets } from "./core/secrets.ts";
6
- export { step, type Step, type AnyStep, type StepDef, type StepInput, type StepKind, type InputEntry, type InputSpec, type LibraryStep, type OutputValue } from "./core/steps.ts";
6
+ export { config, secretRef, isConfigRef, type ConfigEntry, type AnyConfigEntry, type ConfigValues, type ConfigRef, type ConfigNode, type Configurable, type DiscriminatorOf, type VariantOf } from "./core/config.ts";
7
+ export { hmacSha256, githubSignature, bearer, type WebhookVerify, type WebhookEvent } from "./core/webhooks.ts";
8
+ export { resolveConfigRefs, isLiteral, type Resolved } from "./core/config-refs.ts";
9
+ export { step, type Step, type AnyStep, type StepDef, type StepInput, type StepKind, type ConfigOf, type InputEntry, type InputSpec, type LiteralName, type OutputValue } from "./core/steps.ts";
10
+ export type { Simplify } from "./core/types.ts";
7
11
  export { implement, type Implementation, type Handler, type HandlerResult, type Skipped } from "./core/handlers.ts";
8
12
  export type { Ctx, RunInfo, Sh, ShOptions, ExecOptions, Http, HttpInit, RetryOptions, OutputFileOptions } from "./ctx/types.ts";
9
13
  export type { Ref, Mappable, MapperResult } from "./builder/refs.ts";
10
14
  export { pipeline, Pipeline, type PipelineDef, type InRefs, type InitialAvail, type WhenCond, type PushEvent, type StageEntry, type CheckStage, type MapperFn } from "./builder/pipeline.ts";
11
- export { steps, Source, BuiltImage, HelmRelease, FailureContext, type RunValue, type ScriptDef, type GitHubSourceDef, type ImageBuildDef, type HelmDeployOverSshDef, httpHealthCheck, type HttpHealthCheckOptions } from "./library/index.ts";
15
+ export { steps, Source, BuiltImage, PushedImage, HelmRelease, FailureContext, type GitHubSourceDef, type ImageBuildDef, type ImageBuildTarballDef, type ImageBuildPushDef, type RegistryAuth, type RegistryAuthConfig, type HelmDeployOverSshDef, helmTarget, deployTarget, type DeployTargetOptions, type ComposeDeployDef, type ComposeTargetValue, type HelmDeployInClusterDef, type HelmDeploySshTargetDef, type HelmTargetDeployDef, type HelmTargetValue, type InClusterTargetValue, type SshTargetValue, httpHealthCheck, type HttpHealthCheckOptions, sshSession, type SshSession, serviceUrl, checkSpa } from "./library/index.ts";
@@ -0,0 +1,23 @@
1
+ // Checks for deployed apps, used from custom test handlers.
2
+ import type { Ctx } from "../ctx/types.ts";
3
+
4
+ /** The in-cluster URL of a release's Service, assuming the chart names it after the release. */
5
+ export function serviceUrl(release: { release: string; namespace: string }): string {
6
+ return `http://${release.release}.${release.namespace}.svc.cluster.local`;
7
+ }
8
+
9
+ /**
10
+ * Checks `url` serves a single-page app: `GET <url>/` references a `/assets/*.js` bundle (Vite's
11
+ * layout), and the bundle is served as JavaScript. Returns the page, for app-specific checks.
12
+ */
13
+ export async function checkSpa(ctx: Pick<Ctx, "http" | "log">, url: string): Promise<{ html: string; bundle: string }> {
14
+ const base = url.replace(/\/$/, "");
15
+ const html = await (await ctx.http.expectOk(`${base}/`)).text();
16
+ const bundle = html.match(/src="(\/assets\/[^"]+\.js)"/)?.[1];
17
+ if (bundle === undefined) throw new Error(`${base}/ does not reference an /assets/*.js bundle`);
18
+ const js = await ctx.http.expectOk(`${base}${bundle}`);
19
+ await js.body?.cancel().catch(() => {});
20
+ if (!(js.headers.get("content-type")?.includes("javascript") ?? false)) throw new Error(`${base}${bundle} is not served as JavaScript`);
21
+ ctx.log("SPA served", { url: base, bundle });
22
+ return { html, bundle };
23
+ }
@@ -0,0 +1,96 @@
1
+ // steps.composeDeploy — ask a Docker Compose deployer to recreate a service with an image. The step is
2
+ // thin on purpose: the deployer (pull, recreate, health, rollback) lives beside the compose project
3
+ // and is fixed there, behind a versioned HTTP API, without editing pipelines.
4
+ import type { Ctx } from "../ctx/types.ts";
5
+ import type { Configurable } from "../core/config.ts";
6
+ import { resolveConfigRefs } from "../core/config-refs.ts";
7
+ import { implement, type Implementation } from "../core/handlers.ts";
8
+ import type { Secret } from "../core/secrets.ts";
9
+ import { step, type LiteralName, type Step } from "../core/steps.ts";
10
+ import { httpHealthCheck } from "./http-health.ts";
11
+ import { HelmRelease, PushedImage } from "./models.ts";
12
+
13
+ /** The step's own image: the work is one HTTP call from the harness. */
14
+ export const COMPOSE_DEPLOY_IMAGE = "alpine:3.22";
15
+
16
+ /** A resolved `kind: compose` target. */
17
+ export interface ComposeTargetValue {
18
+ kind: "compose";
19
+ deployerUrl: string;
20
+ service: string;
21
+ token: { secret: string };
22
+ healthUrl?: string | undefined;
23
+ }
24
+
25
+ /** What the deployer answers to `POST /v1/deploy`. */
26
+ export interface ComposeDeployResult {
27
+ status: "deployed" | "rolled-back" | "failed";
28
+ service: string;
29
+ image: string;
30
+ previousImage?: string;
31
+ message?: string;
32
+ log?: string[];
33
+ }
34
+
35
+ export interface ComposeDeployDef<N extends string, K extends string> {
36
+ name: LiteralName<N>;
37
+ /** What the step does, shown in the UI. Required; may be empty. */
38
+ description: string;
39
+ /** A compose target, usually a `select` case's reference. */
40
+ target: Configurable<ComposeTargetValue>;
41
+ /** The secret the target's `token` names (declared by the step). */
42
+ token: Secret<K>;
43
+ timeoutSeconds?: number;
44
+ }
45
+
46
+ const input = { image: PushedImage };
47
+
48
+ /**
49
+ * Deploys a registry image (by digest) to a compose service: `POST <deployerUrl>/v1/deploy
50
+ * { service, image: <repository>@<digest> }` with the target's bearer token. The deployer pulls the
51
+ * image, recreates the service, waits for its health and rolls back on failure; the step reports its
52
+ * answer, then (with the target's `healthUrl`) checks health itself. Output: a `HelmRelease` with the
53
+ * service as `release` and `compose` as `namespace`, so it can share a step name with the helm deploys.
54
+ */
55
+ export function composeDeploy<const N extends string, const K extends string>(
56
+ def: ComposeDeployDef<N, K>,
57
+ ): Implementation<Step<N, typeof input, typeof HelmRelease, K>> {
58
+ const contract = step({
59
+ name: def.name,
60
+ description: def.description,
61
+ kind: "composeDeploy",
62
+ image: COMPOSE_DEPLOY_IMAGE,
63
+ input,
64
+ output: HelmRelease,
65
+ secrets: [def.token],
66
+ timeoutSeconds: def.timeoutSeconds,
67
+ settings: { target: def.target, token: def.token.name },
68
+ });
69
+ return implement(contract, async ({ image }, ctx: Ctx) => {
70
+ const target = resolveConfigRefs(def.target, ctx.config ?? {}) as ComposeTargetValue;
71
+ if (target?.kind !== "compose") throw new Error(`composeDeploy '${def.name}': the target is kind '${String(target?.kind)}', not 'compose'`);
72
+ if (target.token.secret !== def.token.name) throw new Error(`composeDeploy '${def.name}': the target's token names secret ${target.token.secret}, but the step declares ${def.token.name}`);
73
+ const token = (ctx.secrets as Record<string, string>)[def.token.name];
74
+ const ref = `${image.repository}@${image.digest}`;
75
+ const url = `${target.deployerUrl.replace(/\/+$/, "")}/v1/deploy`;
76
+ ctx.log("deploying", { service: target.service, image: ref, deployer: target.deployerUrl });
77
+ const res = await ctx.http.fetch(url, {
78
+ method: "POST",
79
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
80
+ body: JSON.stringify({ service: target.service, image: ref }),
81
+ });
82
+ const text = await res.text();
83
+ let result: ComposeDeployResult | undefined;
84
+ try {
85
+ result = JSON.parse(text) as ComposeDeployResult;
86
+ } catch {
87
+ // reported below
88
+ }
89
+ for (const line of result?.log ?? []) ctx.log(`deployer: ${line}`);
90
+ if (!res.ok || result?.status !== "deployed")
91
+ throw new Error(`the deployer answered HTTP ${res.status}: ${result ? `${result.status}${result.message ? `: ${result.message}` : ""}` : text.slice(0, 300)}`);
92
+ if (target.healthUrl) await httpHealthCheck(ctx, target.healthUrl, { attempts: 12, delaySeconds: 5 });
93
+ ctx.log("deployed", { service: target.service, image: ref, previous: result.previousImage ?? "-" });
94
+ return { release: target.service, namespace: "compose", image: ref };
95
+ }) as never;
96
+ }
@@ -7,7 +7,10 @@ import { join } from "node:path";
7
7
  import { z } from "zod";
8
8
  import type { Ctx } from "../ctx/types.ts";
9
9
  import type { Secret } from "../core/secrets.ts";
10
- import { makeStep, type LibraryStep, type LiteralName, type Step } from "../core/steps.ts";
10
+ import type { Configurable } from "../core/config.ts";
11
+ import { resolveConfigRefs } from "../core/config-refs.ts";
12
+ import { implement, type Implementation } from "../core/handlers.ts";
13
+ import { step, type LiteralName, type Step } from "../core/steps.ts";
11
14
  import { extractTar } from "../util/tar.ts";
12
15
  import { Source } from "./models.ts";
13
16
 
@@ -23,11 +26,25 @@ export interface GitHubSourceDef<N extends string, T extends string> {
23
26
  description: string;
24
27
  /** Token sent to the GitHub API (needed for private repositories; raises the rate limit). */
25
28
  token?: Secret<T>;
29
+ /** GitHub API base (default `https://api.github.com`; GitHub Enterprise: `https://<host>/api/v3`). */
30
+ apiUrl?: Configurable<string | undefined>;
26
31
  timeoutSeconds?: number;
27
32
  }
28
33
 
34
+ export const GITHUB_API_URL = "https://api.github.com";
35
+
36
+ /** A declared secret's value, or "" when it is not set. */
37
+ function optionalSecret(ctx: Pick<Ctx, "secrets">, name: string): string {
38
+ try {
39
+ return ((ctx.secrets as Record<string, string>)[name] ?? "").trim();
40
+ } catch {
41
+ return "";
42
+ }
43
+ }
44
+
29
45
  /** Tarball URL of `repo` at `sha` (the API redirects to codeload.github.com). */
30
- export const gitHubTarballUrl = (repo: string, sha: string): string => `https://api.github.com/repos/${repo}/tarball/${encodeURIComponent(sha)}`;
46
+ export const gitHubTarballUrl = (repo: string, sha: string, apiUrl = GITHUB_API_URL): string =>
47
+ `${apiUrl.replace(/\/+$/, "")}/repos/${repo}/tarball/${encodeURIComponent(sha)}`;
31
48
 
32
49
  /**
33
50
  * Downloads `repo` at `sha` from GitHub as a tarball and outputs it as a `Source` whose `tree` is the
@@ -36,42 +53,48 @@ export const gitHubTarballUrl = (repo: string, sha: string): string => `https://
36
53
  */
37
54
  export function gitHubSource<const N extends string, const T extends string = never>(
38
55
  def: GitHubSourceDef<N, T>,
39
- ): Step<N, GitHubSourceInput, typeof Source, T> & LibraryStep {
40
- return makeStep(
41
- "gitHubSource",
42
- { name: def.name, description: def.description, image: GITHUB_SOURCE_IMAGE, input, output: Source, secrets: def.token ? [def.token] : [], timeoutSeconds: def.timeoutSeconds },
43
- {
44
- manifestConfig: () => ({ ...(def.token ? { token: def.token.name } : {}) }),
45
- async run({ repo, sha }: { repo: string; sha: string }, ctx: Ctx) {
46
- const headers: Record<string, string> = { accept: "application/vnd.github+json", "user-agent": "olve-pipelines" };
47
- if (def.token) headers.authorization = `Bearer ${ctx.secrets[def.token.name]}`;
48
- const url = gitHubTarballUrl(repo, sha);
49
- mkdirSync(ctx.tmpDir, { recursive: true });
50
- const dir = mkdtempSync(join(ctx.tmpDir, "github-source-"));
51
- const archive = join(dir, "repo.tar.gz");
56
+ ): Implementation<Step<N, GitHubSourceInput, typeof Source, T>> {
57
+ const contract = step({
58
+ name: def.name,
59
+ description: def.description,
60
+ kind: "gitHubSource",
61
+ image: GITHUB_SOURCE_IMAGE,
62
+ input,
63
+ output: Source,
64
+ secrets: def.token ? [def.token] : [],
65
+ timeoutSeconds: def.timeoutSeconds,
66
+ settings: { ...(def.token ? { token: def.token.name } : {}), ...(def.apiUrl !== undefined ? { apiUrl: def.apiUrl } : {}) },
67
+ });
68
+ return implement(contract, async ({ repo, sha }, ctx: Ctx) => {
69
+ const headers: Record<string, string> = { accept: "application/vnd.github+json", "user-agent": "olve-pipelines" };
70
+ const token = def.token ? optionalSecret(ctx, def.token.name) : "";
71
+ if (token) headers.authorization = `Bearer ${token}`;
72
+ else if (def.token) ctx.log(`${def.token.name} is not set: fetching anonymously (public repositories only)`);
73
+ const url = gitHubTarballUrl(repo, sha, resolveConfigRefs(def.apiUrl, ctx.config ?? {}) ?? GITHUB_API_URL);
74
+ mkdirSync(ctx.tmpDir, { recursive: true });
75
+ const dir = mkdtempSync(join(ctx.tmpDir, "github-source-"));
76
+ const archive = join(dir, "repo.tar.gz");
52
77
 
53
- ctx.log("fetching source", { repo, sha });
54
- // Retries network errors and 5xx; a 4xx (bad sha, no access) fails at once.
55
- const status = await ctx.retry({ attempts: 4, delaySeconds: 2, backoff: 2 }, async () => {
56
- const res = await ctx.http.fetch(url, { headers });
57
- if (res.status >= 500) throw new Error(`HTTP ${res.status} from ${url}`);
58
- if (res.ok) await Bun.write(archive, res);
59
- return res.status;
60
- });
61
- if (status < 200 || status >= 300) {
62
- throw new Error(`cannot fetch ${repo}@${sha}: HTTP ${status} from ${url}${def.token ? "" : " (no token configured: a private repository needs one)"}`);
63
- }
78
+ ctx.log("fetching source", { repo, sha });
79
+ // Retries network errors and 5xx; a 4xx (bad sha, no access) fails at once.
80
+ const status = await ctx.retry({ attempts: 4, delaySeconds: 2, backoff: 2 }, async () => {
81
+ const res = await ctx.http.fetch(url, { headers });
82
+ if (res.status >= 500) throw new Error(`HTTP ${res.status} from ${url}`);
83
+ if (res.ok) await Bun.write(archive, res);
84
+ return res.status;
85
+ });
86
+ if (status < 200 || status >= 300) {
87
+ throw new Error(`cannot fetch ${repo}@${sha}: HTTP ${status} from ${url}${def.token ? "" : " (no token configured: a private repository needs one)"}`);
88
+ }
64
89
 
65
- const extracted = join(dir, "x");
66
- mkdirSync(extracted);
67
- await extractTar(archive, extracted);
68
- // GitHub tarballs hold one top-level directory, plus a pax_global_header entry (the commit id)
69
- // that some extractors materialise as a file.
70
- const roots = readdirSync(extracted).filter((e) => e !== "pax_global_header" && statSync(join(extracted, e)).isDirectory());
71
- if (roots.length !== 1) throw new Error(`unexpected tarball layout for ${repo}@${sha}: top-level entries ${JSON.stringify(readdirSync(extracted))}`);
72
- const tree = await ctx.output.dir(join(extracted, roots[0]!), { name: "tree" });
73
- return { repo, sha, tree };
74
- },
75
- },
76
- ) as never;
90
+ const extracted = join(dir, "x");
91
+ mkdirSync(extracted);
92
+ await extractTar(archive, extracted);
93
+ // GitHub tarballs hold one top-level directory, plus a pax_global_header entry (the commit id)
94
+ // that some extractors materialise as a file.
95
+ const roots = readdirSync(extracted).filter((e) => e !== "pax_global_header" && statSync(join(extracted, e)).isDirectory());
96
+ if (roots.length !== 1) throw new Error(`unexpected tarball layout for ${repo}@${sha}: top-level entries ${JSON.stringify(readdirSync(extracted))}`);
97
+ const tree = await ctx.output.dir(join(extracted, roots[0]!), { name: "tree" });
98
+ return { repo, sha, tree };
99
+ }) as never;
77
100
  }