On this page
The runner capability's GitHub identity: declared, not named
Every stado runner lifecycle talks to GitHub as one identity. Which credential
that is used to be compiled into the binary: GITHUB_CREDENTIAL_ITEM in
stado-rs/src/deploy/host_precheck_runner.rs was the literal string
"GITHUB_TOKEN", so replacing the identity meant editing Rust and shipping a
release. It is now a declaration that names a Skarbiec route, and Skarbiec
answers which vault item and field that route reaches.
The lifecycle and diagnostic share one route resolver. A successful diagnostic does not authorize unrelated GitHub endpoints: each operation still receives GitHub's own permission verdict.
The declaration
stado-rs/data/github-identity.json declares the route and diagnostic:
{
"schema": "stado.github-identity.v1",
"credential_route": "github:org-runner-admin",
"required_permission": "repo",
"reality_check": "/repos/{organization}/{repository}/actions/runners",
"reality_check_repository": "stado"
}
credential_routeis a Skarbiec route resource, not a vault coordinate. A coordinate such asGITHUB_TOKEN#valueis refused, because it would put the hardcoded id back under a new name. So are theprovider:,agent:andlogin:prefixes: Skarbiec resolves those from what a vault item declares about itself, and no item can declare that it speaks for a GitHub organization role.required_permissionis what GitHub must allow that identity to do, in GitHub's own vocabulary.reality_checkis the endpoint that confronts the declaration with GitHub.{organization}names the runner organization's identity, and{repository}is replaced byreality_check_repository. The shipped check reads the Stado repository's runners; it does not claim organization-runner administration.
Nothing else in Stado names the credential. Rename the vault item, move it to a different field, or replace the identity entirely, and Stado follows without a code change — as long as the route still answers.
Declaring the route
Skarbiec owns the answer. State it once:
skarbiec routes add \
--resource github:org-runner-admin \
--item <item> \
--field <field> \
--reason "Stado resolves the fleet GitHub identity through this route"
skarbiec routes list reports the route table and whether each item and field
is present. Stado reads POST /v1/operator/routes/list and selects the exact
declared resource; it never searches historical tokens or changes the route
implicitly. See
Skarbiec and Stado for the read path, and Skarbiec's own
credential model for what a
route may and may not be.
Reading it back
stado runner credential [--json]
Two calls, deliberately separate. Resolution asks Skarbiec's operator route
which coordinate the name reaches and carries no value, which is why the report
may print it. The read is Stado's ordinary one-field credential read of exactly
that coordinate, through the caller's own consumer grant. The value is used and
never printed: no path puts it in argv, a log, a report, or a refusal.
The report names the route, the coordinate that answered, which declaration asked for it, the endpoint it was confronted with, GitHub's status and message, and both permission sets GitHub named:
route github:org-runner-admin
coordinate GITHUB_TOKEN.value
declared by stado-rs/data/github-identity.json
check https://api.github.com/repos/wisent-ai/stado/actions/runners
status 200
requires repo
grants delete_repo, gist, read:org, repo, workflow
The command exits non-zero when GitHub refuses the identity, because a route that resolves proves only that a credential exists. What makes the declaration true is GitHub accepting that identity on the exact endpoint the runner lifecycle needs.
Refusals
| Refusal | What it means |
|---|---|
Skarbiec answers no credential for the declared GitHub route "<route>" |
Nothing declares the route. The refusal names the route, the declaration that asked for it, and the skarbiec routes add command. |
... does not resolve to a readable field |
The route names an item or field the vault does not carry, or one this host cannot open. Skarbiec's own problem sentence travels with it. |
the credential the GitHub route "<route>" names, <item>.<field>, is empty |
The coordinate resolved and the read returned nothing. |
... is not allowed on <endpoint> |
GitHub refused that identity. The refusal names the permissions it grants, the permission the endpoint answers, and how to repoint the route. |
Every one names the route rather than the item, because the route is the thing an operator can change.
What organization-scoped registration needs
Two different GitHub rights are involved, and conflating them has cost this fleet several re-diagnoses.
| Operation | Endpoint | Needs |
|---|---|---|
| Register an organization-wide runner | POST /orgs/{org}/actions/runners/registration-token |
admin:org |
| Register a runner for one repository | POST /repos/{org}/{repo}/actions/runners/registration-token |
repository admin, which the repo scope carries |
| List or change runner groups | GET/PATCH /orgs/{org}/actions/runner-groups |
admin:org |
An identity that is an organization admin by membership is not the same as a
token carrying admin:org. GitHub answers the difference plainly: the
organization endpoints return HTTP 403 You must be an org admin or have the runners and runner groups fine-grained permission with
x-accepted-oauth-scopes: admin:org, while
GET /orgs/{org}/memberships/{user} for the same identity returns
{"role": "admin", "state": "active"}. The diagnostic now checks repository
runner access by default. Repository registration remains available with
--repository <NAME> without broadening the token's permissions; an operation
that explicitly requests organization scope is checked separately.
A gap this capability has, stated rather than hidden
The runner capability replaced stado host precheck-runner repository-add, and
took every runner-groups call with it. Nothing took its place. Today no Stado
command can say which repositories may schedule on a managed runner group, or
turn on public-repository access for one: the group is passed to the runner's
own registration as ACTIONS_RUNNER_INPUT_RUNNERGROUP and never read back.
That is a capability the fleet lost rather than moved, which is the thing
removal exists to prevent — a replacement takes its
predecessor with it, it does not drop a capability silently. Carrying it back
belongs in the runner declaration rather than in a new verb: a profile would
have to declare which repositories its github_runner_group admits and whether
that group allows public repositories, and stado runner install would
reconcile that declaration against
GET/PATCH /orgs/{org}/actions/runner-groups and
PUT /orgs/{org}/actions/runner-groups/{id}/repositories/{id} — the three calls
that were deleted. Until it does, a repository whose jobs sit queued against a
selected-access group can only be admitted from GitHub's own settings, and only
by an identity carrying admin:org.
Related pages
- Runner — the declared profiles and their lifecycle.
- Skarbiec and Stado — how one field is read without printing it.
- Removal — why a replacement takes its predecessor with it.
- Security — what each Stado bearer opens.
Source: this website