The definition (pipemesh.yaml)
pipemesh.yaml sits at the root of your repository. Its pipemesh: key
names every workload the repository runs, as a pipeline or a workflow.
Every other root key is yours (a job, a body, helpers, an included file),
and pipemesh: reaches it with !ref.
builds: !include .pipemesh/build.yaml
deploy_staging:
type: job
job_type: deploy
production: false
stage: staging
consumes:
APP_IMAGE: build/image/app # $APP_IMAGE is its ref@digest
checkout:
- scripts
- charts
script: |
./scripts/deploy.sh staging "$APP_IMAGE"
echo "deployed $CI_COMMIT_SHORT_SHA"
deployment:
type: pipeline
stages:
- build
- staging
jobs:
build:
job_type: workflow
stage: build
body: !ref builds
deploy_staging: !ref deploy_staging
pipemesh:
pipelines:
pipeline: !ref deployment
workflows:
checks:
body: !include .pipemesh/ci.yaml
on: pull_request
nightly:
body: !include .pipemesh/nightly.yaml
on: schedule
cron: "0 3 * * *"
Three layers
- Bodies say what runs: a
type:(workfloworpipeline),stages:andjobs:. Write one at the root, in an included file, or inline. - Registration names a workload under
pipemesh.pipelinesorpipemesh.workflows, and the body'stype:must match. The entry is the body itself, or a map with it underbody:, besideinputs:and triggers. - Triggers say when a workflow runs. Pipelines take none: every commit on the default branch is a revision.
- Bodies are whole: nothing extends or merges. Reuse a job with a
type: jobdefinition and!ref; a job that varies is a component. Triggers live on the registration, never in a body, so a body can back a registration and a job alike. - A workload is active once named, with a URL, a history and a run counter.
- Names are unique across both sections and match
[a-z0-9][a-z0-9_-]*. Reserved:settings,jobs,runs,events,workflows,pipelines,repository, andpipelinefor a workflow (it's the pipeline's slot). - Every root key must be reached from
pipemesh:, directly or through another definition. An unreached key is a load error naming it, which catches typos.
Triggers
pipemesh:
workflows:
release:
body: !include .pipemesh/release.yaml
inputs:
channel:
default: stable
triggers:
stable:
on: tag
tags:
- "v*"
rc:
on: tag
tags:
- "rc-*"
inputs:
channel: rc
nightly:
on: schedule
cron: "0 3 * * *"
inputs:
channel: nightly
checks:
body: !include .pipemesh/ci.yaml
on: pull_request
smoke:
body: !include .pipemesh/smoke.yaml # no triggers: run it from its page
| event | filter |
|---|---|
push |
branches:, but leave it out (see below) |
pull_request |
targets:, exact branch names (main, not release/*) |
tag |
tags:, globs where * and ? match any characters |
schedule |
cron:, quoted ("0 3 * * *"): unquoted YAML mangles it |
manual |
pushfires on each new commit of the repository's branch: the repository's default branch unless its settings name another. Other branches start nothing, sobranches:can only repeat that name, and any other never fires.on:ortriggers:, not both.on:names the trigger after its event,triggers:after its key. The name filters the run list (?trigger=tag:rc) and fires the trigger by API.- A workload's triggers share its history and run counter. For one event to start several workloads, declare it on each.
inputs:on the entry declares names and defaults. Triggers supply values, frozen into each run's variables; an undeclared input is a load error. Same history, different values: a trigger with inputs. Separate history: a second entry sharing the body.- No triggers means manual only: listed in the catalog, run from its page or the API.
Types
type: is a structure's shape (job, workflow, pipeline or
component), checked where it's used. A !ref to the wrong definition
is a load error.
| used as | must be |
|---|---|
an entry of jobs: |
type: job |
pipemesh.pipelines.<name>, or its body: |
type: pipeline |
pipemesh.workflows.<name>, or its body: |
type: workflow |
the body: of a job_type: workflow job |
type: workflow |
the body: of a job_type: pipeline job |
type: pipeline |
uses:, and each uses: in setup: |
type: component |
- Required where a structure stands alone: a root definition, a job
or body file brought in with
!include, a job a!refpicks out of another definition (!ref ci.jobs.lint), every component. - Optional, but checked, inline: a job under
jobs:, a body asbody:, an entry underpipemesh:. - None on plain data: a
cache:declaration in its own file, a list of script lines, a map of helpers.
job_type: is what a job is, and must agree with its
body's type:. Here they disagree:
orders:
job_type: workflow # runs a workflow and waits for it
stage: dispatch
body: !include .pipemesh/service.yaml # a file that says type: pipeline
That's a load error naming both sides:
pipemesh.pipelines.pipeline: job orders is job_type: workflow, so its
body must be type: workflow; the body is type: pipeline — to hand
revisions to it, write job_type: pipeline
Job types
job_type: says what a job is and where it may appear. It decides
when the job runs, and sets its
checkout default.
pipeline:
type: pipeline
stages:
- build
- staging
- production
jobs:
build:
job_type: build # checkout: true; reuses an earlier pass
stage: build
script: npm ci && npm test && npm run build
produces:
dist: dist
deploy_staging:
job_type: deploy # checkout: false; skips what it already shipped
production: false
stage: staging
consumes:
DIST: build/dist
checkout: # and the deploy scripts
- deploy
script: deploy/deploy.sh staging
smoke:
job_type: task # runs on every revision
stage: staging
needs:
- deploy_staging
checkout:
- smoke.sh
script: ./smoke.sh https://staging.example.com
deploy_prod:
job_type: deploy
production: true
stage: production
needs:
- smoke
consumes:
DIST: build/dist
github_actions:
workflow: deploy.yml
inputs:
environment: production
Every job says its job_type:; a job without one doesn't load.
job_type: |
when it runs | checkout: |
allowed in | runs |
|---|---|---|---|---|
build |
unless an earlier pass has its fingerprint, which it reuses | true by default |
pipelines, workflows | an executor |
deploy |
unless its fingerprint matches its last success | false by default |
pipelines | an executor |
task |
every time | false by default |
pipelines, workflows | an executor |
workflow |
every time; its jobs reuse what they can | not allowed: its jobs say what they read | pipelines, workflows | a workflow body, waited for |
pipeline |
every revision, or only when what its fingerprint: names changes |
not allowed: fingerprint: names what it watches |
pipelines | a child pipeline, handed the revision |
buildis hermetic: an earlier run with the same fingerprint stands in for it, shown as reused (its outputs, or its pass). Tests, lint, image builds and build-graph fingerprint jobs (nx/fingerprint@1, …) are builds, as is a job that signs or packages what it consumes, withcheckout: false.deployships what it consumes to an environment, when that changed since its last success. Deploys belong to pipelines, which alone have a last success, each environment's revision, rollback and holds. A pull-request preview or manual hotfix deploy is ataskin a workflow, not tracked as a deployment.- Every deploy says
production: trueorproduction: false. Production deploys are what DORA metrics,pipemesh deploymentsandpipemesh deployedcount, wherever they sit. Matrix variants share it, and it doesn't change how the job runs. taskruns on every revision that reaches it: smoke tests, notifications, checks that read pull-request context such as a merge-base diff.workflowandpipelinerun no script: they hand the revision to a body.
The job type decides when a job runs; nothing on the job changes it.
A job that must run every time is a task. skip: is deprecated: a job
that still writes it loads, the key is ignored, and its Rules tab and
pipemesh check say so. Set checkout: on the job to
name what it reads. The job's Rules tab shows its job type, its
effective checkout (defaults: when it came from the job type), when it
skips, and what Pipemesh added to the checkout (added:). job_type: belongs
to the job, never a component, so one component can be a build here and
a task there.
A build, deploy or task names exactly one executor:
| executor | runs |
|---|---|
script: |
on hosted runners or your own (runner: <name>), with setup:, image:, image_from:, services:, runner:, dockerd:, cache: and secrets: |
uses: |
a component, which brings the execution keys |
github_actions: |
a GitHub Actions workflow, waited for (a deploy there is still a deploy) |
What a job checks out
checkout: true # the whole repository
checkout: false # nothing
checkout: # exactly these
- services/orders
- libs/money
- package.json
checkout: # everything but these
- "**"
- "!docs"
- "!README.md"
- A path covers itself and everything under it (
services/api,go.mod); globs cut finer (services/*/go.mod,**/Dockerfile). - Paths are anchored at the repository root: for a job with
repo:, that repository's. - Prefer whole directories, and exclusions to long lists. An entry
starting with
!excludes. The list is read as git reads a non-cone sparse checkout: each file follows the most specific path an entry matches (the file, then its directory, then the one above), and among entries on the same path, the last one decides.["**", "!docs"]is everything butdocs/;["**", "!docs", "docs/api"]keepsdocs/api;["**", "!**/*.md"]drops every Markdown file. - Quote exclusions (
- "!docs"): a bare!starts a YAML tag. - The workspace holds exactly the checkout, on hosted runners and
your own, so a job can't read a file its fingerprint leaves out. List
the root files it reads too:
package.json, lockfiles,tsconfig.json,.nvmrc, wrapper scripts, tool pins. - One commit deep: the clone holds the job's commit only, even in a job that checks out nothing. A job that needs history fetches it (pull request workflows).
- Pipemesh adds what it reads itself: each file a cache key hashes
(
${checksum:package-lock.json}), and each image's build context and Dockerfile. They join the checkout and fingerprint, underadded:in the Rules tab. - Load errors: an empty list or
["**"]alone (writefalseortrue),"**"with no exclusion beside it, only exclusions. - On GitHub Actions, the checkout comes from the workflow file.
When a job runs
A job runs when its fingerprint is new. The fingerprint hashes the job's definition and parameters, the digests of what it consumes, the files it checks out (in its repository and each mount), and the versions of its secrets and Settings variables. The job type says which earlier fingerprint lets it skip.
job_type: |
skips when | shows |
|---|---|---|
build |
an earlier successful run had it; its outputs are reused | reused, naming that run |
deploy, and pipeline with fingerprint: |
the fingerprint equals the job's last successful run's | no changes |
task, workflow, and pipeline without fingerprint: |
never |
- After a failed or cancelled run, a deploy runs in full until a run succeeds: a half-finished deploy leaves the service in an unknown state. A successful rollback counts as a success.
- A build in a pull request reuses outputs from trusted runs and its own, never another pull request's. When a pull request merges, the passes of its last run become trusted: the next pull request that leaves a job's inputs alone reuses them, and so does a pipeline job defined the same way, which then ships what the pull request built (its card says "Reused pull request #N's build"; its outputs are kept as its own).
reuse: trustedon a build keeps its pipeline runs to passes a run on the default branch made: it builds again rather than ship what a pull request built.
A build or deploy that reads nothing runs once. With no input (no checkout,
consumes:,image_from:,repos:,secrets:or a!settingsvariable), its fingerprint is its definition alone. It loads, with a warning in its job panel. Say what it reads, or make it a task.
Release flow covers fingerprints and skips across a pipeline.
Jobs
Job keys will look familiar: job_type, stage, script, needs,
runner, dockerd, image, image_from, variables,
timeout_seconds, retry, allow_failure, secrets, id_tokens,
cache, produces, consumes, reports, checkout. Names are lowercase letters, digits, _ and -.
- A job waits only for what it names: its
needs:, and the producers of what itconsumes:.stage:places it on the board but doesn't order the run, so a job that names nothing starts with the revision. - A job that consumes promotes only a revision its producers built:
it takes the outputs of the revision it promotes (in a workflow run, of
that run), never an earlier one's. When
buildran at 12, covering 10 and 11, andteststopped at 10,deploywaits and promotes 12; whenbuildstopped at 10 andtestcovered up to 12,deploypromotes 10. A skip counts as built: the outputs it kept carry to every revision it covers. A producer with no output at that revision fails the consumer before its script. services:is accepted but starts nothing: no runner runs service containers. Start a database in your script, or in Docker withdockerd: true.
Where a job runs
runner: |
vCPU | memory |
|---|---|---|
linux-arm64-micro |
¼ | 512 MiB |
linux-arm64-small (default) |
2 | 6.5 GiB |
linux-arm64-medium |
4 | 13 GiB |
linux-arm64-large |
8 | 26 GiB |
- The same sizes run on x86-64 as
linux-amd64-micro,-small,-mediumand-large(Hosted runners).runner:can also name one of your organization's own runners. - Micro is for jobs that do almost nothing (a notification, a tag): no Docker daemon, and a large checkout can outgrow its 512 MiB.
dockerd: trueadds a Docker daemon (Testcontainers,docker compose). An image Pipemesh builds (build:) implies it. A fork's pull request runs its Docker jobs on nodes apart from the organization's other jobs (What a fork's pull request gets).- A hosted job's log ends with what it used:
[runner] linux-arm64-small: peak memory 1.2 GiB of 6.5 GiB, CPU time 34 s. KUBERNETES_*variables are refused: they set a pod's resources, which isrunner:'s job.
Variables and secrets
Every job gets its commit: CI_COMMIT_SHA, CI_COMMIT_SHORT_SHA,
CI_COMMIT_REF_NAME, and CI_COMMIT_TAG on a tag. A pipeline job also
gets PIPEMESH_REVISION (its revision), and a workflow job
PIPEMESH_RUN_NUMBER (its run's number). A workflow a pipeline runs gets
both: PIPEMESH_REVISION is the pipeline's revision there, so one number
names a release across the pipeline and its workflows. They only grow, so
they make release numbers (:r$PIPEMESH_REVISION).
Run numbers aren't in the fingerprint, and a reused build hands over what it built back then. Apply the number where you tag or release, not in what you build.
deploy_staging:
job_type: deploy
production: false
checkout:
- deploy
variables:
API_URL: !settings
LOG_LEVEL: info
secrets:
- DB_PASSWORD
script: deploy/deploy.sh staging
variables: names the variables the job gets. A plain value is the
definition's; NAME: !settings reads the value set in the repository's
Settings → Secrets and variables (or its organization's). secrets: names the
repository secrets it gets. It gets no others, and a !settings
variable or secret that isn't set fails the job before its script.
- One source per variable. A name is written in the definition or
read from Settings, never both:
!settingson a name the body or the run that started the job also sets is an error. - Each revision pins its Settings. Every edit is a new version, and a job runs with the versions current when its revision started, never an edit made since.
- An edit starts a revision on the current commit, for each pipeline with a job that reads it (edits a few seconds apart share one). So staging gets a new value before production, and rolling back restores the old one.
- Variables aren't secret. Any job that declares one can print it, a pull request's included, and the repository's maintainers see its value in Settings, inherited from the organization or not. Anything sensitive is a secret: each secret names the runs that get it, and pull requests get none unless you allow it.
id_tokens: names OIDC tokens the job gets as masked variables, each
for its audience (VAULT_ID_TOKEN: { aud: https://vault.example.com }),
as GitLab CI declares them: see Job identity.
A job on a runner also gets CI_JOB_TOKEN, which is Pipemesh's job
token, not GitLab's: it authenticates the runner protocol, the job's
calls to Pipemesh and GET /api/v4/job,
and it dies with the run. Services that checked GitLab's job token
take an identity token instead.
Images
image: names a registry image. image: !var NAME takes it from one
of the job's variables, so a deploy can run in an image pinned in
Settings:
deploy_prod:
job_type: deploy
production: true
variables:
DEPLOY_IMAGE: !settings # e.g. ghcr.io/acme/deployer@sha256:…
image: !var DEPLOY_IMAGE
script: deploy/deploy.sh prod
- A
!settingsimage belongs to the revision, resolved when the revision is created, against the Settings it pins. Changing the variable starts a revision in the new image; rolling back restores the old one. The revision's details show it, and the log says[image] DEPLOY_IMAGE = …. - An unset variable, or one that isn't an image reference, stops the
revision: a commit's definition doesn't load (like a missing
!includefile), and an edit's revision doesn't start. The repository says why until a commit or edit fixes it. - A plain variable resolves when the definition loads, so the same value can name the image and reach the script.
!varis the whole value, onlyimage:takes it, and it names a variable, never a secret.pipemesh checkcan't read your Settings, so it checks the name only.
image_from: runs the job in an oci artifact's image, named by path
and pinned by digest. It's consumed like any artifact (the job waits for
the producer), and a child body reaches its parent's image with ../.
image: and image_from: are exclusive.
An oci artifact with build: is an image Pipemesh builds from a
directory and keeps for you in its registry, with no registry token to
store:
build_ci:
job_type: build
stage: image
checkout:
- ci # runs only when ci/ changes
script: echo "building the CI image"
produces:
ci_image:
build: ci # the directory; its Dockerfile is ci/Dockerfile
test:
job_type: build
stage: test
image_from: build_ci/ci_image
script:
- make test
build:is a directory, or{context, dockerfile, args}. The image is built after the script succeeds, so the script can prepare the directory. The directory joins the job's checkout, the job gets a Docker daemon, and the artifact isociwithout saying so. It needs a hosted runner.- It's tagged by its build context's fingerprint, so an image already built from the same content is reused, not rebuilt.
- An image anywhere else is your script's: build and push it with
your own credentials, then record it with
pipemesh produce oci <key> --ref <ref> --digest <digest>(an example). - A pull request builds into a place of its own, which default-branch runs never read. A build reuses, so a pull request that doesn't touch the directory runs in the default branch's image.
Caches
webapp_build:
job_type: build
checkout:
- webapp
cache:
key: npm-${checksum:webapp/package-lock.json}
restore_keys:
- npm-
paths:
- .npm
script: cd webapp && npm ci --cache ../.npm && npm run build
cache: restores its paths: before the script and saves them under
key: after a successful run.
- An entry never changes once saved, so put a checksum of what the paths depend on in the key.
- On a miss,
restore_keys:prefixes are tried in order; the newest match wins. - Trusted runs save; pull requests only read. A pull request
restores its own workflow's entries first, then what any pipeline or
workflow of the same definition saved for the same
paths:. Every pull request can read those: keep credentials out of cached paths. - An entry unused for 14 days is evicted, and a pipeline's entries together stay under 10 GB, the least recently used going first. Both are the instance's defaults; an admin can change them, for the instance or one organization.
Scripts and timeouts
script: is a block scalar: real multi-line shell, verbatim, with #
comments allowed. Blank lines and trailing spaces are kept, so a heredoc
writes exactly what it says.
script: |
for f in a b; do
echo "part $f" # loops need no escaping
done
before_script:,script:andafter_script:run as one script, in that order, in one shell that stops at the first failing command. Soafter_script:doesn't run after a failure, and a failing line in it fails the job.timeout_seconds:defaults to one hour (3600). At the limit the job is stopped and fails. A fork's pull request stops at six hours whatever it says.
Artifacts
produces:artifacts reach only jobs that name them inconsumes:, never one that onlyneeds:the producer. Consuming orders the job after the producer and puts the artifact's digest in its fingerprint.consumes:is a map, variable to artifact:APP_IMAGE: build/imageis the artifactimageof the jobbuild, and the script reads it as$APP_IMAGE. A file artifact's variable is its path in the workspace, where the file lands; anociornpmartifact's isref@digest, and the job pulls with its own credentials. Names follow the variable rules ([A-Z][A-Z0-9_]*, neverPIPEMESH_orCI_), and one thatvariables:orsecrets:also sets is a load error.An artifact consumed without a name is no variable:
image_from:orders the job after the image's build and puts the digest in its fingerprint, and the job runs in that image; a delegating job consumes what its body reaches with../the same way, since it runs no script.Only a job allowed to fail consumes from one: a job with
allow_failure: truethat fails still counts as succeeded, with whatever it uploaded before failing, so its artifacts may be missing or partial. A job that consumes from it, or from a job inside it, must setallow_failure: truetoo; otherwise the definition doesn't load.A file artifact is kept 30 days after its run, or what the instance or your organization sets instead.
expire:on the entry wins:dist: {path: dist, expire: 90d}takesNd,Nh,Nmornever. A changed setting applies to artifacts stored after it.Past its date an artifact is expired, except the artifacts of the job's two newest successful runs, which are kept so a waiting job and a rollback still have them. The Artifacts tab shows each date. An expired artifact downloads no more, a stored run whose outputs expired is not reused (the job builds again), a rollback to it isn't offered, and a job that consumes it fails before its script, naming the producer and the revision to re-run to make it again.
pipemesh artifacts <job>lists a job's entries with their dates, andpipemesh storageyour organization's retention.
Reports
reports: are what a job writes for people to read: test results as a
site, a coverage summary, a screenshot. Each shows in the job panel's
Reports tab.
test:
job_type: build
script: ./gradlew test
reports:
tests: build/reports/tests/test # a directory with index.html: a site
coverage:
path: build/reports/coverage/summary.md
title: Coverage # the tab's label; default the key
expire: 30d # default: the artifact default
- A key is one path, a file or a directory inside the workspace, or
{ path, title, expire }. The format comes from the content: a directory with anindex.htmlis a site, a.mdfile a document, a.png,.jpg,.gif,.webpor.svgfile an image. Anything else is offered as a download. - A report uploads whatever the job's outcome, after the script: failing tests are when it matters most. A missing one never fails the job. The log says what happened to each, and the Reports tab tells a job that failed before writing one from a job that succeeded and wrote nothing there, which usually means its output path changed.
- No job consumes a report, and its content is in no fingerprint.
An output a job reads and people look at goes under both keys:
produces:for the job,reports:for the tab. - Script jobs and GitHub Actions jobs declare them. On a
github_actions:job a report is the run's Actions artifact of the same name, imported whatever the conclusion. A workflow or pipeline job's body's jobs report. - Reports count toward the organization's artifact storage and are
kept as long as artifacts unless
expire:says otherwise. A site holds at most 10,000 files. - A report is shown sandboxed: its page runs in a sandbox with no access to Pipemesh or your session, from a link that lasts a few hours. A document is rendered with its raw HTML shown as text.
Renaming a job
A pipeline job remembers the revision it last ran, and its runs are its
record. Renamed, it would start empty and its old runs would become
unreachable. was: takes them over:
deploy_prod:
job_type: deploy
production: true
was: deploy_staging # takes over deploy_staging's cursor and runs
checkout:
- deploy
script: deploy/deploy.sh prod
deploy_staging: # a new job of the old name, starting empty
job_type: deploy
production: false
checkout:
- deploy
script: deploy/deploy.sh staging
- It applies when the definition lands, once nothing of the pipeline is running. Runs, artifact sets and fingerprints move to the new name, which continues from the revision the job was at. Older runs show "as deploy_staging".
- It applies once: the
was:key can then stay or go. - A name that already has runs of its own can't take over another.
Workflow and pipeline jobs
A job_type: workflow or job_type: pipeline job hands the revision to
a body of the matching type:, given with body: (!ref, !include or
inline), and sets the child's variables:.
build:
job_type: workflow # one run per revision, waited for
stage: build
body: !ref builds
service_a:
job_type: pipeline # the revision, handed to a child pipeline
stage: fanout
body: !include .pipemesh/service.yaml
variables:
SERVICE: service_a
fingerprint:
checkout:
- services/service_a
- libs
job_type: |
receives | takes |
|---|---|---|
workflow |
a run of a workflow body | body, variables |
pipeline |
the revision, into a child pipeline | body, variables, fingerprint |
Both also take stage, needs, timeout_seconds,
allow_failure, matrix and was, and none of the executor keys.
- A workflow job waits for its run: its status is the run's verdict, its artifacts what the run produced.
- A pipeline job dispatches. The child pipeline promotes the revision through its own stages and gates, on its own timeline. The job settles once the revision arrives, shown as Dispatched. If a revision must pass some jobs before going on, make them a workflow.
- Only pipelines dispatch. A workflow can't feed a pipeline, whose
revisions come from its branch:
job_type: pipelinein a workflow's body is a load error, asjob_type: deployis.
A workflow job always runs. Each revision that reaches it starts its
body, and the jobs inside reuse what they can: a build whose fingerprint
matches an earlier pass hands up that pass's artifacts. checkout: is a
load error: its jobs say what they read.
A pipeline job hands every revision over, unless its fingerprint:
says what it watches. The child then decides each of its jobs on its own
fingerprint.
- With
fingerprint:, it holds a revision back until something it names changed. A shared body that picks its service by a variable lets each job watch its own service. - A Settings edit reaches the child through the job: the edit starts a revision of the parent, and the job hands it over.
orders:
job_type: pipeline
stage: fanout
body: !ref service
variables:
SERVICE: orders
fingerprint:
artifacts:
- graph/orders # the build graph's verdict for this service
artifacts:lists produced artifacts by path (<job>/<key>). The job waits for each, and for every artifact the body reaches with../, but only those it lists count.checkout:lists this repository's paths it watches. Left out (orfalse), no files count.- The body's text, the job's variables and the Settings variables and secrets the body reads always count.
- Only a pipeline job has
fingerprint:. A workflow job always runs, and its jobs decide for themselves.
The child is part of the job.
- It's registered under the job's name, under its parent's URL
(
…/repo/-/pipeline/build), and its runs carry anupstreamtrigger. - Editing its body is a structure change of the parent, and its
type:is checked at load. - Children of one parent run in parallel.
- It runs in its parent run's context: a pull request's child (or a
merge queue's) is a pull request's run, with its secrets, caches,
images and job identity, and no
variables:changes that.
Artifacts cross the edge by name.
- Down:
../names the parent's artifacts. In a child body,../build/jarinconsumes:is thejarof the parent'sbuildjob, at the parent revision that started the child. Each..goes one level up (../../graph/ordersis the grandparent's). The parent job waits forbuild; a newjarchanges its fingerprint when itsfingerprint:listsbuild/jar. - Up: consumers name the body's artifacts. A workflow job has no
artifacts of its own:
build/test/reportis thereportof thetestjob in thebuildjob's body.
Other repositories
A pipeline can read other repositories, declared under repos: by alias
and URL. Each must be one Pipemesh reaches in the same organization:
every repository a connection reaches is there, with a pipemesh.yaml of
its own or not.
- A branch goes after
@:github.com/acme/infra@release/2.0. - Without one, the repository is followed on the branch its own
pipemesh.yamlis set up on. - A branch that doesn't exist fails the load, and the pipeline keeps its last good definition until it's fixed.
$selfis the pipeline's own repository on its own branch. Another branch of the same repository is declared by its URL, like any other:app_v1: github.com/acme/app@release/1.x.
pipeline:
type: pipeline
repos:
infra: github.com/acme/infra
jobs:
plan_infra:
job_type: task
repo: infra # works in infra, from its root
checkout: true
script: terraform plan
deploy_staging:
job_type: deploy
production: false
repo: infra
checkout:
- envs/staging
- modules
repos:
$self:
checkout:
- shared-config
mount: app
script: cd envs/staging && terraform apply
- Each revision pins a version of every declared repository, and its jobs use those, whatever is pushed later. A push to one starts a revision on the pipeline's current commit (changes a few seconds apart share one).
repo:is where a job works: it's checked out where the pipeline's repository would be, and the job starts there. Without it, a job works in the pipeline's own repository,$self.checkout:lists files of the job'srepo:.repos:mounts repositories into the workspace, keyed by alias or$self, each with a requiredcheckout:(trueor a list) and amount:path in the workspace.- A mount can't leave the workspace (no leading
/, no..), be a directory the workspace repository has, or nest in another. Mounts stay out of the workspace repository'sgit status. - The fingerprint covers every repository checked out, at the pinned versions. Secrets, Settings variables and commit statuses stay the pipeline's own repository's.
- A fork's pull request clones only public repositories besides its
own: a
repo:orrepos:entry naming a private one fails the job (What a fork's pull request gets).
A pipeline that builds or releases several branches of one repository
(one per supported version, say) reads more clearly from a repository of
its own whose pipemesh.yaml declares each branch
(app_v1: …@release/1.x, app_v2: …@release/2.x). Kept in one of those
branches, that branch's pipemesh.yaml would drive the others' releases,
and which file is in charge is easy to misread.
Several repositories, one pipeline walks through an example.
Running on GitHub Actions
github_actions: runs a build, deploy or task as a GitHub Actions
workflow, in place of script: or uses:. Pipemesh dispatches it,
shows the run's jobs as the job's tasks, and settles the job with the
run's verdict. Your pipeline still runs the release; Actions is the
compute.
deploy_staging:
job_type: deploy
production: false
stage: staging
needs:
- verify
github_actions:
workflow: deploy.yml # file under .github/workflows
inputs:
environment: staging
- Keys:
workflow,inputs,ref(the branch or tag to dispatch on; default: the revision's branch).github_actions: deploy.ymlis short forworkflow: deploy.ymlalone. The job waits for the run. - An input may name the run's variables:
revision: ${PIPEMESH_REVISION}or${CI_COMMIT_SHORT_SHA}is resolved when the run is dispatched, so the workflow learns the revision it acts on without the number ever entering a fingerprint. - The job takes
stage,needs,consumes,produces,checkout,timeout_seconds,retry,allow_failureandmatrix, but none of the keys that run a script on Pipemesh:script,setup,uses,runner,dockerd,image,image_from,services,secrets,id_tokens,cache,publish, or its ownartifacts. - The Pipemesh GitHub App needs Actions: read and write on the organization (GitHub integration).
- Not in forks' pull requests. A
github_actions:job in a fork's pull request fails without dispatching: the workflow would run in your repository, with its secrets, on code the fork proposed. Pull requests from your own branches dispatch as usual.
The receiving workflow declares workflow_dispatch with:
pipemesh_sha(the revision to act on) andpipemesh_run(the job run id), which Pipemesh always sends;- every input in the definition, at most 8 (GitHub caps a dispatch at 10).
GitHub reads the workflow file from the branch, so the workflow checks
out pipemesh_sha itself. Put pipemesh_run in run-name: so Pipemesh
matches the run exactly.
on:
workflow_dispatch:
inputs:
pipemesh_sha:
type: string
required: true
pipemesh_run:
type: string
required: true
environment:
type: string
required: true
run-name: deploy ${{ inputs.environment }} · ${{ inputs.pipemesh_run }}
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: "${{ inputs.pipemesh_sha }}"
- run: ./deploy.sh ${{ inputs.environment }}
On the board, the card expands into the run's jobs, each with its own status, duration and log, sectioned by GitHub's steps. GitHub releases a log's text when its job finishes. Cancelling or superseding in Pipemesh cancels the GitHub run.
What the run checks out
Pipemesh reads the dispatched .github/workflows/<file> at the revision,
and takes the job's checkout from its jobs' steps:
| in the workflow | the job's checkout |
|---|---|
actions/checkout with sparse-checkout: |
those patterns |
actions/checkout without it |
true |
| no checkout step of this repository | false: the run reads only what it consumes |
a checkout of another repository (repository:) |
ignored |
a local reusable workflow (uses: ./.github/workflows/x.yml) |
read in turn |
a remote reusable workflow, a file that doesn't parse, a numeric workflow id, or a sparse-checkout: line that negates or comments |
true, which never skips wrongly |
- In cone mode (the action's default), a pattern is a directory, as
in
checkout:, and the root files come too:sparse-checkout: deploymeansdeployand the root files. - Other steps don't change it. The workflow above checks out
everything, so its job's checkout is
true. - The workflow file, and every local workflow it calls, is in the fingerprint: editing it runs the job again.
- A
checkout:on the job overrides what was read. Pipemesh can't enforce an Actions run's checkout: a narrowed one is a promise the workflow keeps.
Passing artifacts
A produces: file artifact is the run's Actions artifact of the same name,
imported when the run succeeds, so any job can consume it. Artifacts the
job consumes: are handed to the run, which fetches them with the
pipemesh/consume action. From
pipemesh/demo-actions:
build:
job_type: build
stage: build
timeout_seconds: 900
produces:
dist: file
github_actions: build.yml
verify:
job_type: build
stage: build
consumes:
DIST: build/dist # the Actions artifact, unpacked at dist/
checkout: false
script: |
echo "built version $(cat dist/version.txt) at $(cat dist/revision.txt)"
test "$(cat dist/revision.txt)" = "$CI_COMMIT_SHA"
Matrix
matrix: expands a job at load time into real jobs, one per combination
of its variables' values (at most 256). as: picks what the variants
become; nothing new happens at runtime.
test:
job_type: build
stage: test
matrix: # 2×2 = 4 sibling jobs
stream:
- aws
- docker
arch:
- amd64
- arm64
script: |
./test.sh ${{ matrix.stream }} --arch ${{ matrix.arch }}
Values are job variables when the name is shell-exportable ($stream),
and always available as ${{ matrix.<variable> }}.
as: jobs (the default) makes sibling jobs
(test[stream=aws,arch=amd64], …), each addressable, and in a pipeline
each with its own promotion cursor, so one broken lane never blocks the
others. needs: selects them:
needs:
- test # bare name: every variant
needs:
- test[stream=aws] # partial selector: the matching slice
- test[stream=docker, arch=arm64] # full selector: exactly one variant
- job: test # structured form: the same as test[stream=aws]
matrix:
stream: aws
- Selectors are always
key=value, never positional, so a new axis widens them instead of breaking them. They resolve at load: one that matches nothing is an error naming the declared values. - A block list takes bracket selectors as they are. Inside a flow
list (
[...]) they need quotes: that's YAML, not Pipemesh. - A value can pick a variant's checkout
(
"streams/${{ matrix.stream }}.txt"), so editing one stream's files runs only that lane. - Lanes chain by interpolating their own binding, never by shared variable names:
publish:
job_type: deploy
production: true
stage: publish
matrix:
stream:
- aws
- docker
needs:
- test[stream=${{ matrix.stream }}] # publish[stream=aws] → test[stream=aws]
checkout:
- publish.sh
script: ./publish.sh ${{ matrix.stream }}
as: workflow makes the job one node over a generated child
workflow of the variants: one verdict, one cursor, referenced only as the
node (suite in needs:). Use it when the variants pass or fail as a
unit, like a GitHub Actions CI matrix:
suite:
job_type: build
stage: test
matrix:
stream:
- aws
- docker
- slack
as: workflow
script: ./suite.sh ${{ matrix.stream }}
Files and !include
!include <path> puts a file's whole content where the tag stands: a
body, a job, a cache: declaration.
# .pipemesh/ci.yaml — and the same job file in .pipemesh/build.yaml
type: workflow
stages:
- verify
jobs:
backend_build: !include .pipemesh/jobs/backend_build.yaml
lint:
type: job # a !ref reaches it (see below)
job_type: build
stage: verify
script: make lint
# .pipemesh/jobs/backend_build.yaml
type: job
job_type: build
stage: verify
checkout:
- backend
script: make -C backend build
- Paths are relative to the repository root and name a
.yamlor.ymlfile, read at the including file's commit. Included files may include others. - An included file says its shape with
type:, unless it's plain data, and is used whole: nothing merges or overrides. - Editing one is like editing the definition: a pipeline that includes it sees a structure change, and a workflow's next run uses the new content.
- Load errors naming the file: a missing or empty file, a cycle, a
path outside the repository, any other YAML tag. A body is never named
by path:
file:or a path inbody:fails, saying what to write instead.
References and !ref
!ref <path> puts the value at that path where the tag stands. The path
names every key from the root of the tag's own file.
common: # plain data: no type:
login:
script:
- aws sso login
- aws sts get-caller-identity
ci: !include .pipemesh/ci.yaml
deployment:
type: pipeline
stages:
- verify
jobs:
lint: !ref ci.jobs.lint # one job out of an included file; it says type: job
deploy:
job_type: deploy
production: true
stage: verify
checkout:
- deploy.sh
script:
- !ref common.login.script # a list splices in
- ./deploy.sh
- A reference never leaves its file. In
.pipemesh/ci.yaml,!refreaches that file's keys and what it includes, never the definition's. - A reference is a whole value: never part of a string, never merged with keys beside it. As a list item, a reference to a list adds its items.
- Its
type:is checked where it lands (Types). A cycle or a missing key is a load error naming the file and line. - YAML anchors, aliases and merge keys (
&x,*x,<<:) are load errors:!refis the one way to reuse a value, and nothing overrides.
Typed components
A component is a reusable job with parameters, in the same repository,
called with uses: and with:. Its file has type: component, a
name:, params: (each with a type: string, int, bool or
list), and the job: it runs.
# .pipemesh/components/eks-deploy.yaml
type: component
name: eks-deploy
params:
cluster:
type: string
required: true
replicas:
type: int
default: 2
job:
script: |
helm upgrade app charts/app --kube-context ${{ params.cluster }} --set replicas=${{ params.replicas }}
# pipemesh.yaml, in the pipeline's jobs:
deploy_production:
job_type: deploy
production: true
stage: production
checkout:
- charts
uses: ./.pipemesh/components/eks-deploy.yaml
with:
cluster: prod
replicas: 4
${{ params.x }}interpolates at load time. The resolved values are part of the pipeline's structure, so editing a component is a structure change like any other.- Registry components add a stream and a major:
uses: aws/role@1is the registry'saws/roleat its current major, pinned by digest, so a registry advance re-anchors your structure deliberately. - No inheritance, no merging. A component is authored (its own
script:,setup:,image:) or an instance of another (a singleuses:+with:), never both. To specialize one, write an instance that binds some of its params and re-exposes the rest. - Placement keys belong to the job (
job_type,stage,needs,checkout,runner, timeouts), execution keys to the component (script,setup,image). An execution key besideuses:is a load error. - To let callers add behavior, a component declares a hook param and interpolates it: a contract, never a splice.
- A component declares the identity tokens it needs with
id_tokens:in itsjob:, and they join the job's, params interpolated (aud: ${{ params.audience }}). It reads its token from the variable, as the job would. A name the job or another component also declares must name the same audiences.
setup: runs script-only components before the job's own script:
setup:
- uses: ./.pipemesh/components/aws-role.yaml
with:
arn: "arn:aws:iam::123:role/deploy"
region: us-east-1
script: |
aws sts get-caller-identity
Editor validation
Point yaml-language-server (built into the VS Code YAML extension) at Pipemesh's schemas with a modeline at the top of each file:
# yaml-language-server: $schema=https://pipemesh.io/api/meta/pipemesh-definition.schema.json
Use pipemesh-body.schema.json for .pipemesh/*.yaml bodies and
pipemesh-component.schema.json for components. They reject unknown
keys as the loader does, so a typo is a red squiggle before it's a load
error. Declare the tags in your editor settings so they aren't flagged:
"yaml.customTags": ["!include scalar", "!ref scalar", "!settings scalar", "!var scalar"]
The editor reads a tagged value as a plain string; the loader resolves and checks it.