Version comparison, safe replacement and rollback on a failed update
A standalone Bash CLI tool distributed without a package manager needs its own way to stay current on users' machines. A self-update mechanism downloads new versions, verifies them, replaces the running script atomically, and rolls the change back on failure before users even notice anything went wrong.
Table of Contents
- 1. Why a self-update mechanism makes sense for standalone CLI tools
- 2. Version scheme and version comparison in pure Bash
- 3. Safely fetching the current remote version
- 4. Replacing the running script safely and atomically
- 5. Replacing the very script that is currently running, without crashing
- 6. Rollback on a failed update: backup, smoke test, restoration
- 7. Verifying integrity: checksums and optional signatures
- 8. Update channels, opt-out, and detecting CI environments
- 9. Self-update compared to package-manager-based distribution
- 10. Summary
- 11. FAQ
1. Why a self-update mechanism makes sense for standalone CLI tools
Many internal Bash CLI tools are not distributed through a package manager like apt or brew, but installed as a single script via curl, a Git checkout, or an internal download server. Without a package manager, the built-in update mechanism that would otherwise notify or update users automatically is also missing. The result is often dozens of machines running different, sometimes months-old versions of the same tool, with correspondingly inconsistent behavior.
A self-update mechanism closes that gap by having the tool itself check whether a newer version is available and swap itself out when needed. This is especially valuable for deployment scripts running on many servers at once: a central bugfix reaches every instance on the next invocation, without anyone touching each machine by hand. The price is added complexity that has to be built carefully, because a broken self-update can render the tool unusable on every machine at the same time.
2. Version scheme and version comparison in pure Bash
For a tool to decide whether an update is needed at all, it needs a clear versioning scheme. Semantic Versioning (MAJOR.MINOR.PATCH) is the de facto standard because it sorts unambiguously and because major bumps signal that users should check the changelog before updating. The version is usually kept as a constant right in the script and bumped alongside a Git tag at release time.
For the actual version comparison, Bash's sort -V is enough: it sorts version numbers correctly by numeric value instead of lexicographically, so 1.10.0 actually sorts after 1.9.0 instead of before it. That alone reliably determines whether the remote version is newer than the locally installed one, without pulling in an external Semantic Versioning library.
#!/usr/bin/env bash
set -euo pipefail
readonly CURRENT_VERSION="2.4.1"
# Returns 0 (true) if $1 is strictly newer than $2
version_is_newer() {
local candidate="$1" baseline="$2"
[[ "$candidate" == "$baseline" ]] && return 1
local newest
newest="$(printf '%s\n%s\n' "$candidate" "$baseline" | sort -V | tail -n1)"
[[ "$newest" == "$candidate" ]]
}
remote_version="2.5.0"
if version_is_newer "$remote_version" "$CURRENT_VERSION"; then
echo "Update available: $CURRENT_VERSION -> $remote_version"
fi
3. Safely fetching the current remote version
Before the tool downloads anything, it needs to know which version is currently available. A common approach is a small, separate endpoint, such as a VERSION file next to the actual release artifact, or a call to the GitHub releases API. It is important to guard this call with a short timeout so a hanging network request never slows down or blocks every normal invocation of the tool.
It is just as important to never treat the update check as a hard failure condition. If fetching the remote version fails, say because the machine is offline or a proxy blocks the request, the tool should simply keep running with the currently installed version and, at most, print an unobtrusive warning. A self-update that disables the tool's actual function whenever the network is missing is worse than no self-update at all.
#!/usr/bin/env bash
set -uo pipefail # deliberately no -e: a failed check must not kill the tool
fetch_remote_version() {
local url="https://releases.example.com/mytool/VERSION"
curl --fail --silent --show-error --max-time 3 "$url" 2>/dev/null
}
remote_version="$(fetch_remote_version)" || {
echo "Warning: could not check for updates, continuing with current version" >&2
remote_version=""
}
4. Replacing the running script safely and atomically
The trickiest part of a self-update is actually replacing the script file, because overwriting it directly with curl -o /usr/local/bin/mytool is not atomic. If the download aborts partway through, say because the connection drops or the disk fills up, a half-written, broken file is left behind, and every subsequent call to the tool fails.
The safe approach downloads the new version fully into a temporary file on the same filesystem first, verifies its integrity, and only then moves it into place with mv. Within the same filesystem, mv is a single, atomic rename syscall: at the end, either the old file or the complete new file sits at the target path, never a half-finished intermediate state.
#!/usr/bin/env bash
set -euo pipefail
readonly INSTALL_PATH="/usr/local/bin/mytool"
readonly DOWNLOAD_URL="https://releases.example.com/mytool/mytool-latest"
self_update() {
local tmp_file
tmp_file="$(mktemp "${INSTALL_PATH}.XXXXXX")"
trap 'rm -f "$tmp_file"' RETURN
curl --fail --silent --show-error -o "$tmp_file" "$DOWNLOAD_URL"
chmod +x "$tmp_file"
# Same filesystem as INSTALL_PATH -> mv is an atomic rename, not a copy
mv "$tmp_file" "$INSTALL_PATH"
trap - RETURN
echo "Updated successfully to the latest version."
}
5. Replacing the very script that is currently running, without crashing
A peculiarity of self-update scripts is that they overwrite themselves while the interpreter is still executing them. Bash does not read a script fully into memory before it starts; it reads it line by line from the filesystem as it runs. If the file gets swapped out mid-run via mv, the already-open file descriptor of the running process on Linux stays stable regardless, because mv on the same filesystem only assigns a new name and leaves the old inode untouched as long as the process still has it open.
In practice that means the running process keeps working safely with the old version until it exits, while new invocations of the tool already find the new file at the same path. Anyone who wants to keep working with the new version right away, for instance to test it immediately, calls exec "$INSTALL_PATH" "$@" explicitly at the end of the update process and deliberately restarts the process with it, instead of relying on an implicit restart.
6. Rollback on a failed update: backup, smoke test, restoration
Even with an atomic mv, an update can still fail functionally if the new version downloaded correctly but contains a bug that makes it crash immediately. That is why every solid self-update keeps a backup of the previously installed, working version, deleted only after the new version passes a successful smoke test.
The smoke test typically invokes the newly installed version with a harmless flag like --version and checks the exit code and expected output. If that test fails, the script automatically restores the saved previous version instead of leaving a broken installation behind. This rollback behavior is the decisive difference between a self-update users trust and one that triggers anxiety on every release.
#!/usr/bin/env bash
set -euo pipefail
readonly INSTALL_PATH="/usr/local/bin/mytool"
readonly BACKUP_PATH="${INSTALL_PATH}.bak"
self_update_with_rollback() {
local tmp_file
tmp_file="$(mktemp "${INSTALL_PATH}.XXXXXX")"
curl --fail --silent --show-error -o "$tmp_file" "$DOWNLOAD_URL"
chmod +x "$tmp_file"
cp -p "$INSTALL_PATH" "$BACKUP_PATH"
mv "$tmp_file" "$INSTALL_PATH"
if ! "$INSTALL_PATH" --version >/dev/null 2>&1; then
echo "Smoke test failed, rolling back to previous version" >&2
mv "$BACKUP_PATH" "$INSTALL_PATH"
return 1
fi
rm -f "$BACKUP_PATH"
echo "Update verified and applied successfully."
}
7. Verifying integrity: checksums and optional signatures
A download can be not only incomplete but also tampered with, for example when a compromised mirror server or a man-in-the-middle attack serves a modified file. That is why a checksum should always be published alongside the actual release artifact and verified by the self-update script before the downloaded file is even made executable.
sha256sum is entirely sufficient as an integrity check for most internal tools, as long as the checksum is distributed over a trusted channel, ideally the same encrypted connection as the artifact itself. For publicly distributed tools, where the publisher's authenticity also matters, a GPG signature on the release artifacts is worth the extra effort, letting users verify not just the file's integrity but also its origin.
#!/usr/bin/env bash
set -euo pipefail
verify_checksum() {
local file="$1" expected_sha256="$2"
local actual_sha256
actual_sha256="$(sha256sum "$file" | awk '{print $1}')"
if [[ "$actual_sha256" != "$expected_sha256" ]]; then
echo "Checksum mismatch: refusing to install a corrupted or tampered file" >&2
rm -f "$file"
return 1
fi
}
expected="$(curl --fail --silent "$DOWNLOAD_URL.sha256")"
verify_checksum "$tmp_file" "$expected"
8. Update channels, opt-out, and detecting CI environments
Not every environment should allow a self-update without restriction. In CI pipelines, every run must reproducibly use the same tool version, otherwise a failing build becomes hard to diagnose if the tool version changes unnoticed between two runs. A robust self-update detects CI environments automatically, for instance via the CI=true environment variable that virtually every common CI system sets, and disables automatic updates there by default.
In addition, every self-update-capable tool should offer an explicit opt-out via a flag like --no-self-update or an environment variable, and ideally multiple update channels such as stable and beta, so teams can test new versions deliberately on a handful of machines before rolling them out broadly.
9. Self-update compared to package-manager-based distribution
Whether a custom self-update mechanism is the right choice or a classic package manager fits better depends heavily on the target audience and the infrastructure the tool runs in. Both approaches solve the same underlying problem of rolling out distributed updates reliably, with different trade-offs around control, complexity, and dependencies.
| Approach | Dependencies | Rollback | Typical use |
|---|---|---|---|
| Custom self-update | Only curl/sha256sum | Self-built, full control | Internal tools without package manager access |
| apt/yum package | Package manager, repository | Via package manager history | System-wide tools on managed servers |
| Homebrew formula | brew, GitHub releases | brew switch/pin | macOS developer tools |
| Container image tag | Docker/OCI registry | Redeploy the previous image | Containerized CLI tools |
Mironsoft
Shell automation, DevOps tooling and deployment infrastructure
Shell scripts that hold up in production?
We review existing Bash scripts, spot fragile patterns and replace them with robust Bash patterns: complete error handling, logging and safe parallelization for your deployment stack.
Code Review
ShellCheck analysis and manual review for critical Bash pattern violations.
Refactoring
Retrofitting error handling, logging and safe file operations.
CI Integration
Wiring ShellCheck and BATS into pipelines and building regression tests.
10. Summary
Self-Update in Bash Tools: The Essentials at a Glance
Version comparison
sort -V compares version numbers correctly by numeric value and needs no external Semantic Versioning library.
Atomic replace
Download into a temp file, then mv on the same filesystem: never a half-written script file at the target path.
Rollback
Back up the old version before replacing, smoke test with --version afterward, restore automatically on failure.
CI detection
Detect CI=true and disable self-update there by default so pipeline runs stay reproducible.