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. Through the registry, hand it to the factory as an option (go/forge v0.30.0 and later; the adapters from forge-github v0.25.0, forge-gitlab v0.24.0, forge-gitea v0.25.0 and forge-bitbucket v0.16.0 honour it):

forgeCfg := vcs.ConfigFromReader(cfg) // the ROOT configuration
endpoint := forge.Endpoint{Type: "github", Host: "github.com"}

provider, err := factory(ctx, endpoint, forgeCfg,
    vcs.CredentialOptions(endpoint, forgeCfg, "GITHUB_TOKEN")...)

A factory given a source consults it and nothing else, so the construction context bounds the resolution and an error from the chain fails construction with its reason. Bitbucket authenticates with two halves, and the helper hands the factory both (forge.WithUsername and forge.WithCredential, go/forge v0.31.0 and forge-bitbucket v0.17.0): the username and the app password each walk their own env-reference and literal keys and the one keychain entry that holds both, read once, so the shipped username.env and app_password.env defaults resolve the way every other forge's auth.env does. Building typed settings directly, set the field instead:

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

The view no longer disguises the chain

Between the fix for the bare-CI-image failure (#76) and go/forge v0.30.0, ConfigFromReader resolved the chain into auth.value and showed auth.env and auth.keychain as unset, so a factory composing from configuration would not report GTB's shipped default as stale. That resolution ran without the caller's context and turned a refused keychain into an empty credential. With the option in place the view reads every key as configured. A tool that handed the view to a factory without the option relied on the disguise, and gets the stale-key report back until it passes vcs.CredentialOptions.

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. (That field has since become props.Tool.Config.Layers, whose order is the precedence; see the config stack.)

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.