.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.
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
| Field | Type | Required | Description |
|---|---|---|---|
version | "1" | yes | Schema version. Must be "1" or 1. |
name | string | no | Pipeline name. Max 128 chars. Alphanumeric, dash, underscore. |
description | string | no | Human-readable description. Max 1000 chars. |
resource_class | string | no | Default resource class for every job in the pipeline. Individual jobs may override it with their own resource_class. See Resource classes. |
env | map | no | Environment variables injected into every step. Keys max 64 chars, values max 4096 chars. |
on | object | yes | Declared events. See on — declared events. |
jobs | map | yes | Map 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']
| Field | Type | Max items | Description |
|---|---|---|---|
branches | string[] | 20 | Glob patterns for branch names |
paths | string[] | 50 | Glob patterns for changed file paths |
paths_ignore | string[] | 50 | Glob patterns for paths to exclude |
tags | string[] | 20 | Glob patterns for tag names |
tags_ignore | string[] | 20 | Glob patterns for tags to exclude |
* matches within a path segment, ** matches across segments.
pull_request
on:
pull_request:
branches: [main]
types: [opened, synchronize, reopened]
| Field | Type | Description |
|---|---|---|
branches | string[] | Target branches that trigger. Max 20 entries. |
types | string[] | 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
| Field | Type | Description |
|---|---|---|
name | string | Display name for the job. Max 128 chars. |
image | string | Container image to run the job's steps in. Max 256 chars. |
resource_class | string | Per-job resource class. Overrides the pipeline-level resource_class for this job only. See Resource classes. |
needs | string[] | Jobs that must succeed before this one runs (DAG). Max 20 entries, each max 64 chars. |
environment | string | The project environment this job targets, e.g. prod. Lowercase letters, digits and hyphens, max 63 chars. Selects the environment variables the job receives. |
if | string | Condition 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. |
timeout | string | Accepted, e.g. 30m, but not applied. Use a step-level timeout. See Accepted but not applied. |
env | map | Job-level env, overlaid on global |
services | map | Sidecar containers the job's run: steps can reach by name. Max 3. See services. |
cache | map | Dependency caching for this job. Expands to a restore/save pair. See cache. |
artifacts | map | Files this job hands to the jobs that need it. See artifacts. |
steps | array | 1–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.
| Field | Type | Description |
|---|---|---|
image | string | Required. The container image. Max 256 chars. |
ports | number[] | Ports the service listens on. Optional for well-known images. Max 8. |
env | map | Environment for the service container |
alias | string/string[] | Extra hostnames for the same service. Max 4. |
service_class | string | micro or small. Defaults to micro. Deliberately smaller than a job's resource_class. |
startup_timeout | string | How long to wait for the port to accept connections, e.g. 60s |
command | string[] | Override the image entrypoint. Runs the service as uid 1000, see below. |
args | string[] | 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:andargs: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
| Key | Type | Default | Description |
|---|---|---|---|
paths | string[] | — | Directories to cache. Required, 1–20 entries. |
key_files | string[] | the task's own default | Files whose contents derive the cache key — normally your lockfile. |
prefix | string | default | Namespaces caches within a repository. Letters, digits, ., _, -. |
fail_on_miss | boolean | false | Fail 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
| Key | Type | Description |
|---|---|---|
paths | string[] | 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
needsthe job, directly or through a chain, receives its files at the same paths. They are restored after the receiving job's ownloha/git-cloneand after itscacherestore (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_parallelruns 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-saveandartifacts-restoreare reserved. - If the deployment has no artifact storage, a build whose jobs declare non-empty
pathsis refused withARTIFACTS_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
| Field | Type | Description |
|---|---|---|
name | string | Required. Display name shown in the dashboard. Max 128 chars. |
run | string | Shell script. Mutually exclusive with uses. Max 65536 chars. Validated for unsafe commands. |
uses | string | Reference to an official task. Mutually exclusive with run. Max 256 chars. |
image | string | Container image to run this step in. Max 256 chars. |
with | map | Parameters to pass to a uses task. Values may be strings, numbers, or booleans, and support context tokens. |
env | map | Step-level env, overlaid on job and global |
secrets | string[] | Secret names to inject at runtime. Max 20 entries, each max 128 chars. |
if | string | Condition for running this step, max 256 chars. A false condition skips only this step. See Conditions: if. |
timeout | string | Per-step timeout, e.g. 10m. See Timeout format. |
retries | number | Number of times to retry the step on failure. Integer, 0–3. |
working_directory | string | Directory 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.
| Field | Type | Notes |
|---|---|---|
strategy.matrix | map of name → list | 1–5 axes, each with 1–20 values. Values are strings. |
strategy.max_parallel | number | Optional. 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.
| Token | Expands to | Example |
|---|---|---|
${COMMIT_SHA} | Full commit SHA of the triggering revision | abc1234def5678901234567890abcdef12345678 |
${SHORT_SHA} | First 7 characters of the commit SHA | abc1234 |
${REPO_URL} | Clone URL of the repository | https://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.
| Class | CPU (request → limit) | Memory (reserved = ceiling) |
|---|---|---|
micro | 100m → 250m | 256Mi |
small | 500m → 1 | 1Gi |
medium | 1 → 2 | 4Gi |
large | 2 → 4 | 8Gi |
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
needsit, 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:
| Part | What is accepted |
|---|---|
| Variables | branch: 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 ||. |
| Literals | true and false. |
| Wrapper | Optional: ${{ 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.
| Field | What it does today |
|---|---|
timeout (job) | Nothing. Step-level timeout and the workspace build timeout are both enforced |
working_directory | Nothing. Every step runs in /workspace/source. Use cd in a run: script instead |
on: does not decide when your pipeline runson: 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.)
* 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
runscripts are scanned for unsafe commands (see Errors)- Zod schema validation runs before any pipeline materializes
For the full list of validation errors, see Errors.