conductor

CI task system
git clone git://git.finwo.net/app/conductor
Log | Files | Refs | README | LICENSE

commit 048d4fed616c6848bfbf090593a22196d27b14bc
parent 824a5e90651d6d841539f90b24aa6b0ba0c2328e
Author: finwo <finwo@pm.me>
Date:   Sat, 19 Sep 2026 16:41:03 +0200

Restrict jobs to refs with only:, and publish images on main and tags

Diffstat:
A.conductor.yml | 81+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MREADME.md | 5++++-
Adeploy/publish.sh | 149+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdeploy/smoke.sh | 37+++++++++++++++++++++++++++++++++++++
Mdocs/deployment.md | 72++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mdocs/pipeline.md | 43+++++++++++++++++++++++++++++++++++++++++++
Msrc/conductor/scheduler.js | 4++++
Msrc/lib/pipeline/expand.js | 3+++
Msrc/lib/pipeline/index.js | 10+++++++++-
Asrc/lib/pipeline/only.js | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/lib/pipeline/parse.js | 39+++++++++++++++++++++++++++++++++++++++
Atest/only.test.js | 160+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest/self-pipeline.test.js | 81+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
13 files changed, 723 insertions(+), 12 deletions(-)

diff --git a/.conductor.yml b/.conductor.yml @@ -0,0 +1,81 @@ +# The pipeline conductor runs on itself. +# +# The suite is split in two because most of it needs nothing but node, +# while the parts that exercise object storage and OIDC start real +# containers and so need a worker offering the docker feature. Setting +# CONDUCTOR_TEST_NO_DOCKER skips those rather than failing them, which +# keeps the fast job honest about what it did and did not check. + +version: 1 +visibility: public + +defaults: + image: node:24-bookworm-slim + timeout: 15m + +jobs: + # Style rules that are cheap to break and cheap to check: ASCII only, + # no CRLF, no trailing whitespace. + style: + script: + - npm install --no-audit --no-fund + - node --test test/ascii.test.js + cache: + key: npm + paths: [.npm] + + # Everything that runs without a docker socket. + test: + script: + - npm install --no-audit --no-fund + - npm test + env: + CONDUCTOR_TEST_NO_DOCKER: '1' + cache: + key: npm + paths: [.npm] + + # The same suite with the container-backed tests switched on, so the S3 + # and OIDC paths are exercised against real servers rather than skipped. + integration: + requires: [docker] + script: + - npm install --no-audit --no-fund + - npm test + cache: + key: npm + paths: [.npm] + + # Both images have to build before anything claims a release is possible. + # Host architecture only, since this runs on every branch and the point + # is to catch a broken Dockerfile early rather than to ship anything. + images: + needs: [style, test] + requires: [docker] + image: docker:28-cli + script: + - docker build -f deploy/Dockerfile -t conductor:$CONDUCTOR_SHA . + - docker build -f deploy/Dockerfile.worker -t conductor-worker:$CONDUCTOR_SHA . + + # The whole stack, built from the images and exercised with a real + # pipeline. Catches what unit tests cannot: that the thing people + # actually deploy starts, registers a worker and runs a job. + smoke: + needs: [images] + requires: [docker] + image: docker:28-cli + script: + - apk add --no-cache git openssl curl + - ./deploy/smoke.sh + + # Pushes to Docker Hub. Restricted to main and release tags, so a branch + # build never moves :latest. Without the rule this job would run on every + # push, which is the whole reason only: exists. + publish: + needs: [smoke, integration] + requires: [docker] + image: docker:28-cli + only: + refs: [refs/heads/main, 'refs/tags/v*'] + script: + - ./deploy/publish.sh diff --git a/README.md b/README.md @@ -33,7 +33,7 @@ Under construction. Working today: - the worker: containerised jobs, services, features, caches, cleanup - accounts, OIDC, project ownership, visibility and project variables - the web interface, server rendered with htmx - - container images, compose manifests and a deployment smoke test + - container images for amd64, arm64 and riscv64, published on release Installation ------------ @@ -44,6 +44,9 @@ With docker: docker compose -f deploy/docker-compose.yml up -d conductor ``` +Images are published as `finwo/conductor` and `finwo/conductor-worker` +for amd64, arm64 and riscv64. + From source, requiring node 24 or newer and no native modules: ```sh diff --git a/deploy/publish.sh b/deploy/publish.sh @@ -0,0 +1,149 @@ +#!/bin/sh +# deploy/publish.sh - build and push the images to Docker Hub +# +# Run by the publish job in .conductor.yml, which restricts it to main and +# to release tags. Safe to run by hand for a dry run. +# +# What gets pushed, from CONDUCTOR_REF: +# +# refs/heads/main finwo/conductor:latest and :<short sha> +# refs/tags/v1.2.0 finwo/conductor:1.2.0, :1.2 and :latest +# +# A tag publishes :latest as well, so :latest follows releases once there +# are any, and follows main until then. +# +# Needs REGISTRY_USERNAME and REGISTRY_TOKEN as project variables. They are +# masked in the log by the conductor, but this still keeps them off the +# command line, where they would show up in a process list. +# +# DRY_RUN=1 build for the host architecture only, and push nothing + +set -eu + +: "${CONDUCTOR_REF:?CONDUCTOR_REF is not set}" +: "${CONDUCTOR_SHA:?CONDUCTOR_SHA is not set}" + +DRY_RUN="${DRY_RUN:-0}" +CONDUCTOR_IMAGE="${CONDUCTOR_IMAGE:-finwo/conductor}" +WORKER_IMAGE="${WORKER_IMAGE:-finwo/conductor-worker}" + +# riscv64 has no node image and no static docker CLI, which is why both +# Dockerfiles are built on Alpine. Everything here is available there. +PLATFORMS="${PLATFORMS:-linux/amd64,linux/arm64,linux/riscv64}" + +ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +SHORT=$(printf '%s' "${CONDUCTOR_SHA}" | cut -c1-12) + +log() { printf '\n== %s\n' "$*"; } +fail() { printf '\nFAILED: %s\n' "$*" >&2; exit 1; } + +# Turns a ref into the list of tags it should publish. +# +# A version tag also publishes the major.minor series, so v1.2.1 moves +# :1.2 as well. Deliberately no bare major: :1 moving across minor +# releases tends to surprise people more than it helps them. +tags_for_ref() { + ref=$1 + case "${ref}" in + refs/heads/main) + printf 'latest\n%s\n' "${SHORT}" + ;; + refs/tags/v*) + version=${ref#refs/tags/v} + case "${version}" in + *[!0-9.]*) + fail "tag ${ref} is not a plain version, refusing to guess what to publish" + ;; + esac + printf '%s\n' "${version}" + # v1.2.3 -> 1.2, but v1.2 stays as it is. + series=$(printf '%s' "${version}" | cut -d. -f1-2) + [ "${series}" = "${version}" ] || printf '%s\n' "${series}" + printf 'latest\n' + ;; + *) + fail "ref ${ref} does not publish anything; the only: rule in .conductor.yml should have prevented this" + ;; + esac +} + +TAGS=$(tags_for_ref "${CONDUCTOR_REF}") + +log "publishing from ${CONDUCTOR_REF} at ${SHORT}" +printf 'tags:\n' +printf ' %s\n' ${TAGS} +printf 'platforms: %s\n' "${PLATFORMS}" + +# Collects --tag arguments for one image. +tag_args() { + image=$1 + for tag in ${TAGS}; do + printf -- '--tag\n%s:%s\n' "${image}" "${tag}" + done +} + +if [ "${DRY_RUN}" = 1 ]; then + log "dry run: building for this architecture only, pushing nothing" + for spec in "deploy/Dockerfile ${CONDUCTOR_IMAGE}" "deploy/Dockerfile.worker ${WORKER_IMAGE}"; do + # shellcheck disable=SC2086 + set -- ${spec} + dockerfile=$1 + image=$2 + printf '\n-- %s\n' "${image}" + docker build -f "${ROOT}/${dockerfile}" -t "${image}:dry-run" "${ROOT}" >/dev/null + printf 'would push:\n' + for tag in ${TAGS}; do printf ' %s:%s\n' "${image}" "${tag}"; done + done + printf '\nPASSED (dry run)\n' + exit 0 +fi + +: "${REGISTRY_USERNAME:?REGISTRY_USERNAME is not set}" +: "${REGISTRY_TOKEN:?REGISTRY_TOKEN is not set}" + +log "registering emulators" +# arm64 and riscv64 are emulated unless the worker is that architecture. +# Without this, buildx cannot run the foreign binaries a build needs. +docker run --privileged --rm tonistiigi/binfmt:qemu-v9.2.2 --install all >/dev/null + +log "preparing the builder" +# The default builder cannot do more than one platform at a time. +docker buildx inspect conductor-publish >/dev/null 2>&1 \ + || docker buildx create --name conductor-publish --driver docker-container --bootstrap >/dev/null +docker buildx use conductor-publish + +log "signing in" +printf '%s' "${REGISTRY_TOKEN}" | docker login --username "${REGISTRY_USERNAME}" --password-stdin >/dev/null + +# Whatever happens next, do not leave the credentials behind on a worker +# that is shared with other people's jobs. +cleanup() { docker logout >/dev/null 2>&1 || true; } +trap cleanup EXIT + +log "building and pushing ${CONDUCTOR_IMAGE}" +# shellcheck disable=SC2046 +docker buildx build \ + --platform "${PLATFORMS}" \ + --file "${ROOT}/deploy/Dockerfile" \ + $(tag_args "${CONDUCTOR_IMAGE}" | tr '\n' ' ') \ + --push \ + "${ROOT}" + +log "building and pushing ${WORKER_IMAGE}" +# shellcheck disable=SC2046 +docker buildx build \ + --platform "${PLATFORMS}" \ + --file "${ROOT}/deploy/Dockerfile.worker" \ + $(tag_args "${WORKER_IMAGE}" | tr '\n' ' ') \ + --push \ + "${ROOT}" + +log "verifying the manifests" +for image in "${CONDUCTOR_IMAGE}" "${WORKER_IMAGE}"; do + first=$(printf '%s' "${TAGS}" | head -1) + docker buildx imagetools inspect "${image}:${first}" \ + | grep -i platform \ + || fail "no platforms reported for ${image}:${first}" +done + +printf '\nPASSED\n' diff --git a/deploy/smoke.sh b/deploy/smoke.sh @@ -64,6 +64,11 @@ jobs: publish: needs: [build] script: ['echo published'] + release: + needs: [build] + only: + refs: [refs/heads/main] + script: ['echo released'] PIPELINE echo 'smoke test repository' > "${WORK}/repo/README.md" git -C "${WORK}/repo" init -q -b main @@ -177,4 +182,36 @@ printf 'artifact contents: %s\n' "${CONTENT}" log "checking the interface" curl -fsS "http://127.0.0.1:${PORT}/" | grep -q 'conductor' || fail "the interface did not render" +# The release job is restricted to main. A push to anything else must not +# produce it at all, rather than produce it and skip it, since a skipped +# job fails the run. +log "checking that a branch push leaves the restricted job out" +BRANCH_BODY=$(printf '{"sha":"%s","ref":"refs/heads/feature"}' "${SHA}") +BRANCH_SIG=$(printf '%s' "${BRANCH_BODY}" | openssl dgst -sha256 -hmac "${SECRET}" | sed 's/^.*[= ]//') +BRANCH=$(curl -fsS -X POST "http://127.0.0.1:${PORT}/api/trigger/demo" \ + -H 'Content-Type: application/json' \ + -H "X-Hub-Signature-256: sha256=${BRANCH_SIG}" \ + -d "${BRANCH_BODY}") +printf '%s\n' "${BRANCH}" + +printf '%s' "${BRANCH}" | grep -q '"jobs":2' \ + || fail "expected 2 jobs on a branch, got: ${BRANCH}" + +BRANCH_RUN=$(printf '%s' "${BRANCH}" | sed 's/.*"run_id":"\([^"]*\)".*/\1/') +i=0 +while [ "${i}" -lt 90 ]; do + BRANCH_STATE=$(curl -fsS "http://127.0.0.1:${PORT}/api/runs/${BRANCH_RUN}" | sed 's/.*"state":"\([^"]*\)".*/\1/') + case "${BRANCH_STATE}" in + success|failed|cancelled) break ;; + esac + i=$((i + 1)) + sleep 1 +done + +curl -fsS "http://127.0.0.1:${PORT}/api/runs/${BRANCH_RUN}" | grep -q '"release"' \ + && fail "the release job should not exist on a branch run" + +printf 'branch run finished as %s without the release job\n' "${BRANCH_STATE}" +[ "${BRANCH_STATE}" = success ] || fail "the branch run ended as ${BRANCH_STATE}" + printf '\nPASSED\n' diff --git a/docs/deployment.md b/docs/deployment.md @@ -9,11 +9,11 @@ Quick start ----------- ```sh -docker compose -f deploy/docker-compose.yml up -d --build +docker compose -f deploy/docker-compose.yml up -d ``` -That brings up a conductor on port 8080 with sqlite on a volume, and one -worker. The compose file needs a worker token, so the first run is +That pulls the published images and brings up a conductor on port 8080 +with sqlite on a volume, and one worker. The compose file needs a worker token, so the first run is two steps: ```sh @@ -42,16 +42,25 @@ log and artifact came back. Images ------ -| Image | Contents | -| -------------------- | ----------------------------------------------- | -| `deploy/Dockerfile` | the conductor: node, git, the application | -| `deploy/Dockerfile.worker` | the worker: node, git, tar, the docker CLI | +| Image | Contents | +| ------------------------- | ------------------------------------------- | +| `finwo/conductor` | the conductor: node, git, the application | +| `finwo/conductor-worker` | the worker: node, git, tar, the docker CLI | + +Published for `linux/amd64`, `linux/arm64` and `linux/riscv64`. Tags: +`latest` follows releases, `1.2.3` and `1.2` pin a version, and a twelve +character commit id pins an exact build from main. + +Both are built on Alpine rather than the official node image, which is +only published for amd64, arm64 and ppc64le. Alpine 3.23 carries node 24 +on every architecture here, and a docker client too, so the worker no +longer fetches a static binary that has no riscv64 build at all. -Both build from the repository root: +To build them yourself: ```sh -docker build -f deploy/Dockerfile -t conductor . -docker build -f deploy/Dockerfile.worker -t conductor-worker . +docker build -f deploy/Dockerfile -t finwo/conductor . +docker build -f deploy/Dockerfile.worker -t finwo/conductor-worker . ``` The worker image installs `yaml` so its configuration file may be YAML; the @@ -179,6 +188,49 @@ docker compose -f deploy/docker-compose.yml pull docker compose -f deploy/docker-compose.yml up -d ``` +Pin a version rather than tracking `latest` if an unattended restart +picking up a new release would be unwelcome: + +```sh +CONDUCTOR_IMAGE=finwo/conductor:1.2.3 \ +CONDUCTOR_WORKER_IMAGE=finwo/conductor-worker:1.2.3 \ + docker compose -f deploy/docker-compose.yml up -d +``` + Workers can be upgraded independently and in any order. The worker API is the boundary between them, and a worker that goes away mid-job has that job requeued after `scheduler.heartbeat_timeout`. + +Publishing +---------- + +The images are built and pushed by the project's own pipeline, from +`deploy/publish.sh`. It runs only on `main` and on `v*` tags, enforced by +an `only:` rule in `.conductor.yml` rather than by a check inside the +script, so a branch build cannot move `latest` even by accident. + +| Ref | Tags pushed | +| ------------------ | ------------------------------------ | +| `refs/heads/main` | `latest`, `<short sha>` | +| `refs/tags/v1.2.3` | `1.2.3`, `1.2`, `latest` | + +A tag that is not a plain version, `v1.2.3-rc1` for instance, is refused +rather than guessed at. + +Pushing needs `REGISTRY_USERNAME` and `REGISTRY_TOKEN` as project +variables. They are masked in job logs, and passed to `docker login` on +standard input rather than on the command line so they stay out of the +process list. The job logs out again on the way out, whether or not it +succeeded, so nothing is left behind on a shared worker. + +The foreign architectures are emulated with QEMU, registered per run with +`tonistiigi/binfmt`. A riscv64 build under emulation is slow; giving the +pool a worker of that architecture makes it native instead, since the +worker declares its own architectures when it polls. + +To see what a release would push without pushing it: + +```sh +CONDUCTOR_REF=refs/tags/v1.2.3 CONDUCTOR_SHA=$(git rev-parse HEAD) \ + DRY_RUN=1 deploy/publish.sh +``` diff --git a/docs/pipeline.md b/docs/pipeline.md @@ -66,6 +66,7 @@ Jobs | `allow_failure` | boolean | `false` | yes | | `timeout` | duration | server default | yes | | `max_attempts` | integer, 1 to 10 | `1` | yes | +| `only` | mapping | none | no | `image` and `script` are the only required fields, and `image` may come from `defaults`. @@ -92,6 +93,48 @@ exceed seven days; `artifacts.expire` may not exceed a year. Fanning out ----------- +### only + +Restricts a job to certain refs. Without it a job runs on every push. + +```yaml +publish: + needs: [build] + only: + refs: [refs/heads/main, 'refs/tags/v*'] + script: + - ./deploy/publish.sh +``` + +Patterns match the whole ref, so `refs/heads/main` rather than `main`. +A `*` stands for any run of characters; everything else is literal, so a +dot is a dot rather than any character. + +A job that does not match is left out of the run entirely, not recorded +as skipped. A skipped job means a dependency collapsed and fails the run, +which is the wrong reading for a publish step that was never meant to run +on this branch. + +Anything depending on an excluded job is excluded with it. Dropping the +dependency instead would let a job run without something it declared it +needed, which is the worse surprise: + +```yaml +# On a branch, none of these three run. +publish: + only: { refs: [refs/heads/main] } + script: ['make publish'] +announce: + needs: [publish] + script: ['make announce'] +``` + +A run triggered without a ref matches no pattern, so restricted jobs stay +out rather than being handed a ref they were never written for. + +The rule is not inheritable from `defaults`. A restriction that silently +applied to every job is hard to spot when the symptom is an empty run. + ### arch Expands the job into one job per architecture, and restricts each to a worker diff --git a/src/conductor/scheduler.js b/src/conductor/scheduler.js @@ -113,6 +113,10 @@ export function createScheduler({ cfg, db, git, logs, storage, projects, variabl source: project.config_path, defaultTimeout: cfg.scheduler.default_job_timeout, defaultAttempts: 1, + // Decides which jobs this push runs at all, so a publish step + // restricted to main is absent from a branch run rather than + // recorded against it. + ref: ref ?? '', }); const info = await git.commitInfo(project, headSha).catch(() => ({ subject: null })); diff --git a/src/lib/pipeline/expand.js b/src/lib/pipeline/expand.js @@ -149,6 +149,9 @@ export function expandPipeline(pipeline, options = {}) { allow_failure: job.allow_failure, timeout: job.timeout ?? defaultTimeout, max_attempts: job.max_attempts ?? defaultAttempts, + // Every instance of a matrix job inherits the ref rule, so a + // restricted job stays restricted across all of its expansions. + only: job.only ?? null, needs: [], }); } diff --git a/src/lib/pipeline/index.js b/src/lib/pipeline/index.js @@ -8,17 +8,25 @@ import { parsePipeline } from './parse.js'; import { expandPipeline } from './expand.js'; import { topologicalOrder, depths, CycleError } from './dag.js'; +import { selectForRef } from './only.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 { refMatches, jobRunsOnRef, selectForRef } from './only.js'; export { PipelineError } from './schema.js'; export { SUPPORTED_VERSION, VISIBILITIES } from './parse.js'; export function compilePipeline(text, options = {}) { const pipeline = parsePipeline(text, options); - const jobs = expandPipeline(pipeline, options); + const expanded = expandPipeline(pipeline, options); + + // Jobs restricted to other refs drop out before the graph is checked, so + // ordering and depth describe the run that will actually happen. Without + // a ref nothing is filtered, which keeps validation of a pipeline + // separate from deciding what one push will run. + const jobs = options.ref === undefined ? expanded : selectForRef(expanded, options.ref); try { topologicalOrder(jobs); diff --git a/src/lib/pipeline/only.js b/src/lib/pipeline/only.js @@ -0,0 +1,51 @@ +// src/lib/pipeline/only.js - restricting jobs to particular refs +// +// A job carrying an `only` rule runs when the ref that triggered the run +// matches one of its patterns, and is left out of the run entirely when it +// does not. Left out rather than recorded as skipped, because the +// scheduler treats a skipped job as a reason to fail the run: that is the +// right reading when a dependency collapsed, and the wrong one for a +// publish step that was never meant to run on this branch. +// +// Anything depending on an excluded job is excluded with it. The +// alternative, quietly dropping the dependency, would let a job run +// without something it declared it needed, which is a worse surprise than +// the job not running. + +import { transitiveDependents } from './dag.js'; + +// Glob matching over a whole ref. Only * is special, standing for any run +// of characters including none; everything else is literal. Deliberately +// not a regular expression, since these come out of a repository and are +// read by people who are not thinking about regular expression syntax. +export function refMatches(pattern, ref) { + if (typeof pattern !== 'string' || typeof ref !== 'string') return false; + if (!pattern.includes('*')) return pattern === ref; + + const source = pattern + .split('*') + .map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) + .join('.*'); + return new RegExp(`^${source}$`).test(ref); +} + +export function jobRunsOnRef(job, ref) { + if (!job.only) return true; + + // A run with no ref at all, which a manual or api trigger may produce, + // cannot match a ref pattern. Restricted jobs stay out rather than + // being handed a ref they were never written for. + if (typeof ref !== 'string' || ref === '') return false; + + return job.only.refs.some((pattern) => refMatches(pattern, ref)); +} + +// The jobs that belong in a run for this ref, in their original order. +export function selectForRef(jobs, ref) { + const excluded = new Set(jobs.filter((job) => !jobRunsOnRef(job, ref)).map((job) => job.name)); + if (excluded.size === 0) return jobs; + + for (const name of transitiveDependents(jobs, [...excluded])) excluded.add(name); + + return jobs.filter((job) => !excluded.has(job.name)); +} diff --git a/src/lib/pipeline/parse.js b/src/lib/pipeline/parse.js @@ -31,6 +31,7 @@ export const VISIBILITIES = ['public', 'private']; const JOB_KEYS = [ 'image', 'script', 'needs', 'arch', 'matrix', 'requires', 'services', 'env', 'artifacts', 'cache', 'allow_failure', 'timeout', 'max_attempts', + 'only', ]; // Fields a job may inherit from defaults. @@ -39,6 +40,7 @@ const DEFAULT_KEYS = [ 'allow_failure', 'timeout', 'max_attempts', ]; +const ONLY_KEYS = ['refs']; const ARTIFACT_KEYS = ['paths', 'expire', 'when']; const ARTIFACT_WHEN = ['on_success', 'on_failure', 'always']; const CACHE_KEYS = ['key', 'paths']; @@ -203,9 +205,46 @@ function parseJob(problems, path, raw, defaults) { max_attempts: raw.max_attempts === undefined ? defaults.max_attempts : asInteger(problems, `${path}.max_attempts`, raw.max_attempts, { min: 1, max: 10 }), + only: parseOnly(problems, `${path}.only`, raw.only), }; } +// Restricts a job to certain refs. Deliberately not inheritable from +// defaults: a rule that silently applied to every job is the kind of thing +// that stops a pipeline running at all and takes an afternoon to find. +// +// only: +// refs: [refs/heads/main, 'refs/tags/v*'] +// +// Patterns match the whole ref, so refs/heads/main rather than main, with +// * standing for any run of characters. Absent means the job always runs. +function parseOnly(problems, path, raw) { + if (raw === undefined || raw === null) return null; + if (!isPlainObject(raw)) { + problems.add(path, 'must be a mapping, for example: only: { refs: [refs/heads/main] }'); + return null; + } + + for (const key of Object.keys(raw)) { + if (!ONLY_KEYS.includes(key)) { + problems.add(`${path}.${key}`, `unknown key, expected one of: ${ONLY_KEYS.join(', ')}`); + } + } + + if (raw.refs === undefined) { + problems.add(path, 'needs a refs list, otherwise it restricts nothing'); + return null; + } + + const refs = asStringList(problems, `${path}.refs`, raw.refs, { max: 64 }); + if (refs.length === 0) { + problems.add(`${path}.refs`, 'must list at least one ref pattern'); + return null; + } + + return { refs }; +} + // 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) { diff --git a/test/only.test.js b/test/only.test.js @@ -0,0 +1,160 @@ +// test/only.test.js - restricting jobs to particular refs + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { compilePipeline, refMatches, PipelineError } from '../src/lib/pipeline/index.js'; + +const PIPELINE = ` +version: 1 +defaults: + image: alpine:3 +jobs: + build: + script: ['make'] + publish: + needs: [build] + only: + refs: [refs/heads/main, 'refs/tags/v*'] + script: ['make publish'] +`; + +function names(text, ref) { + return compilePipeline(text, { ref }).jobs.map((j) => j.name).sort(); +} + +test('a pattern without a star matches exactly', () => { + assert.ok(refMatches('refs/heads/main', 'refs/heads/main')); + assert.ok(!refMatches('refs/heads/main', 'refs/heads/main-2')); + assert.ok(!refMatches('refs/heads/main', 'refs/heads/mai')); + + // A pattern is matched against the whole ref, so a branch name on its + // own is not enough. + assert.ok(!refMatches('main', 'refs/heads/main')); +}); + +test('a star matches any run of characters', () => { + assert.ok(refMatches('refs/tags/v*', 'refs/tags/v1.2.0')); + assert.ok(refMatches('refs/tags/v*', 'refs/tags/v')); + assert.ok(!refMatches('refs/tags/v*', 'refs/heads/v1')); + assert.ok(refMatches('*', 'refs/heads/anything')); + assert.ok(refMatches('refs/*/main', 'refs/heads/main')); +}); + +test('the rest of a pattern is literal, not a regular expression', () => { + // A dot is a dot. Were this compiled straight to a regular expression, + // v1.2.0 would also match v1x2x0. + assert.ok(refMatches('refs/tags/v1.2.0', 'refs/tags/v1.2.0')); + assert.ok(!refMatches('refs/tags/v1.2.0', 'refs/tags/v1x2x0')); + + // And these would be quantifiers rather than characters. + assert.ok(refMatches('refs/heads/fix+', 'refs/heads/fix+')); + assert.ok(!refMatches('refs/heads/fix+', 'refs/heads/fixx')); +}); + +test('a restricted job is left out of runs for other refs', () => { + assert.deepEqual(names(PIPELINE, 'refs/heads/main'), ['build', 'publish']); + assert.deepEqual(names(PIPELINE, 'refs/tags/v1.2.0'), ['build', 'publish']); + assert.deepEqual(names(PIPELINE, 'refs/heads/feature'), ['build']); +}); + +test('it is left out rather than recorded as skipped', () => { + // The scheduler reads a skipped job as a reason to fail the run, so a + // publish step that was never meant to run here must not appear at all. + const { jobs } = compilePipeline(PIPELINE, { ref: 'refs/heads/feature' }); + assert.ok(!jobs.some((j) => j.name === 'publish')); +}); + +test('jobs depending on an excluded job are excluded too', () => { + const text = ` +version: 1 +defaults: + image: alpine:3 +jobs: + build: + script: ['make'] + publish: + needs: [build] + only: + refs: [refs/heads/main] + script: ['make publish'] + announce: + needs: [publish] + script: ['make announce'] +`; + // announce carries no rule of its own, but running it without publish + // would mean running it without something it declared it needed. + assert.deepEqual(names(text, 'refs/heads/feature'), ['build']); + assert.deepEqual(names(text, 'refs/heads/main'), ['announce', 'build', 'publish']); +}); + +test('depth and ordering describe the run that will happen', () => { + const { jobs } = compilePipeline(PIPELINE, { ref: 'refs/heads/feature' }); + assert.equal(jobs.length, 1); + assert.equal(jobs[0].name, 'build'); + assert.equal(jobs[0].depth, 0); +}); + +test('without a ref nothing is filtered, so a pipeline can still be validated', () => { + const { jobs } = compilePipeline(PIPELINE); + assert.deepEqual(jobs.map((j) => j.name).sort(), ['build', 'publish']); +}); + +test('a restricted job cannot match a run that has no ref', () => { + assert.deepEqual(names(PIPELINE, ''), ['build']); +}); + +test('a malformed rule is reported rather than ignored', () => { + const cases = [ + ['only: refs/heads/main', 'must be a mapping'], + ['only:\n branches: [main]', 'unknown key'], + ['only:\n refs: []', 'at least one'], + ['only: {}', 'needs a refs list'], + ]; + + for (const [fragment, expected] of cases) { + const text = ` +version: 1 +defaults: + image: alpine:3 +jobs: + publish: + script: ['make publish'] + ${fragment} +`; + assert.throws( + () => compilePipeline(text, { ref: 'refs/heads/main' }), + (error) => { + assert.ok(error instanceof PipelineError, `${fragment}: expected PipelineError`); + const text = error.errors.map((e) => e.message).join('; '); + assert.match(text, new RegExp(expected), `${fragment}: got ${text}`); + return true; + }, + `expected ${JSON.stringify(fragment)} to be rejected`, + ); + } +}); + +test('the rule is not inherited from defaults', () => { + // Inheriting it would restrict every job at once, which is a hard + // mistake to spot when the symptom is an empty run. + const text = ` +version: 1 +defaults: + image: alpine:3 + only: + refs: [refs/heads/main] +jobs: + build: + script: ['make'] +`; + assert.throws( + () => compilePipeline(text, { ref: 'refs/heads/main' }), + (error) => { + assert.ok(error instanceof PipelineError); + const message = error.errors.map((e) => `${e.path}: ${e.message}`).join('; '); + assert.match(message, /defaults\.only/, `expected defaults.only to be rejected, got ${message}`); + return true; + }, + ); +}); diff --git a/test/self-pipeline.test.js b/test/self-pipeline.test.js @@ -0,0 +1,81 @@ +// test/self-pipeline.test.js - the pipeline this project runs on itself +// +// .conductor.yml is not covered by examples.test.js, and it is the one +// pipeline whose breakage stops the project from building at all. It also +// encodes decisions that are easy to undo by accident: which jobs may run +// on any worker, and which need a docker socket. + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { compilePipeline } from '../src/lib/pipeline/index.js'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +async function selfPipeline(arches = ['x86_64'], ref = 'refs/heads/main') { + const text = await fs.readFile(path.join(ROOT, '.conductor.yml'), 'utf8'); + return compilePipeline(text, { source: '.conductor.yml', arches, ref }); +} + +test('the pipeline this project runs on itself compiles', async () => { + const { jobs } = await selfPipeline(); + assert.ok(jobs.length > 0, '.conductor.yml produced no jobs'); +}); + +test('its jobs declare the features they actually need', async () => { + const { jobs } = await selfPipeline(); + const byName = new Map(jobs.map((job) => [job.name, job])); + + for (const name of ['style', 'test', 'integration', 'images', 'smoke', 'publish']) { + assert.ok(byName.has(name), `expected a ${name} job`); + } + + // The fast job has to actually skip the container-backed tests, or it + // fails on any worker without a docker socket. + assert.equal(byName.get('test').env.CONDUCTOR_TEST_NO_DOCKER, '1'); + + // Anything starting containers has to say so, otherwise it gets handed + // to a worker that cannot run it. + for (const name of ['integration', 'images', 'smoke', 'publish']) { + assert.ok( + byName.get(name).requires.includes('docker'), + `${name} starts containers and must require the docker feature`, + ); + } + + // Conversely, the jobs meant to run anywhere must not demand features. + for (const name of ['style', 'test']) { + assert.deepEqual(byName.get(name).requires, [], `${name} should run on any worker`); + } +}); + +test('publishing is restricted to main and release tags', async () => { + // The cost of getting this wrong is a branch build overwriting :latest + // on Docker Hub, which is not something a test should leave to trust. + const onMain = await selfPipeline(); + assert.ok(onMain.jobs.some((j) => j.name === 'publish')); + + for (const ref of ['refs/heads/feature', 'refs/heads/main-2', 'refs/pull/7/head', '']) { + const { jobs } = compilePipeline( + await fs.readFile(path.join(ROOT, '.conductor.yml'), 'utf8'), + { source: '.conductor.yml', arches: ['x86_64'], ref }, + ); + assert.ok( + !jobs.some((j) => j.name === 'publish'), + `publish must not run for ${JSON.stringify(ref)}`, + ); + // The rest of the pipeline still has to run on a branch. + assert.ok(jobs.some((j) => j.name === 'test'), `test should still run for ${JSON.stringify(ref)}`); + } + + for (const ref of ['refs/heads/main', 'refs/tags/v1.2.0']) { + const { jobs } = compilePipeline( + await fs.readFile(path.join(ROOT, '.conductor.yml'), 'utf8'), + { source: '.conductor.yml', arches: ['x86_64'], ref }, + ); + assert.ok(jobs.some((j) => j.name === 'publish'), `publish should run for ${ref}`); + } +});