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_route is a Skarbiec route resource, not a vault coordinate. A coordinate such as GITHUB_TOKEN#value is refused, because it would put the hardcoded id back under a new name. So are the provider:, agent: and login: 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_permission is what GitHub must allow that identity to do, in GitHub's own vocabulary.
  • reality_check is the endpoint that confronts the declaration with GitHub. {organization} names the runner organization's identity, and {repository} is replaced by reality_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.

  • 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