Skip to main content

.loha.yml schema

.loha.yml is your Pipeline-as-Code config. Commit it at the root of your repo. Loha parses it on every webhook and turns it into a Tekton PipelineRun.

Checked against the real parser

Field details below mirror the Zod schema in packages/pipeline-config. Every .loha.yml example across this site is validated in CI against that parser and against the official task registry, so an example that no longer works fails the build rather than shipping.

Top-level structure​

version: '1' # required
name: my-pipeline # optional, max 128 chars
description: Build and test # optional, max 1000 chars
resource_class: small # optional default for all jobs
env: # optional global env
NODE_ENV: production
on: # required — when to run
push:
branches: [main]
jobs: # required — 1–20 named jobs
build:
steps:
- name: Build
run: make build

Top-level fields​

FieldTypeRequiredDescription
version"1"yesSchema version. Must be "1" or 1.
namestringnoPipeline name. Max 128 chars. Alphanumeric, dash, underscore.
descriptionstringnoHuman-readable description. Max 1000 chars.
resource_classstringnoDefault resource class for every job in the pipeline. Individual jobs may override it with their own resource_class. See Resource classes.
envmapnoEnvironment variables injected into every step. Keys max 64 chars, values max 4096 chars.
onobjectyesDeclared events. See on — declared events.
jobsmapyesMap of job name to job definition. 1–20 jobs. Job names max 64 chars.

_source may appear in configs generated by the dashboard. It is an internal field. Don't set it by hand.

on — declared events​

on: is required and takes push, pull_request, and workflow_dispatch. It records which events the pipeline is written for.

It does not decide when the pipeline runs. See the note at Accepted but not applied. Filtering happens on the trigger.

push​

on:
push:
branches: [main, develop]
paths: [src/**, package.json]
paths_ignore: [docs/**, '*.md']
tags: ['v*.*.*']
tags_ignore: ['*-beta']
FieldTypeMax itemsDescription
branchesstring[]20Glob patterns for branch names
pathsstring[]50Glob patterns for changed file paths
paths_ignorestring[]50Glob patterns for paths to exclude
tagsstring[]20Glob patterns for tag names
tags_ignorestring[]20Glob patterns for tags to exclude

* matches within a path segment, ** matches across segments.

pull_request​

on:
pull_request:
branches: [main]
types: [opened, synchronize, reopened]
FieldTypeDescription
branchesstring[]Target branches that trigger. Max 20 entries.
typesstring[]Event types: opened, synchronize, reopened, closed

workflow_dispatch​

on:
workflow_dispatch: true

A boolean toggle that allows manual triggering from the dashboard. Input parameters are not supported in V1: workflow_dispatch is on or off.

jobs​

Each named job has the following fields:

jobs:
build:
needs: [test]
resource_class: medium
env:
JOB_LEVEL: 'yes'
steps:
- name: Checkout
uses: loha/git-clone@v1
FieldTypeDescription
namestringDisplay name for the job. Max 128 chars.
imagestringContainer image to run the job's steps in. Max 256 chars.
resource_classstringPer-job resource class. Overrides the pipeline-level resource_class for this job only. See Resource classes.
needsstring[]Jobs that must succeed before this one runs (DAG). Max 20 entries, each max 64 chars.
environmentstringThe project environment this job targets, e.g. prod. Lowercase letters, digits and hyphens, max 63 chars. Selects the environment variables the job receives.
ifstringCondition for running the job, max 256 chars. A job whose condition is false is skipped, and so is every job that needs it. See Conditions: if.
timeoutstringAccepted, e.g. 30m, but not applied. Use a step-level timeout. See Accepted but not applied.
envmapJob-level env, overlaid on global
servicesmapSidecar containers the job's run: steps can reach by name. Max 3. See services.
cachemapDependency caching for this job. Expands to a restore/save pair. See cache.
artifactsmapFiles this job hands to the jobs that need it. See artifacts.
stepsarray1–50 step definitions

A job may set its own resource_class to run heavier (or lighter) than the rest of the pipeline; if it doesn't, it inherits the pipeline-level resource_class (and, when that's also unset, the workspace default). Job overrides are still bounded by your workspace's allowed classes. See Resource classes.

services​

Containers that run alongside a job's run: steps for their lifetime — a real Postgres for integration tests, Redis for a queue, a mocked API. Each service is reachable by the name you give it:

version: 1
name: api
on:
push:
branches: [main]
jobs:
test:
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: test
POSTGRES_DB: app_test
redis:
image: redis:7
steps:
- name: Checkout
uses: loha/git-clone@v1
- name: Migrate
run: DATABASE_URL=postgres://postgres:test@postgres:5432/app_test pnpm db:migrate
- name: Test
run: REDIS_URL=redis://redis:6379 pnpm test

Note postgres:5432 and redis:6379 — the service name works as a hostname, so a config migrated from GitLab CI or GitHub Actions needs no edit.

FieldTypeDescription
imagestringRequired. The container image. Max 256 chars.
portsnumber[]Ports the service listens on. Optional for well-known images. Max 8.
envmapEnvironment for the service container
aliasstring/string[]Extra hostnames for the same service. Max 4.
service_classstringmicro or small. Defaults to micro. Deliberately smaller than a job's resource_class.
startup_timeoutstringHow long to wait for the port to accept connections, e.g. 60s
commandstring[]Override the image entrypoint. Runs the service as uid 1000, see below.
argsstring[]Override the image arguments. Runs the service as uid 1000, see below.

The service name (and every alias) must be a DNS label: lowercase letters, digits and hyphens, starting with a letter.

Rules worth knowing before you write one​

Your run: steps must be contiguous. uses: steps are allowed before and after the block, but not in the middle:

steps:
- { name: Checkout, uses: loha/git-clone@v1 } # OK — prefix
- { name: Migrate, run: pnpm db:migrate }
- { name: Test, run: pnpm test }
- { name: Save cache, uses: loha/cache@v2 } # OK — suffix

A uses: step between two run: steps is rejected. The reason is worth understanding rather than working around: services live in a single pod, and the job's run: steps are collapsed into that one pod so they share the database. A uses: step resolves to a separate task in its own pod, so it splits the block — and the steps after it would silently get a second, empty service. Failing at parse time is better than a test suite that passes against a database its migrations never ran on.

Only run: steps see the services. A job that declares services but has no run: steps is rejected.

Two services cannot share a port. Services share one network namespace, so each port is bound once. This is the one place Loha is stricter than GitLab, which gives every service its own IP address. Give one of them a different port.

Names and aliases must be unique, and localhost is reserved.

The image must be on the allowed images list. The same list applies to run: step images.

Max 3 services per job. Each one is another container in the same pod, charged to your workspace's namespace quota.

Startup and debugging​

Before your first run: step, Loha injects a loha-wait-for-services step that waits for each service's port to accept a TCP connection — up to startup_timeout (default 60s). Your steps never start against a service that is not listening yet.

Ports come from ports: when you declare them, or from the image for well-known ones (postgres → 5432, redis → 6379, mysql/mariadb → 3306, mongo → 27017, and others). An image Loha does not recognise that declares no ports: gets no wait step, and a warning saying so — rather than a guess that hangs.

Each service streams its own logs to the build page as service-<name>. When a service fails to start, that log is where the reason is: the wait step can only tell you the port never opened.

Service containers run under the image's own entrypoint, so an official image that starts as root and drops to its own user (as postgres, mysql and mongo all do) works unchanged. Your run: steps are unaffected by this and keep running as a non-root user with all Linux capabilities dropped, exactly as in a job with no services.

Services with command: or args:​

A service that sets command: or args: no longer runs only the image's own entrypoint, so Loha treats it like your run: steps: it runs as uid 1000, non-root, with all Linux capabilities dropped. This is not the image's own user, and GitLab CI and GitHub Actions do not do this.

Images that write to directories owned by their own user (a data or log directory such as /data) then fail at start with Permission denied, and the wait step reports that the port never opened. To fix it:

  • Drop command: and args: when the image's defaults are enough. The service then runs as the image's own user.
  • Or point data paths at /tmp, which uid 1000 can write. A path must exist in the image: many servers stop at start when their data directory is missing, on any CI system.
services:
objects:
image: rustfs/rustfs:1.0.0
ports: [9000]
args: ['/tmp'] # the image's /data belongs to its own user; uid 1000 cannot write it

cache​

Dependency caching for a job, as one block instead of a hand-written pair of steps.

version: '1'

on:
push:
branches: [main]

jobs:
test:
cache:
paths: [node_modules, .npm]
key_files: [package-lock.json]
prefix: web
steps:
- name: install
run: npm ci
KeyTypeDefaultDescription
pathsstring[]—Directories to cache. Required, 1–20 entries.
key_filesstring[]the task's own defaultFiles whose contents derive the cache key — normally your lockfile.
prefixstringdefaultNamespaces caches within a repository. Letters, digits, ., _, -.
fail_on_missbooleanfalseFail the job on a cache miss instead of continuing cold.

This expands to a loha/cache@v2 restore step before the job's own steps and a save step after them. Writing that pair by hand still works and is exactly what this produces — the block exists because the two halves must agree on paths, key_files and prefix. A pair that disagrees is not an error anywhere: it saves under one key and restores under another, giving a permanent cache miss that looks identical to a cache which is simply always cold.

Both steps appear on the build page with their own logs, so a miss is something you can read rather than infer from timings.

Caching must be enabled for the repository, and the run must have come from one — a manual run without a repository has no stable identity to cache against, and is told so in the build warnings. See the caching guide for how the per-branch scoping decides when you get a hit.

cache combines with services: the pair lands outside the job's run: block, which is where the contiguity rule already allows uses: steps.

artifacts​

Files a job hands to the jobs that needs it, as one block instead of upload and download steps. See Passing files between jobs for the full guide.

version: '1'

on:
push:
branches: [main]

jobs:
build:
steps:
- name: Checkout
uses: loha/git-clone@v1
- name: Build
run: npm ci && npm run build
artifacts:
paths: [dist/, build-info.json]

test:
needs: [build]
steps:
- name: Checkout
uses: loha/git-clone@v1
- name: Test
run: npm test # dist/ and build-info.json are already here
KeyTypeDescription
pathsstring[]Files or globs to save, relative to the workspace root. Required, at most 20 entries, each at most 512 characters. [] means "passes nothing".

The block accepts no other key: there is no per-job expiry. A path may not start with /, contain a .. segment, a backslash or a control character.

How it behaves:

  • The archive is saved once every step of the job has succeeded. A declared path or glob that matches no files fails the job: Declared artifact path '<entry>' matched no files in job <jobId>.
  • Every job that needs the job, directly or through a chain, receives its files at the same paths. They are restored after the receiving job's own loha/git-clone and after its cache restore (so the files win over a restored cache), in dependency order; a later job's files overwrite an earlier job's.
  • Each matrix shard is its own job with its own archive, so a job that needs a matrix job receives every shard's archive. Shards of the same matrix job never receive each other's files, even when max_parallel runs them one after another.
  • A job skipped by its if: saves nothing, and the jobs that need it are skipped too.
  • If an upstream archive exists but cannot be restored (it never finished uploading, failed, or was deleted), the receiving job fails rather than running without the files: Could not restore job outputs (409 JOB_OUTPUTS_NOT_READY). or (410 JOB_OUTPUTS_GONE).
  • One archive is limited to 1 GiB, counts against the workspace artifact quota, and is kept for the deployment-wide retention window (14 days by default).
  • The job id of a job that saves or restores files must be 45 characters or fewer after normalising. The step names artifacts-save and artifacts-restore are reserved.
  • If the deployment has no artifact storage, a build whose jobs declare non-empty paths is refused with ARTIFACTS_UNAVAILABLE.

artifacts is for passing files between jobs. For a download that people fetch, use loha/upload-artifact; for a speed-up that is never required, use cache.

steps​

A step has either a run block (shell script) or a uses block (official task): exactly one, never both.

steps:
- name: Test
run: npm test
env:
CI: 'true'
timeout: 10m

- name: Build image
uses: loha/buildah-build@v1
with:
IMAGE: registry.example.com/my-org/web:${COMMIT_SHA}
secrets:
- HARBOR_PASSWORD
FieldTypeDescription
namestringRequired. Display name shown in the dashboard. Max 128 chars.
runstringShell script. Mutually exclusive with uses. Max 65536 chars. Validated for unsafe commands.
usesstringReference to an official task. Mutually exclusive with run. Max 256 chars.
imagestringContainer image to run this step in. Max 256 chars.
withmapParameters to pass to a uses task. Values may be strings, numbers, or booleans, and support context tokens.
envmapStep-level env, overlaid on job and global
secretsstring[]Secret names to inject at runtime. Max 20 entries, each max 128 chars.
ifstringCondition for running this step, max 256 chars. A false condition skips only this step. See Conditions: if.
timeoutstringPer-step timeout, e.g. 10m. See Timeout format.
retriesnumberNumber of times to retry the step on failure. Integer, 0–3.
working_directorystringDirectory to run the step in. Max 512 chars. Must be a relative path: no leading /, no .. traversal, no null bytes.

A step's if skips that step alone; the rest of the job still runs. See Conditions: if.

Matrix builds​

A job can fan out across a matrix of values — one run of the job per combination. Use it to test several runtime versions, or to build for several targets, without copying the job.

jobs:
test:
strategy:
matrix:
node: ['20', '22']
os: [alpine, debian]
max_parallel: 2
steps:
- name: test
image: node:${{ matrix.node }}-${{ matrix.os }}
run: node --version && npm test

That declares four instances — 20/alpine, 20/debian, 22/alpine, 22/debian — and runs at most two at a time.

FieldTypeNotes
strategy.matrixmap of name → list1–5 axes, each with 1–20 values. Values are strings.
strategy.max_parallelnumberOptional. 1–20. How many instances may run at once.

Using the values​

Inside a matrix job, ${{ matrix.<axis> }} is substituted into the step image, run, with: values and env, and into the job's image and name. Every axis is also available as an environment variable named MATRIX_<AXIS> (uppercased, hyphens become underscores), so a script can branch on it without interpolation:

- name: test
run: echo "building for $MATRIX_OS on node $MATRIX_NODE"

${{ matrix.<axis> }} is the only expression syntax Loha supports, and it works only inside a job that declares a matrix. A reference to an axis that does not exist is rejected when the build starts, naming the axis — it is never silently replaced with an empty string.

Depending on a matrix job​

A job that needs: a matrix job waits for every instance:

jobs:
test:
strategy:
matrix: { node: ['20', '22'] }
steps: [{ name: test, run: npm test }]

deploy:
needs: [test] # waits for both instances
steps: [{ name: deploy, run: ./deploy.sh }]

Limits​

  • At most 20 combinations from one matrix.
  • At most 20 jobs after expansion, counting ordinary jobs — the same ceiling that applies to hand-written jobs. A 5×5 matrix is rejected; a 4×4 alongside three other jobs is fine.
  • Each instance gets its own workspace, so instances never overwrite each other's checkout and can be spread across build machines. That means a matrix also consumes one workspace volume per instance, which your plan's volume limit bounds — see Limits.

Set max_parallel when a wide matrix would otherwise queue: it lowers the capacity a run reserves, so the build starts sooner on a busy cluster.

Context tokens​

with: values support a small set of build-context placeholders. Loha substitutes them when it materializes the PipelineRun (the literal value is baked into the Tekton param before the run is created), so you can tag an image or reference the repo with the exact revision that triggered the build.

TokenExpands toExample
${COMMIT_SHA}Full commit SHA of the triggering revisionabc1234def5678901234567890abcdef12345678
${SHORT_SHA}First 7 characters of the commit SHAabc1234
${REPO_URL}Clone URL of the repositoryhttps://git.example.com/org/repo.git
steps:
- name: build
uses: loha/docker-build@v1
with:
IMAGE: registry.example.com/my-org/app:${COMMIT_SHA}

Substitution happens when the build manifest is generated, before the pod starts. These are the only ${...} tokens Loha expands; anything else of that form (e.g. ${HOME}) is passed through untouched, so ordinary shell variables in a run: script behave normally.

Do not use Tekton's own $(params.REVISION) syntax to tag an image: the generated Pipeline declares no pipeline-level params, so Tekton's admission webhook rejects the entire run with a non-existent variable in ... error (surfaced as PIPELINE_TRIGGER_FAILED).

The ${{ ... }} expression language​

There is exactly one: ${{ matrix.<axis> }}, and it works only inside a job that declares a strategy.matrix. There is no git.branch, no workspace.slug, no comparisons and no function calls.

A ${{ matrix.<axis> }} naming an axis the job does not declare is rejected when the build starts, naming the axis. Any other ${{ ... }} is passed through as literal text — not an error, but usually worse, since it reaches the shell verbatim.

Timeout format​

timeout is a string: a number followed by a unit (s for seconds, m for minutes, h for hours, or d for days). Examples: 30s, 10m, 2h, 1d. Max 32 chars. There is no separate timeout_minutes field. The effective ceiling is 24 hours. See Limits.

Resource classes​

resource_class set at the top level is the default for every job. A job can override it with its own resource_class to run at a different size, so one heavy job (say an image build) can run large while the rest of the pipeline stays medium:

version: 1
name: example
resource_class: medium # pipeline default
on:
push:
branches: [main]
jobs:
test:
steps:
- name: test
run: go test ./...
build-image:
resource_class: large # per-job override
steps:
- name: build
run: buildah bud -t registry.example.com/my-org/app:${COMMIT_SHA} .

Here test runs at medium (inherited) and build-image runs at large. A job with no resource_class inherits the pipeline-level value; when neither is set, the workspace default applies.

Resolution order, per job: job resource_class → pipeline resource_class → workspace default.

Every resolved class (including a per-job override) must be one your workspace's plan allows. A FREE-plan workspace that requests large on any job is rejected with a clear, job-named quota error; the plan limit can't be bypassed by moving resource_class from the pipeline level down to a job.

ClassCPU (request → limit)Memory (reserved = ceiling)
micro100m → 250m256Mi
small500m → 11Gi
medium1 → 24Gi
large2 → 48Gi

Your workspace's limit preset controls which classes are available. The CPU/memory each class gets is fixed platform-wide. See Limits → Resource classes.

Conditions: if​

A job or a step can carry an if: condition. Loha evaluates it once, when the build starts, against the build's branch and event:

  • A job whose condition is false is skipped with all of its steps. Every job that needs it, directly or through a chain, is skipped too, and a skipped job saves no artifacts.
  • A step whose condition is false is skipped on its own; the rest of the job runs. A step in a skipped job never runs, whatever its own condition says.
version: '1'
on:
push:
branches: [main]
jobs:
test:
steps:
- name: Unit
run: npm test
deploy:
needs: [test]
if: branch == 'main' && event == 'push'
steps:
- name: Ship
run: ./deploy.sh

The condition language is deliberately small:

PartWhat is accepted
Variablesbranch: the build's branch (the tag name for a tag build). event: what started the build, for example push, pull_request or tag. github.ref (refs/heads/<branch> or refs/tags/<tag>) and github.event_name (the same as event) are accepted so conditions copied from GitHub Actions keep working.
Comparisons== and != against a quoted string, 'main' or "main". A variable cannot be used on its own.
Combining&&, || and parentheses. && binds tighter than ||.
Literalstrue and false.
WrapperOptional: ${{ branch == 'main' }} is the same as branch == 'main'.

Anything else is rejected when the config is validated, before a build starts: another variable (Unknown condition variable "<name>". Only branch and event are supported.) or a function such as startsWith(...). A quoted value is compared exactly, never as a pattern: branch == 'release/*' matches only a branch literally named release/*. Compare against each branch name, joined with ||.

A condition chooses work inside one pipeline. Whether a build starts at all is still decided by the trigger and its filters.

Accepted but not applied​

These fields validate, so a config using them parses cleanly, but nothing consumes them yet. They are listed here so you don't build a pipeline that depends on behaviour the platform doesn't have.

FieldWhat it does today
timeout (job)Nothing. Step-level timeout and the workspace build timeout are both enforced
working_directoryNothing. Every step runs in /workspace/source. Use cd in a run: script instead
on: does not decide when your pipeline runs

on: is required by the schema, and it documents your intent. But it is not what gates execution. Filtering happens on the trigger: its event list and branch filters are evaluated when the webhook arrives, and the config's on: block is not consulted.

So narrowing on: to branches: [main] does not stop builds on other branches. Set the branch filter on the trigger.

This also means the top level and the on: block are not strict: an unrecognised key there is discarded silently, with no error. (jobs: and steps: are strict and do reject unknown keys.)

Globs support only * and **

This applies wherever you write a branch, tag, or path pattern (on a trigger's filters especially). A pattern containing {}, [], or () (like {main,develop} or src/**/*.[jt]s) is rejected internally and silently matches nothing, which disables the filter it belongs to rather than erroring.

Write main and develop as two entries instead of one brace expansion.

Limits and security hardening​

  • Max document size: 1 MB
  • Max YAML anchors: 100 (prevents anchor bombs)
  • Max YAML aliases: 100
  • Custom YAML tags are blocked
  • run scripts are scanned for unsafe commands (see Errors)
  • Zod schema validation runs before any pipeline materializes

For the full list of validation errors, see Errors.