> ## 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.

# Workflow ownership

> Move CI workflows to preloop one file at a time, and catch the ones still enabled on GitHub.

Migrating from GitHub Actions is **per workflow**, not per repository. A
repository usually mixes workflow files with very different constraints:

| Workflow kind | Where it runs after migration | Why |
| - | - | - |
| Lint, test, build, PR checks | **preloop** | Ordinary Linux compute; preloop executes it from GitHub's webhook delivery. |
| Release, publish, deploy, tag, `environment:` | **GitHub** | npm, PyPI and crates.io trusted publishing only accept GitHub's OIDC issuer, and protected environments stay on GitHub. |

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: every workflow a delivery matches
  still runs. Migration is opt-in per repository.
* **An empty record owns nothing**. `PUT` with `{"workflows": []}` is a valid,
  explicit "preloop executes 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 ci 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:

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

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:

| GitHub state | Meaning |
| - | - |
| `active` | **Drift** when the path is owned: GitHub will run it too. Disable it again. |
| `disabled_manually`, `disabled_inactivity`, `disabled_fork` | Not drift — GitHub will not run it. |
| any other state | Not drift; reported verbatim so an unexpected value is visible. |
| `unknown` | GitHub could not be asked, or does not list an owned path. **Never** reported as drift. |

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`.

```sh theme={null}
PRELOOP=http://127.0.0.1:9090
TOKEN="$PRELOOP_TOKEN"   # the engine's native/system token
```

List every record:

```sh theme={null}
curl -s -H "authorization: Bearer $TOKEN" \
  "$PRELOOP/api/v1/owned-workflows"
```

```json theme={null}
{
  "owned_workflows": [
    { "repo": "owner/repo", "workflows": [".github/workflows/ci.yml"] }
  ]
}
```

Read one record (`404` when the repository has none and therefore still owns
everything):

```sh theme={null}
curl -s -H "authorization: Bearer $TOKEN" \
  "$PRELOOP/api/v1/owned-workflows/owner/repo"
```

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

```sh theme={null}
curl -s -X PUT -H "authorization: Bearer $TOKEN" \
  -H 'content-type: application/json' \
  -d '{"workflows":[".github/workflows/ci.yml",".github/workflows/lint.yaml"]}' \
  "$PRELOOP/api/v1/owned-workflows/owner/repo"
```

```json theme={null}
{
  "repo": "owner/repo",
  "workflows": [".github/workflows/ci.yml", ".github/workflows/lint.yaml"]
}
```

Drop the record, returning the repository to "preloop owns every workflow it
receives" (idempotent):

```sh theme={null}
curl -s -X DELETE -H "authorization: Bearer $TOKEN" \
  "$PRELOOP/api/v1/owned-workflows/owner/repo"
```

```json theme={null}
{ "ok": true, "repo": "owner/repo", "removed": true }
```

Check drift now (the periodic watcher also writes these observations):

```sh theme={null}
curl -s -H "authorization: Bearer $TOKEN" \
  "$PRELOOP/api/v1/owned-workflows/owner/repo/drift"
```

```json theme={null}
{
  "repo": "owner/repo",
  "checked_at": "2026-11-01T12:00:00Z",
  "workflows": [
    {
      "path": ".github/workflows/ci.yml",
      "owned": true,
      "github_state": "active",
      "drift": true,
      "since": "2026-11-01T11:30:00Z"
    },
    {
      "path": ".github/workflows/release.yml",
      "owned": false,
      "github_state": "active",
      "drift": false
    }
  ]
}
```

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:

```json theme={null}
{
  "repo": "owner/repo",
  "checked_at": "2026-11-01T12:00:00Z",
  "unknown": true,
  "error": "GET … returned 502: …",
  "workflows": [
    { "path": ".github/workflows/ci.yml", "owned": true, "github_state": "unknown", "drift": false }
  ]
}
```

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

```toml theme={null}
# owner/repo → the workflow paths preloop owns in that repository.
[owned_workflows]
"owner/repo" = [".github/workflows/ci.yml", ".github/workflows/lint.yaml"]

# How often the drift watcher asks GitHub for each recorded repository.
# Default 900 seconds; 0 disables the watcher.
owned_workflows_drift_interval_secs = 900
```

`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.


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