Skip to content

feat: add the upgrade command#24

Merged
muhammetsafak merged 15 commits into
mainfrom
feat/upgrade-command
Jul 26, 2026
Merged

feat: add the upgrade command#24
muhammetsafak merged 15 commits into
mainfrom
feat/upgrade-command

Conversation

@muhammetsafak

Copy link
Copy Markdown
Member

Adds commitbrief upgrade [--check], which checks GitHub Releases for a newer version and installs it using the right mechanism for how the binary was installed. Design: ADR-0034.

Behavior

  • Detects the install method from the resolved executable path (symlinks evaluated — Homebrew links its binary out of the Cellar) and delegates to the owning package manager: brew upgrade / scoop update / go install …@latest. Overwriting a manager-owned binary desynchronizes its metadata, so the next upgrade would conflict or silently revert.
  • Self-replaces only a manual tarball install, after downloading the release asset and verifying its SHA-256 against the release checksums.txt. Unix uses a single atomic rename; Windows renames the running .exe aside and rolls back on failure, naming the recovery path if the rollback itself also fails.
  • The write-permission gate runs before any network I/O. An unwritable target aborts without downloading a byte and prints the exact command to run. CommitBrief never invokes sudo itself. This gate is also what protects distro packages (nix/AUR/apt) that detection can only classify as "manual" — a wrong guess produces a clear error, never a broken install.
  • After a manual swap, the new binary is re-run to confirm it reports the expected version, and a separate check warns when another commitbrief shadows it earlier on PATH. Both are warnings; neither turns a completed upgrade into a failure.
  • --check reports without installing and always exits 0. --json implies --check.

Privacy

The version check runs only when the user invokes the command — no automatic check, no nudge, no config key, no telemetry. ADR-0034 records that this narrows system-context.md's previously unqualified "no update check" to "no automatic update check", and adds the corresponding egress-table row.

Honest limitation, recorded in the ADR: checksums.txt is unsigned, so the trust anchor is TLS to github.com. Verification catches a corrupt or truncated download, not a compromised release. Release-artifact signing (cosign / goreleaser signs:) is listed as future work.

Notes

  • No new module dependency — semver comparison is hand-rolled, per the stdlib-first rule in the dependency standard (precedent: the hand-rolled glob matcher in ADR-0026).
  • man/commitbrief-guard.1 and man/commitbrief-mcp.1 appear as new files: those pages had never been committed, and make manpage regenerates the whole tree.
  • Docs updated in the same change: README, CHANGELOG ([Unreleased]), the .ssot/ contracts/architecture set, ADR-0034, and a new wiki/Upgrade-command.md (the wiki is a separate repo, pushed by hand).

make check is green: gofmt, vet, golangci-lint, go test ./..., release-check, i18n-check (EN/TR parity), spdx-check (274 files), gosec.

extractZip had no test coverage even though it is the only extraction
path a real Windows manual install exercises, and a stale skip comment
claimed otherwise. Also add coverage for checksums.txt downloading
successfully but omitting an entry for our asset, which must fail
closed rather than pass silently.
Previously a dev/unparseable build under --json fell into the dev-build
branch, printed human text to stderr, and returned nil with empty stdout
and exit 0 — leaving a parsing script unable to distinguish 'no update
available' from 'the command did nothing'. --json now always emits a
report object, even when the current version can't be parsed.
After a manual replacement, nothing re-ran the new binary to confirm the
swap took effect. If another commitbrief comes earlier on the user's
PATH, the file swap succeeds but the command they type is still the old
binary, and 'Upgraded to vX.Y.Z' would report success regardless.

verifyReplacement re-runs <exe> --version and warns (never fails) on a
mismatch; manual path only, since a package manager may relocate its own
binary. reportsVersion trims the tag's leading v before comparing, since
goreleaser injects the version without one.
Final review fix wave on the upgrade command before merge:

- Download used the 30s whole-request API client timeout, which counts
  time spent reading the body; a slow connection on a ~12 MiB archive
  failed permanently. Downloads now use a separate client with no
  whole-request timeout, bounded by a 30s ResponseHeaderTimeout instead.
  The API client's own timeout is restored to the originally specified 10s.
- verifyReplacement's mismatch warning blamed PATH shadowing, but it execs
  the resolved binary directly and cannot observe PATH. Split into two
  independent checks: a version-mismatch warning that says only what it
  knows, and a new PATH-shadow check (LookPath + EvalSymlinks) that can
  actually detect a shadowing commitbrief and names both paths.
- CleanupStale only swept the Windows .old file; interrupted manual
  installs left .commitbrief-dl-*/.commitbrief-bin-* scratch files behind
  forever. It's now shared across platforms and also sweeps those temps.
- --check skipped the write-permission gate on the manual path, so it
  could report an install that a real run would refuse; it now runs the
  same preflight and warns without failing.
- A malformed API response was reported as a network failure; it now maps
  to a distinct ErrBadResponse sentinel and message.
- Fixed a tautological mode-preservation test and removed a dead no-op
  string replacement in path normalization.

Docs (.ssot, wiki) corrected to match the accurate two-check verify
rationale and the --check permission-gate behavior.
…shadow check

Second review round on the timeout/verify fixes from the previous commit:

- Assets' transport was a bare &http.Transport{}, not DefaultTransport with
  ResponseHeaderTimeout changed. A zero-valued Transport drops Proxy (so
  HTTPS_PROXY/HTTP_PROXY was silently ignored for downloads, while the API
  client still honored it — a proxied user would see an update detected and
  then fail to download it) and DialContext/TLSHandshakeTimeout (so a
  blackholed connect or stalled handshake had no bound at all, since
  ResponseHeaderTimeout only starts after connect+TLS finish). Now built via
  http.DefaultTransport.(*http.Transport).Clone() with only
  ResponseHeaderTimeout overridden.
- The download test asserted nothing about NewClient's actual client — it
  overrode Assets with its own and used a delay far short of any timeout it
  claimed to exercise, so it passed even against a broken Assets config.
  Replaced with a structural test that reads NewClient's fields directly;
  kept a corrected functional test alongside it.
- shadowingPath compared paths with ==, which can false-positive on Windows
  over letter case or separator style that EvalSymlinks doesn't normalize.
  Exported detect.go's existing normalizePath logic as SamePath and used it
  instead — normalizePath itself is untouched.
@muhammetsafak
muhammetsafak merged commit ec201f6 into main Jul 26, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant