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:
- build/image/app
checkout:
- scripts
- charts
script: |
./scripts/deploy.sh staging
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 branch the repository was added with (its default branch). 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 sets the
job's checkout and skip
defaults.
pipeline:
type: pipeline
stages:
- build
- staging
- production
jobs:
build:
job_type: build # checkout: true, skip: built
stage: build
script: npm ci && npm test && npm run build
produces:
dist: dist
deploy_staging:
job_type: deploy # checkout: false, skip: unchanged
production: false
stage: staging
consumes:
- build/dist
checkout: # and the deploy scripts
- deploy
script: deploy/deploy.sh staging
smoke:
job_type: task # skip: never: 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:
- build/dist
github_actions:
workflow: deploy.yml
inputs:
environment: production
job_type: |
checkout: default |
skip: default |
allowed in | runs |
|---|---|---|---|---|
build |
true |
built |
pipelines, workflows | an executor |
deploy |
false |
unchanged |
pipelines | an executor |
task (default) |
false |
never |
pipelines, workflows | an executor |
workflow |
its jobs' combined | its jobs': built if all of them reuse, else never |
pipelines, workflows | a workflow body, waited for |
pipeline |
false |
unchanged |
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. task(the default) runs 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.
Override any default on the job, such as a build's checkout: list or a
task's skip: unchanged. The job's Rules tab shows its job type,
effective checkout and skip, which came from the job type (defaults:),
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:, publish: 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. - History is complete: the clone isn't shallow, so
git log,git diffandgit merge-basework in every job, even one that checks out nothing. - Pipemesh adds what it reads itself: each file a cache key hashes
(
${checksum:package-lock.json}), and eachpublish: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. skip: says which earlier fingerprint lets
it skip.
skip: |
default for | skips when | shows |
|---|---|---|---|
unchanged |
deploys, pipeline jobs | the fingerprint equals the job's last successful run's | no changes |
built |
builds | an earlier successful run had it; its outputs are reused | reused, naming that run |
never |
tasks | never |
unchangedis a pipeline's policy: a workflow body that declares it fails to load.- After a failed or cancelled run,
unchangedruns in full until a run succeeds: a half-finished deploy leaves the service in an unknown state. A successful rollback counts as a success. - With
built, a pull request reuses outputs from trusted runs and its own, never another pull request's.
A job that reads nothing runs once. If its
skip:isn'tneverand it has 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 give itskip: never. Tasks never skip by default; deploys and pipeline jobs that consume nothing, and builds that check out nothing, can.
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,
artifacts, cache, publish, produces, consumes, checkout,
skip. 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. 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-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-small,-mediumand-large(Hosted runners).runner:can also name one of your organization's own runners. dockerd: trueadds a Docker daemon (Testcontainers,docker compose).publish:implies it.- 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). Both 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 → 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.
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 entry's image, named by path
and pinned by digest. It's consumed like any entry (the job waits for
the producer), and a child body reaches its parent's image with ../.
image: and image_from: are exclusive.
publish: without repo: builds a job image Pipemesh keeps for
you in its registry, with no registry token to store:
build_ci:
job_type: build
stage: image
checkout:
- ci
script: echo "building the CI image"
publish:
- context: ci # no repo: Pipemesh keeps it
key: ci_image
test:
job_type: build
stage: test
image_from: build_ci/ci_image
script:
- make test
- It's tagged by its build context's fingerprint, so an image already built from the same content is reused, not rebuilt.
- A pull request builds into a place of its own, which default-branch
runs never read. A build reuses (
skip: built), 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.
Scripts and timeouts
script: is a block scalar: real multi-line shell, verbatim, with #
comments allowed.
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.
Artifacts and entries
artifacts:flow alongneeds:edges, with what the needs inherited too: a deploy that names only its test lane still gets the bundle that lane validated. An artifact arriving over two edges is unpacked once, and keeps its provenance (which job built it, at which revision).produces:entries reach only jobs that name them inconsumes:(build/jar), never one that onlyneeds:the producer. Consuming orders the job after the producer and puts the entry's digest in its fingerprint.
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
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, checkout |
Both also take stage, needs, consumes, skip, 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 reads what its jobs read.
- Its fingerprint combines their inputs: their checkouts (one
truemakes it the whole repository), the Settings variables and secrets they read, what they consume from outside the body, and the body's own text. - When every job reuses, a revision none of them reads reuses the whole run, entries included.
skip: neveris the only override, andcheckout:is a load error.- It runs every time when nested in a workflow run, or when a job
works in or mounts another repository (
repo:,repos:), whose inputs can't be combined at load.
A pipeline job should hand over only when its service changed. The
child decides each job on its own fingerprint, so name what selects the
service: its entry from a build-graph job (graph/orders in
consumes:) or, with no build graph, its files (services/orders and
libs/shared in checkout:; nothing is checked out, as the job runs no
script). Given neither, its defaults (checkout: false,
skip: unchanged) hand the revision over once, and never again.
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.
Artifacts and entries cross the edge.
- Down: the child's root jobs (no
needs:of their own) receive what the parent job inherits, and pass it on along their own edges. Nothing is copied. - Up: when a workflow child settles, what it produced becomes the
parent job's artifacts, inherited by jobs that
needs:it. A workflow job has no entries of its own: consumers name its body's (build/test/report). ../names the parent's entries. 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 consumes the same entry, so it waits forbuild, and a newjarchanges its fingerprint.
Other repositories
A pipeline can read other repositories, declared under repos: by alias
and URL. Each must be added to the same organization (with or without a
pipeline of its own), and is followed on its default branch.
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.
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) andartifacts.github_actions: deploy.ymlis short forworkflow: deploy.ymlalone. The job waits for the run. - The job takes
stage,needs,consumes,produces,checkout,skip,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,cache,publish, or its ownartifacts. - The Pipemesh GitHub App needs Actions: read and write on the organization (GitHub integration).
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 entries and artifacts
A produces: file entry is the run's Actions artifact of the same name,
imported when the run succeeds, so any job can consume it. Entries 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:
- 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"
An artifacts: list under github_actions: (dist, or "*" for all)
keeps the run's artifacts as the job's own artifacts: instead,
inherited along needs: edges. Each extracts into a directory named
after it: GitHub packs uploads relative to their common root, so you get
dist/ whichever path was uploaded.
GitHub Enterprise Server works the same way: for an organization on your GHES host, Pipemesh talks to its
/api/v3.
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,skip,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.
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.