> ## Documentation Index
> Fetch the complete documentation index at: https://preloop.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Migrate from GitHub Actions

> Move CI workflows from GitHub-hosted runners to Preloop, one workflow at a time, without ever running the same job twice.

Preloop runs the same workflow files GitHub does, so a migration is mostly a decision about which workflows Preloop should own. `preloop migrate` makes that decision, rewrites the one line that has to change, and opens a pull request for you.

Migration is **per workflow, not per job**. Jobs in one workflow share `needs:`, artifacts and caches, so a workflow split between GitHub and Preloop cannot be made to work. Each workflow gets exactly one of three verdicts:

| Verdict | Meaning |
| - | - |
| `moves` | CI: lint, test and build jobs that run on Linux. Preloop will own it. |
| `stays_on_github` | Release, publish, deploy or tag work, or any job pinned to a GitHub `environment:`. |
| `unsupported` | A job needs macOS, Windows or a GPU, and Preloop's pool is Linux. A mixed workflow is unsupported as a whole. |

## Why releases stay on GitHub

Publishing is not just another Linux job:

* npm, PyPI and crates.io trusted publishing only accept GitHub's OIDC issuer. Moving the workflow breaks the credential, not just the environment.
* A deploy usually carries an `environment:` with protection rules and required reviewers. That state lives in GitHub.
* `docker/build-push-action` only matters as a publisher when it runs on a release or tag trigger; a plain build-and-test image job still moves.

Preloop never asks for more permission to make this easier. Its GitHub App keeps `contents: read`, `checks: write` and `actions: read`; it never pushes to your repository and never needs `workflows: write`. Everything that edits `.github/workflows/` runs on your machine with your own `git` and `gh` credentials.

## Scan

```sh theme={null}
preloop migrate scan
preloop migrate scan --dir path/to/repo --json
```

`scan` reads `<dir>/.github/workflows` and prints a table of workflow, verdict and reason, followed by the secrets each workflow reads:

```text theme={null}
Workflows in .
workflow       verdict          reason
release.yml    stays_on_github  triggered by release
ci.yml         moves            push, pull_request workflow with no release, deploy or publish surface
build-mac.yml  unsupported      macOS runner in job `build` (runs-on `macos-14`)

1 move, 1 stay on GitHub, 1 unsupported

Secrets
  fetched from a vault at run time (OIDC trust only, nothing to import)
    VAULT_TOKEN     release.yml
  plain `secrets.X` (import with `preloop secret set <name>`)
    CODECOV_TOKEN   ci.yml
```

`--json` prints the same report as machine-readable JSON. Codes such as `release_trigger`, `tag_trigger`, `environment`, `publisher`, `deploy_oidc`, `release_named`, `unsupported_runner`, `parse_error`, `ci_workflow` and the advisory `needs_review` are stable, so a script can branch on them.

A workflow that grants `contents: write` or `packages: write` still moves, but carries an extra `needs_review` reason: check the steps that use the token before you disable the GitHub copy.

## Secrets

The scan groups secret names by what they need from you:

| Workflow shape | What to do |
| - | - |
| Already fetches from a vault (`hashicorp/vault-action`, `aws-actions/aws-secretsmanager-get-secrets`, `google-github-actions/get-secretmanager-secrets`) | Nothing but OIDC trust: the value is fetched at run time. |
| Fetches from a vault and also reads `secrets.X` | Map those names to vault references; they are fetched at run time. |
| No vault | Import the names into Preloop's store with `preloop secret set`. |

`GITHUB_TOKEN` is never reported: the engine provides it.

## Apply

```sh theme={null}
preloop migrate apply --dry-run          # print the diff, change nothing
preloop migrate apply                    # branch, commit, push, open the pull request
preloop migrate apply --yes              # no confirmation prompt
preloop migrate apply --label preloop    # custom runner label
```

`apply` runs the scan, rewrites `runs-on` in the workflows that move, prints a unified diff, asks for confirmation, then creates a branch (default `preloop/migrate-<yyyymmdd>`), commits, pushes and opens a pull request with `gh`. The pull request lists every changed line and every workflow it deliberately left alone, with the reason.

The rewrite is textual. Comments, key order, quoting and formatting survive; only the `runs-on` value changes. Scalar, flow-sequence and block-sequence forms are all handled:

```yaml theme={null}
# before                                  # after
runs-on: ubuntu-latest              ->     runs-on: self-hosted
runs-on: [ubuntu-latest, x64]       ->     runs-on: self-hosted
runs-on:                            ->     runs-on:
  - ubuntu-latest                            - self-hosted
  - x64
```

Two forms are left untouched and reported under `Needs manual review`:

* Expressions such as `runs-on: ${{ matrix.os }}`, because the engine resolves them per job at run time.
* Mapping forms (`group:`/`labels:`), because Preloop does not model GitHub runner groups.

`apply` needs `git` and `gh` on `PATH`. It refuses to start when `gh` is missing, and it never falls back to Preloop's own credentials: nothing is written before both tools are found. `--no-pr` creates the branch and commit locally without pushing and without opening a pull request.

`--override '<path>=moves|stays'` forces a verdict for one workflow and is repeatable. Use it when the scanner is too conservative for a workflow you know is safe to move.

## The runner label

The default rewritten label is `self-hosted`. That is not an accident: a stock Preloop engine advertises exactly the labels a job needs to be claimed, and `self-hosted` is in the base set of every runner it starts, whatever golden, architecture or `PRELOOP_RUNNER_LABELS` the operator configured.

The routing rule, from `job_matches_runner` in the scheduler, is: every label in `runs-on` must be present in the runner's label set (case-insensitive). A GitHub-hosted image label (`ubuntu-latest`, `ubuntu-24.04`) additionally matches a Linux runner, and never stands in across operating systems. Base labels are:

| Label | Advertised by |
| - | - |
| `self-hosted` | every engine runner, always |
| `Linux` | every engine runner, always |
| the host architecture (`x86_64`, `aarch64`) | every engine runner, always |
| `ubuntu-latest`, `ubuntu-24.04` | the official golden |
| anything else | `PRELOOP_RUNNER_LABELS` on each engine host |

So `--label <name>` only works out of the box for the labels above. For anything else, set `PRELOOP_RUNNER_LABELS=<name>` (comma-separated) on every engine host; the command warns you when the label you chose is not a stock one. The same rule is why a job that declares `runs-on: [self-hosted, X64]` is reported for manual review rather than rewritten: the pool advertises `x86_64`, not `X64`, so the job would never be claimed.

`self-hosted` also keeps the "no double run" promise: it is not a GitHub-hosted image label, so if someone re-enables a moved workflow on GitHub, the job waits for a self-hosted runner that does not exist instead of running on GitHub's infrastructure.

## Disable the workflows on GitHub, after the merge

`apply` prints the exact commands at the end:

```sh theme={null}
gh workflow disable ci.yml -R owner/repo
```

Run them **after** the migration pull request merges. While a workflow is still enabled on GitHub, every push creates a GitHub run as well; disabling it before the merge leaves a window where neither system runs it. Once disabled, GitHub stops scheduling runs, but the webhooks still reach Preloop, which keeps running the workflow, reporting its checks, and serving the per-check **Re-run** button through the GitHub App.

`gh workflow disable` takes one workflow per call, which is why there is one line per moved workflow.

## Verify

```sh theme={null}
preloop migrate status
preloop migrate status --repo owner/repo --dir path/to/repo
```

`status` asks GitHub (`gh api repos/<owner>/<repo>/actions/workflows`) for the state of every workflow that should have moved:

| State | Meaning |
| - | - |
| `disabled` | GitHub will not schedule it. This is what you want. |
| `active` | Warning: GitHub and Preloop will both run it. Disable it on GitHub. |
| `missing` | GitHub has no workflow at that path, so there is nothing to disable. |

`status` only checks the workflows that move; workflows that stay on GitHub are counted but not inspected.

## Drive the migration from an AI agent

`preloop migrate` also has a machine-readable surface for coding agents: `plan --json` returns every verdict, secret name, command and human gate in one document, `apply --json` reports the branch, commit, pull request and manual-review items, and `verify --json` exits `0` when the migration is complete, `10` when actions remain and `3` when `gh` or `git` needs attention. `preloop migrate guide` prints the full playbook, which is also published on [Onboarding an AI agent](https://preloop.mintlify.site/agent-onboarding.md).

Re-running a finished job on Preloop needs no extra step: `preloop rerun` already exists.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.