fix(installer): stop the release refusing and looping around non-release builds

Two defects made a local `0.10.2 (dev)` build permanent for the person who
installed it: the website installer refused to replace it, and `codewhale
update` reported "Already up to date" forever because it compared only
CARGO_PKG_VERSION.

install.sh (website and release archive):
- Resolve and validate the install directory before downloading, so a managed
  or symlinked directory fails before ~100 MB of release assets.
- CODEWHALE_FORCE_REINSTALL=1 replaces a differing codewhale/codew in the
  install directory and keeps each previous file beside it as
  <name>.bak-<utc-timestamp>. Symlinks and non-regular files are still never
  replaced, and every other guard is unchanged.
- A forced run that fails after moving a file aside puts it back. The
  publication state and exit trap now install before the preflight checks, so
  a refusal or a failed publish cannot leave a command missing from the
  directory while its bytes sit in a .bak file.
- Dropped the codewhale-tui compatibility-path guard. It was not a published
  destination: the installer never writes that name, so any stale legacy file
  or symlink beside the real commands aborted a correct install.

crates/cli/src/update.rs:
- `(dev)` in CODEWHALE_BUILD_VERSION is the build script's marker for an
  unstamped checkout; a release artifact carries its short SHA and a Cargo
  source package carries the plain version. When the installed build carries
  that marker and its version equals the latest tag, `update` and
  `update --check` install the release artifact instead of reporting up to
  date. Packaged-source and release builds are untouched.
- `Current version:` prints the build version, so the output names the actual
  provenance (`0.10.2 (dev)` vs `0.10.2 (ff466b2ed0)`).

docs/INSTALL.md and docs/zh_hans/INSTALL.md describe the force flag, the
same-version non-release replacement, and the download-before-refusal wording.

Verified on macOS arm64, source 4d2b3fdb1b plus this patch:
- cargo build -p codewhale-cli --locked: exit 0 (16m55s cold, 6s incremental;
  one pre-existing __eh_frame linker warning).
- scripts/dev-test.sh cli: 501 passed, 0 failed.
- scripts/dev-test.sh release: 57 passed, 0 failed.
- web node scripts/derive-install.mjs && node scripts/check-docs.mjs: PASS.
- Website installer matrix against a local file:// fixture: fresh install,
  identical re-run, differing-without-force (original preserved), force
  (backup holds the original bytes), stale codewhale-tui present (untouched),
  symlink destination with force (still a symlink), managed directory
  (refused with zero downloads). All seven behave as described.
- Interrupted forced run (second destination present as a directory): exits 1
  and restores the first command instead of leaving it in .bak. Same case for
  the archive installer.
- Real release: installed into a scratch directory from GitHub, reports
  0.10.2 (ff466b2ed0), sha256 da022961...590d matches the manifest, for both
  codewhale and codew.
- Real self-update: a fresh `0.10.2 (dev)` build ran `update --check` then
  `update`, downloaded and verified the release, and ended at
  0.10.2 (ff466b2ed0); a second `--check` reports Already up to date.

Not verified: clippy, Windows/NSIS installer, Linux/binary install paths,
concurrent forced runs in the same directory, and the deployed codewhale.net
copy of install.sh (needs a deploy, which stays a separate authorized action).
The packaged-source (`cargo install`) branch of the new predicate is reasoned
from format_build_version, not executed.
This commit is contained in:
CodeWhale Bot committed 2026-10-24 22:58:47 -07:00
1 parent 4d2b3fdb1b
commit e9c9de775f
5 files changed
+173 -75

No files matched your search

+23 -3
View File
@@ -61,6 +61,12 @@ pub fn run_update(beta: bool, check_only: bool, proxy_arg: Option<String>) -> Re
let plan = update_plan_for_exe(&current_exe);
let channel = ReleaseChannel::from_beta_flag(beta);
let current_version = env!("CARGO_PKG_VERSION");
let build_version = env!("CODEWHALE_BUILD_VERSION");
// The build script marks an unstamped checkout with `(dev)` and nothing
// else: a release artifact carries its short SHA and a Cargo source
// package carries the plain version. Only the checkout marker means the
// installed file has no published artifact behind it.
let unreleased_checkout_build = build_version.ends_with(" (dev)");
let proxy = proxy_arg
.as_deref()
.map(validate_and_build_proxy)
@@ -68,7 +74,7 @@ pub fn run_update(beta: bool, check_only: bool, proxy_arg: Option<String>) -> Re
println!("Checking for {} updates...", channel.label());
println!("Current binary: {}", current_exe.display());
println!("Current version: v{current_version}");
println!("Current version: v{build_version}");
if legacy_binary {
println!();
println!("{}", legacy_binary_message(&current_exe));
@@ -94,7 +100,14 @@ pub fn run_update(beta: bool, check_only: bool, proxy_arg: Option<String>) -> Re
.with_context(update_network_fallback_hint)?;
let latest_tag = &fetched.release.tag_name;
println!("Latest {} release: {latest_tag}", channel.label());
if update_is_needed(channel, current_version, latest_tag)? {
let replace_same_version = unreleased_checkout_build
&& compare_release_versions(current_version, latest_tag)? == Ordering::Equal;
if update_is_needed(channel, current_version, latest_tag)? || replace_same_version {
if replace_same_version {
println!(
"This build is not a published release ({build_version}); the {latest_tag} release artifact replaces it."
);
}
if install_method.supports_self_update() && !protected_location {
println!(
"Update available. Run `{} update` to install {latest_tag}.",
@@ -138,7 +151,9 @@ pub fn run_update(beta: bool, check_only: bool, proxy_arg: Option<String>) -> Re
fetched.source.describe()
);
}
if !update_is_needed(channel, current_version, latest_tag)? {
let replace_same_version = unreleased_checkout_build
&& compare_release_versions(current_version, latest_tag)? == Ordering::Equal;
if !update_is_needed(channel, current_version, latest_tag)? && !replace_same_version {
if compare_release_versions(current_version, latest_tag)? == Ordering::Greater {
println!(
"Current build is newer than the latest published release; keeping v{current_version}. No downgrade or download performed."
@@ -148,6 +163,11 @@ pub fn run_update(beta: bool, check_only: bool, proxy_arg: Option<String>) -> Re
}
return Ok(());
}
if replace_same_version {
println!(
"This build is not a published release ({build_version}); installing the {latest_tag} release artifact over it."
);
}
// Reject unrelated command paths before downloads or any sibling changes.
for target in &plan.target_paths {
+17 -2
View File
@@ -170,7 +170,9 @@ What it does (verified):
* It **never uses sudo and never edits your shell profile.** It refuses to
install into system or package-manager directories (`/usr/bin`,
`~/.cargo/bin`, Homebrew, `node_modules`, `/nix/store`…) and refuses to
overwrite a *different* existing `codewhale`.
overwrite a *different* existing `codewhale` unless you ask for it with
`CODEWHALE_FORCE_REINSTALL=1` (see [re-running the
installer](#re-running-the-installer)).
Expected output:
@@ -258,8 +260,15 @@ codewhale doctor # diagnostics; see the note in §8 about what it does NO
`Already installed: …` and exits 0.
* Different version already installed: it **refuses**
(`codewhale install: refusing to replace existing …/codewhale`), exits 1 and
changes nothing. It downloads ~160 MB before refusing. Use
changes nothing. It downloads the release before refusing. Use
[`codewhale update`](#7-updating-and-rolling-back) instead.
* Not a release build, at the same version (a local `cargo build` reports
`0.10.2 (dev)`): `codewhale update` still reinstalls the release artifact
over it, because that build has no release provenance to preserve.
* Deliberate replacement: `CODEWHALE_FORCE_REINSTALL=1` replaces a differing
`codewhale`/`codew` in the install directory and keeps each previous file
beside it as `<name>.bak-<utc-timestamp>`. It never replaces a symlink or a
non-regular file, and never replaces a file in a managed directory.
### Upgrade / uninstall
@@ -587,6 +596,12 @@ It updates `codewhale` **and** `codew` together. It took 10 s here. Run it
again and you get `Already up to date; no download needed.` Other options:
`--beta` and `--proxy <URL>`.
A build that is not a published release reports its provenance in
`codewhale --version` (`0.10.2 (dev)` for a local build, a commit hash for a
release). `codewhale update` compares the release, not just the version, so a
same-version `(dev)` build is replaced by the release artifact instead of
reporting "Already up to date" forever.
<a id="roll-back-to-a-previous-release"></a>
### Rolling back (e.g. to v0.9.13)
+5 -1
View File
@@ -198,7 +198,9 @@ codewhale doctor # diagnostics; see the note in §8 about what it does NO
### 重新运行安装器
* 已安装的就是同一版本:无害。它会打印 `Already installed: …` 并以 0 退出。
* 已安装的是不同版本:它会**拒绝**(`codewhale install: refusing to replace existing …/codewhale`),以 1 退出,并且不改动任何东西。拒绝之前它已经下载了约 160 MB。请改用 [`codewhale update`](#7-更新与回滚)。
* 已安装的是不同版本:它会**拒绝**(`codewhale install: refusing to replace existing …/codewhale`),以 1 退出,并且不改动任何东西。拒绝之前它已经下载了发布包。请改用 [`codewhale update`](#7-更新与回滚)。
* 是同一版本但不是发布构建(本地 `cargo build` 会报告 `0.10.2 (dev)`):`codewhale update` 仍会用发布产物覆盖它,因为这种构建没有需要保留的发布溯源信息。
* 主动替换:`CODEWHALE_FORCE_REINSTALL=1` 会替换安装目录中不同的 `codewhale`/`codew`,并把每个旧文件保留在同目录的 `<名称>.bak-<UTC 时间戳>`。它绝不替换符号链接或非普通文件,也绝不替换受管目录中的文件。
### 升级 / 卸载
@@ -447,6 +449,8 @@ codewhale update
它会把 `codewhale` **和** `codew` 一起更新,在测试机上用了 10 秒。再运行一次会得到 `Already up to date; no download needed.`。其他选项:`--beta` 和 `--proxy <URL>`。
不是发布构建的构建会在 `codewhale --version` 中显示自己的来源(本地构建是 `0.10.2 (dev)`,发布构建是提交哈希)。`codewhale update` 比较的是发布产物而不只是版本号,因此同版本的 `(dev)` 构建会被发布产物替换,而不是永远报告 "Already up to date"。
<a id="roll-back-to-a-previous-release"></a>
### 回滚(例如回到 v0.9.13)
+45 -22
View File
@@ -95,7 +95,8 @@ preflight_glibc() {
}
# This script installs an already checksum-verified archive. Existing different
# binaries go through `codewhale update`, the sole version-aware updater.
# binaries go through `codewhale update`, or through CODEWHALE_FORCE_REINSTALL=1
# here when the installed file is not a published release artifact.
case "$BIN_DIR" in
/*) ;;
*) echo "ERROR: PREFIX must be an absolute user path" >&2; exit 1 ;;
@@ -119,8 +120,21 @@ check_destination() {
destination_exists=1
return 0
fi
if [[ "${CODEWHALE_FORCE_REINSTALL:-}" == 1 && ! -L "$dst" && -f "$dst" ]]; then
local backup="$dst.bak-$(date -u +%Y%m%dT%H%M%SZ)" suffix=1
while [[ -e "$backup" || -L "$backup" ]]; do
backup="$dst.bak-$(date -u +%Y%m%dT%H%M%SZ)-$suffix"
suffix=$((suffix + 1))
done
mv "$dst" "$backup"
forced_backups+=("$backup")
echo "Replacing $dst (previous file kept at $backup)"
return 0
fi
echo "ERROR: refusing to replace existing $dst; no existing file was changed." >&2
echo "For an existing direct Codewhale install, run its full path with 'update'." >&2
echo "To replace it with this archive and keep the previous file beside it:" >&2
echo ' CODEWHALE_FORCE_REINSTALL=1 ./install.sh' >&2
echo "To migrate, install this verified archive into a fresh user prefix:" >&2
echo ' mkdir -p "$HOME/.local"' >&2
echo ' codewhale_prefix="$(mktemp -d "$HOME/.local/codewhale-release.XXXXXX")"' >&2
@@ -133,26 +147,10 @@ check_destination() {
# The image is an explicit installer choice; presence never changes Core's
# Node default. Archives carry notices/source beside any qualified image.
payloads=(codewhale codew)
if [[ "${CODEWHALE_INSTALL_COMPILED_HOST:-}" == 1 ]]; then
for file in codewhale-extension-host codewhale-extension-host.LICENSES.txt codewhale-extension-host.relink-source.tar.gz codewhale-extension-host.release.json; do
[[ -f "$SCRIPT_DIR/$file" && ! -L "$SCRIPT_DIR/$file" ]] || { echo "ERROR: compiled host requested but archive has no complete qualified payload ($file); use Node" >&2; exit 1; }
payloads+=("$file")
done
fi
# Validate every source and destination before the first write.
for bin in "${payloads[@]}"; do
src="$SCRIPT_DIR/$bin"
[[ -f "$src" ]] || { echo "ERROR: $src not found in archive" >&2; exit 1; }
mode=0644
case "$bin" in codewhale|codew|codewhale-extension-host) mode=0755; preflight_glibc "$src" "${bin/codewhale-extension-host/compiled host}" ;; esac
check_destination "$src" "$BIN_DIR/$bin" "$mode"
done
legacy_tui="$BIN_DIR/codewhale-tui"
if [[ -e "$legacy_tui" || -L "$legacy_tui" ]]; then
check_destination "$SCRIPT_DIR/codewhale" "$legacy_tui"
fi
# Backups this run made under CODEWHALE_FORCE_REINSTALL. A run that fails after
# moving a command aside puts it back, so a forced install that does not
# complete never leaves a command missing from the prefix.
forced_backups=()
stage=""
stage_dir=""
# Commands this run published, as "source<TAB>destination" lines. If a later
@@ -171,12 +169,37 @@ rollback_published() {
done <<< "$published"
}
on_exit() {
local status=$?
local status=$? dst backup
if [[ -n "$stage" ]]; then rm -f "$stage"; fi
if [[ -n "$stage_dir" ]]; then rmdir "$stage_dir"; fi
if [[ "$status" -ne 0 && -n "$published" ]]; then rollback_published; fi
if [[ "$status" -ne 0 ]]; then
for backup in "${forced_backups[@]}"; do
[[ -e "$backup" ]] || continue
dst="$(printf '%s' "$backup" | sed 's/\.bak-[0-9TZ]*\(-[0-9][0-9]*\)\{0,1\}$//')"
if [[ ! -e "$dst" && ! -L "$dst" ]]; then
mv "$backup" "$dst" && echo "Restored $dst: this install did not complete." >&2
fi
done
fi
}
trap on_exit EXIT
payloads=(codewhale codew)
if [[ "${CODEWHALE_INSTALL_COMPILED_HOST:-}" == 1 ]]; then
for file in codewhale-extension-host codewhale-extension-host.LICENSES.txt codewhale-extension-host.relink-source.tar.gz codewhale-extension-host.release.json; do
[[ -f "$SCRIPT_DIR/$file" && ! -L "$SCRIPT_DIR/$file" ]] || { echo "ERROR: compiled host requested but archive has no complete qualified payload ($file); use Node" >&2; exit 1; }
payloads+=("$file")
done
fi
# Validate every source and destination before the first write.
for bin in "${payloads[@]}"; do
src="$SCRIPT_DIR/$bin"
[[ -f "$src" ]] || { echo "ERROR: $src not found in archive" >&2; exit 1; }
mode=0644
case "$bin" in codewhale|codew|codewhale-extension-host) mode=0755; preflight_glibc "$src" "${bin/codewhale-extension-host/compiled host}" ;; esac
check_destination "$src" "$BIN_DIR/$bin" "$mode"
done
install_binary() {
local src="$1" dst="$2" mode="${3:-0755}"
check_destination "$src" "$dst" "$mode"
+83 -47
View File
@@ -16,6 +16,9 @@ Usage:
Environment:
CODEWHALE_INSTALL_DIR Install directory. Default: $HOME/.local/bin
CODEWHALE_VERSION Release tag for a fresh directory. Default: latest
CODEWHALE_FORCE_REINSTALL=1
Replace differing codewhale/codew already in the install
directory, keeping each old file beside it as .bak-<time>
CODEWHALE_RELEASE_BASE_URL
Custom release asset base URL ending in /download
CODEWHALE_INSTALL_COMPILED_HOST=1
@@ -223,9 +226,75 @@ cli_asset="codewhale-$target"
shim_asset="codew-$target"
manifest_asset="codewhale-artifacts-sha256.txt"
# Resolve the real directory before applying managed-prefix checks. Never use
# sudo or allow an install directory symlink to obscure which files will change.
case "$install_dir" in
/*) ;;
*) fail "CODEWHALE_INSTALL_DIR must be an absolute path" ;;
esac
[ ! -L "$install_dir" ] || fail "install directory is a symlink: $install_dir; choose a fresh user directory"
mkdir -p "$install_dir" || fail "cannot create $install_dir; choose a writable user directory (no sudo is used)"
install_dir="$(cd -P "$install_dir" && pwd)"
case "$install_dir/" in
/bin/*|/sbin/*|/usr/bin/*|/usr/sbin/*|/nix/store/*|/gnu/store/*|*/node_modules/*|*/Cellar/*|*/.linuxbrew/*|*/linuxbrew/*|*/.cargo/bin/*)
fail "refusing managed/system directory $install_dir; use a fresh user directory"
;;
esac
[ -w "$install_dir" ] || fail "$install_dir is not writable; choose a user directory (no sudo is used)"
tmpdir="$(mktemp -d 2>/dev/null || mktemp -d -t codewhale-install)"
trap 'rm -rf "$tmpdir"' EXIT INT TERM
# Backups this run made under CODEWHALE_FORCE_REINSTALL, newest last. A run that
# fails after moving a command aside puts it back, so a forced install that does
# not complete never leaves the command missing from the install directory.
forced_backups="$tmpdir/.forced-backups"
: > "$forced_backups"
stage=""
stage_dir=""
# Commands this run published. If a later publication fails they are removed
# again, but only while each is still the exact file this run wrote, so a
# failed install leaves no half-installed pair and never touches a file that
# was already installed.
published="$tmpdir/.published"
: > "$published"
rollback_published() {
while IFS= read -r name; do
destination="$install_dir/$name"
if [ ! -L "$destination" ] && [ -f "$destination" ] && cmp -s "$tmpdir/$name" "$destination"; then
rm -f "$destination"
say "Removed $destination: this install did not complete." >&2
fi
done < "$published"
}
restore_forced_backups() {
[ -f "$forced_backups" ] || return 0
while IFS= read -r backup; do
[ -n "$backup" ] || continue
[ -e "$backup" ] || continue
# The name this run generated ends in .bak-<utc-stamp>[-<n>]; the
# destination is everything before it.
destination="$(printf '%s' "$backup" | sed 's/\.bak-[0-9TZ]*\(-[0-9][0-9]*\)\{0,1\}$//')"
if [ ! -e "$destination" ] && [ ! -L "$destination" ]; then
mv "$backup" "$destination" && say "Restored $destination: this install did not complete." >&2
fi
done < "$forced_backups"
}
on_exit() {
status=$?
if [ -n "$stage" ]; then rm -f "$stage"; fi
if [ -n "$stage_dir" ]; then rmdir "$stage_dir"; fi
if [ "$status" -ne 0 ]; then
rollback_published
restore_forced_backups
fi
rm -rf "$tmpdir"
}
trap on_exit EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
say "Installing Codewhale for $target"
say "Release assets: $release_base"
say "Install dir: $install_dir"
@@ -243,22 +312,6 @@ if command -v xattr >/dev/null 2>&1; then
xattr -d com.apple.quarantine "$tmpdir/codewhale" "$tmpdir/codew" 2>/dev/null || true
fi
# Resolve the real directory before applying managed-prefix checks. Never use
# sudo or allow an install directory symlink to obscure which files will change.
case "$install_dir" in
/*) ;;
*) fail "CODEWHALE_INSTALL_DIR must be an absolute path" ;;
esac
[ ! -L "$install_dir" ] || fail "install directory is a symlink: $install_dir; choose a fresh user directory"
mkdir -p "$install_dir" || fail "cannot create $install_dir; choose a writable user directory (no sudo is used)"
install_dir="$(cd -P "$install_dir" && pwd)"
case "$install_dir/" in
/bin/*|/sbin/*|/usr/bin/*|/usr/sbin/*|/nix/store/*|/gnu/store/*|*/node_modules/*|*/Cellar/*|*/.linuxbrew/*|*/linuxbrew/*|*/.cargo/bin/*)
fail "refusing managed/system directory $install_dir; use a fresh user directory"
;;
esac
[ -w "$install_dir" ] || fail "$install_dir is not writable; choose a user directory (no sudo is used)"
check_destination() {
destination="$1"
source="$2"
@@ -269,10 +322,24 @@ check_destination() {
destination_exists=1
return
fi
if [ "${CODEWHALE_FORCE_REINSTALL:-}" = 1 ] && [ ! -L "$destination" ] && [ -f "$destination" ]; then
backup="$destination.bak-$(date -u +%Y%m%dT%H%M%SZ)"
suffix=1
while [ -e "$backup" ] || [ -L "$backup" ]; do
backup="$destination.bak-$(date -u +%Y%m%dT%H%M%SZ)-$suffix"
suffix=$((suffix + 1))
done
mv "$destination" "$backup" || fail "cannot move $destination aside; it was not replaced"
printf '%s\n' "$backup" >> "$forced_backups"
say "Replacing $destination (previous file kept at $backup)"
return
fi
cat >&2 <<EOF
codewhale install: refusing to replace existing $destination.
It may be a newer build, another installation, or a symlink. No existing file was changed.
For an existing direct Codewhale install, run its full path with 'update'.
To replace it with this release and keep the previous file beside it:
curl -fsSL https://codewhale.net/install.sh | CODEWHALE_FORCE_REINSTALL=1 sh
To migrate safely from a package manager or mixed installation, create a fresh directory:
mkdir -p "\$HOME/.local"
codewhale_install_dir="\$(mktemp -d "\$HOME/.local/codewhale-release.XXXXXX")"
@@ -316,38 +383,7 @@ fi
check_destination "$install_dir/codewhale" "$tmpdir/codewhale"
check_destination "$install_dir/codew" "$tmpdir/codew"
legacy_tui="$install_dir/codewhale-tui"
if [ -e "$legacy_tui" ] || [ -L "$legacy_tui" ]; then
check_destination "$legacy_tui" "$tmpdir/codewhale"
fi
stage=""
stage_dir=""
# Commands this run published. If a later publication fails they are removed
# again, but only while each is still the exact file this run wrote, so a
# failed install leaves no half-installed pair and never touches a file that
# was already installed.
published="$tmpdir/.published"
: > "$published"
rollback_published() {
while IFS= read -r name; do
destination="$install_dir/$name"
if [ ! -L "$destination" ] && [ -f "$destination" ] && cmp -s "$tmpdir/$name" "$destination"; then
rm -f "$destination"
say "Removed $destination: this install did not complete." >&2
fi
done < "$published"
}
on_exit() {
status=$?
if [ -n "$stage" ]; then rm -f "$stage"; fi
if [ -n "$stage_dir" ]; then rmdir "$stage_dir"; fi
if [ "$status" -ne 0 ]; then rollback_published; fi
rm -rf "$tmpdir"
}
trap on_exit EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
install_binary() {
source="$1"
destination="$2"