Change a repository's GitHub relationship
POST/api/repos/{owner}/{repo}/github-relationship
updateGithubRelationship
Moves the repository's relationship under a **compare-and-swap**: `expected_mode` and `expected_authority_epoch` must both match the stored row, or the request is refused with `409 relationship_conflict` and nothing is written. `expected_mode: null` with `expected_authority_epoch: 0` is how a caller says "this repository has no relationship yet" — which is a different precondition from a row that happens to sit at epoch 0. The demotion re-checks the precondition inside its own write, so a demotion racing another demotion is refused rather than lost. The arming edges check it against the row the request read — which is the whole guarantee this build makes, and enough of one, because `neosource_readonly_mirror` is the only relationship any writer in this build can establish, so an arm has nothing to race. Requires repository **admin**, and — for the arming edges only — a linked GitHub account with a stored access token, since that is the credential the metadata backfill and the recurring polls run on. The demotion never requires one: it is the kill switch, and a repository whose importer lost their GitHub connection is exactly the case that needs it. Omitting `expected_authority_epoch` is refused by body validation rather than read as an implicit `0`: a defaulted precondition is a compare-and-swap that silently is not one. A negative value is `400 invalid_input`. Three edges are always admitted, all of them landing on `neosource_readonly_mirror`: arming the relationship on a repository that has none, an idempotent re-arm (which does **not** move the epoch, leaves the existing importer in place, and doubles as the recovery surface for a sync parked in `error`), and the demotion of a `github_managed` repository — ADR 0115's "stop accepting writes immediately" kill switch, an edge that bumps `authority_epoch` and so invalidates any provider lease registered under the old one. On an **attached** repository that keeps its own issues (`metadata_mode: off`) the kill switch does not demote — a mirror would pull GitHub's issues over the native ones — it **pauses** instead: the relationship stays `github_managed` at the same `authority_epoch`, with `write_health: paused`, `provider_admission_open: false` and the git pull disarmed, and pushes are refused with a "GitHub sync is paused" error. `POST …/github-detach` turns sync off for good. Arming never discards local work: on a repository whose branches hold commits no upstream branch or tag is proven to contain (a local-only, ahead or diverged branch), or that holds an annotated tag the upstream does not have, the first git pull is refused and nothing moves — `git_last_error` then starts with `github_sync_would_discard_refs`, gives the exact count and names each ref with its local and upstream tip, in refname order (up to 500 refs or 64 KiB). To sync, fetch the repository from neosource and push each listed tip to GitHub — a branch under any branch name (`git push <github-remote> <local-oid>:refs/heads/neosource-rescue/<name>`), a tag as a tag (`git push <github-remote> <local-oid>:refs/tags/<name>`) — then re-arm; or turn on GitHub sync (`POST …/github-attach`), which reconciles them. A deployment that **offers** the `github_managed` relationship admits one more: an idempotent re-assert of a repository already in it (which does **not** move the epoch, so an outstanding provider lease survives it), and only for a repository carrying a GitHub App installation whose grant is neither revoked nor too narrow and a `write_health` that is not `lfs_blocked` — one that fails either is refused with `409 relationship_conflict` and a message naming the state to fix. Every other edge **into** `github_managed` (no relationship row, an armed `neosource_readonly_mirror`, a detached `neosource_managed` repository) is refused there with `409 relationship_conflict` pointing at `POST /api/repos/{owner}/{repo}/github-attach`, which previews and reconciles every ref before GitHub takes authority. A deployment that does not offer the relationship refuses every edge into it with `409 relationship_unavailable`. Everything else is `409 relationship_unavailable`, which is **never** retryable: `neosource_managed` and `github_backup` belong to later phases, and a repository this deployment is already authoritative for never goes back to GitHub through this route. No `Idempotency-Key`: the compare-and-swap is the idempotency, since a replay carries a now-stale epoch.
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-relationship' \
-H 'Authorization: Bearer $NEOSOURCE_TOKEN' \
-H 'Content-Type: application/json' \
-d '{"expected_authority_epoch":0,"mode":"string"}'fetch
fetch("https://neosource.dev/api/repos/OWNER/REPO/github-relationship", {
method: "POST",
headers: {
Authorization: "Bearer $NEOSOURCE_TOKEN",
"Content-Type": "application/json",
},
body: JSON.stringify({"expected_authority_epoch":0,"mode":"string"}),
});Path parameters
ownerrequiredRepository owner or organization slug.
reporequiredRepository name.
Request bodyrequired
application/json
Responses
200The repository's relationship, after the change
application/json
Standard errors
Bodies documented once for the whole API — see standard errors.
- 400Bad Request — one of: invalid_input
- 401Authentication required
- 403Forbidden — one of: forbidden, github_app_install_required
- 404Not Found — one of: not_found
- 409Conflict — one of: conflict, relationship_conflict, relationship_unavailable
- 429Rate limited — retry after the `Retry-After` header
- 500Internal server error
- 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.
GithubAttachOrigin
Who started an attach — `github_attach_runs.origin`.
"import""user"
GithubAttachRunState
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"
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`].
The GitHub-sync offering is not enabled for this repository.
kindrequired"feature_not_offered"
No App installation is bound to the repository.
kindrequired"no_app_binding"
[`GithubAppBindingHealth::Unverified`], or `RulesetUnverified` with no refusing audit outcome recorded (awaiting a (re-)audit).
kindrequired"binding_unverified"
[`GithubAppBindingHealth::Error`]: the binding probe failed.
kindrequired"binding_error"
[`GithubAppBindingHealth::Revoked`].
kindrequired"binding_revoked"
[`GithubAppBindingHealth::InsufficientScope`].
kindrequired"binding_insufficient_scope"
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"ruleset_audit"outcomerequired
[`RulesetAuditOutcome::ProtectionEventsNotSubscribed`], with the webhook events the installation has not accepted (from the audit's detail; display only).
kindrequired"protection_events_not_subscribed"missing_eventsrequired- items
[`RulesetAuditOutcome::ClassicProtectionUnreadable`] on an installation without `administration: read`.
kindrequired"administration_read_missing"
The write health refuses managed writes on its own ([`GithubWriteHealth::refuses_managed_writes`]).
healthrequiredkindrequired"writes_refused"
[`LfsVerificationResult::PointersDetected`] over the post-attach ref set, published refs included.
kindrequired"lfs_pointers_detected"
[`LfsVerificationResult::Unverified`], or no verification at all.
kindrequired"lfs_unverified"
The GitHub repository is archived.
kindrequired"github_archived"
GitHub advertises `refname` inside the preserved namespace.
kindrequired"github_advertises_preserved_prefix"refnamerequired
This repository already holds `refname` inside the preserved namespace.
kindrequired"local_ref_under_preserved_prefix"refnamerequired
No preserved name within [`MAX_REFNAME_BYTES`] can be formed for `refname`.
kindrequired"preserved_name_too_long"refnamerequired
A GitHub ref intent is not terminal (an `ambiguous` one included).
kindrequired"unsettled_ref_intent"
Another attach run for this repository is not settled.
kindrequired"unsettled_attach"
GithubSyncFix
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"