# Migrate GitHub Actions to Latchkey CI with latchkey migrate

> Migrate GitHub Actions to Latchkey CI with latchkey migrate: what plan and apply do, what converts, what does not, and when a runs-on line is enough.

Source: https://latchkey.dev/learn/migrate/github-actions-to-latchkey-ci  
Updated: 2026-11-12

To migrate GitHub Actions to Latchkey CI, run `latchkey migrate plan <owner/name>`, answer the items it marks as needing a person, and run `latchkey migrate apply <plan-id>`: Latchkey opens one pull request that adds files under `.latchkey/workflows/`, and a person merges it. It is a move to a different runtime, not a label swap, because Latchkey CI runs only its own `latchkey/` steps and no GitHub actions; if you want the runners and nothing else, change `runs-on` and stay on GitHub Actions.

There are two ways to put a repository on Latchkey, and they are easy to confuse. One keeps GitHub Actions as the workflow engine and changes only the machine each job runs on. The other moves the workflow itself to Latchkey CI, which reads its own files, writes its own checks and refuses anything it cannot run rather than skipping it.

This page is about the second, and about the command that does it. Everything here comes from the `--help` output of `@latchkeydev/cli` 0.16.0 and from latchkey.dev/documentation, read 2026-11-12. Where Latchkey CI does less than GitHub Actions, the page says so.

## Two moves, the same runners

The first move is a routing change. Latchkey's documentation says only the `runs-on:` label changes, and that actions, secrets, environments and caches on GitHub's side keep working untouched. Every job gets a single-use VM, self-healing is on, and `latchkey-small` is $0.0025 a minute against GitHub-hosted $0.006. Rollback is reverting the line.

The second move is the subject of this page. Latchkey CI takes the same runner sizes, written `runs-on: small` or `runs-on: latchkey-small`, but the workflow file moves to `.latchkey/workflows/`, and Latchkey compiles it, plans the run and reports the checks.

```.github/workflows/ci.yml (the first move)
jobs:
  test:
    runs-on: latchkey-small   # was: ubuntu-latest
```

## What latchkey migrate does

The command has three subcommands, and the repository must already be connected to Latchkey CI in your workspace.

1. `plan <owner/name>` reads the GitHub Actions workflow files at the head of the default branch and prints, for each pipeline, whether it moves, and each step, job or change that needs a person's answer. The plan is saved on your machine with the answers it was made with.
2. `--answers <file>` passes those answers as JSON, in the shape the plan names them.
3. `apply <plan-id>` opens the one pull request that adds the Latchkey CI workflow files, once every item has an answer, and closes any earlier apply's open pull request. A person merges it. The key needs the `migration:apply` scope, and that works only while its creator is an owner or admin.
4. `status <owner/name>` shows the newest migration pull request: open, merged, closed or superseded.

## Running it

`apply` is sent once. If its answer is lost, the help says to run `status` before applying again, so a retry does not open a second pull request. Exit code 2 means a usage error or a plan id this machine has no saved plan for, 1 means Latchkey refused or could not be reached, and 0 means the plan, the pull request or the status was printed.

```Terminal
npm install -g @latchkeydev/cli
latchkey login
latchkey migrate plan acme/api
latchkey migrate plan acme/api --answers answers.json
latchkey migrate apply <plan-id>
latchkey migrate status acme/api
```

## The checklist: what changes in each file

These rows are the documented replacements. A file that still holds the left column after the move is refused at the line, with the reason, rather than run with the key ignored.

| In GitHub Actions | On Latchkey CI |
| --- | --- |
| `runs-on: ubuntu-latest` | `runs-on: small` or `latchkey-small`, up to `xlarge`; never mapped silently |
| `secrets.X` anywhere | Set with `latchkey secrets set`; read as the whole value of a step's `env:` variable |
| `actions/cache` with `restore-keys` | `latchkey/cache-restore` and `latchkey/cache-save`: exact key and a `version`, no fallback, kept 14 days |
| `strategy.matrix`, `fail-fast: true` | Matrix kept; every leg runs, so `fail-fast: true` and `max-parallel` are refused |
| `actions/upload-artifact`, `download-artifact` | `latchkey/artifact-upload` and `latchkey/artifact-download`, kept 7 days |
| `concurrency:` with `cancel-in-progress: true` | Groups kept; the holder is never cancelled, so `cancel-in-progress: true` is refused |
| `actions/checkout`, `actions/setup-*` | Removed: checkout is automatic, and the image carries the toolchains |
| A third-party action | A `run:` step. Latchkey CI does not run GitHub actions |
| `environment:` with reviewers or a wait timer | An environment an owner or admin approves; reviewer lists and wait timers are not supported yet |

## Before: a GitHub Actions workflow

A sharded test matrix with a prefix-matching cache, a build that uploads `dist`, and a concurrency group that cancels the previous run on the same ref.

```.github/workflows/ci.yml
name: ci
on:
  push:
    branches: [main]
  pull_request:
concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true
jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: true
      matrix:
        shard: [1, 2, 3]
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
        with:
          path: ~/.npm
          key: npm-${{ hashFiles('package-lock.json') }}
          restore-keys: npm-
      - run: npm ci
      - run: npm test -- --shard=${{ matrix.shard }}/3
  build:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - run: npm ci && npm run build
      - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
        with:
          name: dist
          path: dist
```

## After: the Latchkey CI workflow

The checkout step and `fail-fast` are gone, `cancel-in-progress` is gone because a newer run waits for the group instead, and the cache is two steps with an exact key. Version 1.1.0 of the cache steps appends a hash of the files named in `hash-files` to the key, as `hashFiles()` did. A lockfile change starts a cold cache rather than falling back to an older one.

```.latchkey/workflows/ci.yml
name: ci
on:
  push:
    branches: [main]
  pull_request:
concurrency:
  group: ci-${{ github.ref }}
jobs:
  test:
    runs-on: small
    strategy:
      matrix:
        shard: [1, 2, 3]
    steps:
      - uses: latchkey/cache-restore@1.1.0
        with:
          key: npm
          version: '1'
          hash-files: package-lock.json
          into: '~'
      - run: npm ci
      - uses: latchkey/cache-save@1.1.0
        with:
          key: npm
          version: '1'
          hash-files: package-lock.json
          from: '~'
          path: .npm
      - run: npm test -- --shard=${{ matrix.shard }}/3
  build:
    needs: test
    runs-on: small
    steps:
      - run: npm ci && npm run build
      - uses: latchkey/artifact-upload@1.0.0
        with:
          name: dist
          path: dist
```

## Advisory mode, side by side, then the switch

Merging the migration pull request does not turn GitHub Actions off, and it does not make Latchkey CI's verdict count yet. While the GitHub Actions file a Latchkey file replaces is still in the commit, that Latchkey pipeline runs on trial, where its failure fails no check. Enrolling the repository with Move CI to Latchkey, under Migrate in the dashboard, goes further and puts it in advisory mode: every failure is written as a neutral check titled with the real outcome, such as Advisory: failed. In a trial run, a job that names an environment is held unless its file writes `advisory: run` and gives it a dry run, and even then it still waits for its approval unless the environment needs none.

You compare results commit by commit on Side by side or the Runs page, and choose Switch to Latchkey CI when you trust it. The switch opens one pull request that removes the GitHub Actions files the Latchkey files replace. Until it merges, a job that exists in both files can run in both places, and Latchkey never cancels or disables a GitHub Actions workflow to prevent that, so a deploy job can deploy twice. Take any check that comes from a removed workflow out of branch protection before you merge the switch.

## Check a file before you push it

`validate` compiles one file as Latchkey would and prints either the first refusal with its line or each job it compiles to, with the most one run can cost at list rates. `preflight` runs that file's jobs on your working tree before you commit, with no secrets, no identity token and no artifacts, so a job that needs one is reported as not run, with the reason.

```Terminal
latchkey validate .latchkey/workflows/ci.yml
latchkey preflight --workflow .latchkey/workflows/ci.yml --repository acme/api
printf '%s' "$NPM_TOKEN" | latchkey secrets set NPM_TOKEN --repository acme/api
latchkey environments list --repository acme/api
```

## When to stay on GitHub Actions

Stay if your workflows lean on marketplace actions. Every one becomes a `run:` step you write and maintain, and Latchkey states it does not promise an Actions-compatible runtime. Stay if you need Windows, macOS or arm64: Latchkey runners are Ubuntu 24.04 on x86_64. Stay if you depend on reviewer lists, wait timers, `cancelled()`, outputs from a matrix job, or `cancel-in-progress`, none of which Latchkey CI accepts today.

Move when you want what only Latchkey CI does: a file that is refused at the exact line instead of half-running, a plan check that lists jobs added, removed and changed on every pull request, dry runs for deploy jobs during a trial, and `preflight` on your working tree before a commit exists. Both moves use the same runner sizes and the same self-healing. If price and repairs are the reason, the `runs-on` line gets you both for an afternoon's work.

## FAQ

### Does latchkey migrate change my repository directly?

No. `plan` only reads the workflow files on the default branch and saves the plan on your machine. `apply` opens a pull request that adds the Latchkey CI files, and nothing changes until a person merges it. The later switch is a second pull request, which removes the replaced GitHub Actions files.

### Can Latchkey CI run GitHub marketplace actions?

No. A `uses:` step must name one of Latchkey's own steps, written `uses: latchkey/<name>@<version>` with an exact version, and any other `uses:` is refused at its line. A third-party action becomes a `run:` step that does the same work.

### Do I need Latchkey CI to use Latchkey runners?

No. GitHub Actions jobs run on Latchkey runners by changing `runs-on` to a label such as `latchkey-small`, and every action, secret and environment keeps working.

## References

- [Latchkey documentation: run your workflow file on Latchkey (verified 2026-11-12)](https://latchkey.dev/documentation/workflow-files)
- [Latchkey documentation: Latchkey steps (verified 2026-11-12)](https://latchkey.dev/documentation/steps)
- [Latchkey documentation: migrate from GitHub-hosted runners (verified 2026-11-12)](https://latchkey.dev/documentation/migrate-from-github-hosted)
- [Latchkey CLI documentation and changelog (verified 2026-11-12)](https://latchkey.dev/documentation/latchkey-cli)
- [Latchkey pricing: runner sizes and rates (verified 2026-11-12)](https://latchkey.dev/pricing)

---

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
