@olivervea/pipelines 2.0.0-beta.2 → 2.0.0-beta.20

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 (54) hide show
  1. package/README.md +125 -62
  2. package/manifest.schema.json +208 -11
  3. package/package.json +6 -4
  4. package/src/assets.d.ts +2 -2
  5. package/src/build/build.ts +121 -51
  6. package/src/build/cli.ts +29 -22
  7. package/src/build/elf.ts +8 -7
  8. package/src/build/hashes.ts +82 -20
  9. package/src/build/project.ts +95 -28
  10. package/src/builder/pipeline.ts +344 -88
  11. package/src/builder/refs.ts +15 -5
  12. package/src/core/brands.ts +7 -5
  13. package/src/core/config-refs.ts +103 -0
  14. package/src/core/config.ts +224 -0
  15. package/src/core/external.ts +86 -0
  16. package/src/core/handlers.ts +61 -15
  17. package/src/core/models.ts +32 -15
  18. package/src/core/origin.ts +23 -0
  19. package/src/core/secrets.ts +2 -2
  20. package/src/core/steps.ts +89 -38
  21. package/src/core/types.ts +5 -1
  22. package/src/core/webhooks.ts +21 -7
  23. package/src/ctx/files.ts +111 -40
  24. package/src/ctx/index.ts +84 -31
  25. package/src/ctx/sh.ts +105 -44
  26. package/src/ctx/types.ts +12 -4
  27. package/src/describe/describe.ts +701 -133
  28. package/src/describe/manifest-schema.ts +258 -61
  29. package/src/describe/mapper-source.ts +249 -104
  30. package/src/describe/record.ts +67 -37
  31. package/src/dev/cli.ts +72 -35
  32. package/src/harness/env.ts +45 -25
  33. package/src/harness/execute.ts +58 -29
  34. package/src/harness/index.ts +22 -9
  35. package/src/harness/main.ts +239 -78
  36. package/src/harness/result.ts +38 -22
  37. package/src/harness/validate.ts +77 -15
  38. package/src/index.ts +124 -11
  39. package/src/library/checks.ts +28 -0
  40. package/src/library/compose-deploy.ts +113 -0
  41. package/src/library/github-source.ts +100 -47
  42. package/src/library/helm-deploy-ssh.ts +452 -108
  43. package/src/library/helm-deploy.ts +449 -0
  44. package/src/library/http-health.ts +7 -5
  45. package/src/library/image-build.ts +499 -105
  46. package/src/library/index.ts +48 -12
  47. package/src/library/models.ts +33 -9
  48. package/src/library/ssh.ts +67 -0
  49. package/src/testing/index.ts +74 -36
  50. package/src/util/canonical.ts +7 -4
  51. package/src/util/handler-hash.ts +17 -7
  52. package/src/util/hash.ts +5 -5
  53. package/src/util/tar.ts +86 -36
  54. package/src/library/script.ts +0 -85
@@ -1,22 +1,30 @@
1
- import { z } from "zod";
2
- import { isModel, isOptional } from "../core/models.ts";
3
- import type { AnyStep } from "../core/steps.ts";
1
+ import { z } from 'zod';
2
+ import { isModel, isOptional } from '../core/models.ts';
3
+ import { isExternal } from '../core/external.ts';
4
+ import type { AnyStep } from '../core/steps.ts';
4
5
 
5
6
  export type Validation<T> = { ok: true; value: T } | { ok: false; issues: string[] };
6
7
 
7
8
  export function formatIssues(err: z.ZodError, prefix: string): string[] {
8
- return err.issues.map((i) => `${prefix}${i.path.length ? "." + i.path.map(String).join(".") : ""}: ${i.message}`);
9
+ return err.issues.map(
10
+ (i) =>
11
+ `${prefix}${i.path.length ? '.' + i.path.map(String).join('.') : ''}: ${i.message}`,
12
+ );
9
13
  }
10
14
 
11
15
  const isPlainObject = (v: unknown): v is Record<string, unknown> =>
12
- typeof v === "object" && v !== null && !Array.isArray(v) && (Object.getPrototypeOf(v) === Object.prototype || Object.getPrototypeOf(v) === null);
16
+ typeof v === 'object' &&
17
+ v !== null &&
18
+ !Array.isArray(v) &&
19
+ (Object.getPrototypeOf(v) === Object.prototype || Object.getPrototypeOf(v) === null);
13
20
 
14
21
  /**
15
22
  * zod strips unknown keys silently (at every depth). Compares the raw value with the parsed one:
16
23
  * any key present in a raw object but absent from the parsed object at the same place was stripped.
17
24
  */
18
- export function strippedKeys(raw: unknown, parsed: unknown, path = ""): string[] {
19
- if (Array.isArray(raw) && Array.isArray(parsed)) return raw.flatMap((x, i) => strippedKeys(x, parsed[i], `${path}[${i}]`));
25
+ export function strippedKeys(raw: unknown, parsed: unknown, path = ''): string[] {
26
+ if (Array.isArray(raw) && Array.isArray(parsed))
27
+ return raw.flatMap((x, i) => strippedKeys(x, parsed[i], `${path}[${i}]`));
20
28
  if (isPlainObject(raw) && isPlainObject(parsed)) {
21
29
  const out: string[] = [];
22
30
  for (const k of Object.keys(raw)) {
@@ -30,27 +38,76 @@ export function strippedKeys(raw: unknown, parsed: unknown, path = ""): string[]
30
38
  }
31
39
 
32
40
  /** Strict parse: zod validation plus rejection of unknown keys at any depth. */
33
- export function parseStrict(schema: z.ZodType, value: unknown, prefix: string): Validation<unknown> {
41
+ export function parseStrict(
42
+ schema: z.ZodType,
43
+ value: unknown,
44
+ prefix: string,
45
+ ): Validation<unknown> {
34
46
  const r = schema.safeParse(value);
35
47
  if (!r.success) return { ok: false, issues: formatIssues(r.error, prefix) };
36
48
  const extra = strippedKeys(value, r.data);
37
- if (extra.length) return { ok: false, issues: extra.map((k) => `${prefix}.${k}: unknown field (outputs are validated strictly)`) };
49
+ if (extra.length)
50
+ return {
51
+ ok: false,
52
+ issues: extra.map(
53
+ (k) => `${prefix}.${k}: unknown field (outputs are validated strictly)`,
54
+ ),
55
+ };
38
56
  return { ok: true, value: r.data };
39
57
  }
40
58
 
41
59
  function entrySchema(e: unknown): z.ZodType {
42
- if (isOptional(e)) return e.model.schema;
60
+ if (isOptional(e) || isExternal(e)) return e.model.schema;
43
61
  if (isModel(e)) return e.schema;
44
62
  return e as z.ZodType;
45
63
  }
46
64
 
65
+ /** Reserved input.json key holding the step's config entries (written by the service). */
66
+ export const CONFIG_KEY = '$config';
67
+
68
+ /** Parses `$config` with each declared entry's schema (defaults applied). Undeclared entries are ignored. */
69
+ export function validateConfig(
70
+ step: AnyStep,
71
+ raw: unknown,
72
+ ): Validation<Record<string, unknown>> {
73
+ const given = isPlainObject(raw) ? raw[CONFIG_KEY] : undefined;
74
+ if (given !== undefined && !isPlainObject(given))
75
+ return {
76
+ ok: false,
77
+ issues: [`input.${CONFIG_KEY} must be an object keyed by config entry names`],
78
+ };
79
+ const issues: string[] = [];
80
+ const out: Record<string, unknown> = {};
81
+ for (const e of step.config ?? []) {
82
+ const value = given?.[e.name];
83
+ const r = e.schema.safeParse(value);
84
+ if (!r.success)
85
+ issues.push(
86
+ ...formatIssues(r.error, `config.${e.name}`).map((i) =>
87
+ value === undefined ? `${i} (config entry '${e.name}' is not set)` : i,
88
+ ),
89
+ );
90
+ else if (r.data !== undefined) out[e.name] = r.data;
91
+ }
92
+ return issues.length ? { ok: false, issues } : { ok: true, value: out };
93
+ }
94
+
47
95
  /** Validates input.json against the step's input entries. Unknown top-level keys are rejected; nested values are parsed (unknown nested keys stripped). */
48
- export function validateInput(step: AnyStep, raw: unknown): Validation<Record<string, unknown>> {
49
- if (!isPlainObject(raw)) return { ok: false, issues: ["input.json must be a JSON object keyed by the step's input names"] };
96
+ export function validateInput(
97
+ step: AnyStep,
98
+ raw: unknown,
99
+ ): Validation<Record<string, unknown>> {
100
+ if (!isPlainObject(raw))
101
+ return {
102
+ ok: false,
103
+ issues: ["input.json must be a JSON object keyed by the step's input names"],
104
+ };
50
105
  const issues: string[] = [];
51
106
  const out: Record<string, unknown> = {};
52
107
  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}'`);
108
+ for (const k of Object.keys(raw))
109
+ if (k !== CONFIG_KEY && !Object.hasOwn(declared, k))
110
+ issues.push(`input.${k}: not an input of step '${step.name}'`);
54
111
  for (const [k, e] of Object.entries(declared)) {
55
112
  if (!Object.hasOwn(raw, k)) {
56
113
  if (isOptional(e)) continue;
@@ -68,7 +125,12 @@ export function validateInput(step: AnyStep, raw: unknown): Validation<Record<st
68
125
  export function validateOutput(step: AnyStep, value: unknown): Validation<unknown> {
69
126
  if (!step.output) {
70
127
  if (value === undefined || value === null) return { ok: true, value: null };
71
- return { ok: false, issues: [`step '${step.name}' declares no output model but the handler returned a value`] };
128
+ return {
129
+ ok: false,
130
+ issues: [
131
+ `step '${step.name}' declares no output model but the handler returned a value`,
132
+ ],
133
+ };
72
134
  }
73
- return parseStrict(step.output.schema, value, "output");
135
+ return parseStrict(step.output.schema, value, 'output');
74
136
  }
package/src/index.ts CHANGED
@@ -1,12 +1,125 @@
1
1
  /// <reference path="./assets.d.ts" />
2
- // @olve/pipelines — authoring API (contracts, handlers, pipeline builder).
3
- export { z } from "zod";
4
- export { model, fileRef, optional, type Model, type AnyModel, type Infer, type InferInput, type Plain, type FileRef, type Optional } from "./core/models.ts";
5
- export { secret, type Secret, type DeclaredSecrets } from "./core/secrets.ts";
6
- export { hmacSha256, githubSignature, bearer, type WebhookVerify, type WebhookEvent } from "./core/webhooks.ts";
7
- export { step, type Step, type AnyStep, type StepDef, type StepInput, type StepKind, type InputEntry, type InputSpec, type LibraryStep, type OutputValue } from "./core/steps.ts";
8
- export { implement, type Implementation, type Handler, type HandlerResult, type Skipped } from "./core/handlers.ts";
9
- export type { Ctx, RunInfo, Sh, ShOptions, ExecOptions, Http, HttpInit, RetryOptions, OutputFileOptions } from "./ctx/types.ts";
10
- export type { Ref, Mappable, MapperResult } from "./builder/refs.ts";
11
- export { pipeline, Pipeline, type PipelineDef, type InRefs, type InitialAvail, type WhenCond, type PushEvent, type StageEntry, type CheckStage, type MapperFn } from "./builder/pipeline.ts";
12
- export { steps, Source, BuiltImage, HelmRelease, FailureContext, type RunValue, type ScriptDef, type GitHubSourceDef, type ImageBuildDef, type HelmDeployOverSshDef, httpHealthCheck, type HttpHealthCheckOptions } from "./library/index.ts";
2
+ // @olivervea/pipelines — authoring API (contracts, handlers, pipeline builder).
3
+ export { z } from 'zod';
4
+ export {
5
+ model,
6
+ fileRef,
7
+ optional,
8
+ type Model,
9
+ type AnyModel,
10
+ type Infer,
11
+ type InferInput,
12
+ type Plain,
13
+ type FileRef,
14
+ type Optional,
15
+ } from './core/models.ts';
16
+ export { secret, type Secret, type DeclaredSecrets } from './core/secrets.ts';
17
+ export {
18
+ config,
19
+ secretRef,
20
+ isConfigRef,
21
+ type ConfigEntry,
22
+ type AnyConfigEntry,
23
+ type ConfigValues,
24
+ type ConfigRef,
25
+ type ConfigNode,
26
+ type Configurable,
27
+ type DiscriminatorOf,
28
+ type VariantOf,
29
+ } from './core/config.ts';
30
+ export {
31
+ hmacSha256,
32
+ githubSignature,
33
+ bearer,
34
+ type WebhookVerify,
35
+ type WebhookEvent,
36
+ } from './core/webhooks.ts';
37
+ export { resolveConfigRefs, isLiteral, type Resolved } from './core/config-refs.ts';
38
+ export {
39
+ callback,
40
+ external,
41
+ person,
42
+ poll,
43
+ type External,
44
+ type ExternalOptions,
45
+ type ExternalSource,
46
+ type PollOptions,
47
+ } from './core/external.ts';
48
+ export {
49
+ step,
50
+ type Step,
51
+ type AnyStep,
52
+ type StepDef,
53
+ type StepInput,
54
+ type StepKind,
55
+ type ConfigOf,
56
+ type InputEntry,
57
+ type InputSpec,
58
+ type LiteralName,
59
+ type OutputValue,
60
+ } from './core/steps.ts';
61
+ export type { Simplify } from './core/types.ts';
62
+ export {
63
+ implement,
64
+ type Implementation,
65
+ type Handler,
66
+ type HandlerResult,
67
+ type Skipped,
68
+ } from './core/handlers.ts';
69
+ export type {
70
+ Ctx,
71
+ RunInfo,
72
+ Sh,
73
+ ShOptions,
74
+ ExecOptions,
75
+ Http,
76
+ HttpInit,
77
+ RetryOptions,
78
+ OutputFileOptions,
79
+ } from './ctx/types.ts';
80
+ export type { Ref, Mappable, MapperResult } from './builder/refs.ts';
81
+ export {
82
+ pipeline,
83
+ Pipeline,
84
+ type PipelineDef,
85
+ type RetentionPolicy,
86
+ type InRefs,
87
+ type InitialAvail,
88
+ type WhenCond,
89
+ type PushEvent,
90
+ type StageEntry,
91
+ type CheckStage,
92
+ type MapperFn,
93
+ } from './builder/pipeline.ts';
94
+ export {
95
+ steps,
96
+ Source,
97
+ BuiltImage,
98
+ PushedImage,
99
+ HelmRelease,
100
+ FailureContext,
101
+ type GitHubSourceDef,
102
+ type ImageBuildDef,
103
+ type ImageBuildTarballDef,
104
+ type ImageBuildPushDef,
105
+ type RegistryAuth,
106
+ type RegistryAuthConfig,
107
+ type HelmDeployOverSshDef,
108
+ helmTarget,
109
+ deployTarget,
110
+ type DeployTargetOptions,
111
+ type ComposeDeployDef,
112
+ type ComposeTargetValue,
113
+ type HelmDeployInClusterDef,
114
+ type HelmDeploySshTargetDef,
115
+ type HelmTargetDeployDef,
116
+ type HelmTargetValue,
117
+ type InClusterTargetValue,
118
+ type SshTargetValue,
119
+ httpHealthCheck,
120
+ type HttpHealthCheckOptions,
121
+ sshSession,
122
+ type SshSession,
123
+ serviceUrl,
124
+ checkSpa,
125
+ } from './library/index.ts';
@@ -0,0 +1,28 @@
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(
14
+ ctx: Pick<Ctx, 'http' | 'log'>,
15
+ url: string,
16
+ ): Promise<{ html: string; bundle: string }> {
17
+ const base = url.replace(/\/$/, '');
18
+ const html = await (await ctx.http.expectOk(`${base}/`)).text();
19
+ const bundle = html.match(/src="(\/assets\/[^"]+\.js)"/)?.[1];
20
+ if (bundle === undefined)
21
+ throw new Error(`${base}/ does not reference an /assets/*.js bundle`);
22
+ const js = await ctx.http.expectOk(`${base}${bundle}`);
23
+ await js.body?.cancel().catch(() => {});
24
+ if (!(js.headers.get('content-type')?.includes('javascript') ?? false))
25
+ throw new Error(`${base}${bundle} is not served as JavaScript`);
26
+ ctx.log('SPA served', { url: base, bundle });
27
+ return { html, bundle };
28
+ }
@@ -0,0 +1,113 @@
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')
72
+ throw new Error(
73
+ `composeDeploy '${def.name}': the target is kind '${String(target?.kind)}', not 'compose'`,
74
+ );
75
+ if (target.token.secret !== def.token.name)
76
+ throw new Error(
77
+ `composeDeploy '${def.name}': the target's token names secret ${target.token.secret}, but the step declares ${def.token.name}`,
78
+ );
79
+ const token = (ctx.secrets as Record<string, string>)[def.token.name];
80
+ const ref = `${image.repository}@${image.digest}`;
81
+ const url = `${target.deployerUrl.replace(/\/+$/, '')}/v1/deploy`;
82
+ ctx.log('deploying', {
83
+ service: target.service,
84
+ image: ref,
85
+ deployer: target.deployerUrl,
86
+ });
87
+ const res = await ctx.http.fetch(url, {
88
+ method: 'POST',
89
+ headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
90
+ body: JSON.stringify({ service: target.service, image: ref }),
91
+ });
92
+ const text = await res.text();
93
+ let result: ComposeDeployResult | undefined;
94
+ try {
95
+ result = JSON.parse(text) as ComposeDeployResult;
96
+ } catch {
97
+ // reported below
98
+ }
99
+ for (const line of result?.log ?? []) ctx.log(`deployer: ${line}`);
100
+ if (!res.ok || result?.status !== 'deployed')
101
+ throw new Error(
102
+ `the deployer answered HTTP ${res.status}: ${result ? `${result.status}${result.message ? `: ${result.message}` : ''}` : text.slice(0, 300)}`,
103
+ );
104
+ if (target.healthUrl)
105
+ await httpHealthCheck(ctx, target.healthUrl, { attempts: 12, delaySeconds: 5 });
106
+ ctx.log('deployed', {
107
+ service: target.service,
108
+ image: ref,
109
+ previous: result.previousImage ?? '-',
110
+ });
111
+ return { release: target.service, namespace: 'compose', image: ref };
112
+ }) as never;
113
+ }
@@ -2,19 +2,25 @@
2
2
  //
3
3
  // Runs entirely inside the harness (HTTP + tar extraction in Bun): no git, wget or tar is needed
4
4
  // in the image, so v1's busybox-wget TLS footgun (`--no-check-certificate`) is gone.
5
- import { mkdirSync, mkdtempSync, readdirSync, statSync } from "node:fs";
6
- import { join } from "node:path";
7
- import { z } from "zod";
8
- import type { Ctx } from "../ctx/types.ts";
9
- import type { Secret } from "../core/secrets.ts";
10
- import { makeStep, type LibraryStep, type LiteralName, type Step } from "../core/steps.ts";
11
- import { extractTar } from "../util/tar.ts";
12
- import { Source } from "./models.ts";
5
+ import { mkdirSync, mkdtempSync, readdirSync, statSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ import { z } from 'zod';
8
+ import type { Ctx } from '../ctx/types.ts';
9
+ import type { Secret } from '../core/secrets.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';
14
+ import { extractTar } from '../util/tar.ts';
15
+ import { Source } from './models.ts';
13
16
 
14
17
  /** Image the step runs in. The work happens in the harness, so any image would do. */
15
- export const GITHUB_SOURCE_IMAGE = "alpine:3.22";
18
+ export const GITHUB_SOURCE_IMAGE = 'alpine:3.22';
16
19
 
17
- const input = { repo: z.string().regex(/^[\w.-]+\/[\w.-]+$/, "expected owner/name"), sha: z.string().min(1) };
20
+ const input = {
21
+ repo: z.string().regex(/^[\w.-]+\/[\w.-]+$/, 'expected owner/name'),
22
+ sha: z.string().min(1),
23
+ };
18
24
  export type GitHubSourceInput = typeof input;
19
25
 
20
26
  export interface GitHubSourceDef<N extends string, T extends string> {
@@ -23,11 +29,29 @@ export interface GitHubSourceDef<N extends string, T extends string> {
23
29
  description: string;
24
30
  /** Token sent to the GitHub API (needed for private repositories; raises the rate limit). */
25
31
  token?: Secret<T>;
32
+ /** GitHub API base (default `https://api.github.com`; GitHub Enterprise: `https://<host>/api/v3`). */
33
+ apiUrl?: Configurable<string | undefined>;
26
34
  timeoutSeconds?: number;
27
35
  }
28
36
 
37
+ export const GITHUB_API_URL = 'https://api.github.com';
38
+
39
+ /** A declared secret's value, or "" when it is not set. */
40
+ function optionalSecret(ctx: Pick<Ctx, 'secrets'>, name: string): string {
41
+ try {
42
+ return ((ctx.secrets as Record<string, string>)[name] ?? '').trim();
43
+ } catch {
44
+ return '';
45
+ }
46
+ }
47
+
29
48
  /** 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)}`;
49
+ export const gitHubTarballUrl = (
50
+ repo: string,
51
+ sha: string,
52
+ apiUrl = GITHUB_API_URL,
53
+ ): string =>
54
+ `${apiUrl.replace(/\/+$/, '')}/repos/${repo}/tarball/${encodeURIComponent(sha)}`;
31
55
 
32
56
  /**
33
57
  * Downloads `repo` at `sha` from GitHub as a tarball and outputs it as a `Source` whose `tree` is the
@@ -36,42 +60,71 @@ export const gitHubTarballUrl = (repo: string, sha: string): string => `https://
36
60
  */
37
61
  export function gitHubSource<const N extends string, const T extends string = never>(
38
62
  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");
52
-
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
- }
63
+ ): Implementation<Step<N, GitHubSourceInput, typeof Source, T>> {
64
+ const contract = step({
65
+ name: def.name,
66
+ description: def.description,
67
+ kind: 'gitHubSource',
68
+ image: GITHUB_SOURCE_IMAGE,
69
+ input,
70
+ output: Source,
71
+ secrets: def.token ? [def.token] : [],
72
+ timeoutSeconds: def.timeoutSeconds,
73
+ settings: {
74
+ ...(def.token ? { token: def.token.name } : {}),
75
+ ...(def.apiUrl !== undefined ? { apiUrl: def.apiUrl } : {}),
76
+ },
77
+ });
78
+ return implement(contract, async ({ repo, sha }, ctx: Ctx) => {
79
+ const headers: Record<string, string> = {
80
+ accept: 'application/vnd.github+json',
81
+ 'user-agent': 'olve-pipelines',
82
+ };
83
+ const token = def.token ? optionalSecret(ctx, def.token.name) : '';
84
+ if (token) headers.authorization = `Bearer ${token}`;
85
+ else if (def.token)
86
+ ctx.log(
87
+ `${def.token.name} is not set: fetching anonymously (public repositories only)`,
88
+ );
89
+ const url = gitHubTarballUrl(
90
+ repo,
91
+ sha,
92
+ resolveConfigRefs(def.apiUrl, ctx.config ?? {}) ?? GITHUB_API_URL,
93
+ );
94
+ mkdirSync(ctx.tmpDir, { recursive: true });
95
+ const dir = mkdtempSync(join(ctx.tmpDir, 'github-source-'));
96
+ const archive = join(dir, 'repo.tar.gz');
64
97
 
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 };
98
+ ctx.log('fetching source', { repo, sha });
99
+ // Retries network errors and 5xx; a 4xx (bad sha, no access) fails at once.
100
+ const status = await ctx.retry(
101
+ { attempts: 4, delaySeconds: 2, backoff: 2 },
102
+ async () => {
103
+ const res = await ctx.http.fetch(url, { headers });
104
+ if (res.status >= 500) throw new Error(`HTTP ${res.status} from ${url}`);
105
+ if (res.ok) await Bun.write(archive, res);
106
+ return res.status;
74
107
  },
75
- },
76
- ) as never;
108
+ );
109
+ if (status < 200 || status >= 300) {
110
+ throw new Error(
111
+ `cannot fetch ${repo}@${sha}: HTTP ${status} from ${url}${def.token ? '' : ' (no token configured: a private repository needs one)'}`,
112
+ );
113
+ }
114
+
115
+ const extracted = join(dir, 'x');
116
+ mkdirSync(extracted);
117
+ await extractTar(archive, extracted);
118
+ // GitHub tarballs hold one top-level directory, plus a pax_global_header entry (the commit id)
119
+ // that some extractors materialise as a file.
120
+ const roots = readdirSync(extracted).filter(
121
+ (e) => e !== 'pax_global_header' && statSync(join(extracted, e)).isDirectory(),
122
+ );
123
+ if (roots.length !== 1)
124
+ throw new Error(
125
+ `unexpected tarball layout for ${repo}@${sha}: top-level entries ${JSON.stringify(readdirSync(extracted))}`,
126
+ );
127
+ const tree = await ctx.output.dir(join(extracted, roots[0]!), { name: 'tree' });
128
+ return { repo, sha, tree };
129
+ }) as never;
77
130
  }