Auto-Update Lifecycle¶
GTB includes a robust self-update mechanism that ensures users are always running the latest version of your tool without manual intervention. This system balances user convenience with operational stability.
The Self-Update Workflow¶
The update process is typically split into two stages: Discovery and Execution.
1. Discovery (The Throttled Check)¶
To prevent overwhelming GitHub APIs and slowing down CLI usage, update checks are throttled using a file-based marker system.
graph TD
Start[Command Execution] --> Check[Should check for updates?]
Check -- No (Throttled) --> Continue[Run Command]
Check -- Yes --> API[Query GitHub Releases]
API --> Compare[Compare Versions]
Compare -- Latest --> Save[Update 'checked' Marker]
Compare -- Outdated --> Prompt[Prompt User]
- Markers: Status files (e.g.,
last_checked) are stored in the tool's config directory. - Throttling: By default, checks occur at most once every 24 hours.
- Command exemptions: some commands never trigger the check, decided by typed command metadata rather than command names: anything wrapped with the
UpdateCmd/InitCmdfeature, and anything stamped withsetup.MarkSkipUpdateCheck(version,doctor,mcpstamp themselves; a stamped group covers its whole subtree). Cobra's generatedhelp/completion/__completecommands never reach the check at all: they take the root pre-run's auxiliary fast path. - Two channels, one seam: the updater reads releases through a
ReleaseChannel(spec 0203 D4) with two branches. The forge branch is the tool's forge provider (GitHub, GitLab, Gitea, Bitbucket, or go/forge's direct source). The static branch reads a plain https location and imports no forge:<base>/latest.json, the pointer, names the current tag and its manifest;<base>/<tag>/release.jsonlists that release's archives with platform, size and SHA-256, itschecksums.txt, its signature and the tag before it, so the manifests form a chain a reader can walk with no listing. The pointer is the one object on the channel that ever changes, moved last and by a conditional write, so a reader never sees a release the location does not fully hold; everything under a tag is immutable. Verification is the same code on both branches. On the static channelupdateis also exempt from the root pre-run's unlinked-forge check and missing-config gate, because its updater needs neither and exists to let a broken install pull its own fix (D10). gtb itself is on this channel. The layout is the static release channel reference. - Consent default: the update prompt defaults to No, and an aborted prompt declines rather than updates. A run nobody can answer (no terminal, or CI) never reaches the prompt, because the pre-run check is skipped for it altogether (see Background update checks). Update such an install with the explicit
updatecommand.
2. Execution (Atomic Installation)¶
When an update is triggered, the SelfUpdater performs an atomic replacement of the running binary. This "bait and switch" approach is technically necessary because most filesystems (particularly Windows, but also many Unix-based systems) prevent writing to or deleting a binary file while it is actively being executed by the operating system.
The installation follows these steps to bypass this limitation:
- Download: The platform-specific
.tar.gzasset is downloaded into memory. - Extract: The binary is extracted from the archive.
- Temp Buffer: The new binary is written to a temporary file (e.g.,
mytool_) in the same directory as the current executable. - Swap: The temporary file is renamed to the target filename (overwriting the old one), and permissions are set.
By writing to a temporary file first and then performing a rename, we ensure that the update is atomic: either it succeeds completely, or the old binary remains untouched.
Key Components¶
SelfUpdater¶
The core engine (pkg/setup/update.go) that communicates with GitHub, compares semantic versions, and manages the local filesystem during the update.
IsLatestVersion¶
A method on SelfUpdater that compares the CurrentVersion (compiled into the binary via ldflags) against the latest GitHub release. It handles edge cases like future versions and development builds using pkg/version.CompareVersions and IsDevelopment detection.
Downgrade guard¶
Checksum and signature verification authenticate that an artefact is a
genuine release: they say nothing about its recency. If the release
source reports a "latest" version older than the running binary (a stale
listing, a deleted newest release, or a rollback attack re-serving an old,
validly signed release), the implicit update path (update with no
--version) refuses with a non-zero exit rather than silently
downgrading. The refusal names both versions and hints at the sanctioned
routes:
update --version <tag>: an explicit version is sufficient intent on its own; at a terminal it asks first, and unattended it proceeds;update --rollback: asks first, then pins the older release (see Rollback and pin);update --force: overrides the guard on the implicit path.
The guard compares against the running binary version only (no persisted
high-water mark), and does not change development-build (v0.0.0)
behaviour, which already requires --force for any update. The throttled
background check merely notifies that the source reports an older latest;
it never applies a downgrade automatically. See
0167-self-update-downgrade-guard.
Package-managed guard¶
Before it downloads or reads anything, the updater asks internal/pkgmanager
whether a package manager owns the binary it would replace: a Homebrew Caskroom
or Cellar path (after resolving symlinks), or a file dpkg or rpm records as
installed. If one does, both Update and UpdateFromFile return
ErrManagedInstall with that manager's own upgrade command as the hint, and
--force does not override it. The detector is shared so that apt and dnf
packaging can defer to the same answer. The command reference lists what is
recognised: Package-managed installs.
Rollback and pin¶
A rollback is a downgrade a person asked for, so it is verified exactly as any update is and confirmed first. Without
a pin it would not last: once the check interval passed, the pre-run check would find the newer release again and
offer it, remind about it, or under the enabled policy refuse every command until the user moved forward. So a
successful rollback pins its target, and a pinned tool's pre-run check makes no probe at all. The pin wins over the
author's enabled policy, because it records a choice the user has already made. A hard minimum version set by an
author would be a separate feature.
The pin is a marker file, pinned_version, beside last_checked in the tool's config directory, not a config key.
It is operational state for one installation: it survives init, it never shows in a config diff, and it is
owner-only like the other markers. A marker that does not hold a release tag is ignored with a warning, so a corrupt
file cannot wedge the tool.
The release before the running one is chosen by version, not by publish order. On the static channel each manifest
names the tag published before it, but a patch to an older line published after a newer release would make that
pointer name the newer release. The updater lists the newest releases and takes the highest version below the running
one. See 0104-update-channels-rollback.
Testability via Abstraction¶
The update system is designed to be fully testable despite its heavy reliance on the network and filesystem:
- ReleaseChannel: the seam both branches sit behind; a test injects a forge double (go/forge/test) or serves a static channel from an httptest.Server.
- afero.Fs: Used for all filesystem operations, allowing the entire download/extract/swap flow to be verified in-memory.
Offline Update Path¶
For air-gapped or restricted environments, the update system supports installing from a local .tar.gz archive via UpdateFromFile. This path skips both the Discovery and Download stages entirely. The binary is extracted directly from a pre-downloaded archive using the same atomic installation flow.
When a .sha256 sidecar file exists alongside the archive, VerifyChecksum validates the file integrity before extraction. This matches the GoReleaser checksum output format.
See Configure Self-Updating: Air-Gapped Environments for the full workflow.
Best Practices¶
- Explicit Update Command: Always provide an
updatecommand (viasetup.Register) for users who want to force a sync manually. - Developer Safeguards: The updater detects development versions (e.g.,
v0.0.0) and prompts for a--forceflag to avoid accidental overrides of local builds.
CLI Implementation & Architecture¶
Online Update Process¶
- Validates version format (if specified).
- Downloads the target version from GitHub/GitLab.
- Replaces the current binary.
- Updates configuration files in standard locations.
- Displays release notes for the new version.
Offline Update (Air-Gapped Environments)¶
When --from-file is provided, the command bypasses all network calls:
If a .sha256 sidecar file exists alongside the tarball (e.g., mytool_Linux_x86_64.tar.gz.sha256), the checksum is verified before extraction. If no sidecar is present, a warning is logged and installation proceeds, unless update.require_checksum is set, when the install is refused. With update.require_signature set, the offline path is always refused, because a local archive carries no signature to check.
No VCS client, API token, or network access is required for offline updates.
Implementation¶
The update command is implemented in cmd/update/update.go. Online updates use pkg/setup.NewUpdater() while offline updates use pkg/setup.NewOfflineUpdater().
Injecting updater factories (testing)¶
NewCmdUpdate, Update, and the offline path accept UpdateConfigOptions. To substitute the updater in tests (without touching any package-level state) pass a factory option:
cmd := update.NewCmdUpdate(props,
update.WithUpdater(func(ctx context.Context, p *props.Props, version string, force bool) (update.Updater, error) {
return myFakeUpdater, nil
}),
update.WithOfflineUpdater(func(p *props.Props) update.Updater {
return myFakeOfflineUpdater
}),
)
Each call site receives its own factory, so concurrent (t.Parallel) tests cannot clobber one another.
Deprecated: ExportNewUpdater / ExportNewOfflineUpdater
The package-level vars ExportNewUpdater and ExportNewOfflineUpdater are deprecated. They are mutable global test seams that race under parallel tests. They still work (consulted as the default when no option is given) and are retained for one minor release; migrate to WithUpdater / WithOfflineUpdater.
Error handling in init subcommands¶
The init ai, init github, and init bitbucket subcommands use cobra RunE and return configuration errors rather than calling logger.Fatalf. Returning the error routes it through the framework's standard error path (user-facing hints, the configurable ExitFunc, and the deferred telemetry flush all apply) instead of terminating the process abruptly and bypassing them.
Background update checks: the ForcedUpdate policy¶
On every invocation with a person at a terminal, the root command may run a throttled update check. Its behaviour is governed by a three-state policy so a background check never silently hijacks an unrelated command:
| Policy | When a newer release is found |
|---|---|
disabled |
Log that an update is available and continue. No prompt, no block. Framework default. |
prompt |
Ask "update now?"; decline continues with the command, accept updates then asks you to re-run. The gtb CLI itself uses this. |
enabled |
Block every command run at a terminal until updated. A declined required update exits non-zero (never a masked exit 0). A run with nobody at the terminal is not blocked (see below). |
Resolution precedence: --ci / ci: true / the CI=true environment
variable bypass the check entirely → then the update.policy config key → then
the tool author's baseline (props.Tool.UpdatePolicy, default disabled). The
flag/config key and the CI=true environment variable are treated identically,
so a CI run that forgets --ci is still recognised.
Only a person at a terminal is checked. When stdin is not a terminal (a
service under systemd, a pod, cron, piped input, MCP stdio), nobody can answer,
so the check is skipped altogether under every policy: no probe, no prompt, no
block and no self-update, and stdin is never read
(spec 0208).
Under enabled such a run logs one warning that the policy was not enforced, so
whoever set it can see why. Keep a service current by redeploying it, or by
running update from the deployment. The out-of-date reminder is suppressed
too, as under CI. A service whose stdin is a terminal (tty: true,
docker run -t) is treated as attended; pass --ci there.
update:
policy: prompt # disabled | prompt | enabled (empty = tool baseline)
check_interval: 24h # any Go duration; 0 = check every invocation
A tool author sets the baselines on the Tool:
props.Tool{
// ...
UpdatePolicy: props.UpdatePolicyPrompt,
UpdateCheckInterval: 7 * 24 * time.Hour, // optional; zero = framework default (24h)
}
Check-interval precedence: a valid update.check_interval config value
(where 0/0s means "check every run") → the props.Tool.UpdateCheckInterval
baseline (if positive) → the framework default (24h). A zero-value baseline is
treated as "unset" and falls through to 24h, so an "every run" cadence is only
reachable via runtime config, never as a compiled-in baseline.
Persistent out-of-date reminder. The latest version discovered by a check is
cached in the last_checked marker's body (its modtime still drives the
check_interval throttle. One file, two jobs). While the running binary is
behind that cached version, a single WARN is emitted on every invocation
(even when the network check is throttled), so a user who declined, or who runs
a disabled-policy tool, keeps being reminded to upgrade. --ci / ci: true
/ CI=true suppress it, for full flag/environment parity with the check itself.
Failed updates exit non-zero. A successful update that needs a restart exits 0 and asks you to re-run; a failed update (e.g. no release asset for the platform) propagates a non-zero exit rather than masking the failure as success.
See 0087-forced-update-feature.