name: Release on: push: tags: - 'v*' permissions: contents: write concurrency: group: release-${{ github.ref_name }} cancel-in-progress: true jobs: release: runs-on: ${{ matrix.os }} strategy: matrix: include: - os: windows-2022 - os: ubuntu-22.04 - os: macos-14 steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '20' - name: Setup Rust uses: dtolnay/rust-toolchain@stable - name: Add macOS targets if: runner.os == 'macOS' run: | rustup target add aarch64-apple-darwin x86_64-apple-darwin - name: Install Linux system deps if: runner.os == 'Linux' shell: bash run: | set -euxo pipefail sudo apt-get update # Core build tools and pkg-config sudo apt-get install -y --no-install-recommends \ build-essential \ pkg-config \ curl \ wget \ file \ patchelf \ libssl-dev # GTK/GLib stack for gdk-3.0, glib-2.0, gio-2.0 sudo apt-get install -y --no-install-recommends \ libgtk-3-dev \ librsvg2-dev \ libayatana-appindicator3-dev # WebKit2GTK (version differs across Ubuntu images; try 4.1 then 4.0) sudo apt-get install -y --no-install-recommends libwebkit2gtk-4.1-dev \ || sudo apt-get install -y --no-install-recommends libwebkit2gtk-4.0-dev # libsoup also changed major version; prefer 3.0 with fallback to 2.4 sudo apt-get install -y --no-install-recommends libsoup-3.0-dev \ || sudo apt-get install -y --no-install-recommends libsoup2.4-dev - name: Setup pnpm uses: pnpm/action-setup@v2 with: version: 10.12.3 run_install: false - name: Get pnpm store directory id: pnpm-store shell: bash run: echo "path=$(pnpm store path --silent)" >> $GITHUB_OUTPUT - name: Setup pnpm cache uses: actions/cache@v4 with: path: ${{ steps.pnpm-store.outputs.path }} key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} restore-keys: ${{ runner.os }}-pnpm-store- - name: Install frontend deps run: pnpm install --frozen-lockfile - name: Prepare Tauri signing key shell: bash run: | # 调试:检查 Secret 是否存在 if [ -z "${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}" ]; then echo "❌ TAURI_SIGNING_PRIVATE_KEY Secret 为空或不存在" >&2 echo "请检查 GitHub 仓库 Settings > Secrets and variables > Actions" >&2 exit 1 fi RAW="${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}" # 目标:提供正确的私钥“文件路径”给 Tauri CLI,避免内容解码歧义 KEY_PATH="$RUNNER_TEMP/tauri_signing.key" # 情况 1:原始两行文本(第一行以 "untrusted comment:" 开头) if echo "$RAW" | head -n1 | grep -q '^untrusted comment:'; then printf '%s\n' "$RAW" > "$KEY_PATH" echo "✅ 使用原始两行密钥文件格式" else # 情况 2:整体被 base64 包裹(解包后应当是两行) if DECODED=$(printf '%s' "$RAW" | (base64 --decode 2>/dev/null || base64 -D 2>/dev/null)) \ && echo "$DECODED" | head -n1 | grep -q '^untrusted comment:'; then printf '%s\n' "$DECODED" > "$KEY_PATH" echo "✅ 成功解码 base64 包裹密钥,已还原为两行文件" else # 情况 3:已是第二行(纯 Base64 一行)→ 构造两行文件 if echo "$RAW" | grep -Eq '^[A-Za-z0-9+/=]+$'; then ONE=$(printf '%s' "$RAW" | tr -d '\r\n') printf '%s\n%s\n' "untrusted comment: tauri signing key" "$ONE" > "$KEY_PATH" echo "✅ 使用一行 Base64 私钥,已构造两行文件" else echo "❌ TAURI_SIGNING_PRIVATE_KEY 格式无法识别:既不是两行原文,也不是其 base64,亦非一行 base64" >&2 echo "密钥前10个字符: $(echo "$RAW" | head -c 10)..." >&2 exit 1 fi fi fi # 将“完整两行内容”作为环境变量注入(Tauri 支持传入完整私钥文本或文件路径) # 使用多行写入语法,保持换行以便解析 # 将完整两行私钥内容进行 base64 编码,作为单行内容注入环境变量 if command -v base64 >/dev/null 2>&1; then KEY_B64=$(base64 < "$KEY_PATH" | tr -d '\r\n') elif command -v openssl >/dev/null 2>&1; then KEY_B64=$(openssl base64 -A -in "$KEY_PATH") else KEY_B64=$(KEY_PATH="$KEY_PATH" node -e "process.stdout.write(require('fs').readFileSync(process.env.KEY_PATH).toString('base64'))") fi if [ -z "$KEY_B64" ]; then echo "❌ 无法生成私钥 base64 内容" >&2 exit 1 fi echo "TAURI_SIGNING_PRIVATE_KEY=$KEY_B64" >> "$GITHUB_ENV" if [ -n "${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}" ]; then echo "TAURI_SIGNING_PRIVATE_KEY_PASSWORD=${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}" >> $GITHUB_ENV fi echo "✅ Tauri signing key prepared" - name: Build Tauri App (macOS) if: runner.os == 'macOS' run: pnpm tauri build --target universal-apple-darwin - name: Build Tauri App (Windows) if: runner.os == 'Windows' run: pnpm tauri build - name: Build Tauri App (Linux) if: runner.os == 'Linux' run: pnpm tauri build - name: Prepare macOS Assets if: runner.os == 'macOS' shell: bash run: | set -euxo pipefail mkdir -p release-assets VERSION="${GITHUB_REF_NAME}" # e.g., v3.5.0 echo "Looking for updater artifact (.tar.gz) and .app for zip..." TAR_GZ=""; APP_PATH="" for path in \ "src-tauri/target/universal-apple-darwin/release/bundle/macos" \ "src-tauri/target/aarch64-apple-darwin/release/bundle/macos" \ "src-tauri/target/x86_64-apple-darwin/release/bundle/macos" \ "src-tauri/target/release/bundle/macos"; do if [ -d "$path" ]; then [ -z "$TAR_GZ" ] && TAR_GZ=$(find "$path" -maxdepth 1 -name "*.tar.gz" -type f | head -1 || true) [ -z "$APP_PATH" ] && APP_PATH=$(find "$path" -maxdepth 1 -name "*.app" -type d | head -1 || true) fi done if [ -z "$TAR_GZ" ]; then echo "No macOS .tar.gz updater artifact found" >&2 exit 1 fi # 重命名 tar.gz 为统一格式 NEW_TAR_GZ="CC-Switch-${VERSION}-macOS.tar.gz" cp "$TAR_GZ" "release-assets/$NEW_TAR_GZ" [ -f "$TAR_GZ.sig" ] && cp "$TAR_GZ.sig" "release-assets/$NEW_TAR_GZ.sig" || echo ".sig for macOS not found yet" echo "macOS updater artifact copied: $NEW_TAR_GZ" if [ -n "$APP_PATH" ]; then APP_DIR=$(dirname "$APP_PATH"); APP_NAME=$(basename "$APP_PATH") NEW_ZIP="CC-Switch-${VERSION}-macOS.zip" cd "$APP_DIR" ditto -c -k --sequesterRsrc --keepParent "$APP_NAME" "$NEW_ZIP" mv "$NEW_ZIP" "$GITHUB_WORKSPACE/release-assets/" echo "macOS zip ready: $NEW_ZIP" else echo "No .app found to zip (optional)" >&2 fi - name: Prepare Windows Assets if: runner.os == 'Windows' shell: pwsh run: | $ErrorActionPreference = 'Stop' New-Item -ItemType Directory -Force -Path release-assets | Out-Null $VERSION = $env:GITHUB_REF_NAME # e.g., v3.5.0 # 仅打包 MSI 安装器 + .sig(用于 Updater) $msi = Get-ChildItem -Path 'src-tauri/target/release/bundle/msi' -Recurse -Include *.msi -ErrorAction SilentlyContinue | Select-Object -First 1 if ($null -eq $msi) { # 兜底:全局搜索 .msi $msi = Get-ChildItem -Path 'src-tauri/target/release/bundle' -Recurse -Include *.msi -ErrorAction SilentlyContinue | Select-Object -First 1 } if ($null -ne $msi) { $dest = "CC-Switch-$VERSION-Windows.msi" Copy-Item $msi.FullName (Join-Path release-assets $dest) Write-Host "Installer copied: $dest" $sigPath = "$($msi.FullName).sig" if (Test-Path $sigPath) { Copy-Item $sigPath (Join-Path release-assets ("$dest.sig")) Write-Host "Signature copied: $dest.sig" } else { Write-Warning "Signature not found for $($msi.Name)" } } else { Write-Warning 'No Windows MSI installer found' } # 绿色版(portable):仅可执行文件打 zip(不参与 Updater) $exeCandidates = @( 'src-tauri/target/release/cc-switch.exe', 'src-tauri/target/x86_64-pc-windows-msvc/release/cc-switch.exe' ) $exePath = $exeCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1 if ($null -ne $exePath) { $portableDir = 'release-assets/CC-Switch-Portable' New-Item -ItemType Directory -Force -Path $portableDir | Out-Null Copy-Item $exePath $portableDir $portableIniPath = Join-Path $portableDir 'portable.ini' $portableContent = @( '# CC Switch portable build marker', 'portable=true' ) $portableContent | Set-Content -Path $portableIniPath -Encoding UTF8 $portableZip = "release-assets/CC-Switch-$VERSION-Windows-Portable.zip" Compress-Archive -Path "$portableDir/*" -DestinationPath $portableZip -Force Remove-Item -Recurse -Force $portableDir Write-Host "Windows portable zip created: CC-Switch-$VERSION-Windows-Portable.zip" } else { Write-Warning 'Portable exe not found' } - name: Prepare Linux Assets if: runner.os == 'Linux' shell: bash run: | set -euxo pipefail mkdir -p release-assets VERSION="${GITHUB_REF_NAME}" # e.g., v3.5.0 # Updater artifact: AppImage(含对应 .sig) APPIMAGE=$(find src-tauri/target/release/bundle -name "*.AppImage" | head -1 || true) if [ -n "$APPIMAGE" ]; then NEW_APPIMAGE="CC-Switch-${VERSION}-Linux.AppImage" cp "$APPIMAGE" "release-assets/$NEW_APPIMAGE" [ -f "$APPIMAGE.sig" ] && cp "$APPIMAGE.sig" "release-assets/$NEW_APPIMAGE.sig" || echo ".sig for AppImage not found" echo "AppImage copied: $NEW_APPIMAGE" else echo "No AppImage found under target/release/bundle" >&2 fi # 额外上传 .deb(用于手动安装,不参与 Updater) DEB=$(find src-tauri/target/release/bundle -name "*.deb" | head -1 || true) if [ -n "$DEB" ]; then NEW_DEB="CC-Switch-${VERSION}-Linux.deb" cp "$DEB" "release-assets/$NEW_DEB" echo "Deb package copied: $NEW_DEB" else echo "No .deb found (optional)" fi - name: List prepared assets shell: bash run: | ls -la release-assets || true - name: Collect Signatures shell: bash run: | set -euo pipefail echo "Collected signatures (if any alongside artifacts):" ls -la release-assets/*.sig || echo "No signatures found" - name: Upload Release Assets uses: softprops/action-gh-release@v2 with: tag_name: ${{ github.ref_name }} name: CC Switch ${{ github.ref_name }} prerelease: true body: | ## CC Switch ${{ github.ref_name }} Claude Code 供应商切换工具 ### 下载 - **macOS**: `CC-Switch-${{ github.ref_name }}-macOS.zip`(解压即用)或 `CC-Switch-${{ github.ref_name }}-macOS.tar.gz`(Homebrew) - **Windows**: `CC-Switch-${{ github.ref_name }}-Windows.msi`(安装版)或 `CC-Switch-${{ github.ref_name }}-Windows-Portable.zip`(绿色版) - **Linux**: `CC-Switch-${{ github.ref_name }}-Linux.AppImage`(AppImage)或 `CC-Switch-${{ github.ref_name }}-Linux.deb`(Debian/Ubuntu) --- 提示:macOS 如遇"已损坏"提示,可在终端执行:`xattr -cr "/Applications/CC Switch.app"` files: release-assets/* env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: List generated bundles (debug) if: always() shell: bash run: | echo "Listing bundles in src-tauri/target..." find src-tauri/target -maxdepth 4 -type f -name "*.*" 2>/dev/null || true assemble-latest-json: name: Assemble latest.json runs-on: ubuntu-22.04 needs: release permissions: contents: write steps: - name: Prepare GH run: | gh --version || (type -p curl >/dev/null && sudo apt-get update && sudo apt-get install -y gh || true) env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Download all release assets env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euxo pipefail TAG="${GITHUB_REF_NAME}" mkdir -p dl gh release download "$TAG" --dir dl --repo "$GITHUB_REPOSITORY" ls -la dl || true - name: Generate latest.json env: REPO: ${{ github.repository }} TAG: ${{ github.ref_name }} run: | set -euo pipefail VERSION="${TAG#v}" PUB_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) base_url="https://github.com/$REPO/releases/download/$TAG" # 初始化空平台映射 mac_url=""; mac_sig="" win_url=""; win_sig="" linux_url=""; linux_sig="" shopt -s nullglob for sig in dl/*.sig; do base=${sig%.sig} fname=$(basename "$base") url="$base_url/$fname" sig_content=$(cat "$sig") case "$fname" in *.tar.gz) # 视为 macOS updater artifact mac_url="$url"; mac_sig="$sig_content";; *.AppImage|*.appimage) linux_url="$url"; linux_sig="$sig_content";; *.msi|*.exe) win_url="$url"; win_sig="$sig_content";; esac done # 构造 JSON(仅包含存在的目标) tmp_json=$(mktemp) { echo '{' echo " \"version\": \"$VERSION\","; echo " \"notes\": \"Release $TAG\","; echo " \"pub_date\": \"$PUB_DATE\","; echo ' "platforms": {' first=1 if [ -n "$mac_url" ] && [ -n "$mac_sig" ]; then # 为兼容 arm64 / x64,重复写入两个键,指向同一 universal 包 for key in darwin-aarch64 darwin-x86_64; do [ $first -eq 0 ] && echo ',' echo " \"$key\": {\"signature\": \"$mac_sig\", \"url\": \"$mac_url\"}" first=0 done fi if [ -n "$win_url" ] && [ -n "$win_sig" ]; then [ $first -eq 0 ] && echo ',' echo " \"windows-x86_64\": {\"signature\": \"$win_sig\", \"url\": \"$win_url\"}" first=0 fi if [ -n "$linux_url" ] && [ -n "$linux_sig" ]; then [ $first -eq 0 ] && echo ',' echo " \"linux-x86_64\": {\"signature\": \"$linux_sig\", \"url\": \"$linux_url\"}" first=0 fi echo ' }' echo '}' } > "$tmp_json" echo "Generated latest.json:" && cat "$tmp_json" mv "$tmp_json" latest.json - name: Upload latest.json to release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euxo pipefail gh release upload "$GITHUB_REF_NAME" latest.json --clobber --repo "$GITHUB_REPOSITORY"