@olivervea/pipelines 2.0.0-beta.26 → 2.0.0-beta.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -201,6 +201,7 @@ import deployScript from "./scripts/deploy.sh" with { type: "file" };
201
201
  export const deployBeta = implement(step({ name: "deploy-beta", /* … */ }), async ({ artifact }, ctx) => {
202
202
  await ctx.sh(deployScript, { env: { IMAGE_TAR: await ctx.files.fetch(artifact.image), CHART_DIR: await ctx.files.fetch(artifact.chart) } });
203
203
  await ctx.retry({ attempts: 5, delaySeconds: 5 }, () => ctx.http.expectOk("https://pipelines-beta.ovea.pro/api/health"));
204
+ ctx.link("https://pipelines-beta.ovea.pro", "beta"); // shown on the step card and in pl run get
204
205
  return { release: "olve-pipelines", version: artifact.version, url: "https://pipelines-beta.ovea.pro" }; // or ctx.skip("reason")
205
206
  });
206
207
  ```
@@ -324,6 +325,8 @@ It retries network errors, 5xx, 408 and 429. It always verifies `sha256`.
324
325
  { "status": "failed", "error": { "kind": "handler" | "input" | "output", "message": "…" } }
325
326
  ```
326
327
 
328
+ Any of the three may carry `"links": [{ "url": "https://…", "label": "…" }]`, what the handler passed to `ctx.link` (absolute http(s) URLs of at most 2048 characters, labels of at most 100, at most 10). The key is left out when there are none. The server refuses an envelope whose links break these limits.
329
+
327
330
  For `map-trigger`, `output` is the pipeline input after it has been parsed by the input model, so defaults are applied. Validate it again server-side.
328
331
 
329
332
  ### Exit codes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olivervea/pipelines",
3
- "version": "2.0.0-beta.26",
3
+ "version": "2.0.0-beta.27",
4
4
  "description": "Olve.Pipelines v2 SDK: typed pipeline authoring, the step harness, and the build tool.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/ctx/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { mkdirSync, readFileSync } from 'node:fs';
2
2
  import { resolve } from 'node:path';
3
3
  import { skipped } from '../core/handlers.ts';
4
+ import { createLinks } from './links.ts';
4
5
  import type { Secret } from '../core/secrets.ts';
5
6
  import { createFetch, createOutput, redactUrl } from './files.ts';
6
7
  import { createSh } from './sh.ts';
@@ -165,6 +166,7 @@ export function createCtx(o: CtxOptions): Ctx {
165
166
  run: Object.freeze({ ...o.run }),
166
167
  tmpDir: o.paths.tmp,
167
168
  skip: skipped,
169
+ link: createLinks().link,
168
170
  inputUrl: (step, input) => {
169
171
  const name = typeof step === 'string' ? step : step.name;
170
172
  const url = o.callbackUrls?.[`${name}/${input}`];
@@ -0,0 +1,30 @@
1
+ export interface StepLink {
2
+ url: string;
3
+ label?: string;
4
+ }
5
+
6
+ export const MAX_LINKS = 10;
7
+ export const MAX_LINK_URL = 2048;
8
+ export const MAX_LINK_LABEL = 100;
9
+
10
+ /** `ctx.link`, collecting into `links`. */
11
+ export function createLinks(): {
12
+ links: StepLink[];
13
+ link: (url: string, label?: string) => void;
14
+ } {
15
+ const links: StepLink[] = [];
16
+ const link = (url: string, label?: string) => {
17
+ if (typeof url !== 'string' || !/^https?:\/\/[^/\s]/i.test(url) || !URL.canParse(url))
18
+ throw new Error(`ctx.link: '${String(url)}' is not an absolute http or https URL`);
19
+ if (url.length > MAX_LINK_URL)
20
+ throw new Error(`ctx.link: the URL is longer than ${MAX_LINK_URL} characters`);
21
+ if (label !== undefined && typeof label !== 'string')
22
+ throw new Error('ctx.link: the label must be a string');
23
+ if (label !== undefined && label.length > MAX_LINK_LABEL)
24
+ throw new Error(`ctx.link: the label is longer than ${MAX_LINK_LABEL} characters`);
25
+ if (links.length >= MAX_LINKS)
26
+ throw new Error(`ctx.link: a step can add at most ${MAX_LINKS} links`);
27
+ links.push(label ? { url, label } : { url });
28
+ };
29
+ return { links, link };
30
+ }
package/src/ctx/types.ts CHANGED
@@ -111,6 +111,11 @@ export interface Ctx<Sec extends string = string, Cfg extends AnyConfigEntry = n
111
111
  readonly run: RunInfo;
112
112
  /** A writable scratch directory (`$OLVE_DIR/tmp`; images like kaniko have no /tmp). */
113
113
  readonly tmpDir: string;
114
+ /**
115
+ * Link to what the step made or deployed (a site, a release, an image), shown with the attempt in the
116
+ * UI and `pl run get`. Absolute http(s) URL; at most 10 per attempt.
117
+ */
118
+ link(url: string, label?: string): void;
114
119
  /** Runtime escape hatch: produce no output. Prefer `when` blocks. */
115
120
  skip(reason: string): Skipped;
116
121
  /**
@@ -6,15 +6,17 @@ import { isSkipped } from '../core/handlers.ts';
6
6
  import type { AnyStep } from '../core/steps.ts';
7
7
  import type { Ctx, RunInfo } from '../ctx/types.ts';
8
8
  import { objectPath } from '../ctx/files.ts';
9
+ import { createLinks, type StepLink } from '../ctx/links.ts';
9
10
  import { sha256File } from '../util/hash.ts';
10
11
  import { EXIT } from './exit.ts';
11
12
  import { validateConfig, validateInput, validateOutput } from './validate.ts';
12
13
 
13
14
  export type FailureKind = 'handler' | 'input' | 'output';
14
- export type Envelope =
15
+ export type Envelope = (
15
16
  | { status: 'succeeded'; output: unknown }
16
17
  | { status: 'skipped'; reason: string }
17
- | { status: 'failed'; error: { kind: FailureKind; message: string } };
18
+ | { status: 'failed'; error: { kind: FailureKind; message: string } }
19
+ ) & { links?: StepLink[] };
18
20
 
19
21
  export interface Execution {
20
22
  envelope: Envelope;
@@ -85,7 +87,14 @@ export async function executeStep(args: {
85
87
  exitCode: EXIT.inputInvalid,
86
88
  };
87
89
  }
88
- const ctx: Ctx = { ...args.ctx, config: Object.freeze(config.value) as Ctx['config'] };
90
+ const collected = createLinks();
91
+ const ctx: Ctx = {
92
+ ...args.ctx,
93
+ config: Object.freeze(config.value) as Ctx['config'],
94
+ link: collected.link,
95
+ };
96
+ const withLinks = (e: Envelope): Envelope =>
97
+ collected.links.length ? { ...e, links: [...collected.links] } : e;
89
98
 
90
99
  let value: unknown;
91
100
  try {
@@ -98,14 +107,17 @@ export async function executeStep(args: {
98
107
  log(message);
99
108
  if (e instanceof Error && e.stack && !cancelled) log(e.stack);
100
109
  return {
101
- envelope: { status: 'failed', error: { kind: 'handler', message } },
110
+ envelope: withLinks({ status: 'failed', error: { kind: 'handler', message } }),
102
111
  exitCode: cancelled ?? EXIT.handlerFailed,
103
112
  };
104
113
  }
105
114
 
106
115
  if (isSkipped(value)) {
107
116
  log(`step '${step.name}' skipped: ${value.reason}`);
108
- return { envelope: { status: 'skipped', reason: value.reason }, exitCode: EXIT.ok };
117
+ return {
118
+ envelope: withLinks({ status: 'skipped', reason: value.reason }),
119
+ exitCode: EXIT.ok,
120
+ };
109
121
  }
110
122
  const out = validateOutput(step, value);
111
123
  const problems = out.ok
@@ -115,9 +127,12 @@ export async function executeStep(args: {
115
127
  const message = `step '${step.name}': returned value does not match ${step.output?.name ?? 'its (absent) output model'}:\n ${problems.join('\n ')}`;
116
128
  log(message);
117
129
  return {
118
- envelope: { status: 'failed', error: { kind: 'output', message } },
130
+ envelope: withLinks({ status: 'failed', error: { kind: 'output', message } }),
119
131
  exitCode: EXIT.outputInvalid,
120
132
  };
121
133
  }
122
- return { envelope: { status: 'succeeded', output: out.value }, exitCode: EXIT.ok };
134
+ return {
135
+ envelope: withLinks({ status: 'succeeded', output: out.value }),
136
+ exitCode: EXIT.ok,
137
+ };
123
138
  }
package/src/index.ts CHANGED
@@ -77,6 +77,7 @@ export type {
77
77
  RetryOptions,
78
78
  OutputFileOptions,
79
79
  } from './ctx/types.ts';
80
+ export type { StepLink } from './ctx/links.ts';
80
81
  export type { Ref, Mappable, MapperResult } from './builder/refs.ts';
81
82
  export {
82
83
  pipeline,
@@ -20,6 +20,7 @@ export interface ComposeTargetValue {
20
20
  service: string;
21
21
  token: { secret: string };
22
22
  healthUrl?: string | undefined;
23
+ link?: string | undefined;
23
24
  }
24
25
 
25
26
  /** What the deployer answers to `POST /v1/deploy`. */
@@ -103,6 +104,7 @@ export function composeDeploy<const N extends string, const K extends string>(
103
104
  );
104
105
  if (target.healthUrl)
105
106
  await httpHealthCheck(ctx, target.healthUrl, { attempts: 12, delaySeconds: 5 });
107
+ if (target.link) ctx.link(target.link);
106
108
  ctx.log('deployed', {
107
109
  service: target.service,
108
110
  image: ref,
@@ -113,6 +113,8 @@ export interface HelmDeployOverSshDef<
113
113
  helmWait?: boolean;
114
114
  /** `helm upgrade --atomic`: wait like `helmWait`, and roll back when the upgrade fails. Default false. */
115
115
  atomic?: boolean;
116
+ /** Public URL of what the release serves; the step links to it after a successful deploy. */
117
+ link?: Configurable<string>;
116
118
  /** After the rollouts, GET this URL until it returns 2xx (`httpHealthCheck`: 5 × 5 s unless given), or fail the step. */
117
119
  healthUrl?:
118
120
  | Configurable<string>
@@ -407,6 +409,7 @@ function helmDeployOverSshLiteral(
407
409
  values: def.values ?? [],
408
410
  set: def.set ?? {},
409
411
  healthUrl: def.healthUrl,
412
+ link: def.link,
410
413
  };
411
414
 
412
415
  const contract = step({
@@ -436,6 +439,7 @@ function helmDeployOverSshLiteral(
436
439
  ...(def.dependencies ? { dependencies: true } : {}),
437
440
  ...(def.atomic ? { atomic: true } : def.helmWait ? { helmWait: true } : {}),
438
441
  ...(def.healthUrl !== undefined ? { healthUrl: def.healthUrl } : {}),
442
+ ...(def.link !== undefined ? { link: def.link } : {}),
439
443
  },
440
444
  });
441
445
  return implement(contract, async (stepInput, ctx: Ctx) => {
@@ -449,6 +453,7 @@ function helmDeployOverSshLiteral(
449
453
  namespace: string;
450
454
  values: string[];
451
455
  set: Record<string, string>;
456
+ link?: string;
452
457
  healthUrl?:
453
458
  | string
454
459
  | ({ url: string } & Pick<HttpHealthCheckOptions, 'attempts' | 'delaySeconds'>);
@@ -495,6 +500,7 @@ function helmDeployOverSshLiteral(
495
500
  attempts: health.attempts,
496
501
  delaySeconds: health.delaySeconds,
497
502
  });
503
+ if (d.link) ctx.link(d.link);
498
504
  return {
499
505
  release: d.release,
500
506
  namespace: d.namespace,
@@ -53,6 +53,12 @@ function targetVariants<const E extends z.ZodRawShape, const K extends string>(
53
53
  .describe(
54
54
  'Base URL step pods reach the release at (health checks, smoke tests); default http://<release>.<namespace>.svc.cluster.local.',
55
55
  ),
56
+ link: z
57
+ .string()
58
+ .optional()
59
+ .describe(
60
+ 'Public URL of what the target serves (a site, an API); the deploy step links to it.',
61
+ ),
56
62
  ...(o.extend ?? ({} as E)),
57
63
  };
58
64
  const inCluster = z.object({
@@ -86,6 +92,12 @@ function targetVariants<const E extends z.ZodRawShape, const K extends string>(
86
92
  .describe(
87
93
  "Checked by the step after the deployer's own health check, when the step can reach it.",
88
94
  ),
95
+ link: z
96
+ .string()
97
+ .optional()
98
+ .describe(
99
+ 'Public URL of what the target serves (a site, an API); the deploy step links to it.',
100
+ ),
89
101
  ...(o.extend ?? ({} as E)),
90
102
  });
91
103
  const none = z.object({
@@ -156,6 +168,7 @@ interface TargetCommon {
156
168
  values: readonly string[];
157
169
  set: Readonly<Record<string, string>>;
158
170
  url?: string | undefined;
171
+ link?: string | undefined;
159
172
  }
160
173
  /** A resolved `kind: inCluster` target. */
161
174
  export type InClusterTargetValue = TargetCommon & { kind: 'inCluster' };
@@ -472,6 +485,7 @@ function targetDeploy(
472
485
  attempts: 12,
473
486
  delaySeconds: 5,
474
487
  });
488
+ if (target.link) ctx.link(target.link);
475
489
  const main = flags[0];
476
490
  return {
477
491
  release,
@@ -54,7 +54,7 @@ export const HelmRelease = model(
54
54
  export const FailedStep = z.object({
55
55
  step: z.string(),
56
56
  attempt: z.number().int(),
57
- /** `<pipeline id>#<run>/<step>@<attempt>` (spec §5.5). */
57
+ /** `<pipeline id>#<run>/<step>@<attempt>`. */
58
58
  address: z.string(),
59
59
  /** The attempt's failure reason (handler error, exit code, timeout, …). */
60
60
  reason: z.string(),
@@ -74,7 +74,7 @@ export const FailureContext = model(
74
74
  run: z.object({
75
75
  id: z.string(),
76
76
  number: z.number().int(),
77
- /** `<pipeline id>#<run>` (spec §5.5). */
77
+ /** `<pipeline id>#<run>`. */
78
78
  address: z.string(),
79
79
  /** The run's derived version, `yyyy.MM.dd.<run>`. */
80
80
  versionStamp: z.string(),
@@ -14,6 +14,7 @@ import type {
14
14
  } from '../core/steps.ts';
15
15
  import { createCtx } from '../ctx/index.ts';
16
16
  import type { Ctx, RunInfo } from '../ctx/types.ts';
17
+ import type { StepLink } from '../ctx/links.ts';
17
18
  import { executeStep, type FailureKind, type StepFunction } from '../harness/execute.ts';
18
19
  import { sha256Hex } from '../util/hash.ts';
19
20
 
@@ -39,6 +40,8 @@ export type TestResult<S extends AnyStep> = {
39
40
  filesDir: string;
40
41
  logs: string[];
41
42
  exitCode: number;
43
+ /** What the handler passed to `ctx.link`; absent when nothing. */
44
+ links?: StepLink[];
42
45
  } & (
43
46
  | { status: 'succeeded'; output: OutputValue<S> }
44
47
  | { status: 'skipped'; reason: string }