Docs
Search docs

Turn GitHub sync off, or move a read-only mirror to neosource

POST/api/repos/{owner}/{repo}/github-detach

detachGithub

Detaches a GitHub-synced repository (`github_managed`) or a read-only mirror of GitHub (`neosource_readonly_mirror`, "Move to neosource", ADR 0166): its relationship becomes `neosource_managed` with `authority_handoff: detached`, the pull from GitHub stops (a pull already in flight may finish once, never over a newer push), and this deployment is authoritative from here on. **Nothing is written to GitHub**, and commits pushed there after the last pull stay only there. Any GitHub App binding, every branch and tag, every preserved ref and the repository's issues are kept; the response says what was kept. `expected_authority_epoch` must be the relationship's current epoch, else `409 relationship_conflict`. Refused with `409 relationship_conflict` while a push to GitHub has not settled — the message names the ref transaction to resolve or cancel first — while an attach holds the relationship (for a mirror: while any attach is in flight; cancel it first), or when the repository is neither synced nor mirrored. Requires repository **admin** from a **signed-in session** or an access token with no workspace or repository lock (ADR 0162); a locked token is refused with `403 forbidden`.

Requires authentication using a bearer token or a session cookie — see tokens and scopes.

curl

curl -X POST 'https://neosource.dev/api/repos/OWNER/REPO/github-detach' \
  -H 'Authorization: Bearer $NEOSOURCE_TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{"expected_authority_epoch":0}'

fetch

fetch("https://neosource.dev/api/repos/OWNER/REPO/github-detach", {
  method: "POST",
  headers: {
    Authorization: "Bearer $NEOSOURCE_TOKEN",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({"expected_authority_epoch":0}),
});

Path parameters

ownerrequired

Repository owner or organization slug.

string

reporequired

Repository name.

string

Request bodyrequired

application/json

DetachGithubRequest

object

Body of `POST /api/repos/{owner}/{repo}/github-detach`.

expected_authority_epochrequired

integerint64

The relationship's `authority_epoch` the caller read. Required.

additional propertiesnot allowed

Responses

200Detached

application/json

GithubDetachResponse

object

`200` answer of `POST …/github-detach`.

app_binding_keptrequired

boolean

The repository's GitHub App binding exists and was not touched.

messagerequired

string

What happened, in one sentence.

preserved_refs_keptrequired

integerint32

Preserved refs the repository still holds.

relationshiprequired

GithubRelationshipResponse

object

A repository's GitHub relationship (ADR 0115 Phase 4a) — the response body of both `GET` and `POST /api/repos/{owner}/{repo}/github-relationship`. Every field comes from a column `GithubRepoSyncRecord` already selects (or, for `github_is_authority` and `upstream_url`, is derived from one), plus the one `github_meta_jobs` lookup the legacy status route already makes: no new store read, and no field that is structurally always `None`. `issues_done` / `issues_total` are carried deliberately even though they are backfill progress rather than relationship state. With them this response **strictly supersedes** [`GithubSyncStatusResponse`], which is the precondition for Phase 4c being a deletion of the legacy pair rather than a redesign of this one. `git_last_error` keeps the pull's own error text (#1092 documents the `github_sync_would_discard_refs` refusal it carries). Reserved to later phases and deliberately ABSENT — each would be a field that is always its default until the phase that fills it lands, and a client cannot tell "not shipped" from "nothing to report": `paused_refs` (8), the LFS digest / checked-at / scanner version (5 — `write_health` already carries that verdict), `native_actions_mode` (7), `cutover_state` (9), and the App's permissions and health, which `GET …/github-installation` already serves — one statement per fact.

attach
authority_epochrequired

integerint64

Authority generation. The `expected_authority_epoch` a subsequent `POST` must echo back, and what invalidates a provider projection lease registered under an older one.

authority_handoff
blockersrequired

array

Everything that currently stops GitHub sync for this repository, in a stable order, each with the fix the panel renders. Empty means nothing at the repository level stops it.

full_namerequired

string

GitHub `owner/name` at last observation.

git_consecutive_failures

integer | nullint32

Ticks that have failed in a row; `0` after any success. At `GITHUB_SYNC_MAX_CONSECUTIVE_FAILURES` (10) the row is **parked**: the poller's claim no longer returns it, `git_next_run_at` keeps its (past) timestamp, and only a re-arm — the relationship edge, the metadata-sync toggle, the import-complete hook — resets the budget. Below that it is lagging, not parked: the next attempt is at `git_next_run_at`, on the shared exponential backoff. `null` when the relationship carries no pull-side status.

git_last_error

string | null

Why the most recent tick failed, if it did; cleared by the next success. Never carries a credential. A message starting with `github_sync_would_discard_refs` is the first pull refusing to overwrite or delete local branches or tags no upstream branch or tag is proven to contain; it lists each ref and the remedy, and nothing moved.

git_last_pulled_at

integer | nullint64

Millis since the Unix epoch of the last tick that completed — an unchanged upstream counts, so this is "last checked", not "last changed". `null` until the first tick.

git_next_run_at

integer | nullint64

When the git pull-back next looks at the upstream, millis since the Unix epoch. `null` = **not armed**: nothing pulls this repository's refs from GitHub (the kill switch, and every row whose mode is neosource-authoritative). A timestamp in the past = due, waiting for a poller tick. Armed only on a GitHub-authoritative row — a `neosource_readonly_mirror`, which the pull follows, or a `github_managed` repository, which it observes — by the relationship edge into either mode, the metadata-sync toggle and the import-complete hook. A row that has exhausted its failure budget keeps its timestamp — see `git_consecutive_failures` for the parked state.

github_is_authorityrequired

boolean

Whether `mode` is one of the GitHub-authoritative pair. Derived from `mode` and sent anyway, so the SPA never restates the rule — the two would then be two statements of one fact with nothing comparing them.

issues_donerequired

integerint32

Issues upserted by the latest backfill run; `0` when none has run.

issues_totalrequired

integerint32

Upper bound for the backfill progress bar; `0` when unknown.

last_error

string | null

Most recent sync/backfill error, if any.

last_synced_at

integer | nullint64

Millis since the Unix epoch of the last successful poll/page.

metadata_moderequired
GithubMetadataMode
provider_admission_openrequired

boolean

Whether provider projections may be admitted at all. `false` is the default and the parked state.

sync_staterequired
GithubSyncState
upstream_urlrequired

string

The repository's page on github.com, so the SPA can link out without hard-coding the provider's host.

write_healthrequired
GithubWriteHealth

Standard errors

Bodies documented once for the whole API — see standard errors.

  • 400Bad Request — one of: invalid_input
  • 401Authentication required
  • 403Forbidden — one of: forbidden
  • 404Not Found — one of: not_found
  • 409Conflict — one of: attach_publish_unconfirmed, conflict, relationship_conflict, relationship_unavailable
  • 429Rate limited — retry after the `Retry-After` header
  • 500Internal server error
  • 502Bad Gateway — one of: upstream
  • 503Service temporarily unavailable / at capacity — retry after the `Retry-After` header
  • 504Gateway timeout — the request exceeded the server's handling budget

Schemas

Referenced above. Listed here rather than expanded inline, so the same definition is not repeated at every level.

GithubAttachSummary

object

The latest attach, as the relationship read summarises it.

created_atrequired

integerint64

Millis since the Unix epoch.

finished_at

integer | nullint64

When the run reached a terminal state; `null` while in flight.

last_error

string | null

originrequired

GithubAttachOrigin

string

Who started an attach — `github_attach_runs.origin`.

"import""user"

run_idrequired

string

staterequired

GithubAttachRunState

string

Lifecycle of one attach attempt — `github_attach_runs.state`. ```text planning ─► planned ─(confirm)─► frozen ─► publishing ─► applying ─► applied ▲ │ │ │ └── needs_reconfirm ◄──(undo)───┴───────────┘ (+ failed, cancelled, expired) ``` The six nonterminal states are exactly the partial unique index `github_attach_runs_one_nonterminal_per_repo`, so a repository has at most one attach in flight.

"planning""planned""frozen""publishing""applying""applied""needs_reconfirm""failed""cancelled""expired"

GithubAuthorityHandoff

string

How a `neosource_managed` relationship left GitHub's authority — the `github_repo_sync.authority_handoff` column. NULL on every other mode (a CHECK), and on `neosource_managed` rows that predate the column.

"detached""cut_over"

GithubSyncBlockerRow

One thing that stops GitHub sync: the blocker itself — its `kind` plus whatever detail that kind carries (`outcome`, `health`, `refname`, `missing_events`) — the stable `fix` code the panel renders, and a link to where it is fixed when there is one.

all of
  • GithubSyncBlocker

    Everything that stops GitHub sync, enabling or ongoing: closed and typed, so the GitHub panel can name a fix for each. Each variant mirrors an existing core outcome rather than restating it: the binding ones are [`GithubAppBindingHealth`] values, `RulesetAudit` / `ProtectionEventsNotSubscribed` / `AdministrationReadMissing` are [`RulesetAuditOutcome`] refusals, `WritesRefused` is [`GithubWriteHealth::refuses_managed_writes`], the LFS ones are [`LfsVerificationResult`] verdicts. An empty or unrelated upstream is not a blocker (D11), nor is a tombstone (D7). Serialized internally tagged by `kind`, which is also [`Self::kind`].

    one of
    • object

      The GitHub-sync offering is not enabled for this repository.

      kindrequired

      string

      "feature_not_offered"

    • object

      No App installation is bound to the repository.

      kindrequired

      string

      "no_app_binding"

    • object

      [`GithubAppBindingHealth::Unverified`], or `RulesetUnverified` with no refusing audit outcome recorded (awaiting a (re-)audit).

      kindrequired

      string

      "binding_unverified"

    • object

      [`GithubAppBindingHealth::Error`]: the binding probe failed.

      kindrequired

      string

      "binding_error"

    • object

      [`GithubAppBindingHealth::Revoked`].

      kindrequired

      string

      "binding_revoked"

    • object

      [`GithubAppBindingHealth::InsufficientScope`].

      kindrequired

      string

      "binding_insufficient_scope"

    • object

      The ruleset audit refused the binding for `outcome` ([`RulesetAuditOutcome::remedy`] is the fix). A struct variant because an internally tagged enum cannot carry a bare string newtype.

      kindrequired

      string

      "ruleset_audit"

      outcomerequired

      RulesetAuditOutcome

      string

      What the last ruleset audit of a binding found (`github_app_bindings.ruleset_audit_outcome`, migration `20260928120000_github_binding_ruleset_audit.sql`). [`Self::Clear`] is the one outcome that makes a binding `Healthy`; every other value records WHY it is `ruleset_unverified`. The audit fails closed: anything it cannot prove — a field GitHub did not report, a list it may not read, a rule whose ruleset it cannot see — is a refusal, never an assumed "the App cannot bypass" (`plans/github-managed-repositories-2026-09.md` Phase 2; evidence `docs/evidence/github-managed/provider-contract-2026-09-27-175844.md`).

      "clear""app_can_bypass""bypass_unknown""rulesets_unreadable""ruleset_hidden""too_many_rulesets""classic_protection_unreadable""app_in_classic_allowance""app_holds_administration_write""protection_events_not_subscribed"

    • object

      [`RulesetAuditOutcome::ProtectionEventsNotSubscribed`], with the webhook events the installation has not accepted (from the audit's detail; display only).

      kindrequired

      string

      "protection_events_not_subscribed"

      missing_eventsrequired

      array

      items

      string

    • object

      [`RulesetAuditOutcome::ClassicProtectionUnreadable`] on an installation without `administration: read`.

      kindrequired

      string

      "administration_read_missing"

    • object

      The write health refuses managed writes on its own ([`GithubWriteHealth::refuses_managed_writes`]).

      healthrequired

      GithubWriteHealth

      string

      Whether a GitHub-authoritative repository is currently safe to write through — the `github_repo_sync.write_health` column. Closed vocabulary; the tokens are the CHECK list at `migrations/20260901120000_github_relationship_expand.sql`. The default for a fresh relationship is [`Self::LfsUnverified`], not `Healthy`: a repo whose LFS posture nobody has looked at yet is not known-good, and the fail-closed direction is the one that does not push objects at an upstream that will reject or mangle them.

      "healthy""lfs_unverified""lfs_blocked""paused""error"

      kindrequired

      string

      "writes_refused"

    • object

      [`LfsVerificationResult::PointersDetected`] over the post-attach ref set, published refs included.

      kindrequired

      string

      "lfs_pointers_detected"

    • object

      [`LfsVerificationResult::Unverified`], or no verification at all.

      kindrequired

      string

      "lfs_unverified"

    • object

      The GitHub repository is archived.

      kindrequired

      string

      "github_archived"

    • object

      GitHub advertises `refname` inside the preserved namespace.

      kindrequired

      string

      "github_advertises_preserved_prefix"

      refnamerequired

      string

    • object

      This repository already holds `refname` inside the preserved namespace.

      kindrequired

      string

      "local_ref_under_preserved_prefix"

      refnamerequired

      string

    • object

      No preserved name within [`MAX_REFNAME_BYTES`] can be formed for `refname`.

      kindrequired

      string

      "preserved_name_too_long"

      refnamerequired

      string

    • object

      A GitHub ref intent is not terminal (an `ambiguous` one included).

      kindrequired

      string

      "unsettled_ref_intent"

    • object

      Another attach run for this repository is not settled.

      kindrequired

      string

      "unsettled_attach"

  • object

    fixrequired

    GithubSyncFix

    string

    The stable code the GitHub panel renders as the remedy for a [`GithubSyncBlocker`]. Closed, so the SPA's copy table is exhaustive; one code may answer several blockers ([`GithubSyncBlocker::fix`] is the map).

    "contact_operator""install_github_app""wait_for_binding_audit""recheck_github_app""reinstall_github_app""grant_github_app_permissions""fix_github_rulesets""accept_github_app_events""resume_sync""check_write_health""remove_lfs_pointers""recheck_lfs""unarchive_github_repository""remove_preserved_refs_on_github""rename_local_ref""resolve_ref_intent""settle_attach"

    fix_url

    string | null

    Where the fix is made: the GitHub App's install page for the App-side fixes, the GitHub repository's settings for an archived repository. `null` otherwise, and when this deployment has no App.

GithubMetadataMode

string

Whether GitHub's issues and pull requests are pulled into this repository — the `github_repo_sync.metadata_mode` column (`migrations/20260930170000_github_attach_runs.sql`). [`Self::Pulled`] is every relationship that predates the column and every read-only mirror (a CHECK). [`Self::Off`] is a repository attached to a GitHub repository while keeping its native issues (ADR 0136): its metadata is never claimed by the poller or the backfill.

"pulled""off"

GithubRelationshipMode

string

Which forge owns an imported repository's refs and provider-shaped data. The ONLY relationship vocabulary, in the database (`github_repo_sync.mode`), in Rust, on the wire and in the SPA. The legacy `synced` / `mirror_readonly` pair it replaced was collapsed onto [`Self::NeosourceReadonlyMirror`] by `migrations/20260902120000_github_relationship_contract.sql`.

"github_managed""neosource_readonly_mirror""neosource_managed""github_backup"

GithubSyncState

string

Lifecycle of a repo's metadata sync (`github_repo_sync.state`).

"backfilling""live""error"

GithubWriteHealth

string

Whether a GitHub-authoritative repository is currently safe to write through — the `github_repo_sync.write_health` column. Closed vocabulary; the tokens are the CHECK list at `migrations/20260901120000_github_relationship_expand.sql`. The default for a fresh relationship is [`Self::LfsUnverified`], not `Healthy`: a repo whose LFS posture nobody has looked at yet is not known-good, and the fail-closed direction is the one that does not push objects at an upstream that will reject or mangle them.

"healthy""lfs_unverified""lfs_blocked""paused""error"