Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
279918da1This file is pinned to the selected release. Download the original to open it in your design tool.
#!/usr/bin/env bash
# Release the Linux desktop app: bump version -> build -> package -> upload to a PUBLIC GCS bucket.
# Mirrors scripts/upload-desktop.sh (macOS) — see RELEASE.md for the shared design this follows.
# There is no Apple-equivalent signing/notarization step here: the running app self-updates from a
# checksum-verified manifest entry (DesktopUpdater), which is the trust boundary on both platforms.
#
# Usage:
# bash scripts/upload-desktop-linux.sh # auto-bump (1.2.3 -> 1.2.4; 1.2.99 -> 1.3.1)
# bash scripts/upload-desktop-linux.sh --force # bump the MINOR version (1.2.3 -> 1.3.1) —
# # running apps treat this as a mandatory
# # update (see desktop_updater.dart)
# bash scripts/upload-desktop-linux.sh 1.3.0 # release an explicit version (a major bump,
# # e.g. 2.0.0, is also forced)
# bash scripts/upload-desktop-linux.sh --no-bump # keep the current published version, build + upload
# bash scripts/upload-desktop-linux.sh --no-build # upload the existing build/ artifact as-is
# TARGET_ARCH=arm64 bash scripts/upload-desktop-linux.sh
# TARGET_ARCH=amd64 bash scripts/upload-desktop-linux.sh # amd64 is normalized to x64
# GCS_BUCKET=other bash scripts/upload-desktop-linux.sh # env overrides (see below)
#
# The CURRENT version is read from the remote metadata.json on GCS — the SAME manifest the macOS
# script publishes to, under an architecture-specific key (`desktop-linux-arm64` or
# `desktop-linux-x64`). Nothing is git-committed; pubspec.yaml's `version:` field is never touched
# (see RELEASE.md).
#
# `flutter build linux` has no Info.plist-style version stamping, so this script writes a plain
# version.txt into the built bundle instead — read back by lib/core/app_version.dart at runtime and
# by lib/update/desktop_updater.dart's downloadAndStage() when verifying a downloaded update.
#
# Prereqs: `gcloud storage` authenticated with WRITE access; the bucket/objects must be
# public-read; `flutter` on PATH; must run on an actual Linux (Ubuntu) build host — `flutter build
# linux` cannot cross-compile a Linux bundle from macOS or Windows. APPIMAGETOOL must point at an
# executable `appimagetool-<x86_64|aarch64>.AppImage` — see release.yml for how CI fetches one.
set -euo pipefail
set +x
APP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" # repository root
normalize_architecture() {
case "${1,,}" in
arm64|aarch64) printf 'arm64\n' ;;
x64|x86_64|amd64) printf 'x64\n' ;;
*)
echo "error: unsupported Linux architecture '$1' (expected arm64, aarch64, x64, x86_64, or amd64)" >&2
return 1
;;
esac
}
HOST_ARCH="$(normalize_architecture "$(uname -m)")"
RELEASE_ARCH="$(normalize_architecture "${TARGET_ARCH:-$HOST_ARCH}")"
BUNDLE_DIR="$APP_DIR/build/linux/${RELEASE_ARCH}/release/bundle"
# --- GCS config (all overridable via env) ---
GCS_BUCKET="${GCS_BUCKET:-s3-autonomous-upgrade-3}"
GCS_PUBLIC_BASE_URL="${GCS_PUBLIC_BASE_URL:-https://storage.googleapis.com/${GCS_BUCKET}}"
# The manifest itself stays on the GCS origin (read below, and in the manifest merge) — apps poll it
# every ~60s (lib/update/desktop_updater.dart) and this zone's CDN caps ANY cacheable response at ~31
# days regardless of origin headers (verified live), which would silently delay self-update fleet-wide.
# The AppImage it points at is a different story: immutable once published (never re-upload over an
# existing version — see RELEASE.md), so CDN caching it is pure upside. Only URL below uses this.
CDN_ASSET_BASE_URL="${CDN_ASSET_BASE_URL:-https://cdn.autonomous.ai}"
METADATA_PATH="${METADATA_PATH:-harness/desktop/metadata.json}"
OTA_KEY="${OTA_KEY:-desktop-linux-${RELEASE_ARCH}}" # must match DesktopUpdater's architecture key
next_desktop_version() {
local current="$1" major minor patch
if [[ ! "$current" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
echo "error: version '$current' must look like X.Y.Z" >&2
return 1
fi
major=$((10#${BASH_REMATCH[1]}))
minor=$((10#${BASH_REMATCH[2]}))
patch=$((10#${BASH_REMATCH[3]}))
if (( patch >= 99 )); then
printf '%d.%d.1\n' "$major" "$((minor + 1))"
else
printf '%d.%d.%d\n' "$major" "$minor" "$((patch + 1))"
fi
}
# Same "reset to .1, not .0" convention as next_desktop_version()'s patch rollover above — kept
# consistent with scripts/upload-desktop.sh so "the next minor" means the same thing on both
# platforms. A minor bump is what running apps treat as a mandatory update.
bump_minor_version() {
local current="$1" major minor
if [[ ! "$current" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
echo "error: version '$current' must look like X.Y.Z" >&2
return 1
fi
major=$((10#${BASH_REMATCH[1]}))
minor=$((10#${BASH_REMATCH[2]}))
printf '%d.%d.1\n' "$major" "$((minor + 1))"
}
# --- Parse args (optional explicit version + flags) ---
NEW_VER=""
DO_BUMP=1
DO_FORCE=0
DO_BUILD=1
for arg in "$@"; do
case "$arg" in
--force) DO_FORCE=1 ;;
--no-bump) DO_BUMP=0 ;;
--no-build) DO_BUILD=0 ;;
-*) echo "error: unknown flag '$arg'" >&2; exit 1 ;;
*) NEW_VER="$arg" ;;
esac
done
if [ "$DO_FORCE" -eq 1 ] && [ "$DO_BUMP" -eq 0 ]; then
echo "error: --force and --no-bump contradict each other" >&2
exit 1
fi
[ "$(uname -s)" = "Linux" ] || { echo "error: this must run on a Linux build host — flutter build linux cannot cross-compile" >&2; exit 1; }
if [ "$DO_BUILD" -eq 1 ] && [ "$RELEASE_ARCH" != "$HOST_ARCH" ]; then
echo "error: cannot build linux-${RELEASE_ARCH} on a linux-${HOST_ARCH} host" >&2
echo " Flutter Linux desktop builds use the host architecture. Run this command on a matching host," >&2
echo " or use --no-build with an existing build/linux/${RELEASE_ARCH}/release/bundle." >&2
exit 1
fi
# --- GCS client: `gcloud storage`, and only `gcloud storage` ---
# gsutil was retired from this repo on 2026-09-17. It is a standalone Python tool that only
# understands gcloud's *user* and *service-account-key* credentials: it cannot use the
# external-account (federated) credential Workload Identity Federation issues, so every call fails
# under WIF while the identical `gcloud storage` call works — it is the same gcloud binary that
# performed the token exchange. Every release path here runs on WIF now. Do not reintroduce it.
command -v gcloud >/dev/null 2>&1 || {
echo "error: gcloud not found — install/authenticate the gcloud SDK" >&2
exit 1
}
gcloud storage --help >/dev/null 2>&1 || {
echo "error: this gcloud is too old for 'gcloud storage' — update the gcloud SDK" >&2
exit 1
}
# gcs_cp <src> <dst> [cache-control] [content-type] — either side may be gs:// or a local path or `-`.
gcs_cp() {
local src="$1" dst="$2" cc="${3:-}" ct="${4:-}" args=(storage cp)
if [ -n "$cc" ]; then args+=("--cache-control=$cc"); fi
if [ -n "$ct" ]; then args+=("--content-type=$ct"); fi
gcloud "${args[@]}" "$src" "$dst"
}
# gcs_refuse_republish <gs://…> — a versioned artifact is IMMUTABLE once published: the CDN in front of
# it caches for a year and serves the FIRST bytes it saw for that path. Re-uploading the same version
# (a re-run of a failed release workflow, a second tag on the same version) leaves the manifest naming
# bytes the CDN will never serve — every updater then fails its size/sha check with "Could not
# download and verify". Measured on 1.1.56 (2026-09-18): three workflow runs, one path, two different
# zips, self-update broken for every Mac. The fix is always a new version, never an overwrite.
gcs_refuse_republish() {
local dst="$1"
if gcloud storage ls "$dst" >/dev/null 2>&1; then
echo "error: $dst already exists — versioned artifacts are immutable (the CDN keeps the first bytes)." >&2
echo " Publish a NEW version instead of re-uploading this one (see RELEASE.md)." >&2
exit 1
fi
}
command -v python3 >/dev/null 2>&1 || { echo "error: python3 not found" >&2; exit 1; }
command -v flutter >/dev/null 2>&1 || { echo "error: flutter not found" >&2; exit 1; }
command -v sha256sum >/dev/null 2>&1 || { echo "error: sha256sum not found" >&2; exit 1; }
: "${APPIMAGETOOL:?set APPIMAGETOOL to a path to an appimagetool-<x86_64|aarch64>.AppImage binary}"
[ -x "$APPIMAGETOOL" ] || { echo "error: APPIMAGETOOL ($APPIMAGETOOL) is not executable" >&2; exit 1; }
cleanup() { rm -f "${SRC:-}" "${DST:-}"; rm -rf "${STAGE_ROOT:-}"; }
trap cleanup EXIT
# --- Step 1: resolve the version (source of truth = remote metadata.json) ---
echo ">> target architecture: ${RELEASE_ARCH} (host: ${HOST_ARCH})"
META_URL="${GCS_PUBLIC_BASE_URL%/}/${METADATA_PATH#/}"
CUR="$(curl -fsSL "$META_URL" 2>/dev/null | python3 -c '
import json, sys
try:
print(json.load(sys.stdin).get(sys.argv[1], {}).get("version", ""))
except Exception:
print("")
' "$OTA_KEY" 2>/dev/null || true)"
CUR="$(printf '%s' "$CUR" | tr -d '[:space:]')"
if [ -n "$CUR" ]; then
echo ">> current published version (from metadata.json): $CUR"
else
CUR="1.0.0"
echo ">> could not read remote metadata — starting from $CUR" >&2
fi
if [ -n "$NEW_VER" ]; then
VER="$NEW_VER" # explicit version wins
elif [ "$DO_FORCE" -eq 1 ]; then
VER="$(bump_minor_version "$CUR")"
elif [ "$DO_BUMP" -eq 1 ]; then
VER="$(next_desktop_version "$CUR")"
else
VER="$CUR" # --no-bump: keep current
fi
echo ">> releasing version: $VER"
# --- Step 2: build ---
if [ "$DO_BUILD" -eq 1 ]; then
echo ">> building release $VER"
rm -rf "$BUNDLE_DIR"
( cd "$APP_DIR" && flutter build linux --release )
else
echo ">> skipping build (--no-build)"
fi
[ -d "$BUNDLE_DIR" ] || { echo "error: build bundle missing: $BUNDLE_DIR — drop --no-build" >&2; exit 1; }
[ -x "$BUNDLE_DIR/harness" ] || { echo "error: no harness executable in $BUNDLE_DIR" >&2; exit 1; }
# flutter build linux has no Info.plist-style version stamp — write one ourselves, and assert it
# before packaging so the published manifest always matches what's actually inside the archive.
echo "$VER" > "$BUNDLE_DIR/version.txt"
STAMPED="$(cat "$BUNDLE_DIR/version.txt")"
[ "$STAMPED" = "$VER" ] || { echo "error: version.txt is '$STAMPED', expected '$VER'" >&2; exit 1; }
# --- Step 3: package as a single-file AppImage ---
# AppDir layout: usr/bin/ holds the Flutter bundle verbatim (the `harness` executable, version.txt,
# lib/, data/, and harness.png — the icon CMake already installs at the bundle root, see
# linux/CMakeLists.txt). AppRun is a symlink to the executable rather than a wrapper script: the
# Flutter runner locates its own lib/data next to /proc/self/exe, which resolves to the real binary
# after exec regardless of the symlink used to launch it.
STAGE_ROOT="$(mktemp -d)"
APPDIR="$STAGE_ROOT/AppDir"
[ -f "$BUNDLE_DIR/harness.png" ] || { echo "error: no harness.png in $BUNDLE_DIR (expected via the CMake install rule)" >&2; exit 1; }
mkdir -p "$APPDIR/usr/bin"
cp -a "$BUNDLE_DIR/." "$APPDIR/usr/bin/"
ln -s usr/bin/harness "$APPDIR/AppRun"
cp "$BUNDLE_DIR/harness.png" "$APPDIR/harness.png"
cat > "$APPDIR/harness.desktop" <<EOF
[Desktop Entry]
Type=Application
Name=OpenHarness
Comment=Attach terminals to the agents running on your Harness machines
Exec=harness
Icon=harness
Categories=Development;
Terminal=false
EOF
APPIMAGE_ARCH="$([ "$RELEASE_ARCH" = "arm64" ] && echo aarch64 || echo x86_64)"
OUTPUT="$APP_DIR/build/Harness-linux-${RELEASE_ARCH}-$VER.AppImage"
rm -f "$OUTPUT"
echo ">> packaging $OUTPUT"
ARCH="$APPIMAGE_ARCH" "$APPIMAGETOOL" --appimage-extract-and-run "$APPDIR" "$OUTPUT" \
|| { echo "error: appimagetool failed" >&2; exit 1; }
chmod +x "$OUTPUT"
# --- Step 4: upload the artifact + merge the manifest ---
GCS_PATH="${GCS_PATH:-harness/desktop/${VER}/Harness-linux-${RELEASE_ARCH}.AppImage}"
URL="${CDN_ASSET_BASE_URL%/}/${GCS_PATH#/}"
SHA="$(sha256sum "$OUTPUT" | awk '{print $1}')"
SIZE="$(wc -c < "$OUTPUT" | tr -d ' ')"
echo ">> uploading release $VER ($SIZE bytes, sha256=$SHA)"
echo " dest: gs://${GCS_BUCKET}/${GCS_PATH}"
# Immutable per-version path — see the CDN_ASSET_BASE_URL note near the top of this script. Long
# max-age here is what actually lets the CDN cache it instead of hitting GCS on every install/update.
gcs_refuse_republish "gs://${GCS_BUCKET}/${GCS_PATH}"
gcs_cp "$OUTPUT" "gs://${GCS_BUCKET}/${GCS_PATH}" "public, max-age=31536000, immutable"
echo ">> merging manifest: gs://${GCS_BUCKET}/${METADATA_PATH} (${OTA_KEY})"
SRC="$(mktemp)"; DST="$(mktemp)" # removed by cleanup() on EXIT
if ! gcs_cp "gs://${GCS_BUCKET}/${METADATA_PATH}" "$SRC" 2>/dev/null; then
echo " (no existing metadata.json — creating a new one)"
printf '{}' > "$SRC"
fi
# NOTE: pass paths/values via argv, NEVER pipe the existing JSON into this heredoc — the heredoc
# claims stdin, so the pipe is silently dropped and every upload would blank metadata.json.
python3 - "$SRC" "$DST" "$OTA_KEY" "$VER" "$URL" "$SHA" "$SIZE" <<'PY'
import json, sys
src, dst, key, version, url, sha, size = sys.argv[1:8]
try:
with open(src) as f:
raw = f.read()
data = json.loads(raw) if raw.strip() else {}
except (OSError, json.JSONDecodeError):
data = {}
if not isinstance(data, dict):
data = {}
data[key] = {"version": version, "url": url, "sha256": sha, "size": int(size)}
with open(dst, "w") as f:
json.dump(data, f, indent=2)
f.write("\n")
PY
gcs_cp "$DST" "gs://${GCS_BUCKET}/${METADATA_PATH}" \
"no-cache, no-store, must-revalidate" "application/json"
echo
echo ">> published desktop app (linux-${RELEASE_ARCH}) $VER"
echo " url: $URL"
echo " sha256: $SHA"
echo " manifest: ${GCS_PUBLIC_BASE_URL%/}/${METADATA_PATH#/}"
echo " Running Linux apps poll this on their own schedule (DesktopUpdater, every few hours + on launch)."