Skip to content
Latchkey home
Documentation menu

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.

In short

  1. Commit a workflow under .latchkey/workflows/<name>.yml, in the shape you already know from GitHub Actions, minus uses:.
  2. 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.
  3. runs-on: names a Latchkey size, so the runs-on: ubuntu-latest in a copied file becomes runs-on: small.
  4. 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
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: valuevCPUMemoryDisk
small or latchkey-small28 GB100 GB
medium or latchkey-medium416 GB100 GB
large or latchkey-large832 GB100 GB
xlarge or latchkey-xlarge1664 GB200 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 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:

NameValue
LATCHKEY_CI_RUN_IDThe run this job belongs to
LATCHKEY_CI_ATTEMPTThe attempt number, counting from 1
LATCHKEY_CI_REPOSITORY_IDLatchkey identifier for the repository
LATCHKEY_CI_PROVIDERThe git provider, for example github
LATCHKEY_CI_SHAThe commit the run is pinned to
LATCHKEY_CI_REFThe ref as the provider delivered it
LATCHKEY_CI_ACTORWho triggered the run
LATCHKEY_CI_PR_NUMBERSPull requests carrying this commit, comma joined, at most 16, with a trailing + when there are more.
LATCHKEY_CI_FORKtrue 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_REFThe 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_CLASSThe normalized event that started the run
LATCHKEY_CI_REPOSITORYThe repository this run belongs to, as owner/name
LATCHKEY_CI_STEP_NUMBERThe 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.

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.

ReasonWhat it means
no_workflow_fileNo workflow file under .latchkey/workflows/ at this commit, so nothing ran.
no_workflow_matchedNo workflow under .latchkey/workflows/ matches this event, so nothing ran.
fork_base_no_workflow_fileNo 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_repositoryLatchkey 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
jobs:
  test:
    runs-on: small
    services:
      cache:
        image: redis:7
    steps:
      - run: nc -z cache 6379
.latchkey/workflows/ci.yml
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.