Skip to content

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.envauth.keychainauth.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:

settings := SettingsFromConfig(ep, cfg)

…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:

cfg := forgeConfig{v: store.View()}

provider, err := factory(ctx, ep, cfg)

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 = forge.StaticCredential(tokenFromMyLoginFlow)
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:

fmt.Println(store.View().Explain("github.auth.value"))

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