Self-Updating Bash Tools: Versioning and Safe Self-Update
AI generated
$_
#!/
Bash · CLI Tooling · Versioning · Deployment
Self-Updating Bash Tools
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.

18 min read Version comparison · sort -V Atomic replace · rollback

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.

11. FAQ: Self-Update in Bash Tools: The Essentials at a Glance

1Why is overwriting directly with curl -o dangerous?
curl -o writes straight into the target file. If the download aborts partway through, a half-written, broken script file is left behind. Downloading into a temp file and then using mv is atomic and avoids this problem.
2Why is mv safer than cp when replacing the script?
mv on the same filesystem is a single, atomic rename syscall. cp writes byte by byte and can leave an incomplete file behind if it crashes partway through.
3What happens to the running process when its own file gets replaced?
On Linux, the running process's already-open file descriptor stays stable, because mv on the same filesystem only assigns a new name and leaves the old inode untouched. The running process keeps working safely with the old version.
4How do I reliably compare version numbers in Bash?
With sort -V, which sorts version numbers numerically instead of lexicographically. Comparing two versions via printf and sort -V | tail -n1 reliably shows which one is newer.
5How do I protect against tampered downloads?
Distribute a checksum (sha256sum) over a trusted, encrypted channel and verify it before execution. For publicly distributed tools, also provide a GPG signature on the release artifacts.
6How does a rollback work on a failed update?
Before replacing the file, the old, working version is backed up. After the update, a smoke test (usually --version) checks the new version. If the test fails, the saved previous version is restored automatically.
7Should a self-update fail if no network is available?
No. The update check should never block the tool's actual function. If the network request fails, the tool simply keeps running with the currently installed version.
8How do I stop CI pipelines from silently updating themselves?
Detect the CI=true environment variable that virtually every CI system sets and disable automatic updates there by default. Also offer an explicit --no-self-update flag.
9What is an update channel and when do I need more than one?
An update channel like stable or beta separates vetted releases from newer ones not yet broadly tested. Teams can test new versions deliberately on a handful of machines first before rolling them out further.
10When is a self-update mechanism worth it over a package manager?
When the tool runs on machines without access to a central package manager, such as isolated servers or mixed environments, and fast, centrally controllable updates matter more than integrating with existing system package management.