Migrate from ResolveToken¶
forge.ResolveToken and forge.ResolveTokenContext were removed in v0.9.0.
If you are here because a build broke on one of them, or because you are about to
write a helper that walks auth.env → auth.keychain → auth.value → a
well-known variable: stop. That ladder still exists, and you do not write it.
Do not reimplement the ladder
The single most common migration mistake is porting the four rungs into the
consumer as a private resolveToken helper. It compiles, it passes tests,
and it reintroduces exactly the problem the removal was made to fix: a
second precedence system running inside whatever precedence your config
stack already applies. Two precedence systems in one path is how
configuration becomes unpredictable.
The short answer¶
Each forge adapter already composes its own ladder, scoped to its own section, with its own well-known variable. You hand the registry factory your root config and it does the rest:
factory, err := forge.Lookup(forge.SourceTypeGitHub)
if err != nil {
return err
}
provider, err := factory(ctx, ep, cfg) // cfg is your ROOT config, not a subtree
forge-github's factory is six lines, and the whole per-forge ladder is in them:
…which composes:
forge.FirstCredential(
forge.ConfigCredential(cfg, forge.DefaultAuthKey), // github.auth.value
forge.EnvCredential(DefaultTokenEnv), // GITHUB_TOKEN
)
Register three adapters and you have three ladders, each reading its own section and its own variable, without writing a line of resolution code.
| Adapter | Section | Config key | Well-known variable |
|---|---|---|---|
forge-github |
github |
github.auth.value |
GITHUB_TOKEN |
forge-gitlab |
gitlab |
gitlab.auth.value |
GITLAB_TOKEN |
forge-gitea |
gitea |
gitea.auth.value |
GITEA_TOKEN |
forge-gitea |
codeberg |
codeberg.auth.value |
CODEBERG_TOKEN |
forge-bitbucket |
bitbucket |
bitbucket.username / bitbucket.app_password |
BITBUCKET_USERNAME / BITBUCKET_APP_PASSWORD |
Note that forge-gitea registers two source types reading separate
sections. Sharing one section meant a token stored for either forge was stored
for both, which is why gitea and codeberg are independent.
What you actually build: one config store¶
The rungs that disappeared were config values naming where a credential lives. They become layers in your store, declared once for the whole application rather than per forge:
home, err := os.UserHomeDir()
if err != nil {
return err
}
store, err := config.NewStore(ctx,
// Lowest precedence: files on disk. Paths are used verbatim — `go/config`
// does no tilde expansion, so a literal "~/..." silently matches nothing.
config.WithFiles(config.OS(),
"/etc/mytool/config.yaml",
filepath.Join(home, ".mytool", "config.yaml"),
),
// Secrets above files. This is where `auth.keychain` went.
config.WithBackend(configkeychain.New(
configkeychain.Registered(), "mytool", map[string]string{
"github.auth.value": "github-token",
"gitlab.auth.value": "gitlab-token",
})),
// Highest precedence: the environment. This is where `auth.env` went.
config.WithEnv("MYTOOL"),
)
if err != nil {
return err
}
Later backends win. The ordering is stated once, in one place, and it governs every forge and every other setting the tool reads, not just credentials.
Every declared keychain entry is read at NewStore
config-keychain cannot enumerate a keychain, so it declares its key map up
front and reads all of them eagerly, before any GetString. Six declared
accounts means six keychain reads at start-up whether or not this run uses
them, which on a locked keychain means six unlock prompts. Declare only what
the process needs, or split the stores.
Bridging go/config to forge.Config¶
forge.Config is deliberately a two-method interface so forge never depends on
a particular config library. The bridge is six lines, written once:
type forgeConfig struct{ v *config.View }
func (c forgeConfig) GetString(key string) string { return c.v.GetString(key) }
func (c forgeConfig) Sub(key string) forge.Config { return forgeConfig{v: c.v.Sub(key)} }
Then hand the root view in:
Passing a subtree here is the second-most-common mistake. The adapter calls
Sub itself; give it store.View().Sub("github") and it will look for
github.github.auth.value and quietly find nothing.
A complete multi-forge example¶
package main
import (
"context"
"gitlab.com/phpboyscout/go/config"
configkeychain "gitlab.com/phpboyscout/go/config-keychain"
"gitlab.com/phpboyscout/go/forge"
// Registering adapters is an import side effect.
_ "gitlab.com/phpboyscout/go/forge-gitea"
_ "gitlab.com/phpboyscout/go/forge-github"
_ "gitlab.com/phpboyscout/go/forge-gitlab"
)
type forgeConfig struct{ v *config.View }
func (c forgeConfig) GetString(key string) string { return c.v.GetString(key) }
func (c forgeConfig) Sub(key string) forge.Config { return forgeConfig{v: c.v.Sub(key)} }
func providerFor(ctx context.Context, store *config.Store, ep forge.Endpoint) (forge.Provider, error) {
factory, err := forge.Lookup(ep.Type)
if err != nil {
return nil, err
}
return factory(ctx, ep, forgeConfig{v: store.View()})
}
That is the whole integration. Every forge gets its own section, its own key and its own fallback variable, and none of it is resolution code you maintain.
The matching configuration:
github:
auth:
value: <token> # or MYTOOL_GITHUB_AUTH_VALUE, or the keychain layer
gitlab:
url:
api: https://gitlab.example.com
auth:
value: <token>
codeberg:
auth:
value: <token>
Where each rung went¶
| Was | Becomes |
|---|---|
auth.value: <token> |
unchanged, still the default key |
auth.env: FOO |
an env layer (config.WithEnv), or forge.EnvCredential("FOO") |
auth.keychain: svc/acct |
a config-keychain layer |
fallbackEnv argument |
composed by the adapter's factory; override with Settings.Credential |
ResolveToken(cfg, env) |
nothing. The factory composes it |
ResolveTokenContext(ctx, cfg, env) |
nothing. The factory composes it, and the context is already threaded |
Configuration still carrying auth.env or auth.keychain reports
forge.ErrStaleAuthKeys, but only when nothing else supplied a credential, so a
stale key beside a working variable stays quiet rather than failing a working
setup.
When you genuinely do need your own source¶
Composing the ladder yourself is right when the credential does not come from config at all: a device-flow login, a Vault read, a token you already hold:
settings.Credential = func(ctx context.Context) (string, error) {
secret, err := myVault.Read(ctx, "secret/data/gitlab")
if err != nil {
return "", err
}
return secret.Token, nil
}
Setting Settings.Credential replaces the adapter's composition entirely, which
is the supported way to take over. That is different from rebuilding the ladder
beside it.
Checking what resolved¶
When a token is not what you expected, ask the store rather than guessing:
Explain names the layer a value came from. That is the thing a resolution chain
inside forge could never tell you, and the reason the ordering moved out of this
module in the first place.
See also¶
- Authenticate to a forge: the full
CredentialSourcecontract - Credential pinning: why a token is bound to the host it authenticated against