From 375668c162d8a27f851a03800823d91886e8ad45 Mon Sep 17 00:00:00 2001 From: Shubham Malhotra Date: Fri, 11 Sep 2026 02:21:36 -0700 Subject: [PATCH 1/3] install: suggest glm-5.3-flash, not the retired glm-5.3, and fail CI on a retired id The gateway refuses glm-5.3 with 403 model_not_entitled; the hosted models today are glm-5.3-flash, qwen3.8-27b and gemma-4. Two places still told people to type the retired id: - install.sh's "Next:" block printed `wally opencode --cloud -m glm-5.3` (seen live installing the 0.5.6 nightly on 2026-09-11), so the first command a new user copies fails. - skills/runanywhere/SKILL.md used the same id, and the installer copies that skill into ~/.claude/skills and ~/.agents/skills, so an agent following it hits the same 403. Both now say glm-5.3-flash; the "Next:" columns are re-aligned for the longer id. README.md already used glm-5.3-flash. install.ps1, docs/ and src/ help text carried no retired id. tests/ fixtures that use glm-5.3 as an arbitrary id (config builders, a fake console's usage history) are left alone: they assert nothing a user reads. There is no single declared source for hosted model ids in this repo (cmd_default_models.cpp and preferences hold no hosted id; README.md names them in prose), so the guard is a denylist: scripts/ci/check-retired-model-ids.py holds RETIRED, mirroring launch_models.retired in InferenceInfra's contracts/public/status_semantics.json (glm-5.3, glm-5.2, gemini-2.5-flash), and scans install.sh, install.ps1, the READMEs, docs/, skills/, .claude/skills, .agents/skills and src/. It matches whole ids only, so glm-5.3-flash does not trip it. tests/test_retired_model_ids.py covers the matcher and the repo scan. Both run in ci.yml's distribution job. Break test (reintroduce glm-5.3 in install.sh and SKILL.md, then restore): fixed tree: Ran 6 tests in 0.056s OK no retired model ids in 117 user-facing files exit=0 glm-5.3 put back (sed 's/-m glm-5\.3-flash/-m glm-5.3/'): install.sh:206: retired model id 'glm-5.3' skills/runanywhere/SKILL.md:54: retired model id 'glm-5.3' 2 retired model id(s) in user-facing text; the gateway refuses these with 403 model_not_entitled. exit=1 FAIL: test_repository_has_no_retired_ids_in_user_facing_text Ran 6 tests in 0.047s FAILED (failures=1) restored: Ran 6 tests in 0.049s OK no retired model ids in 117 user-facing files exit=0 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014e9K3N5fHpM7vpRsHkeWW1 --- .github/workflows/ci.yml | 5 ++ install.sh | 6 +- scripts/ci/check-retired-model-ids.py | 112 ++++++++++++++++++++++++++ skills/runanywhere/SKILL.md | 2 +- tests/test_retired_model_ids.py | 56 +++++++++++++ 5 files changed, 177 insertions(+), 4 deletions(-) create mode 100644 scripts/ci/check-retired-model-ids.py create mode 100644 tests/test_retired_model_ids.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8968d27..db7316a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,6 +38,11 @@ jobs: run: python3 contracts/generate_console_binding.py --check - name: Versions consistent with versions.toml run: python3 scripts/ci/check-versions.py + - name: No retired model ids in user-facing text + run: | + set -euo pipefail + python3 tests/test_retired_model_ids.py + python3 scripts/ci/check-retired-model-ids.py macos: # SDK Package.swift is swift-tools-version 6.2 (Xcode 26). macos-15 is diff --git a/install.sh b/install.sh index ad5fac6..8975934 100755 --- a/install.sh +++ b/install.sh @@ -203,7 +203,7 @@ printf ' %s│%s models ~/.local/share/runanywhere\n' "$DIM" "$R" printf ' %s└───────────────────────────────────────────%s\n\n' "$DIM" "$R" printf ' %sNext:%s\n' "$B" "$R" -printf ' wally opencode --cloud -m glm-5.3 code against a hosted model\n' -printf ' wally usage credit left and what you spent\n' -printf ' wally pull qwen3-0.6b download a model to this machine\n' +printf ' wally opencode --cloud -m glm-5.3-flash code against a hosted model\n' +printf ' wally usage credit left and what you spent\n' +printf ' wally pull qwen3-0.6b download a model to this machine\n' printf ' In Claude Code, ask: %s"get me started with RunAnywhere Wally"%s\n\n' "$DIM" "$R" diff --git a/scripts/ci/check-retired-model-ids.py b/scripts/ci/check-retired-model-ids.py new file mode 100644 index 0000000..bede264 --- /dev/null +++ b/scripts/ci/check-retired-model-ids.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +"""Fail if a retired hosted model id appears anywhere a person or an agent is +told what to type. + +A retired id is refused by the gateway with 403 model_not_entitled, so an +installer hint or a skill that names one sends every new user straight into an +error. install.sh printed `wally opencode --cloud -m glm-5.3` in its "Next:" +block, and the RunAnywhere skill the installer copies into agents' skill +folders used the same id, long after `glm-5.3-flash` replaced it. + +RETIRED mirrors `launch_models.retired` in InferenceInfra's +contracts/public/status_semantics.json, which is the machine-readable list of +what RunAnywhere serves. When the service retires an id, add it here. + + python3 scripts/ci/check-retired-model-ids.py + +Scans user-facing text only: the installers, READMEs, docs, skills and the CLI +source (help text). tests/ is excluded on purpose -- fixtures there use +arbitrary ids, including retired ones a real usage history still carries. +Exits non-zero and prints file:line for every hit. +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent.parent + +RETIRED = ("glm-5.3", "glm-5.2", "gemini-2.5-flash") + +# Files and trees a user or an agent reads instructions from. +SCANNED = ( + "install.sh", + "install.ps1", + "README.md", + "CONTRIBUTING.md", + "AGENTS.md", + "docs", + "skills", + ".claude/skills", + ".agents/skills", + "src", +) +TEXT_SUFFIXES = {".sh", ".ps1", ".md", ".cpp", ".h", ".hpp", ".swift", ".txt", ""} + + +def pattern_for(model_id: str) -> re.Pattern[str]: + """Match `model_id` as a whole id: `glm-5.3` but not `glm-5.3-flash`, + `glm-5.30` or `xglm-5.3`. A sentence-ending period still counts as a hit.""" + return re.compile( + r"(? list[tuple[int, str]]: + """(line number, retired id) for every retired id in `text`.""" + hits = [] + for number, line in enumerate(text.splitlines(), start=1): + for model_id, pattern in PATTERNS: + if pattern.search(line): + hits.append((number, model_id)) + return hits + + +def scanned_files(root: Path = ROOT) -> list[Path]: + files = [] + for entry in SCANNED: + path = root / entry + if path.is_file(): + files.append(path) + elif path.is_dir(): + files.extend( + candidate + for candidate in sorted(path.rglob("*")) + if candidate.is_file() and candidate.suffix in TEXT_SUFFIXES + ) + return files + + +def scan(root: Path = ROOT) -> list[str]: + findings = [] + for path in scanned_files(root): + try: + text = path.read_text(encoding="utf-8") + except UnicodeDecodeError: + continue + for number, model_id in find_retired(text): + findings.append(f"{path.relative_to(root)}:{number}: retired model id '{model_id}'") + return findings + + +def main() -> int: + findings = scan() + if findings: + print("\n".join(findings), file=sys.stderr) + print( + f"{len(findings)} retired model id(s) in user-facing text; the gateway " + "refuses these with 403 model_not_entitled.", + file=sys.stderr, + ) + return 1 + print(f"no retired model ids in {len(scanned_files())} user-facing files") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/runanywhere/SKILL.md b/skills/runanywhere/SKILL.md index 69c9d0e..7ed2d3e 100644 --- a/skills/runanywhere/SKILL.md +++ b/skills/runanywhere/SKILL.md @@ -51,7 +51,7 @@ A harness is an existing coding tool that `wally` wires to a model. Today that i **opencode**. ```bash -wally opencode --cloud -m glm-5.3 # hosted, metered +wally opencode --cloud -m glm-5.3-flash # hosted, metered wally opencode -m qwen3-0.6b # a model on this machine ``` diff --git a/tests/test_retired_model_ids.py b/tests/test_retired_model_ids.py new file mode 100644 index 0000000..a9e16bc --- /dev/null +++ b/tests/test_retired_model_ids.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +"""Hermetic tests for scripts/ci/check-retired-model-ids.py.""" + +from __future__ import annotations + +import importlib.util +import pathlib +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location( + "check_retired_model_ids", ROOT / "scripts" / "ci" / "check-retired-model-ids.py" +) +assert SPEC is not None and SPEC.loader is not None +CHECK = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(CHECK) + + +class RetiredModelIdTests(unittest.TestCase): + def test_flags_a_retired_id_in_a_command(self) -> None: + text = "printf ' wally opencode --cloud -m glm-5.3 code against a hosted model\\n'\n" + self.assertEqual(CHECK.find_retired(text), [(1, "glm-5.3")]) + + def test_flags_a_retired_id_ending_a_sentence(self) -> None: + self.assertEqual(CHECK.find_retired("the model is glm-5.2."), [(1, "glm-5.2")]) + + def test_flags_every_retired_id(self) -> None: + text = "\n".join(f"-m {model_id}" for model_id in CHECK.RETIRED) + self.assertEqual([model_id for _, model_id in CHECK.find_retired(text)], list(CHECK.RETIRED)) + + def test_ignores_the_ids_that_replaced_them(self) -> None: + text = "wally opencode --cloud -m glm-5.3-flash\nglm-5.30\nxglm-5.3\ngemini-2.5-flash-lite\n" + self.assertEqual(CHECK.find_retired(text), []) + + def test_scan_reports_file_and_line(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = pathlib.Path(temporary) + (root / "skills" / "runanywhere").mkdir(parents=True) + (root / "skills" / "runanywhere" / "SKILL.md").write_text( + "# RunAnywhere\n\nwally opencode --cloud -m glm-5.3\n", encoding="utf-8" + ) + (root / "tests").mkdir() + (root / "tests" / "fixture.py").write_text('MODEL = "glm-5.3"\n', encoding="utf-8") + self.assertEqual( + CHECK.scan(root), + ["skills/runanywhere/SKILL.md:3: retired model id 'glm-5.3'"], + ) + + def test_repository_has_no_retired_ids_in_user_facing_text(self) -> None: + self.assertEqual(CHECK.scan(), []) + + +if __name__ == "__main__": + unittest.main() From fdd8864e9e0cf14f632cf480b0d1450ea01e769f Mon Sep 17 00:00:00 2001 From: Shubham Malhotra Date: Fri, 11 Sep 2026 02:25:58 -0700 Subject: [PATCH 2/3] install: run under POSIX sh, so `curl ... | sh` works where sh is dash install.sh is documented and served as `curl -fsSL .../install.sh | sh`, but it was a bash script: `set -euo pipefail`, `[[ ]]` in five places, and a `${expected_sha:0:16}` substring. On Debian and Ubuntu sh is dash, which dies on line 2 before printing anything: $ dash install.sh install.sh: 2: set: Illegal option -o pipefail (exit 2) Changes, all to syntax the script already meant: - shebang #!/bin/sh, `set -eu` - `[[ a ]]` -> `[ a ]`; `[[ ! -t 0 || ! -t 1 ]]` -> `[ ! -t 0 ] || [ ! -t 1 ]` - `${expected_sha:0:16}` -> `$(printf '%.16s' "$expected_sha")` - the release lookup fetches first and pipes second, so a failed curl reaches fail() without needing pipefail - a shellcheck directive on the PATH line that is written to the rc file literally on purpose (SC2016) Flags (nightly, --nightly, --print-skill-dirs), install paths, asset names, the checksum verification and the output are unchanged. Verified offline with a stub harness (curl and uname stubbed first on PATH, a local fake release tarball holding a fake wally, throwaway HOME, proxy pointed at a dead port so nothing can reach the network). 14 cases: macOS arm64, nightly and --nightly, Linux x86_64 and amd64, Intel Mac, Linux aarch64 and FreeBSD refused, bad checksum, installed-version skew, release API down, release without tag_name, --print-skill-dirs, both agent homes. The new script under bash, dash and macOS /bin/sh was diffed against the old script under bash (sha hex masked, since the fake tarball's mtime changes every run): 12 of 14 cases byte-identical under all three shells: stdout/stderr, exit code, every curl URL requested, every file written under HOME. api-down and no-tag: same exit 1, plus one line the old script meant to print and never could: > error: Could not determine latest release version. Check your internet connection. Under pipefail a failed curl, or a release body with no tag_name, failed the VERSION=$(...) assignment and set -e exited silently at "[1/5] Resolving the latest release". That fail() was unreachable before. It is the only behaviour change. The old script under dash failed all 14 cases with exit 2 at line 2. CI: ci.yml's distribution job ran `bash -n install.sh`, which cannot fail on any of this: `[[` parses as a command name and pipefail only fails at run time. install.sh now gets its own step: sh -n, dash -n, `shellcheck -s sh`, and scripts/test/test-install-skill-dirs.sh run with WALLY_INSTALL_SH=dash. That test existed but nothing ran it; it now takes the shell from WALLY_INSTALL_SH (default sh, as before). shellcheck is installed if the runner lacks it. Break test (put `set -euo pipefail` and one `[[ -x ... ]]` back, then restore): fixed tree: $ sh -n install.sh exit=0 $ dash -n install.sh exit=0 $ shellcheck -s sh install.sh exit=0 $ WALLY_INSTALL_SH=dash bash scripts/test/test-install-skill-dirs.sh ok claude-only ok agents-only ok both ok neither-defaults-claude all skill-dir cases pass exit=0 bashisms restored: $ sh -n install.sh exit=0 (parse-only cannot bite) $ dash -n install.sh exit=0 (parse-only cannot bite) $ shellcheck -s sh install.sh In install.sh line 6: set -euo pipefail ^------^ SC3040 (warning): In POSIX sh, set option pipefail is undefined. In install.sh line 123: [[ -x "${staged}/bin/wally" ]] || fail "Archive did not contain bin/wally as expected." ^----------------------------^ SC3010 (warning): In POSIX sh, [[ ]] is undefined. exit=1 $ WALLY_INSTALL_SH=dash bash scripts/test/test-install-skill-dirs.sh .../install.sh: 6: set: Illegal option -o pipefail FAIL claude-only FAIL agents-only FAIL both FAIL neither-defaults-claude 4 test(s) failed exit=1 restored (cmp against the fixed copy: identical): sh -n 0, dash -n 0, shellcheck -s sh 0, skill-dirs under dash: all pass, 0 Not verified: the workflow step itself (actionlint is not installed locally; ci.yml parses as YAML), and a real download of a published release. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014e9K3N5fHpM7vpRsHkeWW1 --- .github/workflows/ci.yml | 14 +++++++++++++- install.sh | 24 ++++++++++++++++-------- scripts/test/test-install-skill-dirs.sh | 7 ++++++- 3 files changed, 35 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index db7316a..b5fef3e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,8 +32,20 @@ jobs: set -euo pipefail python3 tests/test_release_assets.py python3 -m py_compile scripts/release/verify-release-assets.py scripts/release/stamp-formula.py - bash -n install.sh scripts/build/package-wally.sh scripts/release/update-tap.sh + bash -n scripts/build/package-wally.sh scripts/release/update-tap.sh ruby -c Formula/wally.rb + - name: install.sh runs under POSIX sh (dash) + # Served as `curl ... | sh`, and sh is dash here as on every Debian and + # Ubuntu host. `sh -n` alone cannot catch a bashism -- `[[` parses as a + # command name and `set -o pipefail` only fails at run time -- so the + # real guards are shellcheck in sh mode and actually running it in dash. + run: | + set -euo pipefail + command -v shellcheck >/dev/null || { sudo apt-get update && sudo apt-get install -y shellcheck; } + sh -n install.sh + dash -n install.sh + shellcheck -s sh install.sh + WALLY_INSTALL_SH=dash bash scripts/test/test-install-skill-dirs.sh - name: Console binding matches its pinned contract run: python3 contracts/generate_console_binding.py --check - name: Versions consistent with versions.toml diff --git a/install.sh b/install.sh index 8975934..8ee2811 100755 --- a/install.sh +++ b/install.sh @@ -1,5 +1,9 @@ -#!/usr/bin/env bash -set -euo pipefail +#!/bin/sh +# POSIX sh, not bash: this is served as `curl ... | sh`, and sh is dash on +# Debian and Ubuntu, which has no [[ ]], no ${var:offset:length} and (before +# 0.5.13) no pipefail. With no pipefail a pipeline's status is its last +# command's, so every download whose failure matters is its own step below. +set -eu # Installs Wally from the GitHub release tarball for this OS. No Homebrew and no # tap: the release bottle already stages `wally` with mlx-swift_Cmlx.bundle @@ -74,10 +78,12 @@ banner printf ' %sInstalling the %s%s%s build%s\n\n' "$DIM" "$R$B" "$CHANNEL" "$R$DIM" "$R" step "Resolving the latest release" -VERSION=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" \ +latest=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest") \ + || fail "Could not determine latest release version. Check your internet connection." +VERSION=$(printf '%s\n' "$latest" \ | grep '"tag_name"' \ | sed 's/.*"v\([^"]*\)".*/\1/') -[[ -n "$VERSION" ]] || fail "Could not determine latest release version. Check your internet connection." +[ -n "$VERSION" ] || fail "Could not determine latest release version. Check your internet connection." ok "v${VERSION}" os=$(uname -s) @@ -109,12 +115,12 @@ curl -fsSL "${URL}.sha256" -o "${tmp}/${ASSET}.sha256" || fail "Could not downlo expected_sha="$(awk 'NF == 2 { print $1 }' "${tmp}/${ASSET}.sha256" | head -1)" ( cd "$tmp" && shasum -a 256 -c "${ASSET}.sha256" >/dev/null 2>&1 ) \ || fail "Checksum verification failed for ${ASSET}. Do not use the download." -ok "sha256 ${expected_sha:0:16}… verified" +ok "sha256 $(printf '%.16s' "$expected_sha")… verified" step "Installing to ${LIB_DIR}" tar -xzf "${tmp}/${ASSET}" -C "$tmp" staged="${tmp}/wally-${PLATFORM}" -[[ -x "${staged}/bin/wally" ]] || fail "Archive did not contain bin/wally as expected." +[ -x "${staged}/bin/wally" ] || fail "Archive did not contain bin/wally as expected." # Replace the install tree wholesale. rm before copy is deliberate: overwriting a # code-signed Mach-O in place while a copy may still be mapped kills it with # SIGKILL (137). A fresh dir sidesteps that. @@ -130,7 +136,7 @@ fi installed_version="$(wally --version 2>/dev/null \ | sed -nE 's/^wally ([0-9]+\.[0-9]+\.[0-9]+).*/\1/p' \ | head -1)" -if [[ "${installed_version}" != "${VERSION}" ]]; then +if [ "${installed_version}" != "${VERSION}" ]; then fail "Installed Wally v${installed_version:-unknown}, but the latest release is v${VERSION}." fi ok "wally v${VERSION} on PATH" @@ -139,6 +145,8 @@ ok "wally v${VERSION} on PATH" case ":${PATH}:" in *":${BIN_DIR}:"*) : ;; *) + # Written to the rc file literally; the user's shell expands it later. + # shellcheck disable=SC2016 line='export PATH="$HOME/.local/bin:$PATH"' case "$(basename "${SHELL:-}")" in zsh) rc="${HOME}/.zshrc" ;; @@ -185,7 +193,7 @@ IFS="$old_ifs" step "Signing in" if wally whoami >/dev/null 2>&1; then ok "already signed in" -elif [[ ! -t 0 || ! -t 1 ]]; then +elif [ ! -t 0 ] || [ ! -t 1 ]; then # No terminal: piped into bash over SSH, or a CI step. The browser flow # would try to open a browser that is not there and then block until the # request expires, which reads as the installer hanging. diff --git a/scripts/test/test-install-skill-dirs.sh b/scripts/test/test-install-skill-dirs.sh index 68f87eb..d6e8c29 100755 --- a/scripts/test/test-install-skill-dirs.sh +++ b/scripts/test/test-install-skill-dirs.sh @@ -8,8 +8,13 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" INSTALL="${SCRIPT_DIR}/../../install.sh" +# The shell that runs the installer. `sh` by default, which is bash on macOS and +# dash on Debian/Ubuntu; CI sets WALLY_INSTALL_SH=dash so the POSIX check does not +# depend on which of those the runner happens to be. +INSTALL_SH="${WALLY_INSTALL_SH:-sh}" + fails=0 -run() { HOME="$1" sh "$INSTALL" --print-skill-dirs; } +run() { HOME="$1" "$INSTALL_SH" "$INSTALL" --print-skill-dirs; } check() { name="$1"; expected="$2"; actual="$3" if [ "$expected" = "$actual" ]; then From a705c28df97c5ebb0a77e2f559cce278d3ec9d6e Mon Sep 17 00:00:00 2001 From: Siddhesh Sonar <67579112+Siddhesh2377@users.noreply.github.com> Date: Fri, 11 Sep 2026 16:03:17 +0530 Subject: [PATCH 3/3] test: add cross-shell install.sh comparison harness + CI step --- .github/workflows/ci.yml | 8 ++ scripts/test/test-install-cross-shell.sh | 132 +++++++++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100755 scripts/test/test-install-cross-shell.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b5fef3e..f617fb4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,6 +46,14 @@ jobs: dash -n install.sh shellcheck -s sh install.sh WALLY_INSTALL_SH=dash bash scripts/test/test-install-skill-dirs.sh + - name: install.sh output is byte-identical under bash, dash and sh + # Runs the real install.sh under all three with a stubbed curl/uname + # and a fixture release -- no network -- across the happy path, an + # unsupported platform, a bad checksum and a failed release lookup, + # and diffs the output. Catches a reintroduced bashism that `sh -n` + # cannot (dash parses `[[` as a command name; `pipefail` only fails + # at run time) without depending on shellcheck knowing every case. + run: bash scripts/test/test-install-cross-shell.sh - name: Console binding matches its pinned contract run: python3 contracts/generate_console_binding.py --check - name: Versions consistent with versions.toml diff --git a/scripts/test/test-install-cross-shell.sh b/scripts/test/test-install-cross-shell.sh new file mode 100755 index 0000000..f4556a7 --- /dev/null +++ b/scripts/test/test-install-cross-shell.sh @@ -0,0 +1,132 @@ +#!/usr/bin/env bash +# Proves install.sh behaves identically under bash, dash and plain `sh` -- +# the shell `curl ... | sh` actually resolves to on Debian/Ubuntu (dash), +# on macOS (bash) and wherever `sh` is something else POSIX. Runs the real +# install.sh under all three with a stubbed curl/uname, a fixture release, +# tarball and checksum, and no network, then diffs the output byte for byte. +# +# Covers the cases PR #79 claims are shell-independent: a full happy-path +# install, an unsupported platform, a bad checksum, and a failed release +# lookup (the case that motivated the POSIX rewrite -- with `pipefail`, +# dash died on `set -o pipefail` before printing anything; without it, the +# failure must still reach `fail` and print a message on all three shells). +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +INSTALL="${SCRIPT_DIR}/../../install.sh" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +STUB="$WORK/stub-bin" +mkdir -p "$STUB" + +# Fake curl: serves a canned GitHub release response, tarball or checksum +# from $WALLY_STUB_DIR by matching the requested URL, the same way the real +# calls in install.sh shape theirs. A fixture that does not exist fails the +# way a real network error would (curl's own exit code for an HTTP failure). +cat > "$STUB/curl" <<'CURL' +#!/bin/sh +out="" +url="" +while [ $# -gt 0 ]; do + case "$1" in + -o) shift; out="$1" ;; + http*) url="$1" ;; + esac + shift +done +case "$url" in + *api.github.com/repos/*/releases/latest) body="$WALLY_STUB_DIR/release.json" ;; + *.sha256) body="$WALLY_STUB_DIR/asset.sha256" ;; + *) body="$WALLY_STUB_DIR/asset.tar.gz" ;; +esac +[ -f "$body" ] || exit 22 +if [ -n "$out" ]; then cp "$body" "$out"; else cat "$body"; fi +CURL +chmod +x "$STUB/curl" + +# Fake uname: reports whatever OS/arch the case under test wants. +cat > "$STUB/uname" <<'UNAME' +#!/bin/sh +case "$1" in + -s) echo "${WALLY_STUB_OS:-Darwin}" ;; + -m) echo "${WALLY_STUB_ARCH:-arm64}" ;; +esac +UNAME +chmod +x "$STUB/uname" + +# A good fixture: a release tarball whose bin/wally answers --version, +# whoami and login the way the real binary does, plus a matching sha256. +GOOD="$WORK/fixture-good" +mkdir -p "$GOOD/wally-macos-arm64/bin" +cat > "$GOOD/wally-macos-arm64/bin/wally" <<'WALLY' +#!/bin/sh +case "$1" in + --version) echo "wally 1.2.3 (stub)" ;; + whoami) exit 1 ;; + login) echo "stub login ok" ;; +esac +WALLY +chmod +x "$GOOD/wally-macos-arm64/bin/wally" +( cd "$GOOD" && tar -czf asset.tar.gz wally-macos-arm64 ) +echo '{"tag_name": "v1.2.3"}' > "$GOOD/release.json" +shasum -a 256 "$GOOD/asset.tar.gz" | awk '{print $1" wally-1.2.3-macos-arm64.tar.gz"}' > "$GOOD/asset.sha256" + +# A fixture whose checksum does not match its tarball. +BADSUM="$WORK/fixture-badsum" +mkdir -p "$BADSUM" +cp "$GOOD/release.json" "$GOOD/asset.tar.gz" "$BADSUM/" +printf '%s wally-1.2.3-macos-arm64.tar.gz\n' \ + "0000000000000000000000000000000000000000000000000000000000000000" \ + > "$BADSUM/asset.sha256" + +# A fixture with no files at all, so the release lookup fails as if the +# network were down. +EMPTY="$WORK/fixture-empty" +mkdir -p "$EMPTY" + +fails=0 +check() { + name="$1"; expected="$2"; actual="$3" + if [ "$expected" = "$actual" ]; then + printf 'ok %s\n' "$name" + else + printf 'FAIL %s\n --- expected ---\n%s\n --- actual ---\n%s\n' \ + "$name" "$expected" "$actual" + fails=$((fails + 1)) + fi +} + +# Runs install.sh under one shell for one case and prints +# "\n", with that run's own $HOME path normalized +# out -- a happy-path install embeds $HOME in its output (install dir, skill +# path, binary path), and each shell gets its own HOME so the three runs +# cannot clobber each other's install tree. +run_case() { + shell="$1"; stub_dir="$2"; os="$3"; arch="$4" + home="$WORK/home-$shell" + rm -rf "$home"; mkdir -p "$home" + set +e + out="$(WALLY_STUB_DIR="$stub_dir" WALLY_STUB_OS="$os" WALLY_STUB_ARCH="$arch" \ + HOME="$home" PATH="$STUB:$PATH" "$shell" "$INSTALL" 2>&1)" + code=$? + set -e + printf '%s\n%s' "$code" "$(printf '%s' "$out" | sed "s#$home#\$HOME#g")" +} + +for case_name in happy-path unsupported-platform bad-checksum failed-release-lookup; do + case "$case_name" in + happy-path) stub="$GOOD"; os="Darwin"; arch="arm64" ;; + unsupported-platform) stub="$GOOD"; os="Darwin"; arch="x86_64" ;; + bad-checksum) stub="$BADSUM"; os="Darwin"; arch="arm64" ;; + failed-release-lookup) stub="$EMPTY"; os="Darwin"; arch="arm64" ;; + esac + bash_out="$(run_case bash "$stub" "$os" "$arch")" + dash_out="$(run_case dash "$stub" "$os" "$arch")" + sh_out="$(run_case sh "$stub" "$os" "$arch")" + check "$case_name: dash byte-identical to bash" "$bash_out" "$dash_out" + check "$case_name: sh byte-identical to bash" "$bash_out" "$sh_out" +done + +[ "$fails" -eq 0 ] || { printf '%d comparison(s) failed\n' "$fails" >&2; exit 1; } +printf 'all cross-shell cases byte-identical\n'