From 4e8d928350baf15b4e6f7e35adb6bde91d8b784d Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Tue, 7 Jul 2026 00:20:00 +0800 Subject: [PATCH] =?UTF-8?q?feat(ci):=20macOS=20+=20iOS=20=E5=AE=A2?= =?UTF-8?q?=E6=88=B7=E7=AB=AF=20CI=20=E8=84=9A=E6=9C=AC(=E7=85=A7=20jiu=20?= =?UTF-8?q?+=20=E7=B3=BB=E7=BB=9F=E6=89=A9=E5=B1=95)+=20=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=20manifest=20=E6=8E=A8=E9=80=81=E6=8E=A5?= =?UTF-8?q?=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit compile-macos.sh(Developer ID 签名+公证+staple, 2 描述文件+libbox 重建+CFBundleVersion 递增, 早期 fail-fast 缺 secret 不浪费构建)、compile-ios.sh(TestFlight, 缺 secret 优雅跳过)。 deploy-client.yml 加 build-macos/build-ios(runs-on mac, continue-on-error+release-deploy 不 needs 它们 → 不拖挂 android/windows)。release-client.sh 兼容自动更新 manifest 推送 + macos zip。 + url_launcher 的 macos/windows 生成插件注册。代码就绪, 待 Apple secret 才能真跑 macOS/iOS。 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u --- .gitea/workflows/ci.yml | 2 + .gitea/workflows/deploy-client.yml | 84 +++++- .../Flutter/GeneratedPluginRegistrant.swift | 2 + .../flutter/generated_plugin_registrant.cc | 3 + .../windows/flutter/generated_plugins.cmake | 1 + scripts/ci/compile-ios.sh | 198 +++++++++++++ scripts/ci/compile-macos.sh | 267 ++++++++++++++++++ scripts/ci/deploy-client.sh | 12 +- scripts/ci/release-client.sh | 85 +++++- 9 files changed, 636 insertions(+), 18 deletions(-) create mode 100755 scripts/ci/compile-ios.sh create mode 100755 scripts/ci/compile-macos.sh diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index c4e174c..aba2ca4 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -58,6 +58,8 @@ jobs: /mnt/scripts/ci/backup-db.sh \ /mnt/scripts/ci/compile-android.sh \ /mnt/scripts/ci/compile-windows.sh \ + /mnt/scripts/ci/compile-macos.sh \ + /mnt/scripts/ci/compile-ios.sh \ /mnt/scripts/ci/release-client.sh \ /mnt/scripts/ci/deploy-client.sh diff --git a/.gitea/workflows/deploy-client.yml b/.gitea/workflows/deploy-client.yml index 68ec250..4beac03 100644 --- a/.gitea/workflows/deploy-client.yml +++ b/.gitea/workflows/deploy-client.yml @@ -1,10 +1,10 @@ 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). +# deploy shape. Android + Windows are required (release-deploy `needs` them); +# macOS + iOS (Phase 3, see docs/superpowers/plans/2026-07-05-cicd.md) are +# intentionally DECOUPLED — see the "why build-macos/build-ios don't block" +# note above the build-macos job below for the mechanism and rationale. # # TODO(controller) — RUNNER AVAILABILITY: per docs/ci-runner.md, pangolin # currently has exactly ONE registered Gitea Actions runner @@ -76,6 +76,77 @@ jobs: name: windows path: dist/ + # Why build-macos/build-ios don't block the working android+windows pipeline + # when Apple secrets aren't configured yet (they aren't, as of this writing): + # 1. release-deploy's `needs:` below is [build-android, build-windows] + # ONLY — macOS/iOS are NOT dependencies, so release-deploy never waits + # on them and never fails because of them. + # 2. `continue-on-error: true` on both jobs keeps the overall workflow-run + # status green even while compile-macos.sh hard-fails (Apple Developer + # ID / notary secrets absent — see its fail-fast checks) — that failure + # is real signal ("go configure the secrets"), but it shouldn't read as + # "the release pipeline is broken" when android+windows shipped fine. + # 3. compile-macos.sh's own default behavior is to hard-fail (not skip) + # when its secrets are missing (macOS distribution must never ship + # unsigned/unnotarized — see its header comment); compile-ios.sh's + # default is to skip gracefully (exit 0) since an unconfigured iOS + # account is a normal "not set up yet" state, not a defect. Either way + # the job produces no dist/pangolin-macos-x64.zip, and + # release-deploy's "Download all artifacts" step (no `name:` filter) + # simply picks up whatever artifacts DO exist — an absent "macos" + # artifact is not an error there. + build-macos: + runs-on: mac + continue-on-error: true + env: + 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 (macOS System Extension app) + env: + MACOS_DEVELOPER_ID_CERT_P12_BASE64: ${{ secrets.MACOS_DEVELOPER_ID_CERT_P12_BASE64 }} + MACOS_DEVELOPER_ID_CERT_PASSWORD: ${{ secrets.MACOS_DEVELOPER_ID_CERT_PASSWORD }} + MACOS_APP_PROVISION_PROFILE_BASE64: ${{ secrets.MACOS_APP_PROVISION_PROFILE_BASE64 }} + MACOS_SYSEXT_PROVISION_PROFILE_BASE64: ${{ secrets.MACOS_SYSEXT_PROVISION_PROFILE_BASE64 }} + APPSTORE_API_KEY_ID: ${{ secrets.APPSTORE_API_KEY_ID }} + APPSTORE_API_ISSUER_ID: ${{ secrets.APPSTORE_API_ISSUER_ID }} + APPSTORE_API_KEY_P8_BASE64: ${{ secrets.APPSTORE_API_KEY_P8_BASE64 }} + REF_NAME: ${{ gitea.ref_name }} + run: bash scripts/ci/compile-macos.sh "$REF_NAME" + + - name: Upload macos artifact + uses: actions/upload-artifact@v3 + with: + name: macos + path: dist/ + + build-ios: + runs-on: mac + continue-on-error: true + env: + 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 & upload to TestFlight (iOS) + env: + IOS_DIST_CERT_P12_BASE64: ${{ secrets.IOS_DIST_CERT_P12_BASE64 }} + IOS_DIST_CERT_PASSWORD: ${{ secrets.IOS_DIST_CERT_PASSWORD }} + IOS_APP_PROVISIONING_PROFILE_BASE64: ${{ secrets.IOS_APP_PROVISIONING_PROFILE_BASE64 }} + IOS_PACKETTUNNEL_PROVISIONING_PROFILE_BASE64: ${{ secrets.IOS_PACKETTUNNEL_PROVISIONING_PROFILE_BASE64 }} + APPSTORE_API_KEY_ID: ${{ secrets.APPSTORE_API_KEY_ID }} + APPSTORE_API_ISSUER_ID: ${{ secrets.APPSTORE_API_ISSUER_ID }} + APPSTORE_API_KEY_P8_BASE64: ${{ secrets.APPSTORE_API_KEY_P8_BASE64 }} + REF_NAME: ${{ gitea.ref_name }} + run: bash scripts/ci/compile-ios.sh "$REF_NAME" + # No artifact upload — compile-ios.sh uploads straight to TestFlight via + # altool (matches jiu); nothing is produced under dist/ for this job. + release-deploy: needs: [build-android, build-windows] runs-on: mac @@ -101,6 +172,11 @@ jobs: env: FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }} FORGEJO_URL: ${{ secrets.FORGEJO_URL }} + # Needed here (not just in the "Deploy" step below) because + # release-client.sh now also SSH-pushes the auto-update manifest + # (version.yaml) straight to pangolin1's /etc/pangolin/ — see the + # header comment in scripts/ci/release-client.sh. + DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }} REF_NAME: ${{ gitea.ref_name }} run: bash scripts/ci/release-client.sh "$REF_NAME" diff --git a/client/macos/Flutter/GeneratedPluginRegistrant.swift b/client/macos/Flutter/GeneratedPluginRegistrant.swift index 5942a33..5c0bdfd 100644 --- a/client/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/client/macos/Flutter/GeneratedPluginRegistrant.swift @@ -11,6 +11,7 @@ import package_info_plus import screen_retriever_macos import shared_preferences_foundation import tray_manager +import url_launcher_macos import window_manager func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { @@ -20,5 +21,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) TrayManagerPlugin.register(with: registry.registrar(forPlugin: "TrayManagerPlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin")) } diff --git a/client/windows/flutter/generated_plugin_registrant.cc b/client/windows/flutter/generated_plugin_registrant.cc index a55a983..e6fb69b 100644 --- a/client/windows/flutter/generated_plugin_registrant.cc +++ b/client/windows/flutter/generated_plugin_registrant.cc @@ -9,6 +9,7 @@ #include #include #include +#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { @@ -18,6 +19,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi")); TrayManagerPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("TrayManagerPlugin")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); WindowManagerPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("WindowManagerPlugin")); } diff --git a/client/windows/flutter/generated_plugins.cmake b/client/windows/flutter/generated_plugins.cmake index 6adb6fa..752feb5 100644 --- a/client/windows/flutter/generated_plugins.cmake +++ b/client/windows/flutter/generated_plugins.cmake @@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST flutter_secure_storage_windows screen_retriever_windows tray_manager + url_launcher_windows window_manager ) diff --git a/scripts/ci/compile-ios.sh b/scripts/ci/compile-ios.sh new file mode 100755 index 0000000..751d680 --- /dev/null +++ b/scripts/ci/compile-ios.sh @@ -0,0 +1,198 @@ +#!/usr/bin/env bash +# compile-ios.sh — build a signed iOS IPA (app + PacketTunnel Network +# Extension) and upload it to TestFlight (App Store Connect). +# +# Mirrors ~/code/jiu/scripts/ci/compile-ios.sh's shape (temp-keychain dist +# cert import, provisioning-profile install by UUID, ExportOptions.plist, +# `flutter build ipa`, `xcrun altool --upload-app`), adapted for pangolin: +# - pangolin's iOS app ships a NEPacketTunnelProvider **app extension** +# (client/ios/PacketTunnel/, bundle com.pangolin.pangolinVpn.PacketTunnel) +# alongside the main app (com.pangolin.pangolinVpn) — jiu has no +# extension at all, just the one app target. So this script needs TWO +# distribution provisioning profiles (app + extension), not one, and the +# ExportOptions.plist provisioningProfiles dict needs both bundle-id -> +# profile-name mappings. +# - pangolin embeds sing-box as a native core (gomobile-built +# Libbox.xcframework, io.nekohasekai.libbox) — jiu's Flutter app has no +# embedded core. This script MUST run `scripts/build-libbox.sh apple ios` +# before `flutter build ipa`, or the IPA links no VPN kernel (mirrors the +# equivalent step in compile-android.sh / compile-macos.sh). +# - Team ID BYL4KQHMTN is hardcoded as a script constant rather than a +# secret (unlike jiu's IOS_TEAM_ID secret): it's already public inside +# this repo (CLAUDE.md, client/ios/Runner.xcodeproj/project.pbxproj +# DEVELOPMENT_TEAM, scripts/local_test.sh SIGN_ID) — not sensitive, no +# reason to add another secret for it. +# - APPSTORE_API_KEY_ID / APPSTORE_API_ISSUER_ID / APPSTORE_API_KEY_P8_BASE64 +# are shared with compile-macos.sh's notarization step (same App Store +# Connect API key serves both notarization and TestFlight upload). +# +# Required CI secrets (missing ANY of them => SKIP gracefully, exit 0 — does +# NOT fail the pipeline; Apple iOS distribution needs an enrolled account + +# certs that may not exist yet, unlike macOS Developer ID which is a hard +# fail-fast in compile-macos.sh): +# IOS_DIST_CERT_P12_BASE64 Apple Distribution 证书(.p12)base64 +# IOS_DIST_CERT_PASSWORD .p12 导出密码 +# IOS_APP_PROVISIONING_PROFILE_BASE64 App Store 类型描述文件(主 app, +# com.pangolin.pangolinVpn)base64 +# IOS_PACKETTUNNEL_PROVISIONING_PROFILE_BASE64 App Store 类型描述文件(扩展, +# com.pangolin.pangolinVpn.PacketTunnel)base64 +# APPSTORE_API_KEY_ID / APPSTORE_API_ISSUER_ID / APPSTORE_API_KEY_P8_BASE64 +# App Store Connect API Key(上传 TestFlight 用;与 compile-macos.sh 公证共用)。 +# +# No dist/ artifact is produced (matches jiu) — the IPA goes straight to +# TestFlight via altool, nothing is uploaded to the Forgejo release. +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-ios.sh }" + +ver_file="/tmp/compile_ios_ver.$$" +ver_from_tag client "$TAG" > "$ver_file" +VER="" +read -r VER < "$ver_file" || true # 无结尾换行的 read 退出码,见 compile-android.sh 同注释 +rm -f "$ver_file" + +# CFBundleVersion(两个 target 均为 "$(FLUTTER_BUILD_NUMBER)",见 +# client/ios/Runner.xcodeproj/project.pbxproj —— 与 macOS 的 PacketTunnel 硬编码 +# CURRENT_PROJECT_VERSION 不同,这里走 flutter build --build-number 走标准路径)。 +# 必须单调递增,否则 TestFlight 拒绝重复上传(同 jiu 注释),公式与 +# compile-android.sh / compile-macos.sh 一致。 +MAJOR="${VER%%.*}" +_REST="${VER#*.}" +MINOR="${_REST%%.*}" +PATCH="${_REST#*.}" +PATCH="${PATCH%%-*}" +BUILD=$(( MAJOR * 10000 + MINOR * 100 + PATCH )) + +echo "==> compile-ios: tag=${TAG} version=${VER} build=${BUILD}" + +# ── 凭证缺失则优雅跳过(不阻塞流水线;见头部说明)───────────────────────── +if [ -z "${IOS_DIST_CERT_P12_BASE64:-}" ] || \ + [ -z "${IOS_DIST_CERT_PASSWORD:-}" ] || \ + [ -z "${IOS_APP_PROVISIONING_PROFILE_BASE64:-}" ] || \ + [ -z "${IOS_PACKETTUNNEL_PROVISIONING_PROFILE_BASE64:-}" ] || \ + [ -z "${APPSTORE_API_KEY_ID:-}" ] || \ + [ -z "${APPSTORE_API_ISSUER_ID:-}" ] || \ + [ -z "${APPSTORE_API_KEY_P8_BASE64:-}" ]; then + echo "==> compile-ios: SKIP — iOS signing secrets not fully configured yet (not a failure)" + exit 0 +fi + +REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +API_URL="${PANGOLIN_API_URL:-https://api.yanmeiai.com}" +TEAM_ID="BYL4KQHMTN" +APP_BUNDLE_ID="com.pangolin.pangolinVpn" +EXT_BUNDLE_ID="com.pangolin.pangolinVpn.PacketTunnel" + +WORK="$(mktemp -d)" +KEYCHAIN="${WORK}/pangolin-ios-ci.keychain-db" +PROFILES_DIR="${HOME}/Library/MobileDevice/Provisioning Profiles" +mkdir -p "$PROFILES_DIR" + +cleanup_ios() { + security delete-keychain "$KEYCHAIN" 2>/dev/null || true + security list-keychains -d user -s login.keychain-db 2>/dev/null || true + [ -n "${APP_PROFILE_UUID:-}" ] && rm -f "${PROFILES_DIR}/${APP_PROFILE_UUID}.mobileprovision" 2>/dev/null || true + [ -n "${EXT_PROFILE_UUID:-}" ] && rm -f "${PROFILES_DIR}/${EXT_PROFILE_UUID}.mobileprovision" 2>/dev/null || true + rm -rf "$WORK" +} +trap cleanup_ios EXIT + +# ── [1/6] 重建 libbox.xcframework(apple ios;gitignore 产物,每次都要重建) ── +echo "==> compile-ios: building Libbox.xcframework (apple ios)" +bash "${REPO_ROOT}/scripts/build-libbox.sh" apple ios +LIBBOX_FW="${REPO_ROOT}/client/ios/Frameworks/Libbox.xcframework" +if [ ! -d "$LIBBOX_FW" ]; then + echo "ERROR: ${LIBBOX_FW} not found after build-libbox.sh — aborting." >&2 + exit 1 +fi + +# ── [2/6] 临时 keychain 导入 Apple Distribution 证书 ───────────────────────── +KEYCHAIN_PWD="ci-temp-$$" +security create-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN" +security set-keychain-settings -lut 21600 "$KEYCHAIN" +security unlock-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN" +printf '%s' "$IOS_DIST_CERT_P12_BASE64" | base64 --decode > "${WORK}/dist.p12" +security import "${WORK}/dist.p12" -k "$KEYCHAIN" \ + -P "$IOS_DIST_CERT_PASSWORD" -T /usr/bin/codesign -T /usr/bin/security +security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PWD" "$KEYCHAIN" >/dev/null +security list-keychains -d user -s "$KEYCHAIN" login.keychain-db + +# ── [3/6] 安装 2 个 App Store 类型描述文件(app + PacketTunnel 扩展) ───────── +printf '%s' "$IOS_APP_PROVISIONING_PROFILE_BASE64" | base64 --decode > "${WORK}/app.mobileprovision" +security cms -D -i "${WORK}/app.mobileprovision" > "${WORK}/app_profile.plist" +/usr/libexec/PlistBuddy -c 'Print :Name' "${WORK}/app_profile.plist" > "${WORK}/app_name.txt" +/usr/libexec/PlistBuddy -c 'Print :UUID' "${WORK}/app_profile.plist" > "${WORK}/app_uuid.txt" +APP_PROFILE_NAME="" +read -r APP_PROFILE_NAME < "${WORK}/app_name.txt" || true +APP_PROFILE_UUID="" +read -r APP_PROFILE_UUID < "${WORK}/app_uuid.txt" || true +cp "${WORK}/app.mobileprovision" "${PROFILES_DIR}/${APP_PROFILE_UUID}.mobileprovision" +echo "==> compile-ios: app profile '${APP_PROFILE_NAME}' (${APP_PROFILE_UUID})" + +printf '%s' "$IOS_PACKETTUNNEL_PROVISIONING_PROFILE_BASE64" | base64 --decode > "${WORK}/ext.mobileprovision" +security cms -D -i "${WORK}/ext.mobileprovision" > "${WORK}/ext_profile.plist" +/usr/libexec/PlistBuddy -c 'Print :Name' "${WORK}/ext_profile.plist" > "${WORK}/ext_name.txt" +/usr/libexec/PlistBuddy -c 'Print :UUID' "${WORK}/ext_profile.plist" > "${WORK}/ext_uuid.txt" +EXT_PROFILE_NAME="" +read -r EXT_PROFILE_NAME < "${WORK}/ext_name.txt" || true +EXT_PROFILE_UUID="" +read -r EXT_PROFILE_UUID < "${WORK}/ext_uuid.txt" || true +cp "${WORK}/ext.mobileprovision" "${PROFILES_DIR}/${EXT_PROFILE_UUID}.mobileprovision" +echo "==> compile-ios: PacketTunnel profile '${EXT_PROFILE_NAME}' (${EXT_PROFILE_UUID})" + +# ── [4/6] App Store Connect API Key(供 altool 上传使用)────────────────────── +API_KEYS_DIR="${HOME}/.appstoreconnect/private_keys" +mkdir -p "$API_KEYS_DIR" +printf '%s' "$APPSTORE_API_KEY_P8_BASE64" | base64 --decode > "${API_KEYS_DIR}/AuthKey_${APPSTORE_API_KEY_ID}.p8" + +# ── [5/6] 同步版本号 + 生成 ExportOptions.plist(manual 签名,两个 bundle 都映射)── +sed -i '' "s/^version:.*/version: ${VER}+${BUILD}/" "${REPO_ROOT}/client/pubspec.yaml" + +cat > "${WORK}/ExportOptions.plist" < + + + + methodapp-store + teamID${TEAM_ID} + signingStylemanual + uploadBitcode + uploadSymbols + provisioningProfiles + + ${APP_BUNDLE_ID}${APP_PROFILE_NAME} + ${EXT_BUNDLE_ID}${EXT_PROFILE_NAME} + + + +EOF + +# ── [6/6] flutter build ipa + 上传 TestFlight ─────────────────────────────── +cd "${REPO_ROOT}/client" +flutter build ipa --release \ + --build-name="${VER}" \ + --build-number="${BUILD}" \ + --export-options-plist="${WORK}/ExportOptions.plist" \ + "--dart-define=PANGOLIN_API_URL=${API_URL}" +cd "$REPO_ROOT" + +IPA="" +for cand in "${REPO_ROOT}"/client/build/ios/ipa/*.ipa; do + if [ -f "$cand" ]; then IPA="$cand"; break; fi +done +if [ -z "$IPA" ]; then + echo "ERROR: IPA not found under client/build/ios/ipa/" >&2 + exit 1 +fi +echo "==> compile-ios: built ${IPA}" + +echo "==> compile-ios: uploading to TestFlight" +xcrun altool --upload-app -f "$IPA" -t ios \ + --apiKey "$APPSTORE_API_KEY_ID" \ + --apiIssuer "$APPSTORE_API_ISSUER_ID" + +echo "==> compile-ios: done — uploaded build ${BUILD} to TestFlight" diff --git a/scripts/ci/compile-macos.sh b/scripts/ci/compile-macos.sh new file mode 100755 index 0000000..f46033d --- /dev/null +++ b/scripts/ci/compile-macos.sh @@ -0,0 +1,267 @@ +#!/usr/bin/env bash +# compile-macos.sh — build the Flutter macOS app (with its embedded +# PacketTunnel System Extension), Developer ID sign + notarize + staple, +# package into dist/. +# +# Mirrors ~/code/jiu/scripts/ci/compile-macos.sh's shape (temp-keychain +# Developer ID import, inside-out codesign, notarytool submit --wait, staple, +# ditto zip), but pangolin's macOS app is heavier than jiu's plain window: +# - it embeds a System Extension (com.pangolin.pangolin.PacketTunnel, +# `.systemextension` bundle under Contents/Library/SystemExtensions/) +# that needs ITS OWN Developer ID provisioning profile + entitlements, +# signed separately, inside-out, before the outer app is signed — see +# CLAUDE.md "client/ macOS 原生隧道" and +# docs/macos-sysext-realize-troubleshooting.html for why every one of +# these steps is load-bearing (wrong order / missing entitlement / +# unsigned nested item => sysextd silently refuses to load it). +# - the Xcode project (client/macos/Runner.xcodeproj) is ALREADY configured +# CODE_SIGN_STYLE=Manual with CODE_SIGN_IDENTITY="Developer ID +# Application" + PROVISIONING_PROFILE_SPECIFIER set per target (unlike +# jiu, whose Release config has CODE_SIGN_IDENTITY="-", i.e. unsigned at +# build time, signed entirely by hand afterward). So `flutter build macos +# --release` here should already produce a signed .app *if* the identity +# is in a searched keychain and the profiles are in place. This script +# still re-runs the explicit inside-out codesign pass below (mirroring +# scripts/local_test.sh's now-legacy `cmd_sign`) as a defensive, +# idempotent safety net — resigning with the same identity is harmless, +# and it removes any dependency on Xcode's automatic nested-item signing +# behaving identically on a CI runner vs. the maintainer's dev machine. +# - CFBundleVersion for the PacketTunnel extension is NOT derived from +# FLUTTER_BUILD_NUMBER (unlike the main Runner target, and unlike iOS +# where every target uses "$(FLUTTER_BUILD_NUMBER)") — it's a literal +# CURRENT_PROJECT_VERSION build setting hardcoded in project.pbxproj +# (currently 53, see CLAUDE.md: "每次构建必递增 CFBundleVersion +# (CURRENT_PROJECT_VERSION)——否则 sysextd 视为同版本不更新"). This script +# computes a monotonic build number from the tag version (same +# major*10000+minor*100+patch formula as compile-android.sh / +# compile-ios.sh) and sed's every CURRENT_PROJECT_VERSION occurrence in +# the pbxproj (RunnerTests + PacketTunnel Debug/Release/Profile, 6 total) +# before building. This edit is NOT committed back to git — each CI run +# starts from the repo's checked-in value and recomputes deterministically +# from the tag, so it stays monotonic across releases as long as the +# version itself increases. +# +# Required CI secrets (fail-fast — ANY missing => abort, never ship unsigned): +# MACOS_DEVELOPER_ID_CERT_P12_BASE64 Developer ID Application 证书(.p12)base64 +# MACOS_DEVELOPER_ID_CERT_PASSWORD .p12 导出密码 +# MACOS_APP_PROVISION_PROFILE_BASE64 "Pangolin App DevID" 描述文件 base64 +# MACOS_SYSEXT_PROVISION_PROFILE_BASE64 "Pangolin PacketTunnel DevID" 描述文件 base64 +# APPSTORE_API_KEY_ID / APPSTORE_API_ISSUER_ID / APPSTORE_API_KEY_P8_BASE64 +# App Store Connect API Key(公证用,notarytool --key/--key-id/--issuer; +# 不同于 scripts/local_test.sh 本机用的 --keychain-profile pangolin-notary, +# CI 无法预置 keychain profile,必须走显式 API key 三件套)。 +# +# Output: dist/pangolin-macos-x64.zip (stable name — deploy-client.sh / +# release-client.sh key off this exact filename). +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-macos.sh }" + +ver_file="/tmp/compile_macos_ver.$$" +ver_from_tag client "$TAG" > "$ver_file" +VER="" +read -r VER < "$ver_file" || true # 无结尾换行的 read 退出码,见 compile-android.sh 同注释 +rm -f "$ver_file" + +# 单调递增 build 号:major*10000 + minor*100 + patch(同 compile-android.sh / +# compile-ios.sh 公式)。纯参数展开,不用 cut/$();剥掉 patch 段的 `-suffix` +# (如预发布 tag "48-rc1")以保证后面的算术展开是纯数字。 +MAJOR="${VER%%.*}" +_REST="${VER#*.}" +MINOR="${_REST%%.*}" +PATCH="${_REST#*.}" +PATCH="${PATCH%%-*}" +BUILD=$(( MAJOR * 10000 + MINOR * 100 + PATCH )) + +echo "==> compile-macos: tag=${TAG} version=${VER} build=${BUILD}" + +# ── [0/7] fail-fast:签名/公证所需 secret 一个不少 ────────────────────────── +# 比 jiu 的 mac 脚本(只查 2 个)更严格:任一缺失都直接报错中止,不产出未签名/ +# 未公证包。用 `${VAR:?msg}` 而非 if-empty,出错信息更精确定位到具体哪个 secret。 +: "${MACOS_DEVELOPER_ID_CERT_P12_BASE64:?缺少 MACOS_DEVELOPER_ID_CERT_P12_BASE64:未配置 Developer ID 证书,构建中止(不产出未签名包)}" +: "${MACOS_DEVELOPER_ID_CERT_PASSWORD:?缺少 MACOS_DEVELOPER_ID_CERT_PASSWORD}" +: "${MACOS_APP_PROVISION_PROFILE_BASE64:?缺少 MACOS_APP_PROVISION_PROFILE_BASE64('Pangolin App DevID' 描述文件)}" +: "${MACOS_SYSEXT_PROVISION_PROFILE_BASE64:?缺少 MACOS_SYSEXT_PROVISION_PROFILE_BASE64('Pangolin PacketTunnel DevID' 描述文件)}" +: "${APPSTORE_API_KEY_ID:?缺少 APPSTORE_API_KEY_ID(公证用 App Store Connect API Key)}" +: "${APPSTORE_API_ISSUER_ID:?缺少 APPSTORE_API_ISSUER_ID}" +: "${APPSTORE_API_KEY_P8_BASE64:?缺少 APPSTORE_API_KEY_P8_BASE64}" + +REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +API_URL="${PANGOLIN_API_URL:-https://api.yanmeiai.com}" +SIGN_ID_PREFIX="Developer ID Application" +TEAM_ID="BYL4KQHMTN" +APP_BUNDLE_ID="com.pangolin.pangolin" +SYSEXT_BUNDLE_ID="com.pangolin.pangolin.PacketTunnel" +APP_GROUP="${TEAM_ID}.com.pangolin.pangolin" + +WORK="$(mktemp -d)" +KEYCHAIN="${WORK}/pangolin-mac-ci.keychain-db" +# 与 scripts/local_test.sh 用的描述文件目录保持一致(Xcode UserData 目录, +# 而非经典的 ~/Library/MobileDevice/Provisioning Profiles/)——CI 与本机联调 +# 复用同一处,两边的 "Pangolin App DevID"/"Pangolin PacketTunnel DevID" 描述 +# 文件本就应该是同一份,CI 覆盖写入是幂等的。故清理阶段【不删除】这两个描述 +# 文件(只清理临时 keychain/证书),避免影响维护者在同一台 mac runner 上继续 +# 用 local_test.sh 手动联调。 +PROF_DIR="${HOME}/Library/Developer/Xcode/UserData/Provisioning Profiles" +mkdir -p "$PROF_DIR" + +cleanup_mac() { + security delete-keychain "$KEYCHAIN" 2>/dev/null || true + # 把 keychain 搜索列表还原成默认单项,不把临时 keychain 的痕迹留在这台常驻 + # runner 上(mac-pangolin-2 同时也被人手动用来跑 local_test.sh)。 + security list-keychains -d user -s login.keychain-db 2>/dev/null || true + rm -rf "$WORK" +} +trap cleanup_mac EXIT + +# ── [1/7] 重建 libbox.xcframework(gitignore 产物,每次换 worktree/CI 都要重建)─ +echo "==> compile-macos: building Libbox.xcframework (apple macos)" +bash "${REPO_ROOT}/scripts/build-libbox.sh" apple macos +LIBBOX_FW="${REPO_ROOT}/client/macos/Frameworks/Libbox.xcframework" +if [ ! -d "$LIBBOX_FW" ]; then + echo "ERROR: ${LIBBOX_FW} not found after build-libbox.sh — aborting." >&2 + exit 1 +fi + +# ── [2/7] 递增 CURRENT_PROJECT_VERSION(sysext CFBundleVersion,见头部说明)── +PBXPROJ="${REPO_ROOT}/client/macos/Runner.xcodeproj/project.pbxproj" +echo "==> compile-macos: bumping CURRENT_PROJECT_VERSION -> ${BUILD} in project.pbxproj" +sed -i '' -E "s/CURRENT_PROJECT_VERSION = [0-9]+;/CURRENT_PROJECT_VERSION = ${BUILD};/g" "$PBXPROJ" +# 同步 pubspec.yaml(主 app 自身的 FLUTTER_BUILD_NUMBER,和 android/windows 脚本一致)。 +sed -i '' "s/^version:.*/version: ${VER}+${BUILD}/" "${REPO_ROOT}/client/pubspec.yaml" + +# ── [3/7] 解码 2 个 Developer ID 描述文件到 local_test.sh 同款目录 ────────── +printf '%s' "$MACOS_APP_PROVISION_PROFILE_BASE64" | base64 --decode > "${WORK}/app.provisionprofile" +printf '%s' "$MACOS_SYSEXT_PROVISION_PROFILE_BASE64" | base64 --decode > "${WORK}/sysext.provisionprofile" + +# 提取 UUID 用来落盘为规范文件名(Xcode/xcodebuild 靠内容匹配,文件名本身不 +# 强制要求,但用 UUID 命名是 Xcode 自身导入时的惯例,避免与其他描述文件重名冲突)。 +security cms -D -i "${WORK}/app.provisionprofile" > "${WORK}/app_profile.plist" +/usr/libexec/PlistBuddy -c 'Print :UUID' "${WORK}/app_profile.plist" > "${WORK}/app_uuid.txt" +APP_PROFILE_UUID="" +read -r APP_PROFILE_UUID < "${WORK}/app_uuid.txt" || true +APP_PROFILE="${PROF_DIR}/${APP_PROFILE_UUID}.provisionprofile" +cp "${WORK}/app.provisionprofile" "$APP_PROFILE" + +security cms -D -i "${WORK}/sysext.provisionprofile" > "${WORK}/sysext_profile.plist" +/usr/libexec/PlistBuddy -c 'Print :UUID' "${WORK}/sysext_profile.plist" > "${WORK}/sysext_uuid.txt" +SYSEXT_PROFILE_UUID="" +read -r SYSEXT_PROFILE_UUID < "${WORK}/sysext_uuid.txt" || true +SYSEXT_PROFILE="${PROF_DIR}/${SYSEXT_PROFILE_UUID}.provisionprofile" +cp "${WORK}/sysext.provisionprofile" "$SYSEXT_PROFILE" + +echo "==> compile-macos: profiles installed to '${PROF_DIR}' (app=${APP_PROFILE_UUID}, sysext=${SYSEXT_PROFILE_UUID})" + +# ── [4/7] 临时 keychain 导入 Developer ID Application 证书 ────────────────── +KEYCHAIN_PWD="ci-temp-$$" +security create-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN" +security set-keychain-settings -lut 21600 "$KEYCHAIN" +security unlock-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN" +printf '%s' "$MACOS_DEVELOPER_ID_CERT_P12_BASE64" | base64 --decode > "${WORK}/devid.p12" +security import "${WORK}/devid.p12" -k "$KEYCHAIN" \ + -P "$MACOS_DEVELOPER_ID_CERT_PASSWORD" -T /usr/bin/codesign -T /usr/bin/security +security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PWD" "$KEYCHAIN" >/dev/null +security list-keychains -d user -s "$KEYCHAIN" login.keychain-db + +security find-identity -v -p codesigning "$KEYCHAIN" > "${WORK}/identities.txt" +grep "$SIGN_ID_PREFIX" "${WORK}/identities.txt" | head -1 | sed -E 's/.*"(.*)"/\1/' > "${WORK}/identity.txt" +IDENTITY="" +read -r IDENTITY < "${WORK}/identity.txt" || true +if [ -z "$IDENTITY" ]; then + echo "ERROR: '${SIGN_ID_PREFIX}' identity not found in imported keychain" >&2 + exit 1 +fi +echo "==> compile-macos: signing identity '${IDENTITY}'" + +# ── [5/7] flutter build macos --release ───────────────────────────────────── +# 项目已配 CODE_SIGN_STYLE=Manual + PROVISIONING_PROFILE_SPECIFIER(见头部说 +# 明),本步应已产出 Developer ID 签名 + hardened runtime 的 .app;下一步的 +# inside-out 重签是幂等的安全网,不依赖这一步是否已经"恰好签对"。 +cd "${REPO_ROOT}/client" +flutter build macos --release --dart-define="PANGOLIN_API_URL=${API_URL}" +cd "$REPO_ROOT" + +APP="${REPO_ROOT}/client/build/macos/Build/Products/Release/pangolin_vpn.app" +SE="${APP}/Contents/Library/SystemExtensions/${SYSEXT_BUNDLE_ID}.systemextension" +[ -d "$APP" ] || { echo "ERROR: build product not found: ${APP}" >&2; exit 1; } +[ -d "$SE" ] || { echo "ERROR: sysext bundle not found: ${SE}" >&2; exit 1; } + +# ── [6/7] inside-out 重签(sysext → app frameworks → app),同 local_test.sh cmd_sign ── +cat > "${WORK}/app.entitlements" < + + + com.apple.application-identifier${TEAM_ID}.${APP_BUNDLE_ID} + com.apple.developer.team-identifier${TEAM_ID} + com.apple.developer.system-extension.install + com.apple.developer.networking.networkextension + packet-tunnel-provider-systemextension + com.apple.security.app-sandbox + com.apple.security.network.client + com.apple.security.network.server + keychain-access-groups + ${APP_GROUP} + +PLIST +cat > "${WORK}/sysext.entitlements" < + + + com.apple.application-identifier${TEAM_ID}.${SYSEXT_BUNDLE_ID} + com.apple.developer.team-identifier${TEAM_ID} + com.apple.developer.networking.networkextension + packet-tunnel-provider-systemextension + com.apple.security.app-sandbox + com.apple.security.application-groups + group.com.pangolin.pangolin + +PLIST + +echo "==> compile-macos: inside-out codesign (sysext -> frameworks -> app)" +xattr -cr "$APP" +cp "$SYSEXT_PROFILE" "${SE}/Contents/embedded.provisionprofile" +cp "$APP_PROFILE" "${APP}/Contents/embedded.provisionprofile" + +# sysext first — libbox is linked (not embedded) into the sysext binary +# itself (see CLAUDE.md), so signing the sysext bundle re-covers its statically +# linked libbox; no separate Libbox.framework to sign inside it. +codesign --force --options runtime --timestamp \ + --entitlements "${WORK}/sysext.entitlements" --sign "$IDENTITY" "$SE" + +if [ -d "${APP}/Contents/Frameworks" ]; then + for item in "${APP}/Contents/Frameworks/"*; do + [ -e "$item" ] && codesign --force --options runtime --timestamp --sign "$IDENTITY" "$item" + done +fi + +codesign --force --options runtime --timestamp \ + --entitlements "${WORK}/app.entitlements" --sign "$IDENTITY" "$APP" + +codesign --verify --deep --strict --verbose=2 "$APP" +echo "==> compile-macos: signed + verified (${IDENTITY})" + +# ── [7/7] 公证(notarytool,API key 三件套) + staple + 打包 ────────────────── +echo "==> compile-macos: notarizing (notarytool submit --wait, ~1-5min)" +NOTARIZE_ZIP="${WORK}/notarize.zip" +ditto -c -k --keepParent "$APP" "$NOTARIZE_ZIP" +printf '%s' "$APPSTORE_API_KEY_P8_BASE64" | base64 --decode > "${WORK}/AuthKey.p8" +xcrun notarytool submit "$NOTARIZE_ZIP" \ + --key "${WORK}/AuthKey.p8" \ + --key-id "$APPSTORE_API_KEY_ID" \ + --issuer "$APPSTORE_API_ISSUER_ID" \ + --wait + +xcrun stapler staple "$APP" +xcrun stapler validate "$APP" +spctl -a -vvv -t install "$APP" || true # 期望 source=Notarized Developer ID +echo "==> compile-macos: notarized + stapled" + +mkdir -p "${REPO_ROOT}/dist" +ditto -c -k --keepParent "$APP" "${REPO_ROOT}/dist/pangolin-macos-x64.zip" + +echo "==> compile-macos: done — dist/ contents:" +ls -lh "${REPO_ROOT}/dist/" diff --git a/scripts/ci/deploy-client.sh b/scripts/ci/deploy-client.sh index 7d83a85..c4451d9 100755 --- a/scripts/ci/deploy-client.sh +++ b/scripts/ci/deploy-client.sh @@ -47,7 +47,7 @@ if ! [[ "$TAG" =~ ^client-v[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.]+)?$ ]]; then exit 1 fi -if [ ! -f dist/pangolin-android.apk ] && [ ! -f dist/pangolin-windows-x64-setup.exe ]; then +if [ ! -f dist/pangolin-android.apk ] && [ ! -f dist/pangolin-windows-x64-setup.exe ] && [ ! -f dist/pangolin-macos-x64.zip ]; then echo "deploy-client: no artifacts found in dist/ — nothing to deploy" >&2 exit 1 fi @@ -70,7 +70,13 @@ if [ -f dist/pangolin-windows-x64-setup.exe ]; then $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). +if [ -f dist/pangolin-macos-x64.zip ]; then + echo "==> deploy-client: uploading pangolin-macos-x64.zip" + $SCP dist/pangolin-macos-x64.zip "root@${DEPLOY_HOST}:/tmp/pangolin-macos-x64.zip.new" + $SSH "root@${DEPLOY_HOST}" "rm -f ${DOWNLOADS_DIR}/pangolin-macos-x64.zip && mv /tmp/pangolin-macos-x64.zip.new ${DOWNLOADS_DIR}/pangolin-macos-x64.zip && chmod 644 ${DOWNLOADS_DIR}/pangolin-macos-x64.zip" +fi + +# iOS has no dist/ artifact to deploy here — compile-ios.sh uploads straight +# to TestFlight via altool (matches jiu). echo "==> deploy-client: done" diff --git a/scripts/ci/release-client.sh b/scripts/ci/release-client.sh index 2715cdc..707e975 100755 --- a/scripts/ci/release-client.sh +++ b/scripts/ci/release-client.sh @@ -1,14 +1,27 @@ #!/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/. +# exists, upload whichever client artifacts are present in dist/, and roll +# the auto-update manifest (deploy/single-node/version.yaml) forward. # # 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). +# upload_asset). +# +# Manifest handling differs from jiu on purpose: jiu's backend reads +# backend/config/version.yaml straight out of its own working directory, so +# rewriting that file in the repo checkout is enough. Pangolin's server reads +# VERSION_MANIFEST from /etc/pangolin (see server/internal/httpapi/version.go +# + deploy/single-node/deploy.sh), a path that lives on pangolin1, not in any +# git checkout — so getting the new version/build_number onto the live host +# needs an SSH push, the same DEPLOY_SSH_KEY / lib-ssh.sh path +# deploy-client.sh already uses for the downloads/ dir (see +# .gitea/workflows/deploy-client.yml, which now passes DEPLOY_SSH_KEY into +# this step too). Also unlike jiu, download_urls are fixed "latest" stable +# URLs (deploy-client.sh keeps only one file per platform) and changelog[] +# isn't populated yet (no CHANGELOG-client.md in this repo) — so only +# version/build_number are rewritten here, everything else in the manifest +# template is passed through unchanged. # # 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 @@ -16,10 +29,13 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" # shellcheck source=scripts/ci/_env.sh . "${SCRIPT_DIR}/_env.sh" # shellcheck source=scripts/ci/lib-forgejo.sh . "${SCRIPT_DIR}/lib-forgejo.sh" +# shellcheck source=scripts/ci/lib-ssh.sh +. "${SCRIPT_DIR}/lib-ssh.sh" TAG="${1:?usage: release-client.sh }" @@ -41,12 +57,59 @@ 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 +if [ -f dist/pangolin-macos-x64.zip ]; then + forgejo_upload_asset "$TAG" dist/pangolin-macos-x64.zip +fi + +# iOS has no dist/ artifact — compile-ios.sh uploads straight to TestFlight +# via altool (matches jiu), nothing to attach to the Forgejo release here. + +# ── Auto-update manifest: bump version/build_number, stage + push to pangolin1 ── +# BUILD is derived the exact same way compile-android.sh derives the APK's +# Android versionCode (major*10000 + minor*100 + patch) so the manifest's +# build_number stays consistent with the shipped APK for a given tag, rather +# than being an independently-incremented counter like jiu's. +MAJOR="${VER%%.*}" +_REST="${VER#*.}" +MINOR="${_REST%%.*}" +PATCH="${_REST#*.}" +PATCH="${PATCH%%-*}" +BUILD=$(( MAJOR * 10000 + MINOR * 100 + PATCH )) + +MANIFEST_TMPL="${REPO_ROOT}/deploy/single-node/version.yaml" +MANIFEST_OUT="/tmp/pangolin_version_manifest.$$.yaml" + +if [ -f "$MANIFEST_TMPL" ]; then + : > "$MANIFEST_OUT" + while IFS= read -r line || [ -n "$line" ]; do + case "$line" in + version:*) printf 'version: "%s"\n' "$VER" >> "$MANIFEST_OUT" ;; + build_number:*) printf 'build_number: %d\n' "$BUILD" >> "$MANIFEST_OUT" ;; + *) printf '%s\n' "$line" >> "$MANIFEST_OUT" ;; + esac + done < "$MANIFEST_TMPL" + + mkdir -p dist + cp "$MANIFEST_OUT" dist/version.yaml + # Stage as a release asset too, so a manual rollback can re-fetch the exact + # manifest that shipped alongside this tag (mirrors jiu's rationale). + forgejo_upload_asset "$TAG" dist/version.yaml + + if [ -n "${DEPLOY_SSH_KEY:-}" ]; then + echo "==> release-client: pushing version.yaml (version=${VER} build_number=${BUILD}) to pangolin1" + setup_ssh + SCP="scp -i ${SSH_KEY_FILE} -P ${DEPLOY_PORT} -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=${SSH_KNOWN_HOSTS_FILE}" + $SSH "root@${DEPLOY_HOST}" "mkdir -p /etc/pangolin" + $SCP "$MANIFEST_OUT" "root@${DEPLOY_HOST}:/tmp/pangolin_version.yaml.new" + $SSH "root@${DEPLOY_HOST}" "mv /tmp/pangolin_version.yaml.new /etc/pangolin/version.yaml && chown pangolin:pangolin /etc/pangolin/version.yaml && chmod 644 /etc/pangolin/version.yaml" + echo "==> release-client: version.yaml deployed to pangolin1 (/etc/pangolin/version.yaml)" + else + echo "==> release-client: DEPLOY_SSH_KEY not set — skipping live manifest push (dist/version.yaml still staged + uploaded as a release asset)" + fi + rm -f "$MANIFEST_OUT" +else + echo "==> release-client: WARN manifest template ${MANIFEST_TMPL} not found — skipping version.yaml update" >&2 +fi echo "==> release-client: done — Release ${TAG} updated. dist/ contents:" ls -lh dist/ 2>/dev/null || true