diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 1ed38cd..c4e174c 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -55,7 +55,11 @@ jobs: /mnt/scripts/ci/release-server.sh \ /mnt/scripts/ci/deploy-server.sh \ /mnt/scripts/ci/test.sh \ - /mnt/scripts/ci/backup-db.sh + /mnt/scripts/ci/backup-db.sh \ + /mnt/scripts/ci/compile-android.sh \ + /mnt/scripts/ci/compile-windows.sh \ + /mnt/scripts/ci/release-client.sh \ + /mnt/scripts/ci/deploy-client.sh - name: shellcheck CI 脚本(ci/) run: | diff --git a/.gitea/workflows/deploy-client.yml b/.gitea/workflows/deploy-client.yml new file mode 100644 index 0000000..610db94 --- /dev/null +++ b/.gitea/workflows/deploy-client.yml @@ -0,0 +1,117 @@ +name: Deploy Client + +# Mirrors ~/code/jiu/.gitea/workflows/deploy-client.yml's tag→build→release→ +# deploy shape, trimmed to the two platforms drafted so far (Android + +# Windows — no client-web/macOS/iOS jobs; pangolin has no Flutter-web build +# in this pipeline and macOS/iOS are a separate not-yet-drafted phase, see +# docs/superpowers/plans/2026-07-05-cicd.md Phase 3). +# +# TODO(controller) — RUNNER AVAILABILITY: per docs/ci-runner.md, pangolin +# currently has exactly ONE registered Gitea Actions runner +# ("mac-pangolin-2", label `nas:host`). Neither `runs-on: mac` nor +# `runs-on: windows` below has any runner registered to pick it up yet — this +# workflow will queue forever until that's fixed. Options: (a) register +# mac-pangolin-2 with an additional `mac` label (it's already a mac host — +# cheapest fix for build-android/release-deploy) and separately stand up + +# register an actual Windows host runner labeled `windows` for build-windows +# (no such machine exists per docs/ci-runner.md), or (b) repoint both at +# `nas` and accept that Android/Windows builds then compete with the +# docker-in-domain nas jobs on the same single mac host. This mirrors the +# `runs-on: mac` / `runs-on: windows` split already planned in +# docs/superpowers/plans/2026-07-05-cicd.md Task 7/10 — written that way here +# for fidelity to that plan, NOT because the runners are confirmed to exist. +on: + push: + tags: + - 'client-v[0-9]*.[0-9]*.[0-9]*' + workflow_dispatch: + +concurrency: + group: deploy-client + cancel-in-progress: false + +jobs: + build-android: + runs-on: mac + env: + GOPROXY: https://goproxy.cn,direct + PUB_HOSTED_URL: https://pub.flutter-io.cn + FLUTTER_STORAGE_BASE_URL: https://storage.flutter-io.cn + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Compile (Android APK) + env: + RELEASE_KEYSTORE: ${{ secrets.RELEASE_KEYSTORE }} + KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} + run: bash scripts/ci/compile-android.sh "${{ gitea.ref_name }}" + + - name: Upload android artifact + uses: actions/upload-artifact@v3 + with: + name: android + path: dist/ + + build-windows: + runs-on: windows + env: + GOPROXY: https://goproxy.cn,direct + PUB_HOSTED_URL: https://pub.flutter-io.cn + FLUTTER_STORAGE_BASE_URL: https://storage.flutter-io.cn + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Compile (Windows installer) + shell: bash + run: bash scripts/ci/compile-windows.sh "${{ gitea.ref_name }}" + + - name: Upload windows artifact + uses: actions/upload-artifact@v3 + with: + name: windows + path: dist/ + + release-deploy: + needs: [build-android, build-windows] + runs-on: mac + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Download android artifact + uses: actions/download-artifact@v3 + with: + name: android + path: dist/ + + - name: Download windows artifact + uses: actions/download-artifact@v3 + with: + name: windows + path: dist/ + + - name: Release → Forgejo + env: + FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }} + FORGEJO_URL: ${{ secrets.FORGEJO_URL }} + run: bash scripts/ci/release-client.sh "${{ gitea.ref_name }}" + + - name: Deploy → pangolin1 (downloads/) + env: + DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }} + run: bash scripts/ci/deploy-client.sh "${{ gitea.ref_name }}" + + - name: Notify + if: always() + env: + TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} + TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }} + run: | + . scripts/ci/notify.sh + if [ "${{ job.status }}" = "success" ]; then + notify_ok "client ${{ gitea.ref_name }} released + deployed" + else + notify_fail "client ${{ gitea.ref_name }} pipeline failed" + fi diff --git a/.gitignore b/.gitignore index 000756a..3306398 100644 --- a/.gitignore +++ b/.gitignore @@ -56,6 +56,14 @@ client/android/app/src/main/java/ client/ios/Flutter/ephemeral/ client/pubspec.lock +# Android release 签名材料(本地或 CI 落盘,绝不入库) +client/android/key.properties +client/android/*.jks +client/android/*.keystore + +# CI 客户端产物暂存目录(scripts/ci/compile-{android,windows}.sh 输出,构建期生成) +/dist/ + # Claude worktrees(临时工作目录,本地专用) .claude/worktrees/ diff --git a/client/android/app/build.gradle b/client/android/app/build.gradle index b497e12..f90d622 100644 --- a/client/android/app/build.gradle +++ b/client/android/app/build.gradle @@ -22,6 +22,19 @@ if (flutterVersionName == null) { flutterVersionName = '1.0' } +// ── release 签名(key.properties 不入库,由本地或 CI 生成)──────────────── +// 缺失时回退 debug 签名,保证本地 `flutter run --release`/无密钥环境仍可构建。 +// CI 侧由 scripts/ci/compile-android.sh 落盘:client/android/key.properties +// (storeFile/storePassword/keyAlias/keyPassword)。 +def keystoreProperties = new Properties() +def keystorePropertiesFile = rootProject.file('key.properties') +def hasReleaseSigning = keystorePropertiesFile.exists() +if (hasReleaseSigning) { + keystorePropertiesFile.withReader('UTF-8') { reader -> + keystoreProperties.load(reader) + } +} + android { namespace "com.pangolin.pangolin_vpn" compileSdkVersion flutter.compileSdkVersion @@ -49,9 +62,20 @@ android { versionName flutterVersionName } + signingConfigs { + if (hasReleaseSigning) { + release { + storeFile file(keystoreProperties['storeFile']) + storePassword keystoreProperties['storePassword'] + keyAlias keystoreProperties['keyAlias'] + keyPassword keystoreProperties['keyPassword'] + } + } + } + buildTypes { release { - signingConfig signingConfigs.debug + signingConfig hasReleaseSigning ? signingConfigs.release : signingConfigs.debug } } } diff --git a/scripts/ci/compile-android.sh b/scripts/ci/compile-android.sh new file mode 100755 index 0000000..49d17de --- /dev/null +++ b/scripts/ci/compile-android.sh @@ -0,0 +1,156 @@ +#!/usr/bin/env bash +# compile-android.sh — build+embed libbox.aar, build a signed Flutter +# Android APK, package into dist/. +# +# Mirrors ~/code/jiu/scripts/ci/compile-android.sh, adapted for pangolin: +# - version derived via pangolin's ver_from_tag (client-v* tag; _env.sh), +# not jiu's ad-hoc `${TAG#client-v}` strip. +# - pangolin embeds sing-box as a native library (io.nekohasekai.libbox, +# gomobile bind) — jiu's Flutter app has no embedded core at all. This +# script MUST build+deploy libbox.aar (scripts/build-libbox.sh android) +# before `flutter build apk`, or the APK links no VPN kernel at all. +# Needs JDK 17 (gomobile bind javac step) — see CLAUDE.md "client/ 移动端" +# and "已知坑" for why: JDK 21 is rejected outright, NDK 27 fails link. +# - signing secrets: RELEASE_KEYSTORE (base64) + KEY_PASSWORD are the +# secret names given for this task ("actual configured" names). jiu uses +# 4 separate secrets (ANDROID_KEYSTORE_BASE64/ANDROID_KEYSTORE_PASSWORD/ +# ANDROID_KEY_ALIAS/ANDROID_KEY_PASSWORD), and pangolin's OWN prior CI +# design docs (docs/cicd-design.html, docs/superpowers/plans/2026-07-05- +# cicd.md, docs/superpowers/specs/2026-07-05-cicd-design.md) planned the +# same 4-secret jiu-style naming. That is a real conflict with this +# task's brief — see the TODO(controller) block below and the draft +# report. This script follows the 2-secret brief as instructed, assuming +# storePassword == keyPassword == KEY_PASSWORD and a derivable keyAlias +# (default "pangolin", overridable via optional RELEASE_KEY_ALIAS). +# - dart-define is PANGOLIN_API_URL (client/lib/services/api_config.dart), +# not jiu's BASE_URL/PUBLIC_URL/APP_VERSION trio. +# - build.gradle previously signed release builds with signingConfigs.debug +# (no release signing config existed at all) — this task's draft also +# wires client/android/app/build.gradle to add a `release` signingConfig +# that reads client/android/key.properties (falls back to debug when +# key.properties is absent, so local/no-secret builds still work). See +# report for the exact diff. +# - universal APK (`flutter build apk --release`, no --split-per-abi) — see +# report for the size-vs-single-stable-URL tradeoff discussion; this +# deviates from docs/superpowers/plans/2026-07-05-cicd.md Task 7 which +# had planned --split-per-abi. +# - output dist/pangolin-android.apk (stable name; deploy-client.sh keeps +# only the latest one under this name on pangolin1). +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=scripts/ci/_env.sh +. "${SCRIPT_DIR}/_env.sh" + +TAG="${1:?usage: compile-android.sh }" + +# No $() anywhere (repo convention): ver_from_tag prints to stdout, captured +# via a temp file + `read`, same pattern as release-server.sh. +ver_file="/tmp/compile_android_ver.$$" +ver_from_tag client "$TAG" > "$ver_file" +VER="" +# `|| true`: ver_from_tag uses printf '%s' (no trailing newline); `read` +# hitting EOF without a newline returns 1 even though VER was assigned — +# under `set -e` that would abort here. Same rationale as release-server.sh. +read -r VER < "$ver_file" || true +rm -f "$ver_file" + +# versionCode 必须单调递增,否则 Android 无法覆盖升级安装(同 jiu 注释)。 +# 由版本号推导:major*10000 + minor*100 + patch(如 1.0.48 -> 10048)。 +# Pure parameter expansion (no `cut`/`$()`), and strip any `-suffix` off the +# patch component so the arithmetic below stays numeric (e.g. "48-rc1"->48). +MAJOR="${VER%%.*}" +_REST="${VER#*.}" +MINOR="${_REST%%.*}" +PATCH="${_REST#*.}" +PATCH="${PATCH%%-*}" +BUILD=$(( MAJOR * 10000 + MINOR * 100 + PATCH )) + +echo "==> compile-android: tag=${TAG} version=${VER} build=${BUILD}" + +API_URL="${PANGOLIN_API_URL:-https://api.yanmeiai.com}" + +# ── [1/3] build+embed libbox.aar (io.nekohasekai.libbox) ──────────────────── +# Must run BEFORE `flutter build apk` — build.gradle's dependency block reads +# app/kernel/dist/android/libbox.aar and just logs a warning (does not fail +# the build!) if it's missing, which would silently ship an APK with no VPN +# kernel. Fail loudly here instead. +JAVA_HOME_17="${JAVA_HOME_17:-/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home}" +if [ ! -d "$JAVA_HOME_17" ]; then + echo "ERROR: JDK 17 not found at ${JAVA_HOME_17} (required for gomobile Android bind)." >&2 + echo " See CLAUDE.md 'libbox 构建' — install via 'brew install openjdk@17' or set JAVA_HOME_17." >&2 + exit 1 +fi +echo "==> compile-android: building libbox.aar (JAVA_HOME=${JAVA_HOME_17}, NDK auto-detected >=28)" +JAVA_HOME="$JAVA_HOME_17" bash scripts/build-libbox.sh android + +LIBBOX_AAR="app/kernel/dist/android/libbox.aar" +if [ ! -f "$LIBBOX_AAR" ]; then + echo "ERROR: ${LIBBOX_AAR} not found after build-libbox.sh — aborting." >&2 + exit 1 +fi + +# ── [2/3] sync pubspec version + release signing ──────────────────────────── +# BSD sed (macOS runner — mac-pangolin-2, see docs/ci-runner.md), same as jiu. +sed -i '' "s/^version:.*/version: ${VER}+${BUILD}/" client/pubspec.yaml + +# 必须 release 签名。缺任一签名 secret 直接失败,绝不回退 debug:debug 包用公开 +# 调试密钥签名,不能正式分发,也无法与正式版互相覆盖升级(同 jiu 注释)。 +: "${RELEASE_KEYSTORE:?缺少 RELEASE_KEYSTORE(base64 编码的 release keystore):未配置签名,构建中止(不回退 debug)}" +: "${KEY_PASSWORD:?缺少 KEY_PASSWORD:未配置签名,构建中止}" + +# TODO(controller): confirm this 2-secret assumption before relying on it. +# - Assumes storePassword == keyPassword == KEY_PASSWORD (true only if the +# keystore was generated with the same password for both). +# - keyAlias is NOT among the secrets this task named, so it defaults to +# "pangolin" below — override with RELEASE_KEY_ALIAS if the real alias +# differs (e.g. keytool default "androiddebugkey" is NOT this — that +# alias belongs to the debug keystore, never use it for release signing). +# - pangolin's OWN prior CI design docs (docs/cicd-design.html §secrets, +# docs/superpowers/plans/2026-07-05-cicd.md Task 6/7, +# docs/superpowers/specs/2026-07-05-cicd-design.md) planned 4 separate +# secrets instead: ANDROID_KEYSTORE_BASE64 / ANDROID_KEYSTORE_PASSWORD / +# ANDROID_KEY_ALIAS / ANDROID_KEY_PASSWORD. If THAT naming is what is +# actually configured in Forgejo (not RELEASE_KEYSTORE/KEY_PASSWORD), +# this script needs updating before first real run. +RELEASE_KEY_ALIAS="${RELEASE_KEY_ALIAS:-pangolin}" + +echo "==> compile-android: configuring release signing (keyAlias=${RELEASE_KEY_ALIAS})" +KEYSTORE_PATH="${PWD}/client/android/pangolin-release.jks" +printf '%s' "$RELEASE_KEYSTORE" | base64 --decode > "$KEYSTORE_PATH" +cat > client/android/key.properties <&2 + exit 1 +fi +cp "$APK" dist/pangolin-android.apk + +# Clean up signing material so it never lingers on the runner workspace. +rm -f client/android/key.properties "$KEYSTORE_PATH" 2>/dev/null || true + +echo "==> compile-android: done — dist/ contents:" +ls -lh dist/ diff --git a/scripts/ci/compile-windows.sh b/scripts/ci/compile-windows.sh new file mode 100755 index 0000000..357ffe3 --- /dev/null +++ b/scripts/ci/compile-windows.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +# compile-windows.sh — build Flutter Windows desktop app, package into +# dist/ via the existing Inno Setup script (client/windows/installer/pangolin.iss). +# +# Mirrors ~/code/jiu/scripts/ci/compile-windows.sh's short-path fix, adapted: +# - version derived via pangolin's ver_from_tag, not jiu's ad-hoc strip. +# - pangolin's Windows client does NOT embed libbox — it bundles a +# downloaded sing-box.exe + wintun.dll as a subprocess (see +# client/windows/README.md, CLAUDE.md "client/ 移动端"'s sibling desktop +# note). Those are fetched by app/kernel/fetch-desktop-bin.sh, NOT built +# by scripts/build-libbox.sh — no JDK/NDK/gomobile needed on Windows. +# - pangolin already has a working local packaging path: +# client/windows/build.ps1 + client/windows/installer/pangolin.iss. This +# script re-implements that flow in bash (matching the repo's +# jiu-mirrored bash-script-per-platform convention) rather than shelling +# out to build.ps1, so it can apply jiu's short-path fix below. +# - jiu's short-path fix: the Forgejo Windows runner checks out under +# C:\Windows\System32\config\systemprofile\.cache\act\...\hostexecutor, +# which triggers WOW64 filesystem redirection that breaks CMake/Flutter +# native paths. jiu copies client/ to C:\jiu-build\client and builds +# there. pangolin's windows/CMakeLists.txt additionally resolves the +# kernel binaries via a path RELATIVE to CMAKE_SOURCE_DIR +# (client/windows/../../app/kernel/dist/desktop/windows-* — see +# client/windows/CMakeLists.txt "Pangolin sing-box kernel" section), so +# this script must copy app/kernel/dist alongside client/ preserving the +# SAME relative layout (BUILD_ROOT/client + BUILD_ROOT/app/kernel/dist), +# not just client/ alone like jiu does — jiu has no such cross-directory +# kernel reference. +# - dart-define is PANGOLIN_API_URL, not jiu's BASE_URL/PUBLIC_URL/APP_VERSION. +# - pangolin.iss hardcodes `#define MyAppVersion "1.0.47"` (no #ifndef guard +# like jiu's jiu-installer.iss), so ISCC's /D command-line override would +# be silently ignored. This script `sed`s the version into the copied +# .iss file instead of passing /DAppVer. +# - output dist/pangolin-windows-x64-setup.exe (stable name — the .iss's +# own OutputBaseFilename is version-suffixed "pangolin-setup-.exe", +# so this script copies+renames it to the stable name deploy-client.sh +# and release-client.sh expect). +# - unsigned (like jiu) — first install shows a SmartScreen warning; no +# code-signing cert configured (see report). +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=scripts/ci/_env.sh +. "${SCRIPT_DIR}/_env.sh" + +TAG="${1:?usage: compile-windows.sh }" + +ver_file="/tmp/compile_windows_ver.$$" +ver_from_tag client "$TAG" > "$ver_file" +VER="" +read -r VER < "$ver_file" || true # 无结尾换行的 read 退出码,见 compile-android.sh 同注释 +rm -f "$ver_file" + +echo "==> compile-windows: tag=${TAG} version=${VER}" + +API_URL="${PANGOLIN_API_URL:-https://api.yanmeiai.com}" + +# ── [1/4] fetch sing-box.exe + wintun.dll into the ORIGINAL checkout ─────── +# Must run before the short-path copy below (step 2), so app/kernel/dist/ +# exists to be copied alongside client/. +echo "==> compile-windows: fetching sing-box.exe + wintun.dll" +bash app/kernel/fetch-desktop-bin.sh windows amd64 + +# ── [2/4] copy client/ + app/kernel/dist to a short path ─────────────────── +BUILD_ROOT="/c/pangolin-build" +BUILD_CLIENT="${BUILD_ROOT}/client" + +echo "==> copying client/ + app/kernel/dist to ${BUILD_ROOT}" +rm -rf "$BUILD_ROOT" +mkdir -p "${BUILD_ROOT}/app/kernel" +cp -r client "$BUILD_CLIENT" +cp -r app/kernel/dist "${BUILD_ROOT}/app/kernel/dist" + +# `cp -r` turns Flutter's plugin symlinks (windows/flutter/ephemeral/.plugin_symlinks/*) +# into real directories. flutter's createPluginSymlinks only cleans up *symlinks*, so it +# then fails to create a symlink over the copied real dir (errno 183 / PathExists). +# Drop all regenerable artifacts so the build below recreates them cleanly (same fix as jiu). +rm -rf "${BUILD_CLIENT}/windows/flutter/ephemeral" \ + "${BUILD_CLIENT}/.dart_tool" \ + "${BUILD_CLIENT}/build" + +# ── [3/4] build ────────────────────────────────────────────────────────────── +# NOTE(controller): `flutter create` on an existing project only fills in +# scaffolding it manages (ephemeral/, generated_plugins.cmake, ...) and should +# not overwrite files that already exist — jiu relies on exactly this. But +# pangolin's windows/ tree is more heavily customized than jiu's (CMakeLists +# kernel-bundling block, runner.exe.manifest requireAdministrator + the +# /MANIFESTUAC:NO link flag — see client/windows/README.md "已知坑"). Verify +# on first real CI run that none of those get clobbered. +pushd "$BUILD_CLIENT" > /dev/null +flutter create --platforms=windows . --project-name pangolin_vpn +flutter build windows --release "--dart-define=PANGOLIN_API_URL=${API_URL}" +popd > /dev/null + +# ── [4/4] package with Inno Setup (existing client/windows/installer/pangolin.iss) ── +ISCC="" +for c in "/c/Program Files (x86)/Inno Setup 6/ISCC.exe" "/c/Program Files/Inno Setup 6/ISCC.exe"; do + if [ -f "$c" ]; then ISCC="$c"; break; fi +done +if [ -z "$ISCC" ]; then + echo "ERROR: Inno Setup (ISCC) not found. Install Inno Setup 6 on this runner first." >&2 + exit 1 +fi + +ISS="${BUILD_CLIENT}/windows/installer/pangolin.iss" +# pangolin.iss hardcodes MyAppVersion (no #ifndef guard) — sed it to match the +# release tag rather than relying on an ISCC /D override (which would be +# shadowed by the unconditional #define — see header note above). +sed -i "s/^#define MyAppVersion .*/#define MyAppVersion \"${VER}\"/" "$ISS" + +echo "==> building installer with Inno Setup (version ${VER})" +# MSYS_NO_PATHCONV / MSYS2_ARG_CONV_EXCL disable Git Bash's automatic conversion +# of paths, matching jiu's ISCC invocation fix. +MSYS_NO_PATHCONV=1 MSYS2_ARG_CONV_EXCL='*' "$ISCC" "$ISS" + +# pangolin.iss has no explicit OutputDir -> Inno defaults to {src}\Output +# (relative to the .iss file), filename OutputBaseFilename=pangolin-setup-. +mkdir -p dist +SETUP_SRC="C:\\pangolin-build\\client\\windows\\installer\\Output\\pangolin-setup-${VER}.exe" +echo "==> copying installer -> dist/pangolin-windows-x64-setup.exe" +powershell -NoProfile -Command \ + "Copy-Item '${SETUP_SRC}' 'dist\\pangolin-windows-x64-setup.exe' -Force" + +echo "==> compile-windows: done — dist/ contents:" +ls -lh dist/ diff --git a/scripts/ci/deploy-client.sh b/scripts/ci/deploy-client.sh new file mode 100755 index 0000000..7d83a85 --- /dev/null +++ b/scripts/ci/deploy-client.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# deploy-client.sh — publish the latest Android/Windows client +# installers to pangolin1's public downloads directory. Keeps only the single +# latest file per platform (jiu-style "only latest" — see jiu's +# deploy-client.sh /opt/jiu/downloads handling), overwriting whatever a +# previous release published under the same stable filename. +# +# Mirrors ~/code/jiu/scripts/ci/deploy-client.sh's "only latest" downloads +# publish, but uses pangolin's own lib-ssh.sh API (setup_ssh / $SSH / +# $RSYNC_SSH, DEPLOY_HOST defaulting to pangolin1's IP — different from +# jiu's lib-forgejo.sh-embedded setup_ssh/EC2_HOST convention) and drops +# jiu's Flutter-web + version.yaml swap entirely: pangolin's client has no +# web build in this pipeline, and no version.yaml self-update manifest. +# +# TODO(controller): this script assumes pangolin-server (or nginx/whatever +# fronts pangolin1's public HTTP) serves ${DOWNLOADS_DIR} at /downloads/ — +# that vhost/route wiring is a SEPARATE, not-yet-done task (same status as +# docs/superpowers/plans/2026-07-05-cicd.md Task 8's "官网下载链接接 +# Android release", which is also not part of this draft). +# +# Usage: scripts/ci/deploy-client.sh (e.g. client-v1.0.49) +# Requires env: DEPLOY_SSH_KEY (see lib-ssh.sh). Assumes compile-android.sh / +# compile-windows.sh (or a prior download-artifact step) have already +# produced dist/pangolin-android.apk and/or dist/pangolin-windows-x64-setup.exe. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=scripts/ci/lib-ssh.sh +. "${SCRIPT_DIR}/lib-ssh.sh" + +# TODO(controller): confirm this path. Proposed to sit alongside the existing +# /var/lib/pangolin/ tree (pangolin.db lives at /var/lib/pangolin/pangolin.db +# per deploy-server.sh) rather than under nginx's webroot directly, so the +# web server config just needs one `location /downloads/ { alias ...; }` +# (or equivalent) added — no file ownership overlap with the control-plane +# SQLite DB / systemd units. +DOWNLOADS_DIR=/var/lib/pangolin/downloads + +TAG="${1:?usage: deploy-client.sh }" + +# Refuse anything that isn't a strict client-vX.Y.Z[-suffix] tag before it can +# reach the remote commands below (command-injection guard, mirroring +# deploy-server.sh's rationale: an anchored regex, not a `case` glob, since a +# trailing `*` would match shell metacharacters too). +if ! [[ "$TAG" =~ ^client-v[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.]+)?$ ]]; then + echo "deploy-client: refusing unexpected tag '$TAG'" >&2 + exit 1 +fi + +if [ ! -f dist/pangolin-android.apk ] && [ ! -f dist/pangolin-windows-x64-setup.exe ]; then + echo "deploy-client: no artifacts found in dist/ — nothing to deploy" >&2 + exit 1 +fi + +setup_ssh +SCP="scp -i ${SSH_KEY_FILE} -P ${DEPLOY_PORT} -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=${SSH_KNOWN_HOSTS_FILE}" + +echo "==> deploy-client: tag=${TAG} host=${DEPLOY_HOST} downloads_dir=${DOWNLOADS_DIR}" +$SSH "root@${DEPLOY_HOST}" "mkdir -p ${DOWNLOADS_DIR}" + +if [ -f dist/pangolin-android.apk ]; then + echo "==> deploy-client: uploading pangolin-android.apk" + $SCP dist/pangolin-android.apk "root@${DEPLOY_HOST}:/tmp/pangolin-android.apk.new" + $SSH "root@${DEPLOY_HOST}" "rm -f ${DOWNLOADS_DIR}/pangolin-android.apk && mv /tmp/pangolin-android.apk.new ${DOWNLOADS_DIR}/pangolin-android.apk && chmod 644 ${DOWNLOADS_DIR}/pangolin-android.apk" +fi + +if [ -f dist/pangolin-windows-x64-setup.exe ]; then + echo "==> deploy-client: uploading pangolin-windows-x64-setup.exe" + $SCP dist/pangolin-windows-x64-setup.exe "root@${DEPLOY_HOST}:/tmp/pangolin-windows-x64-setup.exe.new" + $SSH "root@${DEPLOY_HOST}" "rm -f ${DOWNLOADS_DIR}/pangolin-windows-x64-setup.exe && mv /tmp/pangolin-windows-x64-setup.exe.new ${DOWNLOADS_DIR}/pangolin-windows-x64-setup.exe && chmod 644 ${DOWNLOADS_DIR}/pangolin-windows-x64-setup.exe" +fi + +# TODO(controller): macOS artifact not yet produced by this draft — add the +# same guarded upload+swap once compile-macos.sh lands (Phase 3). + +echo "==> deploy-client: done" diff --git a/scripts/ci/release-client.sh b/scripts/ci/release-client.sh new file mode 100755 index 0000000..2715cdc --- /dev/null +++ b/scripts/ci/release-client.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# release-client.sh — ensure the Forgejo release for a client-v* tag +# exists and upload whichever client artifacts are present in dist/. +# +# Mirrors ~/code/jiu/scripts/ci/release-client.sh's asset-guarding pattern, +# but uses pangolin's own lib-forgejo.sh API (forgejo_release_ensure / +# forgejo_upload_asset — different names/signature than jiu's create_release/ +# upload_asset) and drops jiu's version.yaml/CHANGELOG bookkeeping entirely: +# pangolin has no equivalent backend/config/version.yaml manifest — client +# self-update checking is out of scope here (server just serves the release +# assets; see deploy-client.sh). +# +# Multiple platform build jobs (android/windows/...) all upload into the SAME +# release, guarded by `[ -f ... ]` since a given CI run may only have built a +# subset (e.g. a partial manual re-dispatch, or before macOS/iOS land). +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=scripts/ci/_env.sh +. "${SCRIPT_DIR}/_env.sh" +# shellcheck source=scripts/ci/lib-forgejo.sh +. "${SCRIPT_DIR}/lib-forgejo.sh" + +TAG="${1:?usage: release-client.sh }" + +ver_file="/tmp/release_client_ver.$$" +ver_from_tag client "$TAG" > "$ver_file" +VER="" +read -r VER < "$ver_file" || true # 无结尾换行的 read 退出码,见 release-server.sh 同注释 +rm -f "$ver_file" + +echo "==> release-client: tag=${TAG} ver=${VER}" + +forgejo_release_ensure "$TAG" "client ${VER}" + +if [ -f dist/pangolin-android.apk ]; then + forgejo_upload_asset "$TAG" dist/pangolin-android.apk +fi + +if [ -f dist/pangolin-windows-x64-setup.exe ]; then + forgejo_upload_asset "$TAG" dist/pangolin-windows-x64-setup.exe +fi + +# TODO(controller): macOS artifact not yet built by any compile-*.sh in this +# draft (Phase 3 / Task 9 of docs/superpowers/plans/2026-07-05-cicd.md is out +# of scope for this task). Once compile-macos.sh lands, add here: +# if [ -f dist/pangolin-macos-x64.zip ]; then +# forgejo_upload_asset "$TAG" dist/pangolin-macos-x64.zip +# fi + +echo "==> release-client: done — Release ${TAG} updated. dist/ contents:" +ls -lh dist/ 2>/dev/null || true