Skip to main content
Migrating from GitHub Actions is per workflow, not per repository. A repository usually mixes workflow files with very different constraints: A repository therefore declares the workflow files preloop owns. preloop runs those from webhook deliveries and ignores the rest; GitHub still owns the rest and creates their runs itself.

The model

  • Ownership is a set of paths per repository, e.g. .github/workflows/ci.yml. It is stored in the engine’s config file ([owned_workflows], keyed by "owner/repo") and can be changed live through the API below; a change applies to the next delivery and survives a restart.
  • No record means “own everything”. A repository the operator never configured keeps the historical behaviour: preloop runs every workflow a delivery matches. Migration is opt-in per repository.
  • An empty record owns nothing. PUT with {"workflows": []} is a valid, explicit “preloop runs no workflows here”; DELETE removes the record and restores “own everything”.
  • Only webhook-driven runs are filtered. A native POST /api/v1/runs that names an explicit workflow (what a local preloop run does) is an intentional local submission and is never skipped. Scheduled workflows are only registered for owned files, so a schedule the operator left on GitHub does not silently fire on preloop too.
  • preloop never edits the repository. It holds contents: read, checks: write and actions: read on the GitHub App and never needs workflows: write. Anything that edits .github/workflows/ runs on the operator’s machine with the operator’s git and gh credentials.
A skipped workflow is logged at INFO with the repository, the path, and reason="not_owned"; the webhook delivery still completes successfully.

Disable the moved workflows on GitHub

Ownership only tells preloop which workflows to run. To stop GitHub from running them as well, disable each moved workflow on GitHub with the customer’s own credentials:
GitHub then creates no runs for that workflow. Its webhooks still reach preloop, which runs it, and check runs are reported back through preloop’s GitHub App (Checks API), so required checks and the per-check Re-run button keep working. Re-enabling a workflow on GitHub does not stop preloop from running it — it causes drift (see below). The onboarding flow also rewrites the moving workflow’s runs-on to a preloop label, so a re-enabled workflow on GitHub queues jobs that its hosted runners cannot pick up instead of silently double-running them; the rewrite is a safety net, not a substitute for disabling the workflow.

Drift

A workflow can be disabled on GitHub and later re-enabled (by a person or a policy reset). preloop still owns it, so GitHub creates runs for it too: without the runs-on rewrite the two systems double-run it; with the rewrite GitHub queues jobs no GitHub-hosted runner can satisfy. Either way the migration is no longer what the operator intended, so the drift watcher reports it. preloop checks GitHub’s GET /repos/{owner}/{repo}/actions/workflows for every repository with a record, using an installation token scoped to exactly actions: read. It reports, per workflow: The watcher runs every owned_workflows_drift_interval_secs seconds (default 900 — 15 minutes; 0 disables it). It logs one WARN per transition into drift, records when the episode started, and keeps that timestamp until the workflow is disabled again. A GitHub outage is logged, leaves the recorded state untouched, and shows as unknown; it never fabricates drift and never panics.

API

All routes require the native bearer token (authorization: Bearer …), like /api/v1/secrets.
List every record:
Read one record (404 when the repository has none and therefore still owns everything):
Replace one record. Paths must be under .github/workflows/, end in .yml or .yaml, and are deduplicated; traversal, absolute paths, backslashes and nested directories are rejected with 400:
Drop the record, returning the repository to “preloop owns every workflow it receives” (idempotent):
Check drift now (the periodic watcher also writes these observations):
When GitHub cannot be reached (or no App installation covers the repository), the route answers 200 with every owned workflow unknown, drift: false, and an error string:
404 is returned for /drift when the repository has no record: there is nothing to compare.

Config file

The API persists the same table the engine reads at startup. Editing the file by hand has the same effect on the engine’s next start, like every other policy table (and a malformed path fails startup rather than being silently ignored):
PRELOOP_OWNED_WORKFLOWS_DRIFT_INTERVAL_SECS overrides the interval key.

Troubleshooting

  • A moved workflow still runs on GitHub. GET …/drift shows github_state: "active" and drift: true; run gh workflow disable.
  • A workflow that should run on preloop does not. Check the record lists the exact path GitHub reports (.github/workflows/<file>.yml). Workflow evaluation logs carry reason="not_owned" when the ownership filter skipped it.
  • Everything runs on preloop again. The record was deleted (or the repository key is misspelled). GET /api/v1/owned-workflows shows only records that exist.
  • Drift always says unknown. The engine has no GitHub App installation covering that repository, so it cannot mint the actions: read token. Drift checking does not fall back to a wider PAT.