Skip to main content
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:

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

scan reads <dir>/.github/workflows and prints a table of workflow, verdict and reason, followed by the secrets each workflow reads:
--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: GITHUB_TOKEN is never reported: the engine provides it.

Apply

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:
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: 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:
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

status asks GitHub (gh api repos/<owner>/<repo>/actions/workflows) for the state of every workflow that should have moved: 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. Re-running a finished job on Preloop needs no extra step: preloop rerun already exists.