881 lines
45 KiB
YAML
881 lines
45 KiB
YAML
name: Build Desktop Portable Packages
|
||
|
||
# Triggers:
|
||
# - workflow_dispatch -> build CI artifacts for testing (choose a target platform)
|
||
# - push tag desktop-portable-* -> build all three platforms and publish them
|
||
# together into ONE GitHub Release (tag = the pushed tag)
|
||
# Plain pushes to branches do NOT build (avoids 3 heavy Rust builds per commit).
|
||
"on":
|
||
push:
|
||
tags:
|
||
- "desktop-portable-*"
|
||
workflow_dispatch:
|
||
inputs:
|
||
target:
|
||
description: "Which platform(s) to build (artifacts only; release happens on tag push)"
|
||
required: false
|
||
default: "all"
|
||
type: choice
|
||
options:
|
||
- "all"
|
||
- "windows"
|
||
- "linux"
|
||
- "macos"
|
||
|
||
permissions:
|
||
contents: read
|
||
|
||
env:
|
||
NODE_VERSION: "22.23.2"
|
||
RUST_TOOLCHAIN: "1.95.0"
|
||
PBS_RELEASE: "20260814"
|
||
PBS_PYTHON_VERSION: "3.12.14"
|
||
MACOS_PACKAGING_PYTHON_VERSION: "3.12.10"
|
||
PYTHONDONTWRITEBYTECODE: "1"
|
||
|
||
jobs:
|
||
# ----------------------------------------------------------------------------
|
||
build-windows:
|
||
name: Windows portable
|
||
if: ${{ (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/desktop-portable-')) || (github.event_name == 'workflow_dispatch' && (github.event.inputs.target == 'all' || github.event.inputs.target == 'windows')) }}
|
||
runs-on: windows-2025
|
||
permissions:
|
||
contents: read
|
||
steps:
|
||
- name: Checkout repository
|
||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||
with:
|
||
persist-credentials: false
|
||
|
||
- name: Set up Node.js
|
||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||
with:
|
||
node-version: ${{ env.NODE_VERSION }}
|
||
|
||
- name: Set up Rust
|
||
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable action snapshot
|
||
with:
|
||
toolchain: ${{ env.RUST_TOOLCHAIN }}
|
||
|
||
- name: Cache Rust build
|
||
uses: Swatinem/rust-cache@63fed3e2fecf6f7b51dc6f043341b79ef82a9ae7 # v2.9.2
|
||
with:
|
||
workspaces: frontends/desktop/src-tauri -> target
|
||
|
||
- name: Install desktop dependencies
|
||
working-directory: frontends/desktop
|
||
run: |
|
||
npm install --package-lock=false
|
||
node -e "const v=require('./node_modules/@tauri-apps/cli/package.json').version; if(v!=='2.11.4') throw new Error('unexpected Tauri CLI '+v)"
|
||
test ! -e package-lock.json
|
||
|
||
- name: Verify tracked compiled renderer bytes
|
||
working-directory: frontends/desktop
|
||
run: npm run test:dist
|
||
|
||
- name: Build Windows desktop exe
|
||
working-directory: frontends/desktop
|
||
run: npm run tauri build -- --bundles nsis
|
||
|
||
- name: Assemble self-contained portable bundle
|
||
shell: bash
|
||
run: |
|
||
set -euo pipefail
|
||
|
||
purge_runtime_bytecode() {
|
||
local runtime_root="$1"
|
||
find "$runtime_root" -type d -name '__pycache__' -prune -exec rm -rf {} +
|
||
find "$runtime_root" -type f \( -name '*.pyc' -o -name '*.pyo' \) -delete
|
||
if find "$runtime_root" -type d -name '__pycache__' -print -quit | grep -q .; then
|
||
echo "Python bytecode cache directory remains in packaged runtime: $runtime_root" >&2
|
||
return 1
|
||
fi
|
||
if find "$runtime_root" -type f \( -name '*.pyc' -o -name '*.pyo' \) -print -quit | grep -q .; then
|
||
echo "Python bytecode remains in packaged runtime: $runtime_root" >&2
|
||
return 1
|
||
fi
|
||
}
|
||
|
||
EXE_SRC="$(find frontends/desktop/src-tauri/target/release -maxdepth 1 -type f -name 'ga-desktop.exe' | head -n 1)"
|
||
[[ -n "$EXE_SRC" ]] || { echo "ga-desktop.exe not found" >&2; exit 1; }
|
||
|
||
PKG="artifacts/windows/GenericAgent-Desktop-Windows-Portable"
|
||
RUNTIME="$PKG/runtime"
|
||
mkdir -p "$RUNTIME"
|
||
|
||
# exe at the top level, named GenericAgent (typical portable form)
|
||
cp "$EXE_SRC" "$PKG/GenericAgent.exe"
|
||
cp frontends/desktop/packaging/scripts/windows/install_windows.ps1 "$RUNTIME/install_windows.ps1"
|
||
cp frontends/desktop/packaging/scripts/merge_desktop_settings.py "$RUNTIME/merge_desktop_settings.py"
|
||
# Uninstaller: double-click entry at top level, worker script under runtime/.
|
||
cp frontends/desktop/packaging/scripts/windows/uninstall.bat "$PKG/uninstall.bat"
|
||
cp frontends/desktop/packaging/scripts/windows/uninstall_windows.ps1 "$RUNTIME/uninstall_windows.ps1"
|
||
|
||
# Fixed python-build-standalone Windows x86_64 archive.
|
||
PBS_URL="https://github.com/astral-sh/python-build-standalone/releases/download/${PBS_RELEASE}/cpython-${PBS_PYTHON_VERSION}%2B${PBS_RELEASE}-x86_64-pc-windows-msvc-install_only.tar.gz"
|
||
PBS_SHA256="7330282b47cd43a66b702d39078d2e5a88e580cee351d82f95045f21f5ee042a"
|
||
command -v cygpath >/dev/null 2>&1 || { echo "cygpath not found on Windows runner" >&2; exit 1; }
|
||
RUNNER_TEMP_POSIX="$(cygpath -u "$RUNNER_TEMP")"
|
||
[[ "$RUNNER_TEMP_POSIX" == /* ]] || { echo "cygpath did not return an absolute POSIX path" >&2; exit 1; }
|
||
PBS_ARCHIVE="${RUNNER_TEMP_POSIX}/pbs-windows-x86_64.tar.gz"
|
||
echo "Python build-standalone: $PBS_URL"
|
||
curl --proto '=https' --tlsv1.2 --fail --location --retry 3 --output "$PBS_ARCHIVE" "$PBS_URL"
|
||
printf '%s %s\n' "$PBS_SHA256" "$PBS_ARCHIVE" | sha256sum --check --strict -
|
||
tar -xzf "$PBS_ARCHIVE" -C "$RUNTIME" # extracts a 'python/' dir (python.exe at top)
|
||
PY="$RUNTIME/python/python.exe"
|
||
PYTHONDONTWRITEBYTECODE=1 "$PY" -c 'import platform, sys; assert sys.version_info[:3] == (3, 12, 14); assert platform.machine().lower() in ("amd64", "x86_64"); print(sys.version)'
|
||
|
||
# App-local UCRT: python-build-standalone links the Universal CRT dynamically
|
||
# and does NOT bundle it. Win10/11 ship UCRT in-box, but stripped/older images
|
||
# may not -> python.exe fails to load (missing api-ms-win-crt-*.dll). Copy the
|
||
# redistributable UCRT DLLs next to python.exe (Microsoft-supported app-local
|
||
# deployment). Pure SDK source, no System32 fallback, hard-fail if absent so we
|
||
# never ship a bundle from an unexpected source.
|
||
UCRT_SRC="$(ls -d "/c/Program Files (x86)/Windows Kits/10/Redist/"*/ucrt/DLLs/x64 2>/dev/null | sort -V | tail -1)"
|
||
[[ -n "$UCRT_SRC" ]] || { echo "Windows SDK UCRT redist dir not found on runner" >&2; exit 1; }
|
||
echo "UCRT source: $UCRT_SRC"
|
||
cp "$UCRT_SRC"/api-ms-win-crt-*.dll "$RUNTIME/python/"
|
||
cp "$UCRT_SRC"/ucrtbase.dll "$RUNTIME/python/"
|
||
test -f "$RUNTIME/python/api-ms-win-crt-runtime-l1-1-0.dll" \
|
||
|| { echo "UCRT DLLs missing after copy" >&2; exit 1; }
|
||
|
||
# Offline binary-only wheels. requirements.txt pins direct and transitive versions.
|
||
mkdir -p "$RUNTIME/wheels"
|
||
cp frontends/desktop/packaging/python-runtime-requirements.txt "$RUNTIME/wheels/requirements.txt"
|
||
PYTHONDONTWRITEBYTECODE=1 "$PY" -m pip download --only-binary=:all: --dest "$RUNTIME/wheels" \
|
||
--requirement "$RUNTIME/wheels/requirements.txt"
|
||
|
||
# Runtime source (project root minus heavy/dev/build dirs)
|
||
mkdir -p "$RUNTIME/app"
|
||
tar \
|
||
--exclude='./.git' --exclude='./.github' \
|
||
--exclude='./frontends/tests' \
|
||
--exclude='./frontends/desktop/src-tauri' \
|
||
--exclude='./frontends/desktop/src' \
|
||
--exclude='./frontends/desktop/public' \
|
||
--exclude='./frontends/desktop/scripts' \
|
||
--exclude='./frontends/desktop/release_qualification' \
|
||
--exclude='./frontends/desktop/tests' \
|
||
--exclude='./frontends/desktop/testing' \
|
||
--exclude='./frontends/desktop/spec' \
|
||
--exclude='./frontends/desktop/node_modules' \
|
||
--exclude='./frontends/desktop/dist' \
|
||
--exclude='./frontends/desktop/DESIGN.md' \
|
||
--exclude='./frontends/desktop/package.json' \
|
||
--exclude='./frontends/desktop/package-lock.json' \
|
||
--exclude='./frontends/desktop/.npmrc' \
|
||
--exclude='./frontends/desktop/index.html' \
|
||
--exclude='./frontends/desktop/loading.html' \
|
||
--exclude='./frontends/desktop/setup.html' \
|
||
--exclude='./frontends/desktop/tsconfig*.json' \
|
||
--exclude='./frontends/desktop/vite.config.ts' \
|
||
--exclude='./frontends/desktop/packaging' --exclude='./docs' \
|
||
--exclude='./assets/demo' --exclude='./assets/images' \
|
||
--exclude='./assets/GenericAgent_Technical_Report.pdf' \
|
||
--exclude='./artifacts' \
|
||
--exclude='*/node_modules' --exclude='*/target' \
|
||
--exclude='*/.venv' --exclude='./.venv' \
|
||
--exclude='*/__pycache__' --exclude='*.pyc' \
|
||
-cf - . | tar -xf - -C "$RUNTIME/app"
|
||
test -f "$RUNTIME/app/agentmain.py"
|
||
test -f "$RUNTIME/app/frontends/desktop_bridge.py"
|
||
test -f "$RUNTIME/app/frontends/desktop/static/index.html"
|
||
test ! -e "$RUNTIME/app/frontends/desktop/dist"
|
||
test ! -e "$RUNTIME/app/frontends/desktop/src"
|
||
test ! -e "$RUNTIME/app/frontends/desktop/public"
|
||
test ! -e "$RUNTIME/app/frontends/desktop/package-lock.json"
|
||
test ! -e "$RUNTIME/app/frontends/desktop/node_modules"
|
||
|
||
# Drop python debug symbols (.pdb) to slim the package (~80MB)
|
||
find "$RUNTIME/python" -name '*.pdb' -delete 2>/dev/null || true
|
||
purge_runtime_bytecode "$RUNTIME"
|
||
|
||
cat > "$PKG/readme.txt" <<'EOF'
|
||
================ 中文 ================
|
||
GenericAgent Desktop — Windows 便携版(自包含)
|
||
|
||
无需安装 Python、无需联网装依赖、无需源码仓库——全部已内置。
|
||
|
||
前置条件
|
||
- Windows 10/11 x64
|
||
- Microsoft Edge WebView2 运行时(Win11 一般自带;缺失时首次运行会提示安装)
|
||
|
||
使用
|
||
1. 解压到任意目录(路径建议不含特殊字符)。
|
||
2. 双击 GenericAgent.exe。
|
||
3. 首次启动会离线校验并补齐随包 Python 运行时依赖,界面显示进度,完成后进入主界面。
|
||
4. 之后启动直接秒进。
|
||
|
||
说明
|
||
- 想真正对话,仍需在程序里配置模型 / API Key。
|
||
- 如果 Windows Defender 防火墙拦截 `127.0.0.1` / `localhost` 回环连接,程序可能无法启动或连接本机服务。请允许 `GenericAgent.exe` 和 `runtime\python\python.exe` 通过防火墙,然后重启桌面端。
|
||
- 本便携版尚未使用 Windows 代码签名证书;SmartScreen 可能显示警告。运行前请用随包发布的 SHA256SUMS-windows.txt 核对 ZIP。
|
||
- 可整体移动本文件夹;重启后会自动刷新路径。若需强制重新准备,请删除 runtime\.prepared 后重启。
|
||
- runtime\ 是内置运行环境与源码,正常使用无需改动。
|
||
|
||
================ English ================
|
||
GenericAgent Desktop — Windows Portable (self-contained)
|
||
|
||
No Python install, no internet for dependencies, no source checkout — everything is bundled.
|
||
|
||
Requirements
|
||
- Windows 10/11 x64
|
||
- Microsoft Edge WebView2 runtime (usually preinstalled on Win11; you are prompted if missing)
|
||
|
||
Usage
|
||
1. Extract anywhere (a path without special characters is recommended).
|
||
2. Double-click GenericAgent.exe.
|
||
3. The first launch verifies and completes the bundled Python runtime offline with a
|
||
progress UI, then opens the main window.
|
||
4. Subsequent launches start instantly.
|
||
|
||
Notes
|
||
- To actually chat, configure a model / API key inside the app.
|
||
- If Windows Defender Firewall blocks the `127.0.0.1` / `localhost` loopback connection, the app may fail to start or reach its local service. Allow `GenericAgent.exe` and `runtime\python\python.exe` through the firewall, then restart the desktop app.
|
||
- This portable build is not Windows code-signed, so SmartScreen may warn. Verify the ZIP against the published SHA256SUMS-windows.txt before running it.
|
||
- You may move the whole folder; relaunching refreshes its saved paths. To force a new
|
||
preparation pass, delete runtime\.prepared and relaunch.
|
||
- runtime\ holds the bundled runtime and source; no need to touch it.
|
||
EOF
|
||
sed -i 's/^ //' "$PKG/readme.txt"
|
||
|
||
echo "Package tree (top levels):"
|
||
find "$PKG" -maxdepth 2 -printf '%y %p\n' | sort | head -40
|
||
|
||
- name: Zip portable package
|
||
shell: pwsh
|
||
run: |
|
||
New-Item -ItemType Directory -Force -Path artifacts/windows/out | Out-Null
|
||
Compress-Archive -Path artifacts/windows/GenericAgent-Desktop-Windows-Portable `
|
||
-DestinationPath artifacts/windows/out/GenericAgent-Desktop-Windows-Portable.zip -Force
|
||
$h = Get-FileHash artifacts/windows/out/GenericAgent-Desktop-Windows-Portable.zip -Algorithm SHA256
|
||
"{0} GenericAgent-Desktop-Windows-Portable.zip" -f $h.Hash.ToLowerInvariant() |
|
||
Set-Content -Encoding ASCII artifacts/windows/out/SHA256SUMS-windows.txt
|
||
Get-ChildItem artifacts/windows/out | Format-Table Name, Length
|
||
|
||
- name: Upload workflow artifact
|
||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||
with:
|
||
name: GenericAgent-Desktop-Windows-Portable-${{ github.run_number }}
|
||
path: |
|
||
artifacts/windows/out/GenericAgent-Desktop-Windows-Portable.zip
|
||
artifacts/windows/out/SHA256SUMS-windows.txt
|
||
if-no-files-found: error
|
||
|
||
# ----------------------------------------------------------------------------
|
||
build-linux:
|
||
name: Linux portable
|
||
if: ${{ (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/desktop-portable-')) || (github.event_name == 'workflow_dispatch' && (github.event.inputs.target == 'all' || github.event.inputs.target == 'linux')) }}
|
||
runs-on: ubuntu-22.04
|
||
permissions:
|
||
contents: read
|
||
steps:
|
||
- name: Checkout repository
|
||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||
with:
|
||
persist-credentials: false
|
||
|
||
- name: Set up Node.js
|
||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||
with:
|
||
node-version: ${{ env.NODE_VERSION }}
|
||
|
||
- name: Set up Rust
|
||
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable action snapshot
|
||
with:
|
||
toolchain: ${{ env.RUST_TOOLCHAIN }}
|
||
|
||
- name: Cache Rust build
|
||
uses: Swatinem/rust-cache@63fed3e2fecf6f7b51dc6f043341b79ef82a9ae7 # v2.9.2
|
||
with:
|
||
# Keep registry/git downloads, but never restore binaries built on a newer glibc image.
|
||
prefix-key: "v1-rust-release-ubuntu-22.04-glibc-2.35"
|
||
workspaces: frontends/desktop/src-tauri -> target
|
||
cache-targets: "false"
|
||
cache-bin: "false"
|
||
|
||
- name: Install Tauri Linux dependencies
|
||
run: |
|
||
sudo apt-get update
|
||
sudo apt-get install -y \
|
||
libwebkit2gtk-4.1-dev \
|
||
libgtk-3-dev \
|
||
libayatana-appindicator3-dev \
|
||
librsvg2-dev \
|
||
patchelf \
|
||
binutils \
|
||
file \
|
||
xvfb
|
||
|
||
- name: Install desktop dependencies
|
||
working-directory: frontends/desktop
|
||
run: |
|
||
npm install --package-lock=false
|
||
node -e "const v=require('./node_modules/@tauri-apps/cli/package.json').version; if(v!=='2.11.4') throw new Error('unexpected Tauri CLI '+v)"
|
||
test ! -e package-lock.json
|
||
|
||
- name: Verify tracked compiled renderer bytes
|
||
working-directory: frontends/desktop
|
||
run: npm run test:dist
|
||
|
||
- name: Build Linux AppImage
|
||
working-directory: frontends/desktop
|
||
run: npm run tauri build -- --bundles appimage
|
||
|
||
- name: Assemble self-contained portable bundle
|
||
shell: bash
|
||
run: |
|
||
set -euo pipefail
|
||
|
||
purge_runtime_bytecode() {
|
||
local runtime_root="$1"
|
||
find "$runtime_root" -type d -name '__pycache__' -prune -exec rm -rf {} +
|
||
find "$runtime_root" -type f \( -name '*.pyc' -o -name '*.pyo' \) -delete
|
||
if find "$runtime_root" -type d -name '__pycache__' -print -quit | grep -q .; then
|
||
echo "Python bytecode cache directory remains in packaged runtime: $runtime_root" >&2
|
||
return 1
|
||
fi
|
||
if find "$runtime_root" -type f \( -name '*.pyc' -o -name '*.pyo' \) -print -quit | grep -q .; then
|
||
echo "Python bytecode remains in packaged runtime: $runtime_root" >&2
|
||
return 1
|
||
fi
|
||
}
|
||
|
||
APPIMAGE_SRC="$(find frontends/desktop/src-tauri/target/release/bundle/appimage -maxdepth 1 -type f -name '*.AppImage' | head -n 1)"
|
||
[[ -n "$APPIMAGE_SRC" ]] || { echo "No AppImage found" >&2; exit 1; }
|
||
|
||
PKG="artifacts/linux/GenericAgent-Desktop-Linux-Portable"
|
||
RUNTIME="$PKG/runtime"
|
||
mkdir -p "$RUNTIME"
|
||
|
||
# Stable internal name so the generated .desktop Exec= never changes on upgrades.
|
||
cp "$APPIMAGE_SRC" "$PKG/GenericAgent.AppImage"
|
||
chmod +x "$PKG/GenericAgent.AppImage"
|
||
# Icon for the generated .desktop launcher (Icon= needs a real image file on Linux).
|
||
cp frontends/desktop/src-tauri/icons/icon.png "$PKG/GenericAgent.png"
|
||
cp frontends/desktop/packaging/scripts/linux/install_linux.sh "$RUNTIME/install_linux.sh"
|
||
cp frontends/desktop/packaging/scripts/merge_desktop_settings.py "$RUNTIME/merge_desktop_settings.py"
|
||
chmod +x "$RUNTIME/install_linux.sh"
|
||
# Uninstaller: top-level entry next to the AppImage.
|
||
cp frontends/desktop/packaging/scripts/linux/uninstall.sh "$PKG/uninstall.sh"
|
||
chmod +x "$PKG/uninstall.sh"
|
||
|
||
# Fixed python-build-standalone Linux x86_64 archive.
|
||
PBS_URL="https://github.com/astral-sh/python-build-standalone/releases/download/${PBS_RELEASE}/cpython-${PBS_PYTHON_VERSION}%2B${PBS_RELEASE}-x86_64-unknown-linux-gnu-install_only.tar.gz"
|
||
PBS_SHA256="3297691ae34f75fed81ac424e040145fccb0bafe8e581cd5cadbddfa1c0766c0"
|
||
PBS_ARCHIVE="${RUNNER_TEMP}/pbs-linux-x86_64.tar.gz"
|
||
echo "Python build-standalone: $PBS_URL"
|
||
curl --proto '=https' --tlsv1.2 --fail --location --retry 3 --output "$PBS_ARCHIVE" "$PBS_URL"
|
||
printf '%s %s\n' "$PBS_SHA256" "$PBS_ARCHIVE" | sha256sum --check --strict -
|
||
tar -xzf "$PBS_ARCHIVE" -C "$RUNTIME" # extracts a 'python/' dir
|
||
PY="$RUNTIME/python/bin/python3"
|
||
PYTHONDONTWRITEBYTECODE=1 "$PY" -c 'import platform, sys; assert sys.version_info[:3] == (3, 12, 14); assert platform.machine() == "x86_64"; print(sys.version)'
|
||
|
||
# Offline binary-only wheels. requirements.txt pins direct and transitive versions.
|
||
mkdir -p "$RUNTIME/wheels"
|
||
cp frontends/desktop/packaging/python-runtime-requirements.txt "$RUNTIME/wheels/requirements.txt"
|
||
PYTHONDONTWRITEBYTECODE=1 "$PY" -m pip download --only-binary=:all: --dest "$RUNTIME/wheels" \
|
||
--requirement "$RUNTIME/wheels/requirements.txt"
|
||
|
||
# Runtime source (project root minus heavy/dev/build dirs)
|
||
mkdir -p "$RUNTIME/app"
|
||
tar \
|
||
--exclude='./.git' --exclude='./.github' \
|
||
--exclude='./frontends/tests' \
|
||
--exclude='./frontends/desktop/src-tauri' \
|
||
--exclude='./frontends/desktop/src' \
|
||
--exclude='./frontends/desktop/public' \
|
||
--exclude='./frontends/desktop/scripts' \
|
||
--exclude='./frontends/desktop/release_qualification' \
|
||
--exclude='./frontends/desktop/tests' \
|
||
--exclude='./frontends/desktop/testing' \
|
||
--exclude='./frontends/desktop/spec' \
|
||
--exclude='./frontends/desktop/node_modules' \
|
||
--exclude='./frontends/desktop/dist' \
|
||
--exclude='./frontends/desktop/DESIGN.md' \
|
||
--exclude='./frontends/desktop/package.json' \
|
||
--exclude='./frontends/desktop/package-lock.json' \
|
||
--exclude='./frontends/desktop/.npmrc' \
|
||
--exclude='./frontends/desktop/index.html' \
|
||
--exclude='./frontends/desktop/loading.html' \
|
||
--exclude='./frontends/desktop/setup.html' \
|
||
--exclude='./frontends/desktop/tsconfig*.json' \
|
||
--exclude='./frontends/desktop/vite.config.ts' \
|
||
--exclude='./frontends/desktop/packaging' --exclude='./docs' \
|
||
--exclude='./assets/demo' --exclude='./assets/images' \
|
||
--exclude='./assets/GenericAgent_Technical_Report.pdf' \
|
||
--exclude='./artifacts' \
|
||
--exclude='*/node_modules' --exclude='*/target' \
|
||
--exclude='*/.venv' --exclude='./.venv' \
|
||
--exclude='*/__pycache__' --exclude='*.pyc' \
|
||
-cf - . | tar -xf - -C "$RUNTIME/app"
|
||
test -f "$RUNTIME/app/agentmain.py"
|
||
test -f "$RUNTIME/app/frontends/desktop_bridge.py"
|
||
test -f "$RUNTIME/app/frontends/desktop/static/index.html"
|
||
test ! -e "$RUNTIME/app/frontends/desktop/dist"
|
||
test ! -e "$RUNTIME/app/frontends/desktop/src"
|
||
test ! -e "$RUNTIME/app/frontends/desktop/public"
|
||
test ! -e "$RUNTIME/app/frontends/desktop/package-lock.json"
|
||
test ! -e "$RUNTIME/app/frontends/desktop/node_modules"
|
||
|
||
purge_runtime_bytecode "$RUNTIME"
|
||
|
||
cat > "$PKG/readme.txt" <<'EOF'
|
||
================ 中文 ================
|
||
GenericAgent Desktop — Linux 便携版(自包含)
|
||
|
||
无需安装 Python、无需联网装依赖、无需源码仓库——全部已内置。
|
||
|
||
前置条件
|
||
- Linux x86_64,glibc 2.35 或更高版本(如 Ubuntu 22.04、Debian 12)
|
||
- 桌面环境 + FUSE(运行 AppImage 所需;多数发行版自带)
|
||
|
||
使用
|
||
1. 解压到任意目录(路径建议不含特殊字符)。
|
||
2. 给 AppImage 执行权限并运行:
|
||
chmod +x GenericAgent.AppImage
|
||
./GenericAgent.AppImage
|
||
3. 首次启动会离线校验并补齐随包 Python 运行时依赖,界面显示进度,完成后进入主界面。
|
||
4. 之后启动直接秒进。
|
||
|
||
说明
|
||
- 想真正对话,仍需在程序里配置模型 / API Key。
|
||
- 可整体移动本文件夹;重启后会自动刷新路径。若需强制重新准备,请删除 runtime/.prepared 后重启。
|
||
- runtime/ 是内置运行环境与源码,正常使用无需改动。
|
||
|
||
================ English ================
|
||
GenericAgent Desktop — Linux Portable (self-contained)
|
||
|
||
No Python install, no internet for dependencies, no source checkout — everything is bundled.
|
||
|
||
Requirements
|
||
- Linux x86_64 with glibc 2.35 or newer (for example Ubuntu 22.04 or Debian 12)
|
||
- A desktop environment + FUSE (required to run the AppImage; preinstalled on most distros)
|
||
|
||
Usage
|
||
1. Extract anywhere (a path without special characters is recommended).
|
||
2. Make the AppImage executable and run it:
|
||
chmod +x GenericAgent.AppImage
|
||
./GenericAgent.AppImage
|
||
3. The first launch verifies and completes the bundled Python runtime offline with a
|
||
progress UI, then opens the main window.
|
||
4. Subsequent launches start instantly.
|
||
|
||
Notes
|
||
- To actually chat, configure a model / API key inside the app.
|
||
- You may move the whole folder; relaunching refreshes its saved paths. To force a new
|
||
preparation pass, delete runtime/.prepared and relaunch.
|
||
- runtime/ holds the bundled runtime and source; no need to touch it.
|
||
EOF
|
||
sed -i 's/^ //' "$PKG/readme.txt"
|
||
|
||
mkdir -p artifacts/linux/out
|
||
tar -C artifacts/linux -czf artifacts/linux/out/GenericAgent-Desktop-Linux-Portable.tar.gz GenericAgent-Desktop-Linux-Portable
|
||
( cd artifacts/linux/out && sha256sum GenericAgent-Desktop-Linux-Portable.tar.gz > SHA256SUMS-linux.txt )
|
||
|
||
tar -tzf artifacts/linux/out/GenericAgent-Desktop-Linux-Portable.tar.gz > /tmp/pkglist.txt
|
||
echo "Package contents (first 40 of $(wc -l < /tmp/pkglist.txt) entries):"
|
||
head -40 /tmp/pkglist.txt
|
||
ls -lh artifacts/linux/out
|
||
|
||
# Exercise the exact archived candidate on the oldest supported builder.
|
||
SMOKE_ROOT="${RUNNER_TEMP}/ga-linux-package-smoke"
|
||
mkdir -p "$SMOKE_ROOT"
|
||
tar -xzf artifacts/linux/out/GenericAgent-Desktop-Linux-Portable.tar.gz -C "$SMOKE_ROOT"
|
||
SMOKE_PACKAGE="$SMOKE_ROOT/GenericAgent-Desktop-Linux-Portable"
|
||
APPIMAGE_ABS="$(realpath "$SMOKE_PACKAGE/GenericAgent.AppImage")"
|
||
APPIMAGE_SCAN="$SMOKE_ROOT/appimage-scan"
|
||
mkdir -p "$APPIMAGE_SCAN"
|
||
(cd "$APPIMAGE_SCAN" && "$APPIMAGE_ABS" --appimage-extract >/dev/null)
|
||
MAX_GLIBC=""
|
||
ELF_COUNT=0
|
||
while IFS= read -r -d '' candidate; do
|
||
file -b "$candidate" | grep -q '^ELF' || continue
|
||
ELF_COUNT=$((ELF_COUNT + 1))
|
||
candidate_max="$(readelf --version-info "$candidate" 2>/dev/null \
|
||
| sed -n 's/.*Name: GLIBC_\([0-9][0-9.]*\).*/\1/p' \
|
||
| sort -V | tail -n 1)"
|
||
[[ -n "$candidate_max" ]] || continue
|
||
if [[ -z "$MAX_GLIBC" \
|
||
|| "$(printf '%s\n%s\n' "$MAX_GLIBC" "$candidate_max" | sort -V | tail -n 1)" == "$candidate_max" ]]; then
|
||
MAX_GLIBC="$candidate_max"
|
||
fi
|
||
done < <(find "$SMOKE_PACKAGE" "$APPIMAGE_SCAN/squashfs-root" -type f -print0)
|
||
[[ "$ELF_COUNT" -gt 0 && -n "$MAX_GLIBC" ]] \
|
||
|| { echo "No GLIBC symbol versions found in final Linux package ELFs" >&2; exit 1; }
|
||
[[ "$(printf '%s\n%s\n' "$MAX_GLIBC" '2.35' | sort -V | tail -n 1)" == "2.35" ]] \
|
||
|| { echo "Final package requires GLIBC_$MAX_GLIBC; maximum allowed is GLIBC_2.35" >&2; exit 1; }
|
||
echo "Scanned $ELF_COUNT final package ELFs; maximum symbol is GLIBC_$MAX_GLIBC"
|
||
APPIMAGE_EXTRACT_AND_RUN=1 WEBKIT_DISABLE_COMPOSITING_MODE=1 \
|
||
xvfb-run -a "$SMOKE_PACKAGE/GenericAgent.AppImage" >"$SMOKE_ROOT/app.log" 2>&1 &
|
||
SMOKE_PID=$!
|
||
cleanup_smoke() {
|
||
kill "$SMOKE_PID" 2>/dev/null || true
|
||
wait "$SMOKE_PID" 2>/dev/null || true
|
||
}
|
||
trap cleanup_smoke EXIT
|
||
IDENTITY=""
|
||
for _ in $(seq 1 120); do
|
||
IDENTITY="$(curl --fail --silent --max-time 1 http://127.0.0.1:14168/services/identity || true)"
|
||
[[ -n "$IDENTITY" ]] && break
|
||
sleep 1
|
||
done
|
||
[[ -n "$IDENTITY" ]] || { cat "$SMOKE_ROOT/app.log" >&2; exit 1; }
|
||
EXPECTED_APP_DIR="$SMOKE_PACKAGE/runtime/app/frontends" \
|
||
IDENTITY="$IDENTITY" python3 - <<'PY'
|
||
import json, os, pathlib
|
||
identity = json.loads(os.environ["IDENTITY"])
|
||
actual = pathlib.Path(identity["app_dir"]).resolve()
|
||
expected = pathlib.Path(os.environ["EXPECTED_APP_DIR"]).resolve()
|
||
assert actual == expected, (actual, expected)
|
||
PY
|
||
curl --fail --silent --max-time 3 -X POST http://127.0.0.1:14168/services/bridge/exit >/dev/null
|
||
cleanup_smoke
|
||
trap - EXIT
|
||
|
||
- name: Upload workflow artifact
|
||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||
with:
|
||
name: GenericAgent-Desktop-Linux-Portable-${{ github.run_number }}
|
||
path: |
|
||
artifacts/linux/out/GenericAgent-Desktop-Linux-Portable.tar.gz
|
||
artifacts/linux/out/SHA256SUMS-linux.txt
|
||
if-no-files-found: error
|
||
|
||
# ----------------------------------------------------------------------------
|
||
build-macos:
|
||
name: macOS arm64 DMG
|
||
if: ${{ (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/desktop-portable-')) || (github.event_name == 'workflow_dispatch' && (github.event.inputs.target == 'all' || github.event.inputs.target == 'macos')) }}
|
||
runs-on: macos-15
|
||
permissions:
|
||
contents: read
|
||
steps:
|
||
- name: Checkout repository
|
||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||
with:
|
||
persist-credentials: false
|
||
|
||
- name: Set up Node.js
|
||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||
with:
|
||
node-version: ${{ env.NODE_VERSION }}
|
||
|
||
- name: Set up Rust
|
||
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable action snapshot
|
||
with:
|
||
toolchain: ${{ env.RUST_TOOLCHAIN }}
|
||
|
||
- name: Cache Rust build
|
||
uses: Swatinem/rust-cache@63fed3e2fecf6f7b51dc6f043341b79ef82a9ae7 # v2.9.2
|
||
with:
|
||
workspaces: frontends/desktop/src-tauri -> target
|
||
|
||
- name: Install desktop dependencies
|
||
working-directory: frontends/desktop
|
||
run: |
|
||
npm install --package-lock=false
|
||
node -e "const v=require('./node_modules/@tauri-apps/cli/package.json').version; if(v!=='2.11.4') throw new Error('unexpected Tauri CLI '+v)"
|
||
test ! -e package-lock.json
|
||
|
||
- name: Set up Python DMG tooling runtime
|
||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||
with:
|
||
python-version: ${{ env.MACOS_PACKAGING_PYTHON_VERSION }}
|
||
|
||
- name: Install Python packaging tools
|
||
run: >-
|
||
python3 -m pip install --require-hashes --only-binary=:all:
|
||
--requirement frontends/desktop/packaging/dmg-build-requirements.txt
|
||
|
||
- name: Assert macOS runner architecture
|
||
run: test "$(uname -m)" = arm64
|
||
|
||
- name: Verify tracked compiled renderer bytes
|
||
working-directory: frontends/desktop
|
||
run: npm run test:dist
|
||
|
||
- name: Build macOS .app
|
||
working-directory: frontends/desktop
|
||
run: npm run tauri build -- --bundles app
|
||
|
||
- name: Assemble self-contained DMG app
|
||
shell: bash
|
||
run: |
|
||
set -euo pipefail
|
||
|
||
purge_runtime_bytecode() {
|
||
local runtime_root="$1"
|
||
find "$runtime_root" -type d -name '__pycache__' -prune -exec rm -rf {} +
|
||
find "$runtime_root" -type f \( -name '*.pyc' -o -name '*.pyo' \) -delete
|
||
if find "$runtime_root" -type d -name '__pycache__' -print -quit | grep -q .; then
|
||
echo "Python bytecode cache directory remains in packaged runtime: $runtime_root" >&2
|
||
return 1
|
||
fi
|
||
if find "$runtime_root" -type f \( -name '*.pyc' -o -name '*.pyo' \) -print -quit | grep -q .; then
|
||
echo "Python bytecode remains in packaged runtime: $runtime_root" >&2
|
||
return 1
|
||
fi
|
||
}
|
||
|
||
APP_SRC="$(find frontends/desktop/src-tauri/target/release/bundle/macos -maxdepth 1 -name '*.app' -type d | head -n 1)"
|
||
[[ -n "$APP_SRC" ]] || { echo "No .app found" >&2; exit 1; }
|
||
|
||
# Build one runtime tree and embed it only in the final DMG application.
|
||
RUNTIME_SRC="artifacts/macos/runtime-src"
|
||
mkdir -p "$RUNTIME_SRC"
|
||
cp frontends/desktop/packaging/scripts/macos/install_macos.sh "$RUNTIME_SRC/install_macos.sh"
|
||
cp frontends/desktop/packaging/scripts/merge_desktop_settings.py "$RUNTIME_SRC/merge_desktop_settings.py"
|
||
chmod +x "$RUNTIME_SRC/install_macos.sh"
|
||
|
||
# Fixed python-build-standalone macOS arm64 archive.
|
||
PBS_URL="https://github.com/astral-sh/python-build-standalone/releases/download/${PBS_RELEASE}/cpython-${PBS_PYTHON_VERSION}%2B${PBS_RELEASE}-aarch64-apple-darwin-install_only.tar.gz"
|
||
PBS_SHA256="4572133a5542f306b9bdb155da5800f9e38950cd0a98d469b832ce256fe299ea"
|
||
PBS_ARCHIVE="${RUNNER_TEMP}/pbs-macos-aarch64.tar.gz"
|
||
echo "Python build-standalone: $PBS_URL"
|
||
curl --proto '=https' --tlsv1.2 --fail --location --retry 3 --output "$PBS_ARCHIVE" "$PBS_URL"
|
||
printf '%s %s\n' "$PBS_SHA256" "$PBS_ARCHIVE" | shasum -a 256 --check -
|
||
tar -xzf "$PBS_ARCHIVE" -C "$RUNTIME_SRC" # extracts a 'python/' dir
|
||
PY="$RUNTIME_SRC/python/bin/python3"
|
||
PYTHONDONTWRITEBYTECODE=1 "$PY" -c 'import platform, sys; assert sys.version_info[:3] == (3, 12, 14); assert platform.machine() == "arm64"; print(sys.version)'
|
||
|
||
# Offline binary-only wheels. requirements.txt pins direct and transitive versions.
|
||
mkdir -p "$RUNTIME_SRC/wheels"
|
||
cp frontends/desktop/packaging/python-runtime-requirements.txt "$RUNTIME_SRC/wheels/requirements.txt"
|
||
PYTHONDONTWRITEBYTECODE=1 "$PY" -m pip download --only-binary=:all: --dest "$RUNTIME_SRC/wheels" \
|
||
--requirement "$RUNTIME_SRC/wheels/requirements.txt"
|
||
|
||
# macOS applications must be immutable at first launch. Install from the downloaded
|
||
# wheelhouse before the runtime enters the signed .app, then carry a durable marker.
|
||
# The binary-only offline repair path needs neither setuptools nor wheel. Remove any
|
||
# copies shipped in the base interpreter; bytecode writes are disabled at runtime too.
|
||
PYTHONDONTWRITEBYTECODE=1 "$PY" -m pip install --no-compile --no-index --find-links "$RUNTIME_SRC/wheels" \
|
||
--requirement "$RUNTIME_SRC/wheels/requirements.txt"
|
||
PYTHONDONTWRITEBYTECODE=1 "$PY" -m pip uninstall --yes setuptools wheel
|
||
PYTHONDONTWRITEBYTECODE=1 "$PY" -m pip check
|
||
|
||
PYTHONDONTWRITEBYTECODE=1 "$PY" -c 'import aiohttp, fastapi, importlib.util, pydantic, uvicorn, websockets; assert all(importlib.util.find_spec(name) is None for name in ("setuptools", "wheel", "pkg_resources"))'
|
||
purge_runtime_bytecode "$RUNTIME_SRC"
|
||
printf 'ok\n' > "$RUNTIME_SRC/.prepared"
|
||
test -s "$RUNTIME_SRC/.prepared"
|
||
|
||
# Runtime source (project root minus heavy/dev/build dirs) — bsdtar exclude syntax.
|
||
mkdir -p "$RUNTIME_SRC/app"
|
||
tar \
|
||
--exclude='.git' --exclude='.github' \
|
||
--exclude='frontends/tests' \
|
||
--exclude='frontends/desktop/src-tauri' \
|
||
--exclude='frontends/desktop/src' \
|
||
--exclude='frontends/desktop/public' \
|
||
--exclude='frontends/desktop/scripts' \
|
||
--exclude='frontends/desktop/release_qualification' \
|
||
--exclude='frontends/desktop/tests' \
|
||
--exclude='frontends/desktop/testing' \
|
||
--exclude='frontends/desktop/spec' \
|
||
--exclude='frontends/desktop/node_modules' \
|
||
--exclude='frontends/desktop/dist' \
|
||
--exclude='frontends/desktop/DESIGN.md' \
|
||
--exclude='frontends/desktop/package.json' \
|
||
--exclude='frontends/desktop/package-lock.json' \
|
||
--exclude='frontends/desktop/.npmrc' \
|
||
--exclude='frontends/desktop/index.html' \
|
||
--exclude='frontends/desktop/loading.html' \
|
||
--exclude='frontends/desktop/setup.html' \
|
||
--exclude='frontends/desktop/tsconfig*.json' \
|
||
--exclude='frontends/desktop/vite.config.ts' \
|
||
--exclude='frontends/desktop/packaging' --exclude='docs' \
|
||
--exclude='assets/demo' --exclude='assets/images' \
|
||
--exclude='assets/GenericAgent_Technical_Report.pdf' \
|
||
--exclude='artifacts' \
|
||
--exclude='*/node_modules' --exclude='*/target' \
|
||
--exclude='*/.venv' --exclude='.venv' \
|
||
--exclude='*/__pycache__' --exclude='*.pyc' \
|
||
-cf - . | tar -xf - -C "$RUNTIME_SRC/app"
|
||
test -f "$RUNTIME_SRC/app/agentmain.py"
|
||
test -f "$RUNTIME_SRC/app/frontends/desktop_bridge.py"
|
||
test -f "$RUNTIME_SRC/app/frontends/desktop/static/index.html"
|
||
test ! -e "$RUNTIME_SRC/app/frontends/desktop/dist"
|
||
test ! -e "$RUNTIME_SRC/app/frontends/desktop/src"
|
||
test ! -e "$RUNTIME_SRC/app/frontends/desktop/public"
|
||
test ! -e "$RUNTIME_SRC/app/frontends/desktop/package-lock.json"
|
||
test ! -e "$RUNTIME_SRC/app/frontends/desktop/node_modules"
|
||
|
||
DMG_STAGE="artifacts/macos/dmg-stage"
|
||
DMG_APP="$DMG_STAGE/GenericAgent.app"
|
||
mkdir -p "$DMG_STAGE"
|
||
|
||
# Standard DMG: GenericAgent.app + Applications alias. The post-build
|
||
# repackager below writes the curated two-icon Finder layout.
|
||
ditto "$APP_SRC" "$DMG_APP"
|
||
mkdir -p "$DMG_APP/Contents/Resources"
|
||
ditto "$RUNTIME_SRC" "$DMG_APP/Contents/Resources/runtime"
|
||
test -s "$DMG_APP/Contents/Resources/runtime/.prepared"
|
||
DMG_RUNTIME="$DMG_APP/Contents/Resources/runtime"
|
||
PYTHONDONTWRITEBYTECODE=1 "$DMG_RUNTIME/python/bin/python3" \
|
||
-c 'import aiohttp, fastapi, pydantic, uvicorn, websockets'
|
||
purge_runtime_bytecode "$DMG_RUNTIME"
|
||
# Ad-hoc signing only: this is not Developer ID signing or notarization.
|
||
codesign --force --deep --sign - "$DMG_APP"
|
||
codesign --verify --deep --strict "$DMG_APP"
|
||
ln -s /Applications "$DMG_STAGE/Applications"
|
||
|
||
mkdir -p artifacts/macos/out
|
||
hdiutil create \
|
||
-volname "GenericAgent Desktop" \
|
||
-srcfolder "$DMG_STAGE" \
|
||
-ov \
|
||
-format UDZO \
|
||
"artifacts/macos/out/GenericAgent-Desktop-macOS-aarch64.dmg"
|
||
|
||
# Restore the intentionally minimal Finder presentation: only the app
|
||
# and Applications shortcut, with the established window/icon layout.
|
||
bash frontends/desktop/scripts/post-dmg.sh "artifacts/macos/out/GenericAgent-Desktop-macOS-aarch64.dmg"
|
||
|
||
(
|
||
cd artifacts/macos/out
|
||
shasum -a 256 "GenericAgent-Desktop-macOS-aarch64.dmg" \
|
||
> "GenericAgent-Desktop-macOS-aarch64.dmg.sha256"
|
||
)
|
||
|
||
echo "DMG stage tree:"
|
||
find "$DMG_STAGE" -maxdepth 2 -print | sort
|
||
|
||
- name: Upload workflow artifact
|
||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||
with:
|
||
name: GenericAgent-Desktop-macOS-aarch64-DMG-${{ github.run_number }}
|
||
path: |
|
||
artifacts/macos/out/GenericAgent-Desktop-macOS-aarch64.dmg
|
||
artifacts/macos/out/GenericAgent-Desktop-macOS-aarch64.dmg.sha256
|
||
if-no-files-found: error
|
||
|
||
# ----------------------------------------------------------------------------
|
||
publish-release:
|
||
name: Publish one atomic prerelease
|
||
needs: [build-windows, build-linux, build-macos]
|
||
if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/desktop-portable-') && needs.build-windows.result == 'success' && needs.build-linux.result == 'success' && needs.build-macos.result == 'success' }}
|
||
runs-on: ubuntu-24.04
|
||
permissions:
|
||
contents: write
|
||
steps:
|
||
- name: Download Windows candidate
|
||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||
with:
|
||
name: GenericAgent-Desktop-Windows-Portable-${{ github.run_number }}
|
||
path: ${{ runner.temp }}/release-assets
|
||
|
||
- name: Download Linux candidate
|
||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||
with:
|
||
name: GenericAgent-Desktop-Linux-Portable-${{ github.run_number }}
|
||
path: ${{ runner.temp }}/release-assets
|
||
|
||
- name: Download macOS arm64 candidate
|
||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||
with:
|
||
name: GenericAgent-Desktop-macOS-aarch64-DMG-${{ github.run_number }}
|
||
path: ${{ runner.temp }}/release-assets
|
||
|
||
- name: Validate six assets and publish prerelease
|
||
env:
|
||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||
TAG_NAME: ${{ github.ref_name }}
|
||
TARGET_SHA: ${{ github.sha }}
|
||
shell: bash
|
||
run: |
|
||
set -euo pipefail
|
||
|
||
ASSET_DIR="${RUNNER_TEMP}/release-assets"
|
||
EXPECTED=(
|
||
GenericAgent-Desktop-Linux-Portable.tar.gz
|
||
GenericAgent-Desktop-Windows-Portable.zip
|
||
GenericAgent-Desktop-macOS-aarch64.dmg
|
||
GenericAgent-Desktop-macOS-aarch64.dmg.sha256
|
||
SHA256SUMS-linux.txt
|
||
SHA256SUMS-windows.txt
|
||
)
|
||
|
||
if find "$ASSET_DIR" -mindepth 1 -maxdepth 1 ! -type f -print -quit | grep -q .; then
|
||
echo "Release staging contains a non-regular entry" >&2
|
||
find "$ASSET_DIR" -mindepth 1 -maxdepth 1 -print >&2
|
||
exit 1
|
||
fi
|
||
mapfile -t ACTUAL < <(find "$ASSET_DIR" -mindepth 1 -maxdepth 1 -type f -printf '%f\n' | LC_ALL=C sort)
|
||
if ! diff -u <(printf '%s\n' "${EXPECTED[@]}") <(printf '%s\n' "${ACTUAL[@]}"); then
|
||
echo "Release staging must contain exactly the six expected files" >&2
|
||
exit 1
|
||
fi
|
||
for filename in "${EXPECTED[@]}"; do
|
||
test -s "$ASSET_DIR/$filename" || { echo "Missing or empty release asset: $filename" >&2; exit 1; }
|
||
done
|
||
|
||
verify_checksum_manifest() {
|
||
local manifest="$1"
|
||
local payload="$2"
|
||
local normalized="${RUNNER_TEMP}/${manifest}.normalized"
|
||
tr -d '\r' < "$ASSET_DIR/$manifest" > "$normalized"
|
||
awk -v expected="$payload" '
|
||
NF == 2 && $1 ~ /^[0-9a-fA-F]{64}$/ && $2 == expected { valid += 1 }
|
||
END { exit !(NR == 1 && valid == 1) }
|
||
' "$normalized"
|
||
(cd "$ASSET_DIR" && sha256sum --check --strict "$normalized")
|
||
}
|
||
verify_checksum_manifest SHA256SUMS-windows.txt GenericAgent-Desktop-Windows-Portable.zip
|
||
verify_checksum_manifest SHA256SUMS-linux.txt GenericAgent-Desktop-Linux-Portable.tar.gz
|
||
verify_checksum_manifest GenericAgent-Desktop-macOS-aarch64.dmg.sha256 GenericAgent-Desktop-macOS-aarch64.dmg
|
||
|
||
[[ "$TAG_NAME" =~ ^desktop-portable-[A-Za-z0-9._-]+$ ]] \
|
||
|| { echo "Invalid release tag: $TAG_NAME" >&2; exit 1; }
|
||
[[ "$TARGET_SHA" =~ ^[0-9a-f]{40}$ ]] \
|
||
|| { echo "Invalid release target SHA" >&2; exit 1; }
|
||
TITLE="GenericAgent Desktop ${TAG_NAME}"
|
||
NOTES_FILE="${RUNNER_TEMP}/ga-release-notes.md"
|
||
cat > "$NOTES_FILE" <<'EOF'
|
||
GenericAgent Desktop 桌面版 / Desktop
|
||
|
||
三个平台产物来自同一提交,并在公开发布前统一验证。All three platform artifacts come from one commit and are validated before publication.
|
||
|
||
## 安装方法 / Installation
|
||
- Windows x64:下载 `GenericAgent-Desktop-Windows-Portable.zip`,解压后双击 `GenericAgent.exe`。卸载时运行 `uninstall.bat`,再删除解压目录。
|
||
Windows x64: extract `GenericAgent-Desktop-Windows-Portable.zip`, then launch `GenericAgent.exe`. Run `uninstall.bat` before deleting the extracted directory.
|
||
- Linux x86_64:下载并解压 `GenericAgent-Desktop-Linux-Portable.tar.gz`,执行 `chmod +x GenericAgent.AppImage` 后运行它。卸载时运行 `uninstall.sh`,再删除解压目录。
|
||
Linux x86_64: extract `GenericAgent-Desktop-Linux-Portable.tar.gz`, run `chmod +x GenericAgent.AppImage`, then launch it. Run `uninstall.sh` before deleting the extracted directory.
|
||
- macOS Apple silicon (arm64):打开 `GenericAgent-Desktop-macOS-aarch64.dmg`,把 `GenericAgent.app` 拖入 Applications。
|
||
macOS Apple silicon (arm64): open `GenericAgent-Desktop-macOS-aarch64.dmg` and drag `GenericAgent.app` to Applications.
|
||
|
||
## 签名状态 / Signing status
|
||
Windows 便携版尚未使用代码签名证书,SmartScreen 可能显示警告;运行前请核对 `SHA256SUMS-windows.txt`。
|
||
The Windows portable build is not code-signed, so SmartScreen may warn; verify `SHA256SUMS-windows.txt` before running it.
|
||
|
||
macOS 应用仅使用 ad-hoc 签名,未使用 Apple Developer ID,也未 notarize。首次打开可能需要右键选择“打开”或在“隐私与安全性”中允许。
|
||
The macOS app is ad-hoc signed only. It is neither Developer ID signed nor notarized; first launch may require Open from the context menu or approval in Privacy & Security.
|
||
|
||
SHA-256 清单与各产物一同发布。SHA-256 manifests are included with the artifacts.
|
||
EOF
|
||
sed -i 's/^ //' "$NOTES_FILE"
|
||
|
||
if gh release view "$TAG_NAME" >/dev/null 2>&1; then
|
||
echo "A release already exists for $TAG_NAME; refusing to overwrite it" >&2
|
||
exit 1
|
||
fi
|
||
|
||
ASSETS=()
|
||
for filename in "${EXPECTED[@]}"; do
|
||
ASSETS+=("$ASSET_DIR/$filename")
|
||
done
|
||
# Keep the release invisible while all assets upload; only the final edit publishes it.
|
||
gh release create "$TAG_NAME" "${ASSETS[@]}" \
|
||
--target "$TARGET_SHA" \
|
||
--title "$TITLE" \
|
||
--notes-file "$NOTES_FILE" \
|
||
--draft \
|
||
--prerelease
|
||
|
||
mapfile -t REMOTE_ASSETS < <(gh release view "$TAG_NAME" --json assets --jq '.assets[].name' | LC_ALL=C sort)
|
||
diff -u <(printf '%s\n' "${EXPECTED[@]}") <(printf '%s\n' "${REMOTE_ASSETS[@]}")
|
||
test "$(gh release view "$TAG_NAME" --json isDraft --jq '.isDraft')" = true
|
||
test "$(gh release view "$TAG_NAME" --json isPrerelease --jq '.isPrerelease')" = true
|
||
|
||
gh release edit "$TAG_NAME" --draft=false --prerelease
|
||
test "$(gh release view "$TAG_NAME" --json isDraft --jq '.isDraft')" = false
|
||
test "$(gh release view "$TAG_NAME" --json isPrerelease --jq '.isPrerelease')" = true
|