Skip to content

Migration: forge credential precedence is now GTB's

go/forge v0.8.0 stopped resolving auth.env and auth.keychain itself. Credential ordering moved out of the module and into whatever configures it — for GTB-based tools, that is GTB.

If you use GTB's own forge wiring, nothing changes. github.auth.env, github.auth.keychain and github.auth.value resolve exactly as they did, in the same order, and every config file that worked before still works. This note exists for tools that reach past GTB and build forge settings directly.

What changed

GTB now composes the chain in pkg/vcs, and it is the single place the order is stated:

# Rung Config holds
1 <forge>.auth.env The name of an environment variable
2 <forge>.auth.keychain A service/account keychain reference
3 <forge>.auth.value The credential itself
4 Well-known fallback e.g. GITHUB_TOKEN
// Compose the chain for one forge over an already-scoped config subtree.
sub := vcs.ConfigFromReader(cfg).Sub("github")
credential := vcs.ForgeCredential(sub, "GITHUB_TOKEN")

token, err := credential(ctx) // nothing is read until this call

If you build forge settings yourself

Before — the module resolved the chain, so a bare config was enough:

provider, err := factory(ctx, forge.ReleaseSourceConfig{Host: "github.com"}, cfg)

After — supply the chain explicitly, or get only whatever the adapter's own fallback env var provides:

settings.Credential = vcs.ForgeCredential(
    vcs.ConfigFromReader(cfg).Sub("github"), "GITHUB_TOKEN")

Do not reach for forge.ConfigCredential

The module offers forge.ConfigCredential as a config-backed source. GTB deliberately does not use it, and neither should a GTB-based tool.

Its stale-key report exempts only the single key it was pointed at, and probes the relative auth.env and auth.keychain — precisely the keys GTB ships defaults for. Pointed at auth.value while auth.env is set, it reports a working configuration as stale. TestConfigCredentialIsNotUsed guards its absence from this repository.

Two behaviours worth knowing

Resolution is lazy. Nothing is read, and no keychain is touched, until the returned source is called. This is load-bearing rather than incidental: a repository authenticating over SSH must never trigger an OS unlock prompt for a token it does not need.

That is why the keychain rung is composed as a caller-supplied CredentialSource rather than reached through the config stack. A config.Backend contributes its values from Load, which runs when the store is built — so a keychain-backed backend would put an OS unlock prompt on the startup path of every command, including --help. The framework's default layer set (props.DefaultConfigLayers) is defaults, files, project, env, flags, with no keychain among them, and a tool declaring its own set via props.Tool.ConfigLayers chooses from the same list.

The fallback rung is shadowed for shipped forges. Every forge's embedded bundle sets <forge>.auth.env: <FORGE>_TOKEN as a default, so rung 1 already reads the variable rung 4 would. Exporting GITHUB_TOKEN and configuring nothing resolves through auth.env. Same variable, same value, same outcome — but a diagnostic will name auth.env, not the fallback.

Checking a tool after upgrading

doctor now reports resolution as well as storage, naming the winning rung and never the value:

$ mytool doctor
  [OK] GitHub credential: resolves from auth.env
  [!!] GitLab credential: credential configured but does not resolve

A [!!] here means a credential is configured but cannot be read — a malformed keychain reference, most often. Before this check, that state was indistinguishable from having no credential at all until something tried to authenticate.