forge

package module
v0.2.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 24, 2026 License: MIT Imports: 10 Imported by: 0

README

forge

Forge release operations for Go — a provider contract, registry and credential chain for GitHub, GitLab, Gitea, Codeberg, Bitbucket and plain download sources, with no vendor SDK in the core

Go Reference Pipeline Coverage phpboyscout Go toolkit

Part of the phpboyscout Go toolkit — small, framework-free Go modules extracted from go-tool-base. Docs: forge.go.phpboyscout.uk


Fetching a release from GitHub should not put a GitHub SDK in your dependency graph, and supporting GitLab as well should not mean a second code path.

factory, err := forge.Lookup(sourceType)
provider, err := factory(source, cfg)

rel, err := provider.GetLatestRelease(ctx, owner, repo)

Your code depends on forge.Provider, never on *github.Client. Switching forges is a one-line constructor change.

Design

  • No vendor SDK in the core. Every forge client lives behind a provider module boundary, so accepting a Provider — or authoring one — costs you nothing but this module. A depfootprint guard enforces it, and a second guard proves the contract package pulls in no HTTP stack either.
  • A registry, not a switch. Providers register themselves at init() under a plain string key, so a forge this module has never heard of ships as your module with nothing contributed here. The first-party providers use exactly the same path — a boundary nobody dogfoods is a boundary that rots.
  • One credential chain. Environment reference → OS keychain → literal → well-known fallback, configured identically whichever forge is behind the interface.
  • Credentials pinned to their host. Asset download URLs come from release metadata, which a release author controls; HostTrusted stops a hostile release exfiltrating your token, and refuses scheme downgrades that would put it on the wire in plaintext.
  • Capabilities are opt-in, not required. Checksum and signature retrieval are optional interfaces discovered by type assertion, so adding them never broke an existing implementation — including ones outside this repository.
  • A conformance harness. The compiler checks your method set; it cannot check that you return the right sentinel, never return (nil, nil), or actually enforce a size bound you accepted. RunProviderConformance does.

Install

go get gitlab.com/phpboyscout/go/forge

Providers are separate modules, enabled by a blank import, so you take only what you use:

import (
    _ "gitlab.com/phpboyscout/go/forge/direct"   // ships in this module
    _ "gitlab.com/phpboyscout/go/forge-gitlab"
)

Authoring a provider

Ten symbols, no framework, your own module:

func init() {
    err := forge.Register("mycorp-git", func(src forge.ReleaseSourceConfig, cfg forge.Config) (forge.Provider, error) {
        return New(SettingsFromConfig(src, cfg, "MYCORP_TOKEN"))
    })
    if err != nil {
        panic("myforge: " + err.Error())
    }
}

[!WARNING] Register returns ErrAlreadyRegistered on a duplicate source type rather than overwriting — silent overwriting would let one blank import displace another with no diagnostic, and initialisation order would pick the winner. A provider module panics at its own init, where the message names the module at fault; a programmatic caller handles the error instead.

The direct provider ships inside this module as the reference implementation: same credential path, same config layout, same conformance harness.

Documentation

Full guides: forge.go.phpboyscout.uk. API reference: pkg.go.dev.

License

See LICENSE.

Documentation

Index

Examples

Constants

View Source
const (
	SourceTypeGitHub    = "github"
	SourceTypeGitLab    = "gitlab"
	SourceTypeBitbucket = "bitbucket"
	SourceTypeGitea     = "gitea"
	SourceTypeCodeberg  = "codeberg"
	SourceTypeDirect    = "direct"
)

SourceType constants name the first-party release providers, as a convenience for callers configuring one.

They are NOT a closed set, and declaring one here does not register it: every provider except direct ships as its own module and registers itself when blank-imported. The registry accepts any string, so a provider this module has never heard of works exactly as well — which is the point. Downstream consumers may declare their own constants.

View Source
const (
	// DefaultMaxChecksumsSize bounds a checksums manifest. A GoReleaser
	// manifest for a typical multi-OS release is ~1 KiB, so 1 MiB is roughly
	// 1000x headroom while still capping a hostile stream.
	DefaultMaxChecksumsSize int64 = 1 << 20

	// DefaultMaxSignatureSize bounds a detached signature. An OpenPGP
	// signature over a manifest is a few hundred bytes; 1 MiB is generous.
	DefaultMaxSignatureSize int64 = 1 << 20
)

Size bounds for the optional capability interfaces.

These are DEFAULTS, not mandates. The caller passes the bound it wants to ChecksumProvider.DownloadChecksumManifest and SignatureProvider.DownloadSignature, and the provider enforces whatever it receives — so a tool shipping unusually large artefacts can raise the ceiling without every provider needing a configuration knob of its own.

The split is deliberate: the caller owns the *policy*, the provider owns the *enforcement*. Making these constants here and mandatory in the providers would move policy to the wrong side of the boundary.

Variables

View Source
var (
	// ErrProviderNotFound is returned by Lookup when no factory has been
	// registered for the requested source type.
	ErrProviderNotFound = errors.New("no release provider registered for source type")

	// ErrNotSupported is returned by provider methods that are not applicable
	// for the underlying platform (e.g. ListReleases on Bitbucket).
	ErrNotSupported = errors.New("operation not supported by this release provider")

	// ErrReleaseNotFound is returned when a requested release — by tag, or the
	// latest — does not exist.
	//
	// Not yet honoured uniformly: the forgetest double returns it, but the
	// first-party providers still surface their platform's own not-found error.
	// Treat it as the sentinel to implement in a NEW provider, and do not rely
	// on errors.Is(err, ErrReleaseNotFound) across the first-party set until
	// they are aligned.
	ErrReleaseNotFound = errors.New("release not found")
)
View Source
var ErrAlreadyRegistered = errors.New("a provider is already registered for this source type")

ErrAlreadyRegistered is returned by Register when a factory is already registered for the requested source type.

Functions

func HostTrusted

func HostTrusted(rawURL, trustedBaseURL string, opts ...TrustOption) bool

HostTrusted reports whether rawURL points at the same host as trustedBaseURL, and is safe to attach a credential to.

Why this exists

Release metadata is author-controlled. An asset's download URL comes back from the forge API as data, and on most platforms a release author can set it to any URL at all. A provider that attaches its API token to whatever URL the metadata names will happily send that token to an attacker's server: the victim only has to install a tool whose release was published, or edited, by someone hostile.

So a provider must pin the credential to the host it authenticated against. This is that check, and it fails closed — an unparseable URL, a missing host, or any mismatch returns false, because the cost of a false negative is an unauthenticated request that may 404, while the cost of a false positive is a leaked credential.

What counts as trusted

Both the host (including port) and the scheme must match:

  • Host comparison is case-insensitive, since DNS names are. Ports are part of the comparison: a different port is a different service.
  • The scheme must match too, which is stricter than comparing hosts alone. Without it, a base of https://git.example.com would trust http://git.example.com — and the credential would go out in plaintext to a host an attacker on the network path can impersonate. A downgrade is exactly what an attacker who can rewrite release metadata would choose.

Usage is the same shape in every provider:

if p.token != "" && forge.HostTrusted(downloadURL, p.baseURL) {
    req.Header.Set("Authorization", "token "+p.token)
}

Strictness is the default, deliberately. Where a deployment genuinely needs more — assets on a separate storage domain, or a lab install with no TLS — WithAdditionalHosts and WithInsecureSchemeDowngrade widen it explicitly, at the call site, where the decision is visible and greppable.

Note what this does NOT do: it does not stop the download. An asset hosted elsewhere is still fetched, just without the credential attached — which is the correct behaviour for a public asset on a CDN.

func Register

func Register(sourceType string, factory ProviderFactory) error

Register associates a source type string with a ProviderFactory. Safe to call concurrently.

It returns ErrAlreadyRegistered rather than overwriting. Silently overwriting would let a blank-imported provider module displace another with no diagnostic, with initialisation order deciding the winner — so the failure would surface later, as the wrong provider being used, far from its cause.

Returning an error rather than panicking keeps the decision with the caller. A provider module registering from init() has nothing sensible to do with a failure and should panic at its own call site, where the panic names the module at fault:

func init() {
    if err := forge.Register("mycorp-git", newProvider); err != nil {
        panic("myforge: " + err.Error())
    }
}

Code building a registry programmatically — a plugin host, a test, a tool choosing providers at runtime — can instead handle the error. That case is the reason this does not panic on your behalf.

To register conditionally, consult Registered. To replace a provider deliberately, call Unregister first.

func Registered

func Registered(sourceType string) bool

Registered reports whether a factory is registered for sourceType. Use it to register conditionally without tripping Register's duplicate panic.

func RegisteredTypes

func RegisteredTypes() []string

RegisteredTypes returns a sorted slice of all currently registered source type strings. Used for generating user-facing error messages.

func ResolveToken

func ResolveToken(cfg TokenConfig, fallbackEnv string) string

ResolveToken resolves an authentication token from a config subtree.

Resolution order:

  1. cfg.auth.env — NAME of an environment variable to read
  2. cfg.auth.keychain — "<service>/<account>" reference in the OS keychain; active only when the process has imported pkg/credentials/keychain (or otherwise registered a backend). Silently skipped when no backend is registered or when the keychain is unreachable at the time of the call.
  3. cfg.auth.value — literal token value stored in config. Viper's AutomaticEnv + tool prefix also surfaces <TOOL>_AUTH_VALUE style env vars via this step.
  4. fallbackEnv — well-known unprefixed environment variable (pass "" to skip).

Returns an empty string when no token is found; callers decide whether that is an error condition (e.g. private repositories require a token, public repositories can proceed without one).

The callers-supplied `cfg` is typically `props.Config.Sub("<vcs>")` — GTB's env-aware Sub ensures prefix-aware env binding fires even inside sub-containers (see pkg/config).

ResolveToken uses context.Background when calling the keychain backend. Callers who have a request context (HTTP handler, cobra command, chat provider) SHOULD use ResolveTokenContext instead so a slow remote backend (Vault, SSM) cannot stall the caller beyond its own deadline.

func ResolveTokenContext

func ResolveTokenContext(ctx context.Context, cfg TokenConfig, fallbackEnv string) string

ResolveTokenContext is the context-aware form of ResolveToken. The context is propagated to the credentials backend so remote stores honour deadlines and cancellation. Recommended for any call path that already has a context in scope.

func Unregister

func Unregister(sourceType string) bool

Unregister removes the factory registered for sourceType, reporting whether one was present. It exists so that deliberately replacing a built-in provider stays possible now that Register refuses to overwrite silently — the intent has to be stated rather than assumed.

Like Register, this mutates process-wide state: call it during start-up, not from a test that runs under t.Parallel().

Types

type AuthConfig

type AuthConfig struct {
	Env      string `mapstructure:"env"      json:"env"      yaml:"env"`
	Value    string `mapstructure:"value"    json:"value"    yaml:"value"`
	Keychain string `mapstructure:"keychain" json:"keychain" yaml:"keychain"`
}

AuthConfig is the typed auth shape shared by VCS providers. It still satisfies TokenConfig so existing token-resolution semantics are reused.

func (AuthConfig) GetString

func (cfg AuthConfig) GetString(key string) string

type Authenticator added in v0.2.0

type Authenticator interface {
	// Login runs the provider's interactive authentication flow, surfacing any
	// user-facing step through p, and returns a usable API token on success. It
	// blocks until the user completes the flow, ctx is cancelled, or the flow's own
	// deadline (e.g. the device-code expiry) elapses.
	//
	// Returns [ErrNotSupported] when interactive login is unavailable in the
	// provider's current configuration; the caller then falls back to manual entry.
	Login(ctx context.Context, p Prompter) (token string, err error)
}

Authenticator is an OPTIONAL capability implemented by providers that support an interactive login flow (e.g. an OAuth device flow) yielding an API token.

Providers that only accept a pre-issued token or app password do not implement it — or implement it and return ErrNotSupported when their current configuration cannot perform an interactive login. The caller treats both cases identically: fall back to manual token/app-password entry.

Example

ExampleAuthenticator shows the caller-side capability-detection pattern: use the interactive login when the provider offers it, otherwise fall back to a manual token.

package main

import (
	"context"
	"fmt"
	"time"

	"gitlab.com/phpboyscout/go/forge"
)

// capturePrompter records the DeviceCode it was shown, standing in for a CLI.
type capturePrompter struct {
	dc       forge.DeviceCode
	shown    bool
	failWith error
}

func (c *capturePrompter) ShowDeviceCode(_ context.Context, dc forge.DeviceCode) error {
	if c.failWith != nil {
		return c.failWith
	}

	c.dc = dc
	c.shown = true

	return nil
}

// fakeProvider optionally implements Authenticator + KeyManager, to exercise the
// capability-detection contract a caller uses.
type fakeProvider struct {
	token     string
	loginErr  error
	uploadErr error
	uploaded  []byte
}

func (f *fakeProvider) Login(ctx context.Context, p forge.Prompter) (string, error) {
	if err := p.ShowDeviceCode(ctx, forge.DeviceCode{
		UserCode: "WDJB-MJHT", VerificationURI: "https://example.test/device", ExpiresIn: 15 * time.Minute,
	}); err != nil {
		return "", err
	}

	return f.token, f.loginErr
}

func (f *fakeProvider) UploadKey(_ context.Context, _ string, publicKey []byte) error {
	f.uploaded = publicKey

	return f.uploadErr
}

func main() {
	var provider any = &fakeProvider{token: "gho_example"}

	if auth, ok := provider.(forge.Authenticator); ok {
		tok, err := auth.Login(context.Background(), &capturePrompter{})
		if err == nil {
			fmt.Println("logged in, token:", tok)
		}
	} else {
		fmt.Println("provider has no interactive login; prompt for a token")
	}
}
Output:
logged in, token: gho_example

type ChecksumProvider

type ChecksumProvider interface {
	// DownloadChecksumManifest returns the raw bytes of the checksums
	// manifest for the given release.
	//
	// The implementation MUST enforce maxBytes and return an error when the
	// response exceeds it, so a hostile server cannot stream indefinitely.
	// Enforcement belongs here, at the trust boundary, not in the caller: a
	// provider reaching a URL of its own composition is the only code that
	// sees the response before it is buffered. [DefaultMaxChecksumsSize] is
	// a reasonable bound, but the CALLER chooses the value and passes it —
	// tools shipping unusually large artefacts legitimately raise it.
	//
	// Returns [ErrNotSupported] when the provider is configured in a
	// way that disables checksum retrieval (e.g. the Direct
	// provider's `checksum_url_template` param is empty). The caller
	// treats [ErrNotSupported] exactly like "provider does not
	// implement this interface" — fall back to asset-by-name lookup
	// if one is available, otherwise respect the require_checksum
	// policy.
	DownloadChecksumManifest(ctx context.Context, rel Release, maxBytes int64) ([]byte, error)
}

ChecksumProvider is an OPTIONAL interface implemented by release providers that can fetch a checksums manifest by means other than a standard release-asset download. The canonical case is the Direct provider's `checksum_url_template` param, which composes a URL from a template that may point outside the release-asset listing entirely.

The update flow does a type assertion at runtime — providers that do not implement this interface fall back to the default behaviour of locating `checksums.txt` by filename within the release's asset list. This keeps third-party Provider implementations source-compatible: they gain the feature by opting in, not by implementing a new required method.

type Config

type Config interface {
	GetString(key string) string
	Sub(key string) Config
}

Config is the minimal configuration reader needed by release provider factories.

func SubConfig

func SubConfig(cfg Config, key string) Config

SubConfig returns cfg.Sub(key), guarding nil callers for provider factories that support config-free public release lookups.

type DeviceCode added in v0.2.0

type DeviceCode struct {
	// UserCode is the short code the user types at VerificationURI.
	UserCode string
	// VerificationURI is where the user enters UserCode to authorize the request.
	VerificationURI string
	// VerificationURIComplete, when non-empty, is a URL that pre-fills the code
	// (some providers supply it so the user need not type UserCode).
	VerificationURIComplete string
	// ExpiresIn is how long UserCode remains valid.
	ExpiresIn time.Duration
}

DeviceCode carries the user-facing details of a device-authorization prompt — the "open this URL and enter this code" step of an OAuth device flow.

type KeyManager added in v0.2.0

type KeyManager interface {
	// UploadKey registers publicKey — an OpenSSH-format public key ("ssh-ed25519
	// AAAA… comment") — under the given human-readable name on the authenticated
	// account. The provider resolves its own credentials (as for the release
	// [Provider]); name is a label the user will see in the account's key list.
	UploadKey(ctx context.Context, name string, publicKey []byte) error
}

KeyManager is an OPTIONAL capability implemented by providers that can register an SSH public key on the authenticated account through their API.

Returns ErrNotSupported when the provider exposes no key API or it is disabled; the caller then skips automated upload and instructs the user to add the key manually.

type Prompter added in v0.2.0

type Prompter interface {
	// ShowDeviceCode presents a device-authorization prompt to the user — typically
	// "open dc.VerificationURI and enter dc.UserCode". It returns once the prompt has
	// been shown; the [Authenticator] then polls the provider for completion. A
	// non-nil error aborts the login.
	ShowDeviceCode(ctx context.Context, dc DeviceCode) error
}

Prompter lets a provider surface an interactive authentication prompt without depending on any UI toolkit — the caller (typically a CLI) renders it. This is how forge offers a device flow while staying framework-free.

type Provider

type Provider interface {
	GetLatestRelease(ctx context.Context, owner, repo string) (Release, error)
	GetReleaseByTag(ctx context.Context, owner, repo, tag string) (Release, error)
	ListReleases(ctx context.Context, owner, repo string, limit int) ([]Release, error)

	// DownloadReleaseAsset opens a stream of the asset's bytes.
	//
	// The second return value is a REDIRECT URL, and it is a security
	// boundary rather than a convenience. Some APIs answer an asset request
	// with a redirect to storage the client is expected to follow itself
	// (this is go-github's behaviour, which surfaces the location instead of
	// following it). A provider that receives such a redirect and does NOT
	// follow it returns the location here, with a nil or empty reader.
	//
	// Callers are expected to REFUSE a non-empty redirect rather than follow
	// it: following one would fetch bytes from a host the caller never
	// vetted, defeating the point of pinning the API host. Return "" whenever
	// the body is served directly, which is the common case — three of the
	// four first-party providers always do.
	DownloadReleaseAsset(ctx context.Context, owner, repo string, asset ReleaseAsset) (io.ReadCloser, string, error)
}

Provider defines the operations a release backend must support.

Implement every method. Where a platform has no equivalent concept, return an error wrapping ErrNotSupported rather than a nil result — see ChecksumProvider for how callers treat that sentinel.

type ProviderFactory

type ProviderFactory func(source ReleaseSourceConfig, cfg Config) (Provider, error)

ProviderFactory is a function that constructs a forge.Provider from a ReleaseSourceConfig and an optional configuration reader.

func Lookup

func Lookup(sourceType string) (ProviderFactory, error)

Lookup returns the ProviderFactory registered for the given source type. Returns ErrProviderNotFound if no factory has been registered for that type.

type Release

type Release interface {
	GetName() string
	GetTagName() string
	GetBody() string
	GetDraft() bool
	GetAssets() []ReleaseAsset
}

Release defines the common abstraction for a software forge.

type ReleaseAsset

type ReleaseAsset interface {
	GetID() int64
	GetName() string
	GetBrowserDownloadURL() string
}

ReleaseAsset defines the common abstraction for a release asset.

type ReleaseSourceConfig

type ReleaseSourceConfig struct {
	Type    string
	Host    string
	Owner   string
	Repo    string
	Private bool
	// Params holds provider-specific configuration key/value pairs.
	// Keys use snake_case. Valid keys are documented per provider.
	Params map[string]string
}

ReleaseSourceConfig carries the information a ProviderFactory needs to construct its client. It is populated from props.ReleaseSource, and exists as a separate type to avoid a circular import between pkg/vcs/release and pkg/props.

type SignatureProvider

type SignatureProvider interface {
	// DownloadSignature returns the raw bytes of the detached
	// signature over the checksums manifest for the given release.
	//
	// As with [ChecksumProvider.DownloadChecksumManifest], the
	// implementation MUST enforce maxBytes. The two are deliberately
	// symmetric: an implementer should never have to remember which of the
	// two capped for them. [DefaultMaxSignatureSize] is a reasonable bound,
	// chosen by the caller.
	//
	// Returns [ErrNotSupported] when the provider is configured in a
	// way that disables signature retrieval (e.g. the Direct
	// provider's `signature_url_template` param is empty, or no
	// signature file was uploaded to Bitbucket). The caller treats
	// [ErrNotSupported] exactly like "provider does not implement this
	// interface" — fall back to asset-by-name lookup if one is
	// available, otherwise respect the require_signature policy.
	DownloadSignature(ctx context.Context, rel Release, maxBytes int64) ([]byte, error)
}

SignatureProvider is an OPTIONAL interface implemented by release providers that can fetch a detached signature over the checksums manifest by means other than a standard release-asset download. It mirrors ChecksumProvider exactly: the Direct provider composes a URL from its `signature_url_template` param, and Bitbucket locates an uploaded `checksums.txt.sig` by exact filename in the downloads list.

The update flow type-asserts at runtime — providers that do not implement this interface fall back to locating the signature asset by filename within the release's asset list (the GitHub/GitLab path). This keeps third-party Provider implementations source- compatible: they gain signature support by opting in, not by implementing a new required method.

type TokenConfig

type TokenConfig interface {
	GetString(key string) string
}

TokenConfig is the minimal string lookup surface needed for VCS token resolution.

type TrustOption

type TrustOption func(*trustPolicy)

TrustOption relaxes HostTrusted. Every option widens what is trusted with a credential attached, so each one is a deliberate, greppable decision rather than a default.

func WithAdditionalHosts

func WithAdditionalHosts(hosts ...string) TrustOption

WithAdditionalHosts trusts hosts besides the API host with the credential.

The case this exists for is real: some deployments serve release assets from a separate storage domain that still requires the API credential — a self-hosted instance fronting an object store, or an enterprise install with a dedicated assets host. Without this they would have to choose between a failing download and no pinning at all.

Each host is matched exactly, case-insensitively, including port. It is never a suffix match: a suffix test is how host pinning is usually defeated, since "git.example.com" would then trust "git.example.com.attacker.net".

List only hosts you control. Every entry is somewhere your credential may be sent, and the point of pinning is that this list is short and deliberate.

func WithInsecureSchemeDowngrade

func WithInsecureSchemeDowngrade() TrustOption

WithInsecureSchemeDowngrade permits attaching the credential when the target uses a different scheme from the base — in practice, plaintext HTTP against an HTTPS API.

It is named to be uncomfortable because it should be. A downgrade puts the credential on the wire in cleartext, and it is exactly what an attacker able to rewrite release metadata would choose. There is one defensible use: an air-gapped or lab deployment that genuinely has no TLS, where the operator accepts that the credential is not confidential in transit.

If you are reaching for this against an internet-facing forge, the answer is TLS, not this option.

Directories

Path Synopsis
Package direct provides a forge.Provider implementation for tools distributed via arbitrary HTTP servers.
Package direct provides a forge.Provider implementation for tools distributed via arbitrary HTTP servers.
Package releasetest provides an in-memory forge.Provider test double and asset builders for exercising the self-update pipeline without network or disk access.
Package releasetest provides an in-memory forge.Provider test double and asset builders for exercising the self-update pipeline without network or disk access.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL