Skip to content

Make devpod upgrade compare versions and check permissions first - #31

Merged
renatoathaydes merged 4 commits into
mainfrom
claude/devpod-upgrade-command-9a55d5
Oct 22, 2026
Merged

renatoathaydes merged 4 commits into
mainfrom
claude/devpod-upgrade-command-9a55d5

Conversation

@renatoathaydes

@renatoathaydes renatoathaydes commented Oct 22, 2026 •

Copy link
Copy Markdown
Collaborator

What changed

devpod upgrade:

  • Compares versions. A version is three or more numbers, optionally followed by a suffix (0.8.0-alpha.1, 1.2.3alpha, 1.2.3-beta-1, 1.2.3.4) and build metadata (+abc, ignored when comparing). The automatic upgrade only considers non-draft, non-pre-release releases tagged plain X.Y.Z, and only installs one strictly newer than the running binary. At equal numbers a suffixed version counts as older (so v0.8.0-alpha.1 is offered v0.8.0); extra numbers count (1.2.3.4 > 1.2.3). Before, it installed the latest release whenever it merely differed from the current one, which could downgrade a newer build.
  • Checks permissions before downloading, without touching the binary: that it can create and remove files next to the binary, and what else could stop the swap, by looking only: the sticky rule (the user must own the file or the directory), immutable and append-only flags on the binary, the link and their directories (chflags uchg/schg/uappnd/sappnd, SIP and sunlnk on macOS; chattr +i/+a on Linux, via statx or FS_IOC_GETFLAGS), and on Windows opening the binary and the link with DELETE access, which fails on an ACL that denies it or another process holding the file without sharing delete, plus a read-only link. Directory flags are checked before the create probe, so an append-only directory doesn't keep the probe file. For an upgrade through a symlink, it also probes creating a symlink (Windows needs a privilege or Developer Mode). Probe files and the temporary link get random names, so concurrent upgrades or dry runs and leftovers don't collide.
  • Keeps the binary's mode and owner, instead of always 0755. If the user may not keep the owner or group (it keeps the group if it can), it warns what changed and which chown command fixes it, since the mode is kept.
  • Cleans up after interrupted downloads: Ctrl-C/SIGTERM cancels the download, which removes its temp file; temp files (downloads, probes, temporary links) that a killed upgrade left more than an hour ago are removed on the next one, in every directory it writes to (the binary's, the link's and the link target's).
  • Symlinked CLIs: when the CLI was started through a symlink, it downloads the release next to the link's target as devpod-<tag>, repoints the link (keeping it relative if it was), and logs that the previous binary was left alone. If a file named devpod-<tag> already exists there, it warns that it is replaced (in the dry run too). Both the link's and the target's directories are permission-checked first. A link to another link is refused. The link is found from os.Args[0] (via PATH if needed), checked to be the running binary, because os.Executable resolves links on Linux.
  • --version looks the tag up directly (with or without the leading v), so any release works, and doesn't need the current version to be parseable.
  • New --dry-run reports whether a newer version is available (or what --version would install), what would be replaced or linked, and fails (non-zero exit) if the upgrade would fail. Nothing is downloaded.
  • Removes the unused PrintNewerVersionWarning, NewerVersionAvailable and CheckForNewerVersion.

Dependencies

The download and swap are now done in pkg/upgrade (temp file in the target directory, size check, then rename; on Windows the running exe is moved aside first, to a uniquely named hidden .old file that is removed once it no longer runs). This drops rhysd/go-github-selfupdate and with it inconshreveable/go-update (unmaintained since 2016), google/go-github/v30, google/go-querystring, tcnksm/go-gitconfig and ulikunitz/xz (~53k vendored lines). The licenses page drops them too (the go-licenses workflow would regenerate it anyway). GitHub release JSON uses pkg/download's GithubRelease types, extended with the fields needed. Requests use http.DefaultClient, not pkg/http's client, which skips TLS verification.

Notes for reviewers

  • GitHub intermittently answers GET /releases?per_page=100 with [] (seen 3 times in 6 identical anonymous requests while testing). When the list has no usable release, /releases/latest is tried before giving up.
  • GITHUB_TOKEN is used if set, for GitHub's rate limit.
  • The symlink is found from os.Args[0], because os.Executable resolves links on Linux. That's unit-tested (invokedBinary) and was run for real on macOS and on Linux (Docker).
  • Windows code paths (replace_windows.go, symlink privilege, the DELETE-access check) are vetted and linted but not run here; windows-tests.yaml runs this package's unit tests, including one for a file held open without FILE_SHARE_DELETE.
  • macOS ACLs that deny delete on the binary itself aren't checked (a denied delete on the directory is, through the create-and-remove probe).

Testing

  • pkg/upgrade unit tests, including end-to-end Upgrade runs against an httptest fake of the GitHub API: in-place replace, nothing newer (several current versions), dry run, permission failure before any download, --version (with an unparseable current version, missing tag, same version), symlinks (absolute and relative, read-only link or target dir, link to link, target already named like the release), an empty release list, an immutable binary (macOS chflags uchg, Linux chattr +i) in a real run and a dry run, keeping the mode, and a stalled download cancelled once it has started (temp file removed, stale ones cleaned, also in a link's directories). Unit tests for the sticky rule, append-only files and directories, concurrent permission checks (binary and link stay in place throughout), unique temporary link names, keeping the group and the owner warning. Run with -race, and on Linux in Docker as a non-root user and as root with CAP_LINUX_IMMUTABLE (for the chattr tests, on tmpfs and overlayfs). The sticky check was also run against real root-owned files in sticky directories in Docker.
  • go vet and golangci-lint v1.60.1 for darwin, linux and windows on the changed packages.
  • Real runs against GitHub on macOS: dry run through a PATH symlink, link to link, read-only target dir, a real upgrade through a relative symlink (v0.8.0-alpha.1 → v0.8.0, previous binary untouched), and a real in-place upgrade.

🤖 Generated with Claude Code

renatoathaydes and others added 4 commits October 22, 2026 16:45
devpod upgrade installed the latest release whenever its version differed
from the running one, so it could downgrade a newer build, and it only
found out it couldn't replace the binary after downloading it.

It now parses versions (three or more numbers plus an optional suffix),
only upgrades to a newer plain major.minor.patch release, checks that it
can create files next to the binary before downloading, follows symlinks
to the binary, and accepts --version with or without the leading "v".
The new --dry-run flag reports whether a newer version is available and
whether the upgrade would succeed. The unused newer-version warning code
is removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When the CLI runs through a symlink, devpod upgrade now downloads the new
release next to the file the link points to (named devpod-<tag>) and
repoints the link, leaving the previous binary alone. It finds the link
from os.Args[0], since os.Executable resolves it on Linux. A link to
another link is refused.

Fixes from the review of the first version:
- check that the binary (and, for a link, the link) can be renamed, not
  only that files can be created next to it (sticky directories on
  Unix, ACLs on Windows), still before downloading
- don't require a parseable current version for --version
- look up --version with the tags endpoint instead of the 100 newest
  releases
- ignore +build metadata when comparing versions
- test the whole Upgrade flow against a fake GitHub API
- download and swap the binary here instead of through
  rhysd/go-github-selfupdate and the unmaintained go-update, which
  removes six modules
- reuse pkg/download's GitHub release types
- one helper for comparing tags; slices.Equal in tests

GitHub sometimes answers the release list with an empty array, so the
latest release is also looked up directly before giving up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fixes from the second review of devpod upgrade:
- probe renaming the binary (and the link) on every OS, renaming it
  back, instead of approximating the sticky-bit rule on Unix, so that
  immutable flags and ACLs are caught before downloading too
- probe creating a symlink before an upgrade through a link, since
  Windows needs a privilege or Developer Mode for that
- cancel the download on Ctrl-C or SIGTERM so it removes its temp file,
  and remove temp files that killed upgrades left over an hour ago
- warn when a file named after the release already exists next to the
  link's target and will be replaced, in the dry run too
- give the .old file of a Windows upgrade a unique name, so a previous
  one that still runs can't make the swap fail
- give the new binary the mode and, as far as allowed, the owner of the
  one it replaces, instead of always 0755
- make --dry-run fail when the upgrade would fail
- test how the symlink is found from os.Args[0]

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The permission check renamed the running binary and the link away and
back, even in a dry run, so a concurrent start of devpod could miss it,
and a kill between the renames left it renamed. It now only looks: the
sticky rule, immutable and append-only flags (chflags on macOS, statx or
FS_IOC_GETFLAGS on Linux), and on Windows opening the file with DELETE
access, which fails on an ACL that denies it or another process holding
the file. Directory flags are checked before the create probe, which now
also fails if the probe can't be removed.

Temporary links get random names, so concurrent upgrades and leftovers
don't collide, and leftovers from interrupted upgrades are removed from
every directory the upgrade writes to. The upgrade warns, with the chown
command to run, when the new binary can't keep the owner or group. The
stalled download test cancels once the download has started instead of
after a fixed time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@renatoathaydes
renatoathaydes merged commit a17ba30 into main Oct 22, 2026
20 of 21 checks passed
@renatoathaydes
renatoathaydes deleted the claude/devpod-upgrade-command-9a55d5 branch October 22, 2026 16:09
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