From 2a4751e177154cec55615a2c07c5a52a1cdbe44a Mon Sep 17 00:00:00 2001 From: MickLesk <47820557+MickLesk@users.noreply.github.com> Date: Thu, 12 Nov 2026 16:27:51 +0100 Subject: [PATCH 1/2] Unpack a compressed single binary in singlefile mode goatcounter ships its binary as .gz and restic as .bz2. singlefile saved the compressed file under the program's name, so every script unpacked it by hand (mv + gunzip / bzip2 -d) and AGENTS.md's restic example installs a binary that does not run. singlefile now unpacks .gz, .bz2, .xz and .zst assets into the target and installs the unpacker if it is missing. Tarballs stay as downloaded, as USE_ORIGINAL_FILENAME callers (zulip) expect. The scripts that unpack by hand break once this merges and have to drop that step in the same rollout: zerobyte (ProxmoxVE, Incus) and goatcounter (DevScripts). --- .github/workflows/verify-split-files.yml | 4 ++ lib/forge.func | 36 +++++++++-- tests/forge-singlefile-compressed.sh | 80 ++++++++++++++++++++++++ 3 files changed, 114 insertions(+), 6 deletions(-) create mode 100644 tests/forge-singlefile-compressed.sh diff --git a/.github/workflows/verify-split-files.yml b/.github/workflows/verify-split-files.yml index b2280a1..361c586 100644 --- a/.github/workflows/verify-split-files.yml +++ b/.github/workflows/verify-split-files.yml @@ -72,6 +72,7 @@ on: - "tests/db-helper-context.sh" - "tests/cs-clear.sh" - "tests/forge-appversion-scope.sh" + - "tests/forge-singlefile-compressed.sh" - "tests/motd-os-version.sh" - "tests/gpg-key-reason.sh" - "tests/forge-branch-clean-install.sh" @@ -140,6 +141,7 @@ on: - "tests/db-helper-context.sh" - "tests/cs-clear.sh" - "tests/forge-appversion-scope.sh" + - "tests/forge-singlefile-compressed.sh" - "tests/motd-os-version.sh" - "tests/gpg-key-reason.sh" - "tests/forge-branch-clean-install.sh" @@ -299,6 +301,8 @@ jobs: run: bash tests/cs-clear.sh - name: App version pin and .deb stamp tests run: bash tests/forge-appversion-scope.sh + - name: Compressed single binary tests + run: bash tests/forge-singlefile-compressed.sh - name: GPG key failure reason tests run: bash tests/gpg-key-reason.sh - name: Clean install from a git branch tests diff --git a/lib/forge.func b/lib/forge.func index f884325..72cabde 100644 --- a/lib/forge.func +++ b/lib/forge.func @@ -2093,7 +2093,8 @@ fetch_and_deploy_codeberg_release() { # - source: Alias for tarball (same behavior) # - binary: .deb package install (arch-dependent) # - prebuild: Prebuilt .tar.gz archive (e.g. Go binaries) -# - singlefile: Standalone binary (no archive, direct chmod +x install) +# - singlefile: Standalone binary (no archive, direct chmod +x install; +# a .gz/.bz2/.xz/.zst single binary is unpacked first) # - Handles download, extraction/installation and version tracking in ~/. # # Parameters: @@ -2507,12 +2508,35 @@ fetch_and_deploy_release() { _deploy_unpacked_archive "$tmpdir/$filename" "$target" "$unpack_tmp" || return else mkdir -p "$target" - local target_file="$app" + local target_file="$app" unpack="" unpack_pkg="" [[ "${USE_ORIGINAL_FILENAME:-false}" == "true" ]] && target_file="$filename" - curl_download "$target/$target_file" "$asset_url" || { - msg_error "Download failed: $asset_url" - return 250 - } + # A single binary shipped compressed (goatcounter .gz, restic .bz2) lands + # unpacked, so the target is the program and not the archive. Tarballs + # stay as they are: that is what USE_ORIGINAL_FILENAME callers want. + case "$filename" in + *.tar.* | *.tgz | *.txz) ;; + *.gz) unpack=gzip unpack_pkg=gzip ;; + *.bz2) unpack=bzip2 unpack_pkg=bzip2 ;; + *.xz) unpack=xz unpack_pkg=xz-utils ;; + *.zst) unpack=zstd unpack_pkg=zstd ;; + esac + if [[ -n "$unpack" ]]; then + [[ "$target_file" == "$filename" ]] && target_file="${filename%.*}" + ensure_dependencies "$unpack_pkg" || return 100 + curl_download "$tmpdir/$filename" "$asset_url" || { + msg_error "Download failed: $asset_url" + return 250 + } + "$unpack" -dc "$tmpdir/$filename" >"$target/$target_file" || { + msg_error "Could not unpack $filename" + return 251 + } + else + curl_download "$target/$target_file" "$asset_url" || { + msg_error "Download failed: $asset_url" + return 250 + } + fi if [[ "$target_file" != *.jar && -f "$target/$target_file" ]]; then chmod +x "$target/$target_file" fi diff --git a/tests/forge-singlefile-compressed.sh b/tests/forge-singlefile-compressed.sh new file mode 100644 index 0000000..7b7c1d1 --- /dev/null +++ b/tests/forge-singlefile-compressed.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +set -uo pipefail + +CORE_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +eval "$(awk '/^(fetch_and_deploy_release|_forge_workdir|_forge_is_script_app|_forge_descriptor|_forge_strip_v|_forge_previous_version)[(][)] [{]/,/^}/' "$CORE_DIR/lib/forge.func")" + +TEST_DIR="$(mktemp -d)" +trap 'rm -rf -- "$TEST_DIR"' EXIT +export HOME="$TEST_DIR/home" +mkdir -p "$HOME" +LOG="$TEST_DIR/log" +passed=0 failed=0 + +check() { + if [[ "$3" == "$2" ]]; then + echo "PASS $1" + passed=$((passed + 1)) + else + echo "FAIL $1: expected <$2>, got <$3>" >&2 + sed 's/^/ /' "$LOG" >&2 + failed=$((failed + 1)) + fi +} + +# shellcheck disable=SC2034 # read by the eval'd helpers +STD="" +jq() { command jq "$@" | tr -d '\r'; } +msg_info() { :; } +msg_ok() { echo "ok: $*" >>"$LOG"; } +msg_warn() { :; } +msg_error() { echo "error: $*" >>"$LOG"; } +ensure_dependencies() { echo "deps $*" >>"$LOG"; } +_forge_release_json() { printf '{"tag_name":"v1.0.0"}' >"$5"; } +_forge_asset_urls() { echo "https://example.invalid/$ASSET"; } +# Serves a fake program, packed the way the asset name says. +curl_download() { + local out="$1" + printf '#!/bin/sh\necho program\n' >"$TEST_DIR/plain" + case "$ASSET" in + *.tar.gz) tar -czf "$out" -C "$TEST_DIR" plain ;; + *.gz) gzip -c "$TEST_DIR/plain" >"$out" ;; + *.bz2) bzip2 -c "$TEST_DIR/plain" >"$out" ;; + *.xz) xz -c "$TEST_DIR/plain" >"$out" ;; + *) cp "$TEST_DIR/plain" "$out" ;; + esac +} + +deploy() { + : >"$LOG" + rm -rf "$TEST_DIR/opt" "$HOME"/.[!.]* + fetch_and_deploy_release github "$@" >/dev/null 2>&1 +} +runs() { [[ -x "$1" && "$("$1" 2>/dev/null)" == "program" ]] && echo runs || echo "does not run"; } + +for pair in gz:gzip bz2:bzip2 xz:xz; do + ext="${pair%%:*}" + command -v "${pair##*:}" >/dev/null 2>&1 || continue + ASSET="app-v1.0.0-linux-amd64.$ext" + deploy app owner/app singlefile latest "$TEST_DIR/opt/app" "app-v*-linux-amd64.$ext" + check ".$ext: the program is unpacked and executable" "runs" "$(runs "$TEST_DIR/opt/app/app")" + check ".$ext: no archive is left in the target" "1" "$(find "$TEST_DIR/opt/app" -type f | wc -l | tr -d ' ')" +done + +if command -v bzip2 >/dev/null 2>&1; then + ASSET="restic_0.18.0_linux_amd64.bz2" + USE_ORIGINAL_FILENAME=true deploy restic restic/restic singlefile latest "$TEST_DIR/opt/bin" "restic_*_linux_amd64.bz2" + check "original filename drops the compression suffix" "runs" "$(runs "$TEST_DIR/opt/bin/restic_0.18.0_linux_amd64")" +fi + +ASSET="zulip-server-12.4.tar.gz" +USE_ORIGINAL_FILENAME=true deploy zulip zulip/zulip singlefile latest "$TEST_DIR/opt/zulip" "zulip-server-*.tar.gz" +check "a tarball is kept as downloaded" "yes" "$(tar -tzf "$TEST_DIR/opt/zulip/zulip-server-12.4.tar.gz" >/dev/null 2>&1 && echo yes || echo no)" + +ASSET="app-linux-amd64" +deploy app owner/app singlefile latest "$TEST_DIR/opt/app" "app-linux-amd64" +check "a plain binary is unchanged" "runs" "$(runs "$TEST_DIR/opt/app/app")" +check "and needs no unpacker" "0" "$(grep -c '^deps' "$LOG")" + +echo "${passed} passed, ${failed} failed" +[[ "$failed" -eq 0 ]] From 5b8a89a5c1dc3913d02e1de29fba7142b7285bbb Mon Sep 17 00:00:00 2001 From: MickLesk <47820557+MickLesk@users.noreply.github.com> Date: Fri, 13 Nov 2026 15:44:16 +0100 Subject: [PATCH 2/2] Trim the comments to what a reader would get wrong --- lib/forge.func | 4 +--- tests/forge-singlefile-compressed.sh | 1 - 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/lib/forge.func b/lib/forge.func index 72cabde..6f6a67e 100644 --- a/lib/forge.func +++ b/lib/forge.func @@ -2510,9 +2510,7 @@ fetch_and_deploy_release() { mkdir -p "$target" local target_file="$app" unpack="" unpack_pkg="" [[ "${USE_ORIGINAL_FILENAME:-false}" == "true" ]] && target_file="$filename" - # A single binary shipped compressed (goatcounter .gz, restic .bz2) lands - # unpacked, so the target is the program and not the archive. Tarballs - # stay as they are: that is what USE_ORIGINAL_FILENAME callers want. + # A compressed single binary lands unpacked; tarballs stay as they are. case "$filename" in *.tar.* | *.tgz | *.txz) ;; *.gz) unpack=gzip unpack_pkg=gzip ;; diff --git a/tests/forge-singlefile-compressed.sh b/tests/forge-singlefile-compressed.sh index 7b7c1d1..0fc6929 100644 --- a/tests/forge-singlefile-compressed.sh +++ b/tests/forge-singlefile-compressed.sh @@ -32,7 +32,6 @@ msg_error() { echo "error: $*" >>"$LOG"; } ensure_dependencies() { echo "deps $*" >>"$LOG"; } _forge_release_json() { printf '{"tag_name":"v1.0.0"}' >"$5"; } _forge_asset_urls() { echo "https://example.invalid/$ASSET"; } -# Serves a fake program, packed the way the asset name says. curl_download() { local out="$1" printf '#!/bin/sh\necho program\n' >"$TEST_DIR/plain"