Files
lymerin 956e789643 fix(update): install portable upgrades without re-resolving dependencies
Portable in-app upgrades could stay at 85% and fail: pip --target resolves
dependencies again instead of reusing the ones already installed in the target,
so every upgrade re-downloaded the whole tree (194 packages), timed out on every
mirror, and still reported success while the target held the previous version.

- Ship uv==0.12.22 in the online/offline portable archives and prefer the
  bundled packages/bin updater, which reuses the dependencies already installed
  in the target; start.sh/start.bat declare packages/ as the update target so
  standalone installs use it too.
- For archives built before the bundled updater existed, download only the
  application wheel and check its dependency closure before modifying the target,
  including transitive extras, environment markers and Requires-Python. Install
  the validated local wheel offline with --no-deps. Download and local installation
  each have a 90-second budget. Incompatible dependencies, installation failures or
  failed runtime verification trigger full dependency installation. One 900-second
  fallback window starts at the first full install and is shared across mirrors;
  subsequent installer timeouts are capped by the remaining window. A dependency
  preflight rejection does not overwrite the old application.
- Verify by loading the target's Octop in a fresh interpreter that processes its
  .pth files, then exercise `python -m octop --version`. Parse a marked result
  line so import output cannot obscure the version. Stale dist-info, new
  metadata with old or unloadable code, and a failing CLI entry cannot confirm an
  upgrade; the installed version must be newer and match the requested target.
  Portable and FPK installations share the rule.
- Read the installed target when validating an explicit request, and reject a
  same-or-older version before installing (HTTP 400 before an API task is
  created). Localise results and verification errors through the API and CLI.

The launcher-side version comparison (a reinstalled desktop client kept launching
the previous runtime) is handled in #1561.
2026-10-05 15:59:32 +08:00

316 lines
10 KiB
Bash

# Shared helpers for green portable packaging scripts.
# shellcheck shell=bash
# Sourced by bootstrap-runtime.sh / vendor-wheels.sh / package.sh
GREEN_ROOT="${GREEN_ROOT:-${REPO_ROOT}/desktop/portable}"
GREEN_RUNTIMES="${GREEN_ROOT}/runtimes"
GREEN_WHEELS="${GREEN_ROOT}/wheels"
GREEN_CACHE="${GREEN_ROOT}/.cache"
GREEN_RELEASE="${GREEN_ROOT}/release"
# Bundled installer for online upgrades of --target portable environments.
GREEN_UV_VERSION="0.12.22"
# Pin a known-good python-build-standalone release (override with PBS_TAG / PBS_PY).
PBS_TAG="${PBS_TAG:-20251209}"
PBS_PY="${PBS_PY:-3.12.12}"
# Prefer npmmirror mirror; override with PBS_BASE_URL for GitHub upstream.
PBS_BASE_URL="${PBS_BASE_URL:-https://registry.npmmirror.com/-/binary/python-build-standalone/${PBS_TAG}}"
ALL_PLATS=(
darwin-arm64
darwin-amd64
linux-amd64
linux-arm64
windows-amd64
windows-arm64
)
# Public GitHub Release names: Octop-<kind>-<os>-<arch>-<version>.<ext>
# Zip payload directory stays Octop-<plat>/ (the desktop unzip strips the first
# path component). PyPI wheels keep the PEP 427 name and are not renamed here.
octop_version() {
sed -nE 's/^version[[:space:]]*=[[:space:]]*"([^"]+)".*/\1/p' \
"${REPO_ROOT}/pyproject.toml" | head -1
}
portable_zip_basename() {
echo "Octop-portable-${1}-$(octop_version).zip"
}
desktop_pkg_basename() {
local plat="$1"
local ver
ver="$(octop_version)"
case "$plat" in
darwin-*) echo "Octop-desktop-${plat}-${ver}.dmg" ;;
windows-*) echo "Octop-desktop-${plat}-${ver}.exe" ;;
linux-*) echo "Octop-desktop-${plat}-${ver}.tar.gz" ;;
*)
echo "unknown platform: ${plat}" >&2
return 1
;;
esac
}
# Map GitHub Actions RUNNER_ARCH (X64/ARM64/...) → green arch suffix.
runner_arch_to_green() {
case "$(printf '%s' "${1:-}" | tr '[:lower:]' '[:upper:]')" in
ARM64|ARM) echo arm64 ;;
X64|AMD64) echo amd64 ;;
*) return 1 ;;
esac
}
# Probe python machine() — setup-uv on windows-11-arm provides arm64 CPython
# even when Git Bash itself is an x64 process.
python_machine_arch() {
local py m
for py in python python3; do
if command -v "$py" >/dev/null 2>&1; then
m="$("$py" -c 'import platform; print(platform.machine())' 2>/dev/null | tr -d '\r\n')"
case "$(printf '%s' "$m" | tr '[:lower:]' '[:upper:]')" in
ARM64|AARCH64) echo arm64; return 0 ;;
AMD64|X86_64|X64) echo amd64; return 0 ;;
esac
fi
done
return 1
}
host_plat() {
# CI / operators can pin this when shell arch detection is wrong
# (common on windows-11-arm + x64 Git Bash).
if [[ -n "${GREEN_HOST_PLAT:-}" ]]; then
if ! is_known_plat "$GREEN_HOST_PLAT"; then
echo "GREEN_HOST_PLAT=${GREEN_HOST_PLAT} is not a known plat" >&2
return 1
fi
echo "$GREEN_HOST_PLAT"
return 0
fi
local os arch
case "$(uname -s)" in
Darwin) os=darwin ;;
Linux) os=linux ;;
MINGW*|MSYS*|CYGWIN*|Windows_NT) os=windows ;;
*)
echo "unsupported host OS: $(uname -s)" >&2
return 1
;;
esac
# GitHub Actions: RUNNER_ARCH is the VM architecture (not the shell binary).
if arch="$(runner_arch_to_green "${RUNNER_ARCH:-}")"; then
echo "${os}-${arch}"
return 0
fi
# Windows: detect OS / interpreter arch, not the current process arch.
# Git Bash / MSYS on windows-11-arm often is an x64 binary, so both
# `uname -m` and PROCESSOR_ARCHITECTURE can look like amd64 while the
# machine is ARM64. That falsely enables cross-assemble + --only-binary
# and breaks sdist-only pure-Python deps (e.g. aliyun-python-sdk-core).
if [[ "$os" == windows ]]; then
if arch="$(python_machine_arch)"; then
echo "${os}-${arch}"
return 0
fi
local os_arch="" pa="${PROCESSOR_ARCHITECTURE:-}" pa32="${PROCESSOR_ARCHITEW6432:-}"
if command -v powershell.exe >/dev/null 2>&1; then
os_arch="$(
powershell.exe -NoProfile -Command \
'[System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString()' \
2>/dev/null | tr -d '\r\n'
)"
fi
# Fallback: CIM OS architecture (more reliable on some runner images).
if [[ -z "$os_arch" ]] && command -v powershell.exe >/dev/null 2>&1; then
os_arch="$(
powershell.exe -NoProfile -Command \
'(Get-CimInstance -ClassName Win32_OperatingSystem).OSArchitecture' \
2>/dev/null | tr -d '\r\n'
)"
fi
case "$(printf '%s' "$os_arch" | tr '[:lower:]' '[:upper:]')" in
ARM64|*ARM64*|ARM\ 64*) arch=arm64 ;;
X64|AMD64|*X64*) arch=amd64 ;;
*)
case "$(printf '%s' "${pa32:-$pa}" | tr '[:lower:]' '[:upper:]')" in
ARM64) arch=arm64 ;;
AMD64|X86_64) arch=amd64 ;;
*)
case "$(uname -m)" in
arm64|aarch64) arch=arm64 ;;
x86_64|amd64) arch=amd64 ;;
*)
echo "unsupported arch: OSArchitecture=${os_arch} uname=$(uname -m) PROCESSOR_ARCHITECTURE=${pa} PROCESSOR_ARCHITEW6432=${pa32} RUNNER_ARCH=${RUNNER_ARCH:-}" >&2
return 1
;;
esac
;;
esac
;;
esac
else
case "$(uname -m)" in
arm64|aarch64) arch=arm64 ;;
x86_64|amd64) arch=amd64 ;;
*)
echo "unsupported arch: $(uname -m)" >&2
return 1
;;
esac
fi
echo "${os}-${arch}"
}
# python-build-standalone triple
plat_triple() {
case "$1" in
darwin-arm64) echo "aarch64-apple-darwin" ;;
darwin-amd64) echo "x86_64-apple-darwin" ;;
linux-amd64) echo "x86_64-unknown-linux-gnu" ;;
linux-arm64) echo "aarch64-unknown-linux-gnu" ;;
windows-amd64) echo "x86_64-pc-windows-msvc" ;;
windows-arm64) echo "aarch64-pc-windows-msvc" ;;
*) return 1 ;;
esac
}
# uv --python-platform values
uv_platform() {
case "$1" in
darwin-arm64) echo "aarch64-apple-darwin" ;;
darwin-amd64) echo "x86_64-apple-darwin" ;;
linux-amd64) echo "x86_64-unknown-linux-gnu" ;;
linux-arm64) echo "aarch64-unknown-linux-gnu" ;;
windows-amd64) echo "x86_64-pc-windows-msvc" ;;
windows-arm64) echo "aarch64-pc-windows-msvc" ;;
*) return 1 ;;
esac
}
# pip --platform tags (for optional offline wheel prefetch)
pip_platform() {
case "$1" in
darwin-arm64) echo "macosx_11_0_arm64" ;;
darwin-amd64) echo "macosx_11_0_x86_64" ;;
linux-amd64) echo "manylinux2014_x86_64" ;;
linux-arm64) echo "manylinux2014_aarch64" ;;
windows-amd64) echo "win_amd64" ;;
windows-arm64) echo "win_arm64" ;;
*) return 1 ;;
esac
}
is_known_plat() {
local p
for p in "${ALL_PLATS[@]}"; do
[[ "$p" == "$1" ]] && return 0
done
return 1
}
runtime_python() {
# Print path to python inside a runtime root (may not exist yet).
local runtime_dir="$1"
if [[ -x "${runtime_dir}/bin/python3" ]]; then
echo "${runtime_dir}/bin/python3"
elif [[ -f "${runtime_dir}/python.exe" ]]; then
echo "${runtime_dir}/python.exe"
elif [[ -x "${runtime_dir}/install/bin/python3" ]]; then
echo "${runtime_dir}/install/bin/python3"
else
echo "${runtime_dir}/bin/python3"
fi
}
require_uv() {
if ! command -v uv >/dev/null 2>&1; then
echo "uv is required (https://docs.astral.sh/uv/)" >&2
exit 1
fi
}
pbs_archive_name() {
local plat="$1"
local triple
triple="$(plat_triple "$plat")" || return 1
echo "cpython-${PBS_PY}+${PBS_TAG}-${triple}-install_only_stripped.tar.gz"
}
# Write platform-specific uv/pip overrides (empty file = none).
# Prints the override file path when overrides exist; otherwise prints nothing.
#
# cryptography: locked 49.x dropped macOS Intel / universal2 wheels. Native
# darwin-amd64 builds then compile from sdist and link Homebrew OpenSSL
# (/usr/local/opt/openssl@3), which is missing on end-user machines.
# Pin to the last release that still ships macosx_*_universal2 wheels.
write_green_overrides() {
local plat="$1"
local out="${2:-${GREEN_ROOT}/overrides-${plat}.txt}"
mkdir -p "$(dirname "$out")"
case "$plat" in
windows-arm64)
{
echo "psycopg-binary ; sys_platform == 'octop-unsupported'"
echo "sqlite-vec ; sys_platform == 'octop-unsupported'"
# 46.0.0 is the only release shipping a win_arm64 wheel; newer ones would
# fall back to a Rust + OpenSSL source build that fails on the runner.
echo "cryptography==46.0.0"
} > "$out"
echo "$out"
;;
darwin-amd64)
{
# 46.0.3 still publishes cp*-abi3-macosx_10_9_universal2 (covers x86_64).
# cryptography>=47 only ships macos arm64 wheels → sdist + brew openssl.
echo "cryptography==46.0.3"
} > "$out"
echo "$out"
;;
*)
rm -f "$out"
;;
esac
}
# Fail if any Mach-O under packages/ links Homebrew / MacPorts absolute paths.
# Catches relocatable-package regressions (e.g. cryptography built against brew openssl).
verify_no_homebrew_dylibs() {
local staging_packages="$1"
local plat="$2"
case "$plat" in
darwin-*) ;;
*) return 0 ;;
esac
if ! command -v otool >/dev/null 2>&1; then
echo "[green] WARNING: otool missing — skip Homebrew dylib check" >&2
return 0
fi
local bad=0
local f deps
# Limit to native extension shared objects (skip .dylibs already vendored beside wheels).
while IFS= read -r -d '' f; do
deps="$(otool -L "$f" 2>/dev/null || true)"
if printf '%s\n' "$deps" | grep -E -q '/usr/local/opt/|/opt/homebrew/|/opt/local/'; then
echo "[green] ERROR: non-portable dylib link in ${f}" >&2
printf '%s\n' "$deps" | grep -E '/usr/local/opt/|/opt/homebrew/|/opt/local/' >&2 || true
bad=1
fi
done < <(find "$staging_packages" \( -name '*.so' -o -name '*.dylib' \) -print0 2>/dev/null)
if [[ "$bad" -ne 0 ]]; then
echo "[green] ERROR: green zip must not depend on Homebrew/MacPorts libraries." >&2
echo " Tip: pin cryptography to a release with macos universal2 wheels (see write_green_overrides)," >&2
echo " and keep --only-binary cryptography so sdist builds cannot slip through." >&2
return 1
fi
echo "[green] dylib check OK (no Homebrew/MacPorts absolute paths)"
return 0
}