# Run your workflow file on Latchkey

> The Latchkey workflow file: the keys it accepts, the keys it refuses by name and why, where the file lives, and what happens when you push.

Source: https://latchkey.dev/documentation/workflow-files

## Summary

- Commit a workflow under `.latchkey/workflows/<name>.yml`, in the shape you already know from GitHub Actions, minus `uses:`.
- Latchkey refuses a file rather than running it with a key ignored. One refusal per push: the first refused key in the file, named exactly as you wrote it, with its line, or the file itself where the refusal is about the whole file.
- `runs-on:` names a Latchkey size, so the `runs-on: ubuntu-latest` in a copied file becomes `runs-on: small`.
- Checkout is automatic and the runner image carries twelve language toolchains, so the `actions/checkout` and `actions/setup-*` steps simply go away.

## The promise

Latchkey runs your workflow file. Commit a workflow under `.latchkey/workflows/`, one file per workflow, in the shape you already know from GitHub Actions: jobs, steps with `run:`, `env:`, `runs-on:` naming a Latchkey runner size, `timeout-minutes:`, `needs:` to run a job after another, a matrix, and service containers under `services:`. Push, and Latchkey concludes a small fixed set of contexts on the run record and reports them as checks your branch protection can bind to. The file you commit keeps working unchanged in every later release.

Branch protection can require the Latchkey and Latchkey / plan contexts, but do not require them yet: a check Latchkey cannot write stays absent, and a required absent check blocks the merge; Latchkey / plan also reports Latchkey's own stops as failures, so requiring it would block merges on Latchkey's faults.

A neutral Latchkey check means Latchkey did not verify this commit. Your code neither passed nor failed: something stopped the run before your jobs could decide, and the check text says what stopped it. A neutral check does not block a merge, so branch protection lets the pull request through even though nothing was verified.

## What runs today

In this release a workflow file gives you jobs, steps that run one at a time, `env:`, a runner size, a timeout, a matrix, service containers and jobs that wait for other jobs with `needs:`. A step can read a secret set in Latchkey for this repository or its workspace: write `${{ secrets.NAME }}` as the whole value of a variable in that step's `env:`, and Latchkey hands the value to that step when it starts. A repository secret wins over a workspace secret of the same name, a name set at neither level refuses the run before any job starts, and a run of a pull request from a fork is handed no secrets. A run of a fix Latchkey proposed on its own branch is handed them only when the fix changes no workflow file. In a run handed no secrets, the steps that name one are not run. A value shorter than 28 bytes is never delivered, because the log could not hide it.

## Where the file lives

One file per workflow under `.latchkey/workflows/`. `.yml` is the canonical spelling and `.yaml` is read too, so a file copied out of `.github/workflows/` keeps its extension. Renaming a workflow file starts a new pipeline; the runs under the old name stay under it.

`.latchkey/workflows/ci.yml`
```yaml
name: CI

on:
  push:
    branches: [main]
  pull_request:

latchkey:
  schema: 1

jobs:
  test:
    runs-on: small
    timeout-minutes: 20
    env:
      NODE_ENV: test
    steps:
      - name: Install dependencies
        run: npm ci
      - name: Run the suite
        run: npm test
```

## Choosing a runner

| `runs-on:` value | vCPU | Memory | Disk |
| --- | --- | --- | --- |
| `small` or `latchkey-small` | 2 | 8 GB | 100 GB |
| `medium` or `latchkey-medium` | 4 | 16 GB | 100 GB |
| `large` or `latchkey-large` | 8 | 32 GB | 100 GB |
| `xlarge` or `latchkey-xlarge` | 16 | 64 GB | 200 GB |

Every job names a `runs-on:`. Both spellings are accepted and name the same runner, so a `runs-on:` line copied from a Latchkey Actions workflow works unchanged. The line a copied GitHub-hosted file usually carries is `runs-on: ubuntu-latest`; Latchkey is not GitHub-hosted and will not map that name to a size silently, so change it to `runs-on: small`, or to the size the job needs. All runners are Ubuntu 24.04 LTS on x86_64; see [Limits and concurrency](https://latchkey.dev/documentation/runner-limits) for what each size gives you.

## What we do not emulate

Latchkey does not run GitHub actions. A `uses:` step is refused with a message that names the step and its Latchkey replacement: checkout is automatic (the runner clones your repository at the commit with a per-job token, or with none for a released fork), `actions/cache` is `latchkey cache save` and `latchkey cache restore`, the setup actions are the image's own toolchains (Node, Python, Go, Rust, Java are on the standard runner image), artifacts are not supported yet, and a third-party action becomes a `run:` step. We do not promise an Actions-compatible runtime, and we will not.

## The toolchains are already there

The standard runner image carries Python, Node, Go, Java, Rust, Ruby, PHP, .NET, Haskell, Swift, Kotlin and Julia, and the image build fails if any of the twelve is missing. A `uses: actions/setup-*` step has no replacement to write: delete it and call the tool.

## The checkout

The runner clones your repository at the pushed commit before your first step runs, so a workflow file needs no checkout step. A pull request from a fork runs only once released, and its commit is fetched from this repository's own address with no token at all. The clone is shallow and carries no tags, which is the same default the vendor's own checkout action uses, so a step that derives a version from tags or reads history does not have them in this release.

## How a job runs

Latchkey runs a job's steps one at a time, in file order, each as its own `bash --noprofile --norc -e -o pipefail` script started in the job's workspace with the job's environment. A step stops at its first failing command, and a command that fails inside a pipeline fails the pipeline. Once a step fails, a later step runs only when its `if:` asks for `failure()` or `always()`, and the job fails with the first failing step's exit code. A `cd` or an `export` in one step does not carry into the next.

Because each step is its own script in its own process group, a step that ends its own shell or process group, for example with `kill $$` or `kill 0`, ends that step only: the step fails with exit code 143 and a later `failure()` or `always()` step still runs. Each step's script is also read on its own, so a step that does not parse fails alone with exit code 2 and every later step its `if:` admits still runs; a job whose own `if:` does not hold runs none of its steps, parseable or not. And each step's script is written to the job's temporary directory (`$RUNNER_TEMP`) when that step starts, so a step that removes that directory or the job's working directory, or otherwise takes away what the next step needs to start, stops the job at the next step: that step does not start, no later step runs, not even an `always()` step, and the job fails without an exit code.

`if:` accepts `success()`, `failure()`, `always()` and an equality test on a matrix or `env` value, and nothing else. An `if:` outside that set is refused by name rather than evaluated to something Latchkey guessed at. A job whose own `if:` does not hold is skipped: its steps do not run, and the run's checks count it as skipped, neither passed nor failed.

## Jobs that need other jobs

A job with `needs:` starts after every job it names has passed. Name one job (`needs: build`) or several (`needs: [build, test]`) by their keys under `jobs:`, in the same file. A matrix job has passed when every one of its legs has. A job with no `needs:` starts at once, as before. A job still waiting for the jobs it needs 24 hours after it was queued is stopped, and its tile reads "Stopped". If a job it needs ended without passing by then, it is skipped instead.

While it waits, the job's tile on the run page names the jobs it is still waiting for ("Waiting for build."). When a job it needs fails, is cancelled or is skipped, the job is skipped before it starts: the run's checks count it as skipped, neither passed nor failed, no runner is spent on it, and its tile says which ("Skipped: a job it needs failed."). Skipping runs down the chain: a job that needs a skipped job is skipped too.

On a job with `needs:`, a job-level `if:` can test a matrix or `env` value. `failure()` and `always()` there are refused by name: a job whose need did not pass is skipped before any `if:` is read, so neither could run it.

## The environment Latchkey sets

On a job Latchkey started from your workflow file, these names are exported for you:

| Name | Value |
| --- | --- |
| `LATCHKEY_CI_RUN_ID` | The run this job belongs to |
| `LATCHKEY_CI_ATTEMPT` | The attempt number, counting from 1 |
| `LATCHKEY_CI_REPOSITORY_ID` | Latchkey identifier for the repository |
| `LATCHKEY_CI_PROVIDER` | The git provider, for example `github` |
| `LATCHKEY_CI_SHA` | The commit the run is pinned to |
| `LATCHKEY_CI_REF` | The ref as the provider delivered it |
| `LATCHKEY_CI_ACTOR` | Who triggered the run |
| `LATCHKEY_CI_PR_NUMBERS` | Pull requests carrying this commit, comma joined, at most 16, with a trailing `+` when there are more. |
| `LATCHKEY_CI_FORK` | `true` when the pull request comes from a fork; such a job runs with no repository token, no cache, no secrets and no self-heal, and its steps reach only GitHub and a fixed set of public package registries, with no sudo, no Docker and no service containers |
| `LATCHKEY_CI_BASE_REF` | The base ref as a full `refs/heads/` path: a pull request's base branch, or the ref a new branch was cut from on a push. Empty otherwise. |
| `LATCHKEY_CI_EVENT_CLASS` | The normalized event that started the run |
| `LATCHKEY_CI_REPOSITORY` | The repository this run belongs to, as `owner/name` |
| `LATCHKEY_CI_STEP_NUMBER` | The number of the step that is running, counting from 1. Readable in the step's commands, not in its `if:` |

The repository's full name is exported as bare `owner/name`, never a URL. LATCHKEY_CI_REPOSITORY carries the name the repository was enrolled under, so it does not change if you rename the repository at your code host. Every name beginning with `LATCHKEY_` belongs to Latchkey, so a workflow file may not set one.

## The `latchkey:` block

One optional top-level block carrying exactly one member. Values are read as text, so `schema: 1` and `schema: "1"` are the same file. A file with no `latchkey:` block is read as schema version 1. A block that is present must carry `schema:` and nothing else.

## When the file runs

`on:` accepts `push`, with optional `branches:` and `tags:`, and `pull_request`, with optional `branches:` and `types:`. A bare `on: push` runs on branch pushes only; a tag push runs the file only when the `push` block names `tags:`. Under `on: pull_request`, `branches:` matches the base branch of the pull request and never its head ref.

Latchkey runs a pull request workflow on opened, synchronize and reopened, all three, always, so a `types:` list must name exactly those three or be left out. `on: schedule` is not supported yet.

> **Pull requests from forks**
>
> Latchkey runs a pull request from a fork only when it comes from its author's own fork and the author has write access to this repository, or when a maintainer approves its commit, so this code has not been verified yet. To run it, a maintainer can approve this exact commit (a new push needs a new approval), or push the change to a branch of this repository as a new commit.

If you require the Latchkey checks, a held pull request from a fork blocks the merge until a maintainer approves its commit or the change is pushed to a branch of this repository as a new commit.

## When nothing runs

Three outcomes mean the push was read and nothing matched, and a fourth means Latchkey does not run the pull request. None of them is a failure.

| Reason | What it means |
| --- | --- |
| `no_workflow_file` | No workflow file under .latchkey/workflows/ at this commit, so nothing ran. |
| `no_workflow_matched` | No workflow under .latchkey/workflows/ matches this event, so nothing ran. |
| `fork_base_no_workflow_file` | No workflow file at the base commit this pull request targets, so nothing ran. A fork's own workflow files never run; add the file to a branch of this repository first. |
| `fork_private_repository` | Latchkey does not run pull requests from forks of a private repository, so this code has not been verified. To run it, push the change to a branch of this repository as a new commit. |

## A matrix

A matrix expands to one job per combination. A leg key appends the leg values to the job key, sorted by key and joined with commas, so a job named `build` with `os: linux` and `node: 20` produces the leg key `build(node=20,os=linux)`. No job may expand past 50 legs, no single workflow file may hold more than 50 legs across all of its jobs, and no run may hold more than 150 jobs, counted across every workflow file the event matched.

In Actions, `fail-fast` defaults to true and cancels the other legs when one fails. Latchkey runs every leg and cancels none, so `fail-fast: false` is accepted because it is exactly what Latchkey does, `fail-fast: true` is refused rather than quietly ignored, and `max-parallel` is refused too.

## Service containers

A job may declare service containers under `services:`, each a label with an `image:`. Latchkey starts every service before the job's first step, on a network of the job's own, and your steps reach a service by its label as a host name, on the port its image listens on, so a service labelled `cache` running `redis:7` is `cache:6379` to every step of the job. A service can also publish a port on the runner under `ports:`, written `<runner port>:<service port>`, and your steps then reach it at `localhost:<runner port>` too. A label is lowercase letters, digits and `-`, starts with a letter, does not end with a hyphen, is at most 63 characters and is not `localhost`; a job declares at most 10 services. Steps of a pull request from a fork reach no service container.

`.latchkey/workflows/ci.yml`
```yaml
jobs:
  test:
    runs-on: small
    services:
      cache:
        image: redis:7
    steps:
      - run: nc -z cache 6379
```

`.latchkey/workflows/ci.yml`
```yaml
jobs:
  test:
    runs-on: small
    services:
      db:
        image: postgres:16
        env:
          POSTGRES_PASSWORD: postgres
        ports:
          - 5432:5432
    steps:
      - run: pg_isready -h localhost -p 5432
```

Latchkey waits until each service is ready: healthy, when its image declares a health check; otherwise accepting a connection on a port its image exposes; otherwise running. It waits up to 5 minutes for all of them, pulls included. A service whose image cannot be pulled, that stops, or that is not ready in time fails the job before its first step runs, and the job log names the service, its image and the reason. When the job ends, however it ends, Latchkey removes the services and everything it created for them, and the runner itself is destroyed after its one job in any case.

A service runs a public image. Under a service, `env:` sets the variables its image reads at start, such as `POSTGRES_PASSWORD` for `postgres`, and `ports:` publishes a port on the runner's loopback address. `options:`, `volumes:` and `credentials:` are refused by name, so an image that needs a credential to pull cannot run as a service: start it in a `run:` step instead. A matrix value cannot be used in a service's `image:` or `env:`, and a service's `env:` cannot read a secret.

Latchkey never writes a service's settings to the job log. A setting of 28 bytes or more is also hidden wherever a step prints it, in the log and in what self-heal reads. A shorter one, such as a test database's password, is not hidden, so keep real credentials out of service settings: a value that reads as a credential is refused, and a secret belongs in a step's `env:`.

## How a refusal reads

Latchkey refuses a file rather than running it with a key ignored. One refusal per push: the first refused key in the file, named exactly as you wrote it, with its line, or the file itself where the refusal is about the whole file. The plan context concludes with the refusal on the run record. The Latchkey / plan check names where it was refused.

The three you are most likely to meet when you port a file:

- `uses:` is not supported: write the step as `run:`. Checkout is automatic, caching is `latchkey cache save` and `latchkey cache restore`, and the toolchains (Node, Python, Go, Rust, Java) are on the runner image.
- `options:`, `volumes:` and `credentials:` under a service are not supported: a service runs a public image with the settings in its `env:` and the ports in its `ports:`, and your steps reach it by its label or at its published port.
- `if:` on `notify` uses `always()`, and `notify` has `needs:`. A job whose need did not pass is skipped before it starts, so `always()` and `failure()` cannot run it. Use `success()`, a matrix or `env` test, or remove the `needs:`.

## What a failed job means for you

A job that fails is routed to Latchkey's self-heal, and the heal is reported for the job as a whole, not per step. Self-heal does not run on a pull request from a fork. Not every failure is your code: Latchkey tells its own faults, your cancellations, and billing or connection problems apart from your command's own outcome. Red on the Latchkey check means your command failed or your job reported the failure itself; a fault Latchkey can attest as its own never turns the Latchkey check red, and the check text says the fault was Latchkey's. A job started by a push or a pull request serves its log today through `latchkey logs <job id>` and the MCP `get_job_logs` tool, and appears in the job list and on the run page, where each step shows its own log as it runs. A job Latchkey could not place on a runner says so and is never billed.

To run a finished run's jobs again, press Re-run on the Latchkey check on GitHub, or **Re-run jobs that did not pass** on the run in the dashboard. Latchkey runs, as a new attempt of the same run, every job that did not pass and every job that needs one of them. Every other job that passed is carried over and is not run or billed again. **Cancel run** in the dashboard cancels every job in the run that has not finished, and the run then reads Cancelled.

## What stays the same for your other repositories

Your GitHub Actions jobs on `latchkey-*` runners keep running exactly as they do today, on the same organization, the same bill and the same free-minute pool. You move repositories to Latchkey CI one at a time.

## Forward-compatibility

A file you commit in this release is never migrated: the same compiler reads it in every later release.

---

Latchkey runs CI/CD that repairs its own failures. Agent entry points: https://latchkey.dev/agent.txt, https://latchkey.dev/openapi.json, https://latchkey.dev/llms.txt
