Skip to content

Authentication

internal/github/auth.go resolves the GitHub credential every request in the package authenticates with. It is the only thing in labelsync that github.com/cli/go-gh is a dependency for — everything else goes through go-github.

The chain

Four sources, in a fixed order, first non-empty wins:

#SourceReported asWhere it comes from
1--token--tokenThe flag, discouraged — see below
2GH_TOKEN, then GITHUB_TOKENthe variable’s own nameThe environment
3The gh config filegh configgo-gh’s auth.TokenForHost("github.com")
4gh auth tokengh auth tokenShelling out to the gh binary

GH_TOKEN and GITHUB_TOKEN are two sources rather than one because they are two variables. Telling a user with both exported that “an environment variable won” leaves them no better off than before.

A source that is present but carries nothing is walked past rather than resolved to: a variable exported as the empty string, a variable set to whitespace, and gh auth token printing only its trailing newline are all the same absence. The winner is trimmed, so a stray newline never reaches an Authorization header.

When every source is empty, the run fails with ErrNoTokenerror_kind: no_token — and a message naming all four, because a user who has just been told “no GitHub token found” needs to be told what would count as one.

Why step 4 is not redundant

It looks like dead code sitting behind step 3, and it is not.

Modern gh stores its token in the system keychain by default on macOS, and a keychain token is not written to hosts.yml. go-gh’s config reader therefore returns nothing for a user who is perfectly well logged in. gh itself knows how to read its own keychain entry, and asking it is the only way to reach that token.

Neither step subsumes the other:

  • Step 3 without step 4 misses the developer laptop, where the token is in the keychain.
  • Step 4 without step 3 misses the CI image with a hosts.yml baked in and no gh on PATH.

The reasoning is repeated as a comment on ghAuthToken for the same reason it is here: without it, the function reads as something to delete.

A broken source is not a broken run

A step that fails does not end the walk. gh not being installed is the ordinary case on a CI runner, not something worth reporting to someone who set GITHUB_TOKEN anyway. Failures are logged at debug level and the chain continues, so the only two outcomes a caller ever sees are a token or ErrNoToken.

Tokens are redacted at the type

Token carries the credential and the source that produced it, and it implements both fmt.Stringer and slog.LogValuer to render as token from <source>:

type Token struct {
    Value  string
    Source TokenSource
}

Those are the two routes a struct normally takes to an output stream, and both are closed. The guarantee lives on the type rather than in the discipline of every call site, because a call site that gets it right today says nothing about the one added tomorrow — and a credential written to a CI log is not recoverable by editing the log.

The same rule is why --token is absent from the root command’s flags resolved debug line, which logs token_set instead. --debug is exactly the situation where someone is about to paste output into an issue.

Why --token is discouraged

The flag exists because a script sometimes has nowhere else to put a credential, and its help text says what it costs: a token on the command line is in the shell history and in every process list on the machine. GH_TOKEN is the same convenience without either.

What CI resolves to, and what comes after it

The token GitHub Actions injects into a workflow is scoped to the repository the workflow runs in, so it can write labels to exactly one repository — which is the one thing this tool is not for. Step 2 of the chain is what CI uses instead: a personal access token in a secret, exported as GH_TOKEN. GH_TOKEN and not GITHUB_TOKEN, because it is read first, so a workflow that has both cannot silently resolve to the useless one.

This repository’s own .github/workflows/labels.yml does exactly that, with the PAT in secrets.LABELSYNC_TOKEN.

The cost is rotation, and it is a real one. A fine-grained PAT expires — a year at most, and GitHub’s default is far less — and when it does, every scheduled run fails with no_token until somebody notices and mints a new one. Nothing in the chain warns as the date approaches, because nothing in the chain can see it: a token is an opaque string until a request is made with it. A calendar reminder is the whole mitigation.

A GitHub App installation token is the answer to that: minted per run, so there is no expiry to diarise, with a higher rate limit and an install scoped to selected repositories rather than to everything the human who owns the PAT can reach. It is deliberately not built yet — the PAT works today with no code at all — and the reason it can wait is structural. An App token would be a fifth step in the same Resolver, or a value handed to step 1’s field, and either way it resolves to the same Token and every call site keeps asking the same question. The chain is the seam that makes this a later decision instead of a migration.

Testing

The Resolver’s three seams — LookupEnv, ConfigToken, CLIToken — are function fields, nil in production and stubbed in tests. Precedence cases populate every source below the expected winner, so a passing case proves the ordering rather than merely proving the one populated source was found.

ghAuthToken itself is covered against a fake gh shell script on a PATH built for the test, so the suite says the same thing on a laptop with a gh login as on a runner without one.


The design record for the resolution chain, and the CI options it leaves open, is design.md § Authentication.