commit 5d11ffb12e6835d1516157fbe2e0c39446c4717d
parent ece142938b360219e241e7c2fb0eb938dab8e37c
Author: finwo <finwo@pm.me>
Date: Fri, 18 Sep 2026 23:34:16 +0200
Pipeline parsing, matrix expansion and job graph
Diffstat:
7 files changed, 1446 insertions(+), 0 deletions(-)
diff --git a/src/lib/pipeline/dag.js b/src/lib/pipeline/dag.js
@@ -0,0 +1,91 @@
+// src/lib/pipeline/dag.js - dependency graph checks and traversal
+//
+// The prototype stored a single integer level per job and treated the lowest
+// queued level as the next runnable job. That serialized every run and, on
+// failure, only skipped direct dependents, so a transitive dependent could
+// still be dispatched with its dependency missing. Both problems come from
+// throwing the graph away, so the graph is kept here and used directly.
+
+export class CycleError extends Error {
+ constructor(cycle) {
+ super(`pipeline contains a dependency cycle: ${cycle.join(' -> ')}`);
+ this.name = 'CycleError';
+ this.cycle = cycle;
+ }
+}
+
+// Returns the jobs in an order where every dependency precedes its
+// dependents. Throws CycleError naming the cycle when none exists.
+export function topologicalOrder(jobs) {
+ const byName = new Map(jobs.map((j) => [j.name, j]));
+ const state = new Map();
+ const order = [];
+ const stack = [];
+
+ function visit(name) {
+ const current = state.get(name);
+ if (current === 'done') return;
+ if (current === 'active') {
+ const from = stack.indexOf(name);
+ throw new CycleError([...stack.slice(from), name]);
+ }
+
+ state.set(name, 'active');
+ stack.push(name);
+ for (const dep of byName.get(name).needs) {
+ if (byName.has(dep)) visit(dep);
+ }
+ stack.pop();
+ state.set(name, 'done');
+ order.push(name);
+ }
+
+ // Sorted so that an unchanged pipeline always produces the same order.
+ for (const name of [...byName.keys()].sort()) visit(name);
+ return order.map((name) => byName.get(name));
+}
+
+// Depth of each job, where a job with no dependencies is 0. Used only for
+// display; scheduling reads the edges, never the depth.
+export function depths(jobs) {
+ const byName = new Map(jobs.map((j) => [j.name, j]));
+ const out = new Map();
+ for (const job of topologicalOrder(jobs)) {
+ const deps = job.needs.filter((d) => byName.has(d));
+ out.set(job.name, deps.length === 0 ? 0 : Math.max(...deps.map((d) => out.get(d))) + 1);
+ }
+ return out;
+}
+
+// Every job reachable by following dependents from the given names. This is
+// what a failure must skip: direct dependents alone leave transitive ones
+// runnable against a dependency that never produced anything.
+export function transitiveDependents(jobs, startNames) {
+ const dependents = new Map(jobs.map((j) => [j.name, []]));
+ for (const job of jobs) {
+ for (const dep of job.needs) {
+ if (dependents.has(dep)) dependents.get(dep).push(job.name);
+ }
+ }
+
+ const seen = new Set();
+ const queue = [...startNames];
+ while (queue.length > 0) {
+ const name = queue.shift();
+ for (const child of dependents.get(name) ?? []) {
+ if (seen.has(child)) continue;
+ seen.add(child);
+ queue.push(child);
+ }
+ }
+ return seen;
+}
+
+// Jobs whose dependencies have all reached a satisfying state. Passed the
+// current state of every job by name.
+export function runnable(jobs, stateByName, { satisfied = ['success'] } = {}) {
+ return jobs.filter((job) => {
+ if (stateByName.get(job.name) !== 'queued') return false;
+ return job.needs.every((dep) => satisfied.includes(stateByName.get(dep)));
+ });
+}
diff --git a/src/lib/pipeline/expand.js b/src/lib/pipeline/expand.js
@@ -0,0 +1,221 @@
+// src/lib/pipeline/expand.js - matrix and architecture expansion
+//
+// Turns each job template into one concrete job per combination of its
+// dimensions, then resolves needs between the concrete jobs.
+//
+// Dimension matching is the part worth understanding. When a job declares
+// needs on a template that shares a dimension with it, the dependency is
+// matched on that dimension rather than fanned out across all of it. So an
+// aarch64 package job needs the aarch64 build, not every build. Dimensions
+// the dependency has but the dependent does not are fanned out, which is
+// what you want when a single publish job waits for every architecture.
+// Write `{ job: name, match: all }` to opt out.
+
+import { Problems, MATRIX_KEY_PATTERN } from './schema.js';
+
+// Matches ${{ arch }} and ${{ matrix.pkg }}, tolerating inner whitespace.
+const INTERPOLATION = /\$\{\{\s*([^}]*?)\s*\}\}/g;
+
+// jobs.name is 191 characters in the mysql schema.
+export const MAX_JOB_NAME = 191;
+
+export function interpolate(problems, path, text, context) {
+ if (typeof text !== 'string') return text;
+
+ return text.replace(INTERPOLATION, (whole, expr) => {
+ if (expr === 'arch') {
+ if (context.arch === null) {
+ problems.add(path, 'refers to ${{ arch }} but the job declares no arch');
+ return whole;
+ }
+ return context.arch;
+ }
+
+ const matrixMatch = /^matrix\.([A-Za-z_][A-Za-z0-9_]*)$/.exec(expr);
+ if (matrixMatch) {
+ const key = matrixMatch[1];
+ if (!Object.hasOwn(context.matrix, key)) {
+ const known = Object.keys(context.matrix);
+ problems.add(
+ path,
+ `refers to \${{ matrix.${key} }} which the job does not define` +
+ (known.length > 0 ? `; available: ${known.join(', ')}` : '')
+ );
+ return whole;
+ }
+ return context.matrix[key];
+ }
+
+ problems.add(path, `unknown expression \${{ ${expr} }}; expected arch or matrix.<name>`);
+ return whole;
+ });
+}
+
+function cartesian(dimensions) {
+ let rows = [{}];
+ for (const [key, values] of dimensions) {
+ const next = [];
+ for (const row of rows) {
+ for (const value of values) next.push({ ...row, [key]: value });
+ }
+ rows = next;
+ }
+ return rows;
+}
+
+// build:arch=x86_64,pkg=musl
+function instanceName(baseName, dims) {
+ const entries = Object.entries(dims);
+ if (entries.length === 0) return baseName;
+ return `${baseName}:${entries.map(([k, v]) => `${k}=${v}`).join(',')}`;
+}
+
+function dimensionsOf(job) {
+ const dims = [];
+ if (job.arch.length > 0) dims.push(['arch', job.arch]);
+ for (const [key, values] of Object.entries(job.matrix)) dims.push([key, values]);
+ return dims;
+}
+
+export function expandPipeline(pipeline, options = {}) {
+ const problems = new Problems();
+ const defaultTimeout = options.defaultTimeout ?? 3600;
+ const defaultAttempts = options.defaultAttempts ?? 1;
+
+ // Pass one: build every concrete job.
+ const byBase = new Map();
+ const instances = [];
+
+ for (const [baseName, job] of Object.entries(pipeline.jobs)) {
+ const dims = dimensionsOf(job);
+ const combos = cartesian(dims);
+ const made = [];
+
+ for (const combo of combos) {
+ const arch = combo.arch ?? null;
+ const matrix = { ...combo };
+ delete matrix.arch;
+
+ const name = instanceName(baseName, combo);
+ const path = `jobs.${baseName}`;
+ if (name.length > MAX_JOB_NAME) {
+ problems.add(path, `expands to a job name longer than ${MAX_JOB_NAME} characters: ${name}`);
+ continue;
+ }
+
+ const context = { arch, matrix };
+ const env = {};
+ for (const [key, value] of Object.entries(job.env)) {
+ env[key] = interpolate(problems, `${path}.env.${key}`, value, context);
+ }
+ // Dimension values are exposed to the script, so a matrix job rarely
+ // needs interpolation at all.
+ if (arch !== null) env.ARCH = arch;
+ for (const [key, value] of Object.entries(matrix)) {
+ if (MATRIX_KEY_PATTERN.test(key)) env[`MATRIX_${key.toUpperCase()}`] = value;
+ }
+
+ made.push({
+ name,
+ baseName,
+ arch,
+ matrix,
+ dims: combo,
+ image: interpolate(problems, `${path}.image`, job.image, context),
+ script: job.script.map((line, i) => interpolate(problems, `${path}.script[${i}]`, line, context)),
+ requires: job.requires.map((r, i) => interpolate(problems, `${path}.requires[${i}]`, r, context)),
+ services: job.services.map((service, i) => ({
+ image: interpolate(problems, `${path}.services[${i}].image`, service.image, context),
+ alias: service.alias,
+ env: Object.fromEntries(Object.entries(service.env).map(([k, v]) => [
+ k, interpolate(problems, `${path}.services[${i}].env.${k}`, v, context),
+ ])),
+ entrypoint: service.entrypoint,
+ command: service.command,
+ })),
+ env,
+ artifacts: job.artifacts
+ ? {
+ ...job.artifacts,
+ paths: job.artifacts.paths.map((p, i) => interpolate(problems, `${path}.artifacts.paths[${i}]`, p, context)),
+ }
+ : null,
+ cache: job.cache
+ ? {
+ key: interpolate(problems, `${path}.cache.key`, job.cache.key, context),
+ paths: job.cache.paths.map((p, i) => interpolate(problems, `${path}.cache.paths[${i}]`, p, context)),
+ }
+ : null,
+ allow_failure: job.allow_failure,
+ timeout: job.timeout ?? defaultTimeout,
+ max_attempts: job.max_attempts ?? defaultAttempts,
+ needs: [],
+ });
+ }
+
+ byBase.set(baseName, { job, dimensionNames: dims.map(([k]) => k), instances: made });
+ instances.push(...made);
+ }
+
+ const byName = new Map(instances.map((i) => [i.name, i]));
+
+ // Pass two: resolve needs now that every concrete job exists.
+ for (const [baseName, entry] of byBase) {
+ for (const instance of entry.instances) {
+ const resolved = new Set();
+
+ for (const need of entry.job.needs) {
+ const path = `jobs.${baseName}.needs`;
+
+ // An exact concrete job name wins, which is the escape hatch for
+ // depending on one specific combination.
+ if (byName.has(need.job)) {
+ if (need.job === instance.name) {
+ problems.add(path, `job ${JSON.stringify(baseName)} cannot depend on itself`);
+ } else {
+ resolved.add(need.job);
+ }
+ continue;
+ }
+
+ const target = byBase.get(need.job);
+ if (!target) {
+ const known = [...byBase.keys()].filter((n) => n !== baseName);
+ problems.add(
+ path,
+ `refers to unknown job ${JSON.stringify(need.job)}` +
+ (known.length > 0 ? `; defined jobs are ${known.join(', ')}` : '')
+ );
+ continue;
+ }
+ if (need.job === baseName) {
+ problems.add(path, `job ${JSON.stringify(baseName)} cannot depend on itself`);
+ continue;
+ }
+
+ let candidates = target.instances;
+ if (need.match === 'shared') {
+ const shared = target.dimensionNames.filter((d) => Object.hasOwn(instance.dims, d));
+ if (shared.length > 0) {
+ candidates = candidates.filter((c) => shared.every((d) => c.dims[d] === instance.dims[d]));
+ if (candidates.length === 0) {
+ problems.add(
+ path,
+ `no instance of ${JSON.stringify(need.job)} matches ${instance.name} on ` +
+ `${shared.join(', ')}; add the missing value, or use { job: ${need.job}, match: all }`
+ );
+ continue;
+ }
+ }
+ }
+
+ for (const candidate of candidates) resolved.add(candidate.name);
+ }
+
+ instance.needs = [...resolved].sort();
+ }
+ }
+
+ problems.throwIfAny(pipeline.source);
+ return instances;
+}
diff --git a/src/lib/pipeline/index.js b/src/lib/pipeline/index.js
@@ -0,0 +1,36 @@
+// src/lib/pipeline/index.js - pipeline compilation
+//
+// compilePipeline turns the text of a .conductor.yml into the concrete job
+// graph the scheduler stores. Every failure mode raises PipelineError with
+// the full list of problems, so a bad pipeline is reported once rather than
+// one mistake per push.
+
+import { parsePipeline } from './parse.js';
+import { expandPipeline } from './expand.js';
+import { topologicalOrder, depths, CycleError } from './dag.js';
+import { PipelineError } from './schema.js';
+
+export { parsePipeline } from './parse.js';
+export { expandPipeline, interpolate } from './expand.js';
+export { topologicalOrder, depths, transitiveDependents, runnable, CycleError } from './dag.js';
+export { PipelineError } from './schema.js';
+export { SUPPORTED_VERSION } from './parse.js';
+
+export function compilePipeline(text, options = {}) {
+ const pipeline = parsePipeline(text, options);
+ const jobs = expandPipeline(pipeline, options);
+
+ try {
+ topologicalOrder(jobs);
+ } catch (e) {
+ if (e instanceof CycleError) {
+ throw new PipelineError([{ path: 'jobs', message: e.message }], pipeline.source);
+ }
+ throw e;
+ }
+
+ const depth = depths(jobs);
+ for (const job of jobs) job.depth = depth.get(job.name);
+
+ return { version: pipeline.version, source: pipeline.source, jobs };
+}
diff --git a/src/lib/pipeline/parse.js b/src/lib/pipeline/parse.js
@@ -0,0 +1,373 @@
+// src/lib/pipeline/parse.js - reads and validates a .conductor.yml
+//
+// Produces a normalized document: every job has every field filled in from
+// defaults, so later stages never have to ask whether something was set.
+// Nothing here expands matrices or resolves dependencies; see expand.js.
+
+import YAML from 'yaml';
+import {
+ PipelineError,
+ Problems,
+ isPlainObject,
+ checkUnknown,
+ asString,
+ asName,
+ asBoolean,
+ asInteger,
+ asStringList,
+ asEnvMap,
+ asDuration,
+ typeName,
+ MATRIX_KEY_PATTERN,
+ NAME_PATTERN,
+} from './schema.js';
+
+const TOP_KEYS = ['version', 'defaults', 'jobs'];
+
+const JOB_KEYS = [
+ 'image', 'script', 'needs', 'arch', 'matrix', 'requires', 'services',
+ 'env', 'artifacts', 'cache', 'allow_failure', 'timeout', 'max_attempts',
+];
+
+// Fields a job may inherit from defaults.
+const DEFAULT_KEYS = [
+ 'image', 'arch', 'requires', 'env', 'services', 'cache',
+ 'allow_failure', 'timeout', 'max_attempts',
+];
+
+const ARTIFACT_KEYS = ['paths', 'expire', 'when'];
+const ARTIFACT_WHEN = ['on_success', 'on_failure', 'always'];
+const CACHE_KEYS = ['key', 'paths'];
+const SERVICE_KEYS = ['image', 'alias', 'env', 'entrypoint', 'command'];
+
+export const SUPPORTED_VERSION = 1;
+
+export function parsePipeline(text, options = {}) {
+ const source = options.source || '.conductor.yml';
+ const problems = new Problems();
+
+ let doc;
+ try {
+ doc = YAML.parse(text, { prettyErrors: true });
+ } catch (e) {
+ throw new PipelineError([{ path: '', message: `not valid YAML: ${e.message}` }], source);
+ }
+
+ if (doc === null || doc === undefined) {
+ throw new PipelineError([{ path: '', message: 'file is empty' }], source);
+ }
+ if (!isPlainObject(doc)) {
+ throw new PipelineError([{ path: '', message: `expected a mapping at the top level, got ${typeName(doc)}` }], source);
+ }
+
+ checkUnknown(problems, '', doc, TOP_KEYS);
+
+ if (doc.version === undefined) {
+ problems.add('version', `is required; this conductor understands version ${SUPPORTED_VERSION}`);
+ } else if (doc.version !== SUPPORTED_VERSION) {
+ problems.add('version', `unsupported version ${JSON.stringify(doc.version)}, expected ${SUPPORTED_VERSION}`);
+ }
+
+ const defaults = parseDefaults(problems, doc.defaults);
+
+ if (doc.jobs === undefined) {
+ problems.add('jobs', 'is required');
+ } else if (!isPlainObject(doc.jobs)) {
+ problems.add('jobs', `expected a mapping of job names, got ${typeName(doc.jobs)}`);
+ } else if (Object.keys(doc.jobs).length === 0) {
+ problems.add('jobs', 'must define at least one job');
+ }
+
+ const jobs = {};
+ if (isPlainObject(doc.jobs)) {
+ for (const [name, raw] of Object.entries(doc.jobs)) {
+ const path = `jobs.${name}`;
+ if (!NAME_PATTERN.test(name) || name.length > 100) {
+ problems.add(path, 'job name must start with a letter or digit and contain only letters, digits, underscore, dot and hyphen');
+ continue;
+ }
+ if (!isPlainObject(raw)) {
+ problems.add(path, `expected a mapping, got ${typeName(raw)}`);
+ continue;
+ }
+ jobs[name] = parseJob(problems, path, raw, defaults);
+ }
+ }
+
+ problems.throwIfAny(source);
+ return { version: SUPPORTED_VERSION, defaults, jobs, source };
+}
+
+function parseDefaults(problems, raw) {
+ const empty = {
+ image: undefined,
+ arch: undefined,
+ requires: [],
+ env: {},
+ services: [],
+ cache: null,
+ allow_failure: false,
+ timeout: undefined,
+ max_attempts: undefined,
+ };
+ if (raw === undefined) return empty;
+ if (!isPlainObject(raw)) {
+ problems.add('defaults', `expected a mapping, got ${typeName(raw)}`);
+ return empty;
+ }
+
+ checkUnknown(problems, 'defaults', raw, DEFAULT_KEYS);
+
+ return {
+ image: raw.image === undefined ? undefined : asString(problems, 'defaults.image', raw.image),
+ arch: raw.arch === undefined ? undefined : parseArch(problems, 'defaults.arch', raw.arch),
+ requires: asStringList(problems, 'defaults.requires', raw.requires),
+ env: asEnvMap(problems, 'defaults.env', raw.env),
+ services: parseServices(problems, 'defaults.services', raw.services),
+ cache: parseCache(problems, 'defaults.cache', raw.cache),
+ allow_failure: raw.allow_failure === undefined ? false : asBoolean(problems, 'defaults.allow_failure', raw.allow_failure) ?? false,
+ timeout: raw.timeout === undefined ? undefined : asDuration(problems, 'defaults.timeout', raw.timeout),
+ max_attempts: raw.max_attempts === undefined ? undefined : asInteger(problems, 'defaults.max_attempts', raw.max_attempts, { min: 1, max: 10 }),
+ };
+}
+
+function parseArch(problems, path, raw) {
+ const list = asStringList(problems, path, raw, { max: 32 });
+ const out = [];
+ list.forEach((value, i) => {
+ if (!NAME_PATTERN.test(value)) {
+ problems.add(`${path}[${i}]`, `${JSON.stringify(value)} is not a valid architecture name`);
+ return;
+ }
+ if (out.includes(value)) {
+ problems.add(`${path}[${i}]`, `duplicate architecture ${JSON.stringify(value)}`);
+ return;
+ }
+ out.push(value);
+ });
+ return out;
+}
+
+function parseJob(problems, path, raw, defaults) {
+ checkUnknown(problems, path, raw, JOB_KEYS);
+
+ const image = raw.image === undefined ? defaults.image : asString(problems, `${path}.image`, raw.image);
+ if (image === undefined) {
+ problems.add(`${path}.image`, 'is required; set it on the job or under defaults');
+ }
+
+ if (raw.script === undefined) {
+ problems.add(`${path}.script`, 'is required');
+ }
+ const script = asStringList(problems, `${path}.script`, raw.script, { max: 65536 });
+ if (raw.script !== undefined && script.length === 0) {
+ problems.add(`${path}.script`, 'must contain at least one command');
+ }
+
+ const arch = raw.arch === undefined ? (defaults.arch ?? []) : parseArch(problems, `${path}.arch`, raw.arch);
+ const matrix = parseMatrix(problems, `${path}.matrix`, raw.matrix);
+
+ if (matrix.arch !== undefined) {
+ problems.add(`${path}.matrix.arch`, 'use the arch key instead of a matrix dimension named arch');
+ }
+
+ return {
+ image,
+ script,
+ needs: parseNeeds(problems, `${path}.needs`, raw.needs),
+ arch,
+ matrix,
+ requires: raw.requires === undefined ? defaults.requires : asStringList(problems, `${path}.requires`, raw.requires, { max: 64 }),
+ services: raw.services === undefined ? defaults.services : parseServices(problems, `${path}.services`, raw.services),
+ env: { ...defaults.env, ...asEnvMap(problems, `${path}.env`, raw.env) },
+ artifacts: parseArtifacts(problems, `${path}.artifacts`, raw.artifacts),
+ cache: raw.cache === undefined ? defaults.cache : parseCache(problems, `${path}.cache`, raw.cache),
+ allow_failure: raw.allow_failure === undefined
+ ? defaults.allow_failure
+ : asBoolean(problems, `${path}.allow_failure`, raw.allow_failure) ?? false,
+ timeout: raw.timeout === undefined ? defaults.timeout : asDuration(problems, `${path}.timeout`, raw.timeout),
+ max_attempts: raw.max_attempts === undefined
+ ? defaults.max_attempts
+ : asInteger(problems, `${path}.max_attempts`, raw.max_attempts, { min: 1, max: 10 }),
+ };
+}
+
+// A need is either a job name, or a mapping for the cases where the default
+// dimension matching is not what is wanted.
+function parseNeeds(problems, path, raw) {
+ if (raw === undefined) return [];
+ const list = Array.isArray(raw) ? raw : [raw];
+ const out = [];
+
+ list.forEach((item, i) => {
+ const itemPath = `${path}[${i}]`;
+ if (typeof item === 'string') {
+ out.push({ job: item, match: 'shared' });
+ return;
+ }
+ if (!isPlainObject(item)) {
+ problems.add(itemPath, `expected a job name or a mapping, got ${typeName(item)}`);
+ return;
+ }
+ checkUnknown(problems, itemPath, item, ['job', 'match']);
+ const job = asString(problems, `${itemPath}.job`, item.job);
+ const match = item.match === undefined ? 'shared' : asString(problems, `${itemPath}.match`, item.match);
+ if (match !== undefined && !['shared', 'all'].includes(match)) {
+ problems.add(`${itemPath}.match`, `expected shared or all, got ${JSON.stringify(match)}`);
+ return;
+ }
+ if (job !== undefined) out.push({ job, match: match ?? 'shared' });
+ });
+
+ return out;
+}
+
+function parseMatrix(problems, path, raw) {
+ if (raw === undefined) return {};
+ if (!isPlainObject(raw)) {
+ problems.add(path, `expected a mapping of dimension names to value lists, got ${typeName(raw)}`);
+ return {};
+ }
+
+ const out = {};
+ for (const [key, value] of Object.entries(raw)) {
+ const keyPath = `${path}.${key}`;
+ if (!MATRIX_KEY_PATTERN.test(key)) {
+ problems.add(keyPath, 'dimension name must be a valid environment variable name');
+ continue;
+ }
+ if (!Array.isArray(value)) {
+ problems.add(keyPath, `expected a list of values, got ${typeName(value)}`);
+ continue;
+ }
+ if (value.length === 0) {
+ problems.add(keyPath, 'must contain at least one value');
+ continue;
+ }
+
+ const values = [];
+ value.forEach((item, i) => {
+ if (typeof item !== 'string' && typeof item !== 'number' && typeof item !== 'boolean') {
+ problems.add(`${keyPath}[${i}]`, `expected a scalar value, got ${typeName(item)}`);
+ return;
+ }
+ const s = String(item);
+ if (!NAME_PATTERN.test(s)) {
+ problems.add(
+ `${keyPath}[${i}]`,
+ `${JSON.stringify(s)} may only contain letters, digits, underscore, dot and hyphen, ` +
+ 'because it becomes part of the job name'
+ );
+ return;
+ }
+ if (values.includes(s)) {
+ problems.add(`${keyPath}[${i}]`, `duplicate value ${JSON.stringify(s)}`);
+ return;
+ }
+ values.push(s);
+ });
+ out[key] = values;
+ }
+ return out;
+}
+
+function parseServices(problems, path, raw) {
+ if (raw === undefined) return [];
+ if (!Array.isArray(raw)) {
+ problems.add(path, `expected a list of services, got ${typeName(raw)}`);
+ return [];
+ }
+
+ const out = [];
+ const aliases = new Set();
+ raw.forEach((item, i) => {
+ const itemPath = `${path}[${i}]`;
+ const service = typeof item === 'string' ? { image: item } : item;
+ if (!isPlainObject(service)) {
+ problems.add(itemPath, `expected an image name or a mapping, got ${typeName(item)}`);
+ return;
+ }
+ checkUnknown(problems, itemPath, service, SERVICE_KEYS);
+
+ const image = asString(problems, `${itemPath}.image`, service.image);
+ if (image === undefined) return;
+
+ // Default alias is the image name without registry, path or tag, which
+ // is what a job would naturally use as a hostname.
+ const derived = image.split('/').pop().split(':')[0];
+ const alias = service.alias === undefined
+ ? derived
+ : asName(problems, `${itemPath}.alias`, service.alias, { max: 63 });
+ if (alias === undefined) return;
+ if (aliases.has(alias)) {
+ problems.add(`${itemPath}.alias`, `duplicate service alias ${JSON.stringify(alias)}`);
+ return;
+ }
+ aliases.add(alias);
+
+ out.push({
+ image,
+ alias,
+ env: asEnvMap(problems, `${itemPath}.env`, service.env),
+ entrypoint: asStringList(problems, `${itemPath}.entrypoint`, service.entrypoint),
+ command: asStringList(problems, `${itemPath}.command`, service.command),
+ });
+ });
+ return out;
+}
+
+function parseArtifacts(problems, path, raw) {
+ if (raw === undefined) return null;
+
+ // The common case is a bare list of paths.
+ const spec = Array.isArray(raw) ? { paths: raw } : raw;
+ if (!isPlainObject(spec)) {
+ problems.add(path, `expected a list of paths or a mapping, got ${typeName(raw)}`);
+ return null;
+ }
+ checkUnknown(problems, path, spec, ARTIFACT_KEYS);
+
+ const paths = asStringList(problems, `${path}.paths`, spec.paths, { max: 512 });
+ if (paths.length === 0) {
+ problems.add(`${path}.paths`, 'must list at least one path');
+ return null;
+ }
+ paths.forEach((p, i) => {
+ if (p.startsWith('/')) problems.add(`${path}.paths[${i}]`, 'must be relative to the workspace');
+ });
+
+ const when = spec.when === undefined ? 'on_success' : asString(problems, `${path}.when`, spec.when);
+ if (when !== undefined && !ARTIFACT_WHEN.includes(when)) {
+ problems.add(`${path}.when`, `expected one of ${ARTIFACT_WHEN.join(', ')}, got ${JSON.stringify(when)}`);
+ }
+
+ return {
+ paths,
+ when: ARTIFACT_WHEN.includes(when) ? when : 'on_success',
+ expire: spec.expire === undefined ? null : asDuration(problems, `${path}.expire`, spec.expire, { max: 365 * 24 * 3600 }),
+ };
+}
+
+function parseCache(problems, path, raw) {
+ if (raw === undefined || raw === null) return null;
+ const spec = Array.isArray(raw) ? { paths: raw } : raw;
+ if (!isPlainObject(spec)) {
+ problems.add(path, `expected a list of paths or a mapping, got ${typeName(raw)}`);
+ return null;
+ }
+ checkUnknown(problems, path, spec, CACHE_KEYS);
+
+ const paths = asStringList(problems, `${path}.paths`, spec.paths, { max: 512 });
+ if (paths.length === 0) {
+ problems.add(`${path}.paths`, 'must list at least one path');
+ return null;
+ }
+ paths.forEach((p, i) => {
+ if (p.startsWith('/')) problems.add(`${path}.paths[${i}]`, 'must be relative to the workspace');
+ });
+
+ return {
+ key: spec.key === undefined ? 'default' : asString(problems, `${path}.key`, spec.key, { max: 128 }),
+ paths,
+ };
+}
diff --git a/src/lib/pipeline/schema.js b/src/lib/pipeline/schema.js
@@ -0,0 +1,195 @@
+// src/lib/pipeline/schema.js - validation primitives for pipeline documents
+//
+// A .conductor.yml is written by hand and frequently by someone who is not
+// watching the conductor log, so validation collects every problem and
+// reports them together with a path, rather than failing on the first one.
+// Unknown keys are errors, not warnings: a silently ignored typo in a
+// pipeline is a bad afternoon.
+
+export class PipelineError extends Error {
+ constructor(errors, source) {
+ const list = errors.map((e) => ` ${e.path}: ${e.message}`).join('\n');
+ super(`invalid pipeline${source ? ` in ${source}` : ''}:\n${list}`);
+ this.name = 'PipelineError';
+ this.errors = errors;
+ this.source = source;
+ }
+}
+
+// Collects errors so that one pass reports everything wrong with a document.
+export class Problems {
+ constructor() {
+ this.items = [];
+ }
+
+ add(path, message) {
+ this.items.push({ path, message });
+ return undefined;
+ }
+
+ get length() {
+ return this.items.length;
+ }
+
+ throwIfAny(source) {
+ if (this.items.length > 0) throw new PipelineError(this.items, source);
+ }
+}
+
+export function isPlainObject(v) {
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
+}
+
+export function checkUnknown(problems, path, value, allowed) {
+ for (const key of Object.keys(value)) {
+ if (!allowed.includes(key)) {
+ const hint = suggest(key, allowed);
+ problems.add(`${path}.${key}`, `unknown key${hint ? `, did you mean ${hint}` : ''}`);
+ }
+ }
+}
+
+// Cheap edit distance, only to improve the message on a near miss.
+function suggest(key, allowed) {
+ let best = null;
+ let bestScore = Infinity;
+ for (const candidate of allowed) {
+ const score = distance(key, candidate);
+ if (score < bestScore) {
+ bestScore = score;
+ best = candidate;
+ }
+ }
+ return bestScore <= 2 ? best : null;
+}
+
+function distance(a, b) {
+ const rows = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
+ for (let j = 0; j <= b.length; j += 1) rows[0][j] = j;
+ for (let i = 1; i <= a.length; i += 1) {
+ for (let j = 1; j <= b.length; j += 1) {
+ rows[i][j] = Math.min(
+ rows[i - 1][j] + 1,
+ rows[i][j - 1] + 1,
+ rows[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1)
+ );
+ }
+ }
+ return rows[a.length][b.length];
+}
+
+export function asString(problems, path, value, { max = 4096 } = {}) {
+ if (typeof value !== 'string') return problems.add(path, `expected a string, got ${typeName(value)}`);
+ if (value.length === 0) return problems.add(path, 'must not be empty');
+ if (value.length > max) return problems.add(path, `must be at most ${max} characters`);
+ return value;
+}
+
+export function asBoolean(problems, path, value) {
+ if (typeof value !== 'boolean') return problems.add(path, `expected true or false, got ${typeName(value)}`);
+ return value;
+}
+
+export function asInteger(problems, path, value, { min = 1, max = Number.MAX_SAFE_INTEGER } = {}) {
+ if (typeof value !== 'number' || !Number.isInteger(value)) {
+ return problems.add(path, `expected an integer, got ${typeName(value)}`);
+ }
+ if (value < min || value > max) return problems.add(path, `must be between ${min} and ${max}`);
+ return value;
+}
+
+// Accepts a single string as a one element list, which is how people
+// naturally write a single value.
+export function asStringList(problems, path, value, opts = {}) {
+ if (value === undefined) return [];
+ const list = Array.isArray(value) ? value : [value];
+ const out = [];
+ list.forEach((item, i) => {
+ const s = asString(problems, `${path}[${i}]`, item, opts);
+ if (s !== undefined) out.push(s);
+ });
+ return out;
+}
+
+// Scalar values only. YAML readily produces numbers and booleans here, and
+// silently stringifying them hides mistakes, so they are converted but
+// anything structured is rejected.
+export function asEnvMap(problems, path, value) {
+ if (value === undefined) return {};
+ if (!isPlainObject(value)) {
+ problems.add(path, `expected a mapping of names to values, got ${typeName(value)}`);
+ return {};
+ }
+ const out = {};
+ for (const [key, raw] of Object.entries(value)) {
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
+ problems.add(`${path}.${key}`, 'is not a valid environment variable name');
+ continue;
+ }
+ if (raw === null) {
+ out[key] = '';
+ } else if (typeof raw === 'string' || typeof raw === 'number' || typeof raw === 'boolean') {
+ out[key] = String(raw);
+ } else {
+ problems.add(`${path}.${key}`, `expected a scalar value, got ${typeName(raw)}`);
+ }
+ }
+ return out;
+}
+
+export const MAX_DURATION = 7 * 24 * 60 * 60;
+
+// Accepts 90, '90s', '30m', '1h', or a composite such as '1h30m'.
+export function asDuration(problems, path, value, { max = MAX_DURATION } = {}) {
+ let seconds;
+
+ if (typeof value === 'number') {
+ if (!Number.isInteger(value)) return problems.add(path, 'expected a whole number of seconds');
+ seconds = value;
+ } else if (typeof value === 'string') {
+ const text = value.trim();
+ if (/^\d+$/.test(text)) {
+ seconds = parseInt(text, 10);
+ } else {
+ const matches = [...text.matchAll(/(\d+)([smh])/g)];
+ const consumed = matches.reduce((n, m) => n + m[0].length, 0);
+ if (matches.length === 0 || consumed !== text.length) {
+ return problems.add(path, `expected a duration such as 90s, 30m or 1h30m, got ${JSON.stringify(value)}`);
+ }
+ const unit = { s: 1, m: 60, h: 3600 };
+ seconds = matches.reduce((n, m) => n + parseInt(m[1], 10) * unit[m[2]], 0);
+ }
+ } else {
+ return problems.add(path, `expected a duration, got ${typeName(value)}`);
+ }
+
+ if (seconds < 1) return problems.add(path, 'must be at least 1 second');
+ if (seconds > max) return problems.add(path, `must be at most ${max} seconds`);
+ return seconds;
+}
+
+export function typeName(v) {
+ if (v === null) return 'null';
+ if (Array.isArray(v)) return 'a list';
+ if (v === undefined) return 'nothing';
+ if (typeof v === 'object') return 'a mapping';
+ return `a ${typeof v}`;
+}
+
+// Job and matrix value names appear in generated job names, storage keys and
+// environment variables, so the character set is deliberately narrow.
+export const NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
+export const MATRIX_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
+
+export function asName(problems, path, value, { max = 100 } = {}) {
+ const s = asString(problems, path, value, { max });
+ if (s === undefined) return undefined;
+ if (!NAME_PATTERN.test(s)) {
+ return problems.add(
+ path,
+ `${JSON.stringify(s)} must start with a letter or digit and contain only ` +
+ 'letters, digits, underscore, dot and hyphen'
+ );
+ }
+ return s;
+}
diff --git a/test/dag.test.js b/test/dag.test.js
@@ -0,0 +1,110 @@
+// test/dag.test.js - dependency graph traversal
+//
+// These cover the two scheduling defects carried over from the prototype:
+// serialized dispatch, and a failure cascade that only skipped direct
+// dependents.
+
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { topologicalOrder, depths, transitiveDependents, runnable, CycleError } from '../src/lib/pipeline/dag.js';
+
+// a -> b -> d, a -> c -> d, plus an unrelated island.
+const GRAPH = [
+ { name: 'a', needs: [] },
+ { name: 'b', needs: ['a'] },
+ { name: 'c', needs: ['a'] },
+ { name: 'd', needs: ['b', 'c'] },
+ { name: 'island', needs: [] },
+];
+
+function states(overrides = {}) {
+ const map = new Map(GRAPH.map((j) => [j.name, 'queued']));
+ for (const [k, v] of Object.entries(overrides)) map.set(k, v);
+ return map;
+}
+
+test('topological order places dependencies first', () => {
+ const order = topologicalOrder(GRAPH).map((j) => j.name);
+ assert.ok(order.indexOf('a') < order.indexOf('b'));
+ assert.ok(order.indexOf('a') < order.indexOf('c'));
+ assert.ok(order.indexOf('b') < order.indexOf('d'));
+ assert.ok(order.indexOf('c') < order.indexOf('d'));
+});
+
+test('topological order is deterministic', () => {
+ const first = topologicalOrder(GRAPH).map((j) => j.name);
+ const shuffled = [...GRAPH].reverse();
+ assert.deepEqual(topologicalOrder(shuffled).map((j) => j.name), first);
+});
+
+test('a cycle is detected and named', () => {
+ const cyclic = [
+ { name: 'x', needs: ['z'] },
+ { name: 'y', needs: ['x'] },
+ { name: 'z', needs: ['y'] },
+ ];
+ assert.throws(() => topologicalOrder(cyclic), CycleError);
+ try {
+ topologicalOrder(cyclic);
+ } catch (e) {
+ // The reported path returns to where it started.
+ assert.equal(e.cycle[0], e.cycle[e.cycle.length - 1]);
+ }
+});
+
+test('depth reflects the longest path, not insertion order', () => {
+ const d = depths(GRAPH);
+ assert.equal(d.get('a'), 0);
+ assert.equal(d.get('island'), 0);
+ assert.equal(d.get('b'), 1);
+ assert.equal(d.get('d'), 2);
+});
+
+test('independent jobs are runnable at the same time', () => {
+ // The prototype could only ever return one job here.
+ const ready = runnable(GRAPH, states()).map((j) => j.name);
+ assert.deepEqual(ready.sort(), ['a', 'island']);
+});
+
+test('a job becomes runnable only once every dependency succeeds', () => {
+ assert.deepEqual(
+ runnable(GRAPH, states({ a: 'success', b: 'success' })).map((j) => j.name).sort(),
+ ['c', 'island']
+ );
+ assert.deepEqual(
+ runnable(GRAPH, states({ a: 'success', b: 'success', c: 'success', island: 'success' })).map((j) => j.name),
+ ['d']
+ );
+});
+
+test('a running dependency does not release its dependents', () => {
+ assert.equal(runnable(GRAPH, states({ a: 'running' })).some((j) => j.name === 'b'), false);
+});
+
+test('failure skips transitive dependents, not just direct ones', () => {
+ // This is the prototype bug: it would have skipped only b and c, leaving
+ // d queued and dispatchable with its inputs missing.
+ const skipped = transitiveDependents(GRAPH, ['a']);
+ assert.deepEqual([...skipped].sort(), ['b', 'c', 'd']);
+ assert.equal(skipped.has('island'), false);
+});
+
+test('a skipped dependency never releases a dependent', () => {
+ assert.equal(runnable(GRAPH, states({ a: 'failed' })).some((j) => j.name === 'b'), false);
+ assert.equal(runnable(GRAPH, states({ a: 'skipped' })).some((j) => j.name === 'b'), false);
+});
+
+test('allowed failures can satisfy a dependency when asked to', () => {
+ const ready = runnable(GRAPH, states({ a: 'failed' }), { satisfied: ['success', 'failed'] });
+ assert.deepEqual(ready.map((j) => j.name).sort(), ['b', 'c', 'island']);
+});
+
+test('transitive dependents of a leaf is empty', () => {
+ assert.equal(transitiveDependents(GRAPH, ['d']).size, 0);
+});
+
+test('graph helpers tolerate dependencies outside the set', () => {
+ const partial = [{ name: 'only', needs: ['absent'] }];
+ assert.deepEqual(topologicalOrder(partial).map((j) => j.name), ['only']);
+ assert.equal(depths(partial).get('only'), 0);
+});
diff --git a/test/pipeline.test.js b/test/pipeline.test.js
@@ -0,0 +1,420 @@
+// test/pipeline.test.js - parsing, validation and expansion of .conductor.yml
+
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { compilePipeline, parsePipeline, PipelineError } from '../src/lib/pipeline/index.js';
+
+const MINIMAL = `
+version: 1
+jobs:
+ build:
+ image: alpine
+ script: [make]
+`;
+
+function errorPaths(fn) {
+ try {
+ fn();
+ } catch (e) {
+ assert.ok(e instanceof PipelineError, `expected PipelineError, got ${e}`);
+ return e.errors.map((x) => x.path);
+ }
+ throw new Error('expected the pipeline to be rejected');
+}
+
+function byName(pipeline) {
+ return Object.fromEntries(pipeline.jobs.map((j) => [j.name, j]));
+}
+
+test('a minimal pipeline compiles', () => {
+ const p = compilePipeline(MINIMAL);
+ assert.equal(p.jobs.length, 1);
+ assert.equal(p.jobs[0].name, 'build');
+ assert.deepEqual(p.jobs[0].script, ['make']);
+ assert.equal(p.jobs[0].depth, 0);
+});
+
+test('version is required and pinned', () => {
+ assert.deepEqual(errorPaths(() => compilePipeline('jobs:\n a:\n image: x\n script: [y]\n')), ['version']);
+ assert.deepEqual(errorPaths(() => compilePipeline('version: 2\njobs:\n a:\n image: x\n script: [y]\n')), ['version']);
+});
+
+test('empty and malformed documents are rejected clearly', () => {
+ assert.throws(() => compilePipeline(''), /file is empty/);
+ assert.throws(() => compilePipeline('- a\n- b\n'), /expected a mapping at the top level/);
+ assert.throws(() => compilePipeline('version: 1\njobs: {\n'), /not valid YAML/);
+});
+
+test('image and script are required', () => {
+ const paths = errorPaths(() => compilePipeline('version: 1\njobs:\n a: {}\n'));
+ assert.deepEqual(paths.sort(), ['jobs.a.image', 'jobs.a.script']);
+});
+
+test('image may come from defaults', () => {
+ const p = compilePipeline('version: 1\ndefaults:\n image: alpine\njobs:\n a:\n script: [x]\n');
+ assert.equal(p.jobs[0].image, 'alpine');
+});
+
+test('unknown keys are rejected, with a suggestion when close', () => {
+ try {
+ compilePipeline('version: 1\njobs:\n a:\n image: x\n script: [y]\n scripts: [z]\n');
+ throw new Error('expected rejection');
+ } catch (e) {
+ assert.match(e.message, /jobs\.a\.scripts: unknown key, did you mean script/);
+ }
+});
+
+test('all problems are reported at once', () => {
+ const paths = errorPaths(() => compilePipeline(`
+version: 1
+jobs:
+ a:
+ script: [x]
+ b:
+ image: y
+`));
+ assert.ok(paths.includes('jobs.a.image'));
+ assert.ok(paths.includes('jobs.b.script'));
+});
+
+test('durations accept seconds, units and composites', () => {
+ const p = compilePipeline(`
+version: 1
+jobs:
+ a: { image: x, script: [y], timeout: 90 }
+ b: { image: x, script: [y], timeout: 30m }
+ c: { image: x, script: [y], timeout: 1h30m }
+`);
+ const jobs = byName(p);
+ assert.equal(jobs.a.timeout, 90);
+ assert.equal(jobs.b.timeout, 1800);
+ assert.equal(jobs.c.timeout, 5400);
+});
+
+test('a malformed duration is rejected', () => {
+ assert.deepEqual(
+ errorPaths(() => compilePipeline('version: 1\njobs:\n a: { image: x, script: [y], timeout: soon }\n')),
+ ['jobs.a.timeout']
+ );
+});
+
+test('arch expands into one job per architecture', () => {
+ const p = compilePipeline('version: 1\njobs:\n b:\n image: x\n script: [y]\n arch: [x86_64, aarch64]\n');
+ assert.deepEqual(p.jobs.map((j) => j.name).sort(), ['b:arch=aarch64', 'b:arch=x86_64']);
+ assert.equal(byName(p)['b:arch=x86_64'].env.ARCH, 'x86_64');
+});
+
+test('a matrix expands to the cartesian product', () => {
+ const p = compilePipeline(`
+version: 1
+jobs:
+ p:
+ image: x
+ script: [y]
+ matrix:
+ pkg: [musl, busybox]
+ mode: [debug, release]
+`);
+ assert.equal(p.jobs.length, 4);
+ // Dimensions keep their declaration order, so names are predictable.
+ assert.deepEqual(p.jobs.map((j) => j.name).sort(), [
+ 'p:pkg=busybox,mode=debug',
+ 'p:pkg=busybox,mode=release',
+ 'p:pkg=musl,mode=debug',
+ 'p:pkg=musl,mode=release',
+ ]);
+ const one = byName(p)['p:pkg=musl,mode=debug'];
+ assert.equal(one.env.MATRIX_PKG, 'musl');
+ assert.equal(one.env.MATRIX_MODE, 'debug');
+});
+
+test('needs match on shared dimensions rather than fanning out', () => {
+ const p = compilePipeline(`
+version: 1
+jobs:
+ build:
+ image: x
+ script: [y]
+ arch: [x86_64, aarch64]
+ package:
+ image: x
+ script: [y]
+ arch: [x86_64, aarch64]
+ matrix:
+ pkg: [musl]
+ needs: [build]
+`);
+ const jobs = byName(p);
+ assert.deepEqual(jobs['package:arch=x86_64,pkg=musl'].needs, ['build:arch=x86_64']);
+ assert.deepEqual(jobs['package:arch=aarch64,pkg=musl'].needs, ['build:arch=aarch64']);
+});
+
+test('a job without the shared dimension depends on every instance', () => {
+ const p = compilePipeline(`
+version: 1
+jobs:
+ build:
+ image: x
+ script: [y]
+ arch: [x86_64, aarch64]
+ publish:
+ image: x
+ script: [y]
+ needs: [build]
+`);
+ assert.deepEqual(byName(p).publish.needs, ['build:arch=aarch64', 'build:arch=x86_64']);
+});
+
+test('match all opts out of dimension matching', () => {
+ const p = compilePipeline(`
+version: 1
+jobs:
+ build:
+ image: x
+ script: [y]
+ arch: [x86_64, aarch64]
+ check:
+ image: x
+ script: [y]
+ arch: [x86_64, aarch64]
+ needs:
+ - job: build
+ match: all
+`);
+ assert.deepEqual(byName(p)['check:arch=x86_64'].needs, ['build:arch=aarch64', 'build:arch=x86_64']);
+});
+
+test('needs may name one concrete instance', () => {
+ const p = compilePipeline(`
+version: 1
+jobs:
+ build:
+ image: x
+ script: [y]
+ arch: [x86_64, aarch64]
+ docs:
+ image: x
+ script: [y]
+ needs: ['build:arch=x86_64']
+`);
+ assert.deepEqual(byName(p).docs.needs, ['build:arch=x86_64']);
+});
+
+test('an unsatisfiable dimension match is reported, not silently dropped', () => {
+ try {
+ compilePipeline(`
+version: 1
+jobs:
+ build:
+ image: x
+ script: [y]
+ arch: [x86_64]
+ package:
+ image: x
+ script: [y]
+ arch: [riscv64]
+ needs: [build]
+`);
+ throw new Error('expected rejection');
+ } catch (e) {
+ assert.match(e.message, /no instance of "build" matches package:arch=riscv64 on arch/);
+ assert.match(e.message, /match: all/);
+ }
+});
+
+test('needs on an unknown job lists what is defined', () => {
+ try {
+ compilePipeline('version: 1\njobs:\n a:\n image: x\n script: [y]\n needs: [nope]\n');
+ throw new Error('expected rejection');
+ } catch (e) {
+ assert.match(e.message, /refers to unknown job "nope"/);
+ }
+});
+
+test('self dependency is rejected', () => {
+ assert.throws(
+ () => compilePipeline('version: 1\njobs:\n a:\n image: x\n script: [y]\n needs: [a]\n'),
+ /cannot depend on itself/
+ );
+});
+
+test('a dependency cycle names the cycle', () => {
+ assert.throws(() => compilePipeline(`
+version: 1
+jobs:
+ a: { image: x, script: [s], needs: [c] }
+ b: { image: x, script: [s], needs: [a] }
+ c: { image: x, script: [s], needs: [b] }
+`), /dependency cycle: (a -> c -> b -> a|b -> a -> c -> b|c -> b -> a -> c)/);
+});
+
+test('interpolation substitutes arch and matrix values', () => {
+ const p = compilePipeline(`
+version: 1
+jobs:
+ b:
+ image: 'builder:\${{ arch }}'
+ arch: [x86_64]
+ matrix:
+ pkg: [musl]
+ script: ['build \${{ matrix.pkg }} for \${{ arch }}']
+ env:
+ TAG: '\${{ matrix.pkg }}-\${{ arch }}'
+`);
+ const job = p.jobs[0];
+ assert.equal(job.image, 'builder:x86_64');
+ assert.deepEqual(job.script, ['build musl for x86_64']);
+ assert.equal(job.env.TAG, 'musl-x86_64');
+});
+
+test('interpolating an undefined dimension is an error', () => {
+ try {
+ compilePipeline('version: 1\njobs:\n a:\n image: x\n script: ["\${{ matrix.nope }}"]\n');
+ throw new Error('expected rejection');
+ } catch (e) {
+ assert.match(e.message, /matrix\.nope \}\} which the job does not define/);
+ }
+});
+
+test('referring to arch without declaring one is an error', () => {
+ assert.throws(
+ () => compilePipeline('version: 1\njobs:\n a:\n image: "x:\${{ arch }}"\n script: [y]\n'),
+ /the job declares no arch/
+ );
+});
+
+test('matrix values must be safe for use in a job name', () => {
+ assert.deepEqual(
+ errorPaths(() => compilePipeline('version: 1\njobs:\n a:\n image: x\n script: [y]\n matrix:\n k: ["has space"]\n')),
+ ['jobs.a.matrix.k[0]']
+ );
+});
+
+test('arch may not also be a matrix dimension', () => {
+ assert.deepEqual(
+ errorPaths(() => compilePipeline('version: 1\njobs:\n a:\n image: x\n script: [y]\n matrix:\n arch: [x86_64]\n')),
+ ['jobs.a.matrix.arch']
+ );
+});
+
+test('services get a derived alias and reject duplicates', () => {
+ const p = compilePipeline(`
+version: 1
+jobs:
+ a:
+ image: x
+ script: [y]
+ services:
+ - docker:dind
+ - image: registry.example.com/team/postgres:16
+ alias: db
+`);
+ assert.deepEqual(p.jobs[0].services.map((s) => s.alias), ['docker', 'db']);
+
+ assert.throws(() => compilePipeline(`
+version: 1
+jobs:
+ a:
+ image: x
+ script: [y]
+ services: [docker:dind, docker:24-dind]
+`), /duplicate service alias/);
+});
+
+test('artifacts accept a bare list and reject absolute paths', () => {
+ const p = compilePipeline('version: 1\njobs:\n a:\n image: x\n script: [y]\n artifacts: [dist/**]\n');
+ assert.deepEqual(p.jobs[0].artifacts.paths, ['dist/**']);
+ assert.equal(p.jobs[0].artifacts.when, 'on_success');
+
+ assert.deepEqual(
+ errorPaths(() => compilePipeline('version: 1\njobs:\n a:\n image: x\n script: [y]\n artifacts: [/etc/passwd]\n')),
+ ['jobs.a.artifacts.paths[0]']
+ );
+});
+
+test('artifact when is constrained', () => {
+ assert.deepEqual(
+ errorPaths(() => compilePipeline('version: 1\njobs:\n a:\n image: x\n script: [y]\n artifacts:\n paths: [d]\n when: maybe\n')),
+ ['jobs.a.artifacts.when']
+ );
+});
+
+test('defaults are inherited and overridden per job', () => {
+ const p = compilePipeline(`
+version: 1
+defaults:
+ image: base
+ timeout: 10m
+ env:
+ SHARED: yes
+ requires: [docker]
+jobs:
+ a:
+ script: [x]
+ b:
+ image: custom
+ timeout: 1h
+ script: [x]
+ env:
+ OWN: no
+`);
+ const jobs = byName(p);
+ assert.equal(jobs.a.image, 'base');
+ assert.equal(jobs.a.timeout, 600);
+ assert.deepEqual(jobs.a.requires, ['docker']);
+ assert.equal(jobs.b.image, 'custom');
+ assert.equal(jobs.b.timeout, 3600);
+ assert.equal(jobs.b.env.SHARED, 'yes');
+ assert.equal(jobs.b.env.OWN, 'no');
+});
+
+test('env values must be scalars with valid names', () => {
+ const paths = errorPaths(() => compilePipeline(`
+version: 1
+jobs:
+ a:
+ image: x
+ script: [y]
+ env:
+ 'bad name': 1
+ GOOD: { nested: true }
+`));
+ assert.deepEqual(paths.sort(), ['jobs.a.env.GOOD', 'jobs.a.env.bad name']);
+});
+
+test('parsePipeline normalizes without expanding', () => {
+ const doc = parsePipeline(MINIMAL);
+ assert.equal(doc.version, 1);
+ assert.deepEqual(Object.keys(doc.jobs), ['build']);
+ assert.deepEqual(doc.jobs.build.needs, []);
+});
+
+test('a realistic distribution pipeline produces the expected graph', () => {
+ const p = compilePipeline(`
+version: 1
+defaults:
+ image: debian:bookworm-slim
+jobs:
+ lint:
+ script: [make lint]
+ build:
+ arch: [x86_64, aarch64]
+ script: ['./mk/build.sh $ARCH']
+ package:
+ needs: [build]
+ arch: [x86_64, aarch64]
+ matrix:
+ pkg: [musl, busybox]
+ requires: [sign-key]
+ script: ['./mk/pkg.sh $MATRIX_PKG $ARCH']
+ publish:
+ needs: [package, lint]
+ script: [./mk/publish.sh]
+`);
+
+ assert.equal(p.jobs.length, 1 + 2 + 4 + 1);
+ const jobs = byName(p);
+ assert.equal(jobs.publish.needs.length, 5);
+ assert.equal(jobs.publish.depth, 2);
+ assert.deepEqual(jobs['package:arch=aarch64,pkg=musl'].requires, ['sign-key']);
+});