Files
Thefool 1ef08d5e7d fix(windows): stop in-app updates from rebooting the machine (#8628)
The MSI now carries REBOOT=ReallySuppress, so Windows Installer never restarts the machine when a file is in use during an in-app update, including updates driven by older updaters. The updater also passes REBOOT=ReallySuppress, a leftover instance is closed on the updater path, and the release workflow fails if the built MSI lacks the property.

Fixes #8534
2026-10-21 15:08:23 +08:00

981 lines
40 KiB
YAML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
name: Release
on:
push:
tags:
- 'v*'
# 手动触发即干跑:完整编译、签名、公证,产物只留在 workflow 里,不建 GitHub Release
workflow_dispatch:
permissions:
contents: write
concurrency:
# 带上事件名:手动干跑也能选 tag,和正式发布同组会把正在跑的发布取消掉
group: release-${{ github.event_name }}-${{ github.ref_name }}
cancel-in-progress: true
jobs:
# 应用内更新摘要(src/whats-new/<版本>.json,见同目录 README)随安装包发出。漏写的话
# 升级到这一版的用户什么也看不到;结构写坏的话弹窗渲染出错,整个应用落进错误页。
# 放在最前面查,不白跑编译。推 tag 和 push main 同时发生,不能指望 CI 先跑完单测,
# 所以这里自己跑一遍检查所有随包摘要的那份单测。
whats-new-check:
name: Check in-app what's new summaries
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v6
# 手动干跑可以在任意分支上跑,那时这一版的摘要可能还没写,只在推 tag 时要求存在
- name: Require this version's summary
if: github.event_name == 'push' && github.ref_type == 'tag'
env:
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
file="src/whats-new/${TAG#v}.json"
if [ ! -f "$file" ]; then
echo "::error file=$file::Missing $file. Write the in-app summary for $TAG (see src/whats-new/README.md); use \"items\": [] to skip the popup."
exit 1
fi
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '20'
- name: Setup pnpm
env:
COREPACK_ENABLE_DOWNLOAD_PROMPT: "0"
run: |
corepack enable
corepack install
pnpm --version
- name: Get pnpm store directory
id: pnpm-store
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
- name: Setup pnpm cache
uses: actions/cache@v5
with:
path: ${{ steps.pnpm-store.outputs.path }}
key: ${{ runner.os }}-${{ runner.arch }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: ${{ runner.os }}-${{ runner.arch }}-pnpm-store-
- name: Install frontend deps
run: pnpm install --frozen-lockfile
- name: Validate all summaries
run: pnpm vitest run tests/config/whatsNewEntries.test.ts
release:
needs: whats-new-check
runs-on: ${{ matrix.os }}
environment: release
strategy:
fail-fast: false
matrix:
include:
- os: windows-2022
- os: windows-11-arm
arch: arm64
- os: ubuntu-22.04
- os: ubuntu-22.04-arm
arch: arm64
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '20'
# rust-toolchain.toml 是编译器版本的唯一来源:直接装它,免得先装 stable 再被 rustup 换掉
- name: Read pinned Rust toolchain
id: rust-toolchain
shell: bash
run: |
channel=$(sed -nE 's/^channel *= *"([^"]+)".*/\1/p' rust-toolchain.toml)
test -n "$channel"
echo "channel=$channel" >> "$GITHUB_OUTPUT"
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
toolchain: ${{ steps.rust-toolchain.outputs.channel }}
- name: Add Windows ARM64 target
if: runner.os == 'Windows' && matrix.arch == 'arm64'
shell: pwsh
run: rustup target add aarch64-pc-windows-msvc
- 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 \
rpm \
flatpak \
flatpak-builder \
elfutils \
xdg-utils
# 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
env:
COREPACK_ENABLE_DOWNLOAD_PROMPT: "0"
run: |
corepack enable
corepack install
node --version
pnpm --version
- name: Get pnpm store directory
if: runner.os != 'Windows' || matrix.arch != 'arm64'
id: pnpm-store
shell: bash
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
- name: Setup pnpm cache
if: runner.os != 'Windows' || matrix.arch != 'arm64'
uses: actions/cache@v5
with:
path: ${{ steps.pnpm-store.outputs.path }}
key: ${{ runner.os }}-${{ runner.arch }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: ${{ runner.os }}-${{ runner.arch }}-pnpm-store-
- name: Setup LLVM for Windows ARM64
if: runner.os == 'Windows' && matrix.arch == 'arm64'
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$llvmRoot = 'C:\Program Files\LLVM'
if (-not (Test-Path $llvmRoot)) {
throw "LLVM not found at $llvmRoot"
}
$llvmBin = Join-Path $llvmRoot 'bin'
"LIBCLANG_PATH=$llvmBin" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
"CLANG_PATH=$(Join-Path $llvmBin 'clang.exe')" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
$llvmBin | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8
- name: Install frontend deps
run: pnpm install --frozen-lockfile
- name: Prepare Tauri signing key
uses: ./.github/actions/prepare-tauri-signing-key
with:
private-key: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
password: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
- name: Build Tauri App (Windows)
if: runner.os == 'Windows'
shell: pwsh
env:
WINDOWS_RELEASE_ARCH: ${{ matrix.arch || 'x86_64' }}
run: |
$ErrorActionPreference = 'Stop'
# 只发布 MSI(和便携版 exe),不打用不上的 NSIS 安装包
if ($env:WINDOWS_RELEASE_ARCH -eq 'arm64') {
pnpm tauri build --target aarch64-pc-windows-msvc --bundles msi
} else {
pnpm tauri build --bundles msi
}
- name: Build Tauri App (Linux)
if: runner.os == 'Linux'
run: pnpm tauri build --bundles appimage,deb,rpm
- name: Prepare Windows Assets
if: runner.os == 'Windows'
shell: pwsh
env:
WINDOWS_RELEASE_ARCH: ${{ matrix.arch || 'x86_64' }}
run: |
$ErrorActionPreference = 'Stop'
New-Item -ItemType Directory -Force -Path release-assets | Out-Null
# e.g., v3.5.0;手动干跑时是分支名,斜杠会被当成目录,换成 -
$VERSION = $env:GITHUB_REF_NAME -replace '/', '-'
$isArm64 = $env:WINDOWS_RELEASE_ARCH -eq 'arm64'
$targetRoot = if ($isArm64) { 'src-tauri/target/aarch64-pc-windows-msvc/release' } else { 'src-tauri/target/release' }
$assetSuffix = if ($isArm64) { '-arm64' } else { '' }
# 仅打包 MSI 安装器 + .sig(用于 Updater)
$msi = Get-ChildItem -Path (Join-Path $targetRoot 'bundle/msi') -Recurse -Include *.msi -ErrorAction SilentlyContinue | Select-Object -First 1
if ($null -eq $msi) {
# 兜底:全局搜索 .msi
$msi = Get-ChildItem -Path (Join-Path $targetRoot 'bundle') -Recurse -Include *.msi -ErrorAction SilentlyContinue | Select-Object -First 1
}
if ($null -ne $msi) {
# #8534:旧版本的应用内更新器以 `msiexec /passive /promptrestart` 安装、不带任何 REBOOT
# 选项,文件被占用时会不经提示直接重启系统,所以 MSI 自身必须带 REBOOT=ReallySuppress。
$installer = New-Object -ComObject WindowsInstaller.Installer
$db = $installer.GetType().InvokeMember('OpenDatabase', 'InvokeMethod', $null, $installer, @($msi.FullName, 0))
$view = $db.GetType().InvokeMember('OpenView', 'InvokeMethod', $null, $db, @('SELECT `Value` FROM `Property` WHERE `Property`=''REBOOT'''))
$view.GetType().InvokeMember('Execute', 'InvokeMethod', $null, $view, $null) | Out-Null
$record = $view.GetType().InvokeMember('Fetch', 'InvokeMethod', $null, $view, $null)
$reboot = if ($null -ne $record) { $record.GetType().InvokeMember('StringData', 'GetProperty', $null, $record, 1) } else { $null }
$view.GetType().InvokeMember('Close', 'InvokeMethod', $null, $view, $null) | Out-Null
if ($reboot -ne 'ReallySuppress') {
throw "MSI Property table must set REBOOT=ReallySuppress (found: '$reboot') - see #8534"
}
Write-Host "MSI REBOOT property: $reboot"
$dest = "CC-Switch-$VERSION-Windows$assetSuffix.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 {
throw "Signature not found for $($msi.Name)"
}
} else {
throw "No Windows MSI installer found ($env:WINDOWS_RELEASE_ARCH)"
}
# 绿色版(portable):仅可执行文件打 zip(不参与 Updater)
$exeCandidates = if ($isArm64) {
@('src-tauri/target/aarch64-pc-windows-msvc/release/cc-switch.exe')
} else {
@(
'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 = if ($isArm64) {
@(
'# CC Switch portable ARM64 build marker',
'portable=true',
'arch=arm64'
)
} else {
@(
'# CC Switch portable build marker',
'portable=true'
)
}
$portableContent | Set-Content -Path $portableIniPath -Encoding UTF8
$portableZip = "release-assets/CC-Switch-$VERSION-Windows$assetSuffix-Portable.zip"
Compress-Archive -Path "$portableDir/*" -DestinationPath $portableZip -Force
Remove-Item -Recurse -Force $portableDir
Write-Host "Windows portable zip created: CC-Switch-$VERSION-Windows$assetSuffix-Portable.zip"
} elseif ($isArm64) {
throw 'Portable ARM64 exe not found'
} 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
# e.g., v3.5.0;手动干跑时是分支名,斜杠会被当成目录,换成 -
VERSION="${GITHUB_REF_NAME//\//-}"
ARCH="${{ matrix.arch || 'x86_64' }}"
# Updater artifact: AppImage(含对应 .sig)
APPIMAGE=$(find src-tauri/target/release/bundle -name "*.AppImage" | head -1 || true)
if [ -z "$APPIMAGE" ]; then
echo "❌ No AppImage found under target/release/bundle" >&2
exit 1
fi
if [ ! -f "$APPIMAGE.sig" ]; then
echo "❌ Updater signature not found: $APPIMAGE.sig" >&2
exit 1
fi
NEW_APPIMAGE="CC-Switch-${VERSION}-Linux-${ARCH}.AppImage"
cp "$APPIMAGE" "release-assets/$NEW_APPIMAGE"
cp "$APPIMAGE.sig" "release-assets/$NEW_APPIMAGE.sig"
echo "AppImage copied: $NEW_APPIMAGE"
# 额外上传 .deb(用于 Debian/Ubuntu 自动更新)
DEB=$(find src-tauri/target/release/bundle -name "*.deb" | head -1 || true)
if [ -z "$DEB" ]; then
echo "❌ No .deb found under target/release/bundle" >&2
exit 1
fi
if [ ! -f "$DEB.sig" ]; then
echo "❌ Updater signature not found: $DEB.sig" >&2
exit 1
fi
NEW_DEB="CC-Switch-${VERSION}-Linux-${ARCH}.deb"
cp "$DEB" "release-assets/$NEW_DEB"
cp "$DEB.sig" "release-assets/$NEW_DEB.sig"
echo "Deb package copied: $NEW_DEB"
# 额外上传 .rpm(用于 Fedora/RHEL/openSUSE 等自动更新)
RPM=$(find src-tauri/target/release/bundle -name "*.rpm" | head -1 || true)
if [ -z "$RPM" ]; then
echo "❌ No .rpm found under target/release/bundle" >&2
exit 1
fi
if [ ! -f "$RPM.sig" ]; then
echo "❌ Updater signature not found: $RPM.sig" >&2
exit 1
fi
NEW_RPM="CC-Switch-${VERSION}-Linux-${ARCH}.rpm"
cp "$RPM" "release-assets/$NEW_RPM"
cp "$RPM.sig" "release-assets/$NEW_RPM.sig"
echo "RPM package copied: $NEW_RPM"
- 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 artifacts to workflow
uses: actions/upload-artifact@v7
with:
name: release-assets-${{ runner.os }}-${{ matrix.arch || runner.arch }}
path: release-assets/*
if-no-files-found: error
- 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
# macOS 两个架构分两台机器同时编译(以前在一台机器上串行),再由 macos-release 合并签名。
# 编译不需要任何 secret;挂 release environment 只是为了和其它平台在开头一起批准一次。
macos-binary:
name: Build macOS binary (${{ matrix.target }})
needs: whats-new-check
runs-on: macos-14
environment: release
strategy:
matrix:
target: [aarch64-apple-darwin, x86_64-apple-darwin]
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '20'
# rust-toolchain.toml 是编译器版本的唯一来源:直接装它,免得先装 stable 再被 rustup 换掉
- name: Read pinned Rust toolchain
id: rust-toolchain
shell: bash
run: |
channel=$(sed -nE 's/^channel *= *"([^"]+)".*/\1/p' rust-toolchain.toml)
test -n "$channel"
echo "channel=$channel" >> "$GITHUB_OUTPUT"
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
toolchain: ${{ steps.rust-toolchain.outputs.channel }}
targets: ${{ matrix.target }}
- name: Setup pnpm
env:
COREPACK_ENABLE_DOWNLOAD_PROMPT: "0"
run: |
corepack enable
corepack install
node --version
pnpm --version
- 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@v5
with:
path: ${{ steps.pnpm-store.outputs.path }}
key: ${{ runner.os }}-${{ runner.arch }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: ${{ runner.os }}-${{ runner.arch }}-pnpm-store-
- name: Install frontend deps
run: pnpm install --frozen-lockfile
# 编译只做一次:编译失败是确定性的,重试没有意义。签名和公证依赖 Apple 的服务,
# 在 macos-release 里单独重试,不会再重编。
- name: Build Tauri App (${{ matrix.target }})
timeout-minutes: 60
run: pnpm tauri build --target ${{ matrix.target }} --no-bundle
- name: Upload binary
uses: actions/upload-artifact@v7
with:
name: macos-binary-${{ matrix.target }}
path: src-tauri/target/${{ matrix.target }}/release/cc-switch
if-no-files-found: error
retention-days: 1
# 合并成 universal 二进制后打包、签名、公证。不挂 environment:它依赖的 macos-binary
# 已经过了 release 的批准,这里再挂会在半路要求第二次批准。secret 都是仓库级的。
macos-release:
name: Bundle macOS release
runs-on: macos-14
needs: macos-binary
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '20'
# rust-toolchain.toml 是编译器版本的唯一来源:直接装它,免得先装 stable 再被 rustup 换掉
- name: Read pinned Rust toolchain
id: rust-toolchain
shell: bash
run: |
channel=$(sed -nE 's/^channel *= *"([^"]+)".*/\1/p' rust-toolchain.toml)
test -n "$channel"
echo "channel=$channel" >> "$GITHUB_OUTPUT"
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
toolchain: ${{ steps.rust-toolchain.outputs.channel }}
- name: Setup pnpm
env:
COREPACK_ENABLE_DOWNLOAD_PROMPT: "0"
run: |
corepack enable
corepack install
node --version
pnpm --version
- 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@v5
with:
path: ${{ steps.pnpm-store.outputs.path }}
key: ${{ runner.os }}-${{ runner.arch }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: ${{ runner.os }}-${{ runner.arch }}-pnpm-store-
- name: Install frontend deps
run: pnpm install --frozen-lockfile
- name: Prepare Tauri signing key
uses: ./.github/actions/prepare-tauri-signing-key
with:
private-key: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
password: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
- name: Import Apple signing certificate
shell: bash
run: |
set -euo pipefail
# Decode .p12 certificate from base64
CERT_PATH="$RUNNER_TEMP/certificate.p12"
printf '%s' "${{ secrets.APPLE_CERTIFICATE }}" | (base64 --decode 2>/dev/null || base64 -D) > "$CERT_PATH"
# Save original default keychain for cleanup
ORIGINAL_DEFAULT_KEYCHAIN=$(security default-keychain -d user | tr -d '"' | xargs)
echo "ORIGINAL_DEFAULT_KEYCHAIN=$ORIGINAL_DEFAULT_KEYCHAIN" >> "$GITHUB_ENV"
# Create temporary keychain
KEYCHAIN_PATH="$RUNNER_TEMP/build.keychain-db"
security create-keychain -p "${{ secrets.KEYCHAIN_PASSWORD }}" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security default-keychain -s "$KEYCHAIN_PATH"
security unlock-keychain -p "${{ secrets.KEYCHAIN_PASSWORD }}" "$KEYCHAIN_PATH"
# Import certificate
security import "$CERT_PATH" \
-k "$KEYCHAIN_PATH" \
-P "${{ secrets.APPLE_CERTIFICATE_PASSWORD }}" \
-T /usr/bin/codesign \
-T /usr/bin/security
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "${{ secrets.KEYCHAIN_PASSWORD }}" "$KEYCHAIN_PATH"
# Dynamically resolve signing identity (must be "Developer ID Application")
IDENTITY=$(security find-identity -v -p codesigning "$KEYCHAIN_PATH" \
| grep "Developer ID Application" | grep -oE '"[^"]+"' | head -1 | tr -d '"')
if [ -z "$IDENTITY" ]; then
echo "❌ No 'Developer ID Application' identity found — listing all identities:" >&2
security find-identity -v -p codesigning "$KEYCHAIN_PATH"
exit 1
fi
echo "✅ Signing identity: $IDENTITY"
echo "APPLE_SIGNING_IDENTITY=$IDENTITY" >> "$GITHUB_ENV"
# Cleanup certificate file
rm -f "$CERT_PATH"
- name: Download macOS binaries
uses: actions/download-artifact@v8
with:
pattern: macos-binary-*
path: macos-binaries
# tauri bundle 只读这个 universal 二进制(和 tauri build --target universal-apple-darwin
# 的 lipo 结果相同),不需要各架构的 target 目录。
- name: Merge universal binary
shell: bash
run: |
set -euo pipefail
out=src-tauri/target/universal-apple-darwin/release
mkdir -p "$out"
lipo -create -output "$out/cc-switch" \
macos-binaries/macos-binary-aarch64-apple-darwin/cc-switch \
macos-binaries/macos-binary-x86_64-apple-darwin/cc-switch
chmod +x "$out/cc-switch"
lipo -info "$out/cc-switch"
# 只打 .app(连同 updater 的 .app.tar.gz 和 .sig):发布用的 DMG 在下面由 create-dmg 制作,
# Tauri 自己的 DMG 从来不上传。失败只重试这一步,不会重编。
- name: Bundle, sign and notarize (macOS)
shell: bash
timeout-minutes: 45
env:
APPLE_SIGNING_IDENTITY: ${{ env.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
set -euo pipefail
max_attempts=3
for attempt in $(seq 1 "$max_attempts"); do
echo "=== macOS bundle/notarization attempt ${attempt}/${max_attempts} ==="
bundle_log="$RUNNER_TEMP/tauri-bundle-macos-${attempt}.log"
if pnpm tauri bundle --target universal-apple-darwin --bundles app 2>&1 | tee "$bundle_log"; then
echo "✅ macOS bundle/notarization succeeded"
exit 0
fi
# Apple 拒绝凭据或账号协议(401/403,例如 "A required agreement is missing or has expired")
# 只能人工处理,重试没有意义。
if grep -qE 'HTTP status code: 40[13]' "$bundle_log"; then
echo "❌ Apple rejected the notarization request (HTTP 401/403); retrying cannot fix this." >&2
echo " If it mentions an agreement, the Account Holder must accept it at developer.apple.com, then re-run this job." >&2
exit 1
fi
if [ "$attempt" -eq "$max_attempts" ]; then
echo "❌ macOS bundle/notarization failed after ${max_attempts} attempts" >&2
exit 1
fi
sleep_seconds=$((attempt * 60))
echo "⚠️ macOS bundle/notarization failed, retrying in ${sleep_seconds}s..."
sleep "$sleep_seconds"
done
- name: Prepare macOS Assets
shell: bash
run: |
set -euxo pipefail
mkdir -p release-assets
# e.g., v3.5.0;手动干跑时是分支名,斜杠会被当成目录,换成 -
VERSION="${GITHUB_REF_NAME//\//-}"
# Locate bundle artifacts
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
if [ -z "$APP_PATH" ]; then
echo "❌ No .app found" >&2
exit 1
fi
# Staple notarization ticket to .app (Tauri already notarized it)
xcrun stapler staple "$APP_PATH"
echo "✅ .app stapled"
# 1) Collect .tar.gz (updater artifact)
NEW_TAR_GZ="CC-Switch-${VERSION}-macOS.tar.gz"
cp "$TAR_GZ" "release-assets/$NEW_TAR_GZ"
if [ ! -f "$TAR_GZ.sig" ]; then
echo "❌ Updater signature not found: $TAR_GZ.sig" >&2
exit 1
fi
cp "$TAR_GZ.sig" "release-assets/$NEW_TAR_GZ.sig"
echo "macOS updater artifact copied: $NEW_TAR_GZ"
# 2) Collect .app as zip
NEW_ZIP="CC-Switch-${VERSION}-macOS.zip"
ditto -c -k --sequesterRsrc --keepParent "$APP_PATH" "release-assets/$NEW_ZIP"
echo "macOS zip ready: $NEW_ZIP"
# 3) Create styled DMG with create-dmg (Tauri's built-in DMG styling doesn't work on CI)
if [ -z "${APPLE_SIGNING_IDENTITY:-}" ]; then
echo "❌ APPLE_SIGNING_IDENTITY is missing before DMG creation" >&2
exit 1
fi
HOMEBREW_NO_AUTO_UPDATE=1 brew install create-dmg
NEW_DMG="CC-Switch-${VERSION}-macOS.dmg"
DMG_STAGE_DIR="$RUNNER_TEMP/dmg-stage"
rm -rf "$DMG_STAGE_DIR"
mkdir -p "$DMG_STAGE_DIR"
ditto "$APP_PATH" "$DMG_STAGE_DIR/CC Switch.app"
create-dmg \
--volname "CC Switch" \
--background "src-tauri/icons/dmg-background.png" \
--window-size 660 400 \
--window-pos 200 120 \
--icon-size 80 \
--icon "CC Switch.app" 180 220 \
--hide-extension "CC Switch.app" \
--app-drop-link 480 220 \
--codesign "$APPLE_SIGNING_IDENTITY" \
--no-internet-enable \
"release-assets/$NEW_DMG" \
"$DMG_STAGE_DIR"
rm -rf "$DMG_STAGE_DIR"
echo "✅ Styled DMG created: $NEW_DMG"
- name: Notarize macOS DMG
shell: bash
timeout-minutes: 30
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
set -euo pipefail
DMG_PATH=$(find release-assets -maxdepth 1 -name "*.dmg" -type f | head -1 || true)
if [ -z "$DMG_PATH" ]; then
echo "❌ No .dmg found in release-assets/ to notarize" >&2
exit 1
fi
echo "=== Notarizing DMG: $DMG_PATH ==="
max_attempts=3
for attempt in $(seq 1 "$max_attempts"); do
echo "=== DMG notarization attempt ${attempt}/${max_attempts} ==="
if xcrun notarytool submit "$DMG_PATH" \
--apple-id "$APPLE_ID" \
--password "$APPLE_PASSWORD" \
--team-id "$APPLE_TEAM_ID" \
--wait; then
echo "✅ DMG notarization succeeded"
xcrun stapler staple "$DMG_PATH"
echo "✅ DMG stapled"
break
fi
if [ "$attempt" -eq "$max_attempts" ]; then
echo "❌ DMG notarization failed after ${max_attempts} attempts" >&2
exit 1
fi
sleep_seconds=$((attempt * 60))
echo "⚠️ DMG notarization failed, retrying in ${sleep_seconds}s..."
sleep "$sleep_seconds"
done
- name: Verify macOS code signing and notarization
shell: bash
run: |
set -euo pipefail
# Verify .app (from Tauri bundle)
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 "$APP_PATH" ] && APP_PATH=$(find "$path" -maxdepth 1 -name "*.app" -type d | head -1 || true)
fi
done
if [ -z "$APP_PATH" ]; then
echo "❌ No .app found for verification" >&2
exit 1
fi
echo "=== Verifying .app: $APP_PATH ==="
codesign --verify --deep --strict --verbose=2 "$APP_PATH"
echo "✅ codesign verification passed"
spctl -a -t exec -vv "$APP_PATH"
echo "✅ spctl assessment passed"
xcrun stapler validate "$APP_PATH"
echo "✅ .app stapler validation passed"
# Verify .dmg (from release-assets/, created by create-dmg + notarized)
DMG_PATH=$(find release-assets -maxdepth 1 -name "*.dmg" -type f | head -1 || true)
if [ -n "$DMG_PATH" ]; then
echo "=== Verifying .dmg: $DMG_PATH ==="
codesign --verify --verbose=2 "$DMG_PATH"
echo "✅ .dmg codesign verification passed"
spctl -a -t open --context context:primary-signature -vv "$DMG_PATH"
echo "✅ .dmg spctl assessment passed"
xcrun stapler validate "$DMG_PATH"
echo "✅ .dmg stapler validation passed"
else
echo "❌ No .dmg found for verification — release would ship without verified DMG" >&2
exit 1
fi
- name: List prepared assets
shell: bash
run: |
ls -la release-assets || true
- name: Upload release artifacts to workflow
uses: actions/upload-artifact@v7
with:
name: release-assets-macOS
path: release-assets/*
if-no-files-found: error
- 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
- name: Clean up Apple signing keychain
if: always()
shell: bash
run: |
if [ -n "${ORIGINAL_DEFAULT_KEYCHAIN:-}" ]; then
security default-keychain -s "$ORIGINAL_DEFAULT_KEYCHAIN" || true
fi
if [ -f "$RUNNER_TEMP/build.keychain-db" ]; then
security delete-keychain "$RUNNER_TEMP/build.keychain-db" || true
fi
publish-release:
name: Publish GitHub Release
runs-on: ubuntu-22.04
needs: [release, macos-release]
# 只有推 tag 才发布。手动触发时也能选 tag 作为运行版本,单看 ref_type 会把干跑当成正式发布,
# 覆盖已发布 release 的正文和安装包
if: github.event_name == 'push' && github.ref_type == 'tag'
permissions:
contents: write
steps:
- name: Download built release artifacts
uses: actions/download-artifact@v8
with:
pattern: release-assets-*
path: release-assets
merge-multiple: true
- name: List downloaded release artifacts
shell: bash
run: |
set -euo pipefail
ls -la release-assets
# 签名就在刚下载的产物里:latest.json 随安装包一起上传。缺平台时在建 release 之前就失败,
# 不会留下一个缺 latest.json 的半成品 release。
- 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_x64_url=""; win_x64_sig=""
win_arm64_url=""; win_arm64_sig=""
linux_x64_url=""; linux_x64_sig=""
linux_arm64_url=""; linux_arm64_sig=""
linux_x64_deb_url=""; linux_x64_deb_sig=""
linux_arm64_deb_url=""; linux_arm64_deb_sig=""
linux_x64_rpm_url=""; linux_x64_rpm_sig=""
linux_arm64_rpm_url=""; linux_arm64_rpm_sig=""
shopt -s nullglob
for sig in release-assets/*.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";;
*-Windows-arm64.msi)
win_arm64_url="$url"; win_arm64_sig="$sig_content";;
*-Windows.msi)
win_x64_url="$url"; win_x64_sig="$sig_content";;
*-Linux-arm64.AppImage|*-Linux-arm64.appimage)
linux_arm64_url="$url"; linux_arm64_sig="$sig_content";;
*-Linux-x86_64.AppImage|*-Linux-x86_64.appimage)
linux_x64_url="$url"; linux_x64_sig="$sig_content";;
*-Linux-arm64.deb)
linux_arm64_deb_url="$url"; linux_arm64_deb_sig="$sig_content";;
*-Linux-x86_64.deb)
linux_x64_deb_url="$url"; linux_x64_deb_sig="$sig_content";;
*-Linux-arm64.rpm)
linux_arm64_rpm_url="$url"; linux_arm64_rpm_sig="$sig_content";;
*-Linux-x86_64.rpm)
linux_x64_rpm_url="$url"; linux_x64_rpm_sig="$sig_content";;
esac
done
# 缺任一平台都不能发:缺的那批用户会静默收不到更新
missing=""
[ -n "$mac_sig" ] || missing="$missing macOS"
[ -n "$win_x64_sig" ] || missing="$missing windows-x86_64"
[ -n "$win_arm64_sig" ] || missing="$missing windows-aarch64"
[ -n "$linux_x64_sig" ] || missing="$missing linux-x86_64"
[ -n "$linux_arm64_sig" ] || missing="$missing linux-aarch64"
[ -n "$linux_x64_deb_sig" ] || missing="$missing linux-x86_64-deb"
[ -n "$linux_arm64_deb_sig" ] || missing="$missing linux-aarch64-deb"
[ -n "$linux_x64_rpm_sig" ] || missing="$missing linux-x86_64-rpm"
[ -n "$linux_arm64_rpm_sig" ] || missing="$missing linux-aarch64-rpm"
if [ -n "$missing" ]; then
echo "❌ Missing updater signatures for:$missing" >&2
exit 1
fi
# 构造 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_x64_url" ] && [ -n "$win_x64_sig" ]; then
[ $first -eq 0 ] && echo ','
echo " \"windows-x86_64\": {\"signature\": \"$win_x64_sig\", \"url\": \"$win_x64_url\"}"
first=0
fi
if [ -n "$win_arm64_url" ] && [ -n "$win_arm64_sig" ]; then
[ $first -eq 0 ] && echo ','
echo " \"windows-aarch64\": {\"signature\": \"$win_arm64_sig\", \"url\": \"$win_arm64_url\"}"
first=0
fi
if [ -n "$linux_x64_url" ] && [ -n "$linux_x64_sig" ]; then
[ $first -eq 0 ] && echo ','
echo " \"linux-x86_64\": {\"signature\": \"$linux_x64_sig\", \"url\": \"$linux_x64_url\"}"
first=0
fi
if [ -n "$linux_arm64_url" ] && [ -n "$linux_arm64_sig" ]; then
[ $first -eq 0 ] && echo ','
echo " \"linux-aarch64\": {\"signature\": \"$linux_arm64_sig\", \"url\": \"$linux_arm64_url\"}"
first=0
fi
if [ -n "$linux_x64_deb_url" ] && [ -n "$linux_x64_deb_sig" ]; then
[ $first -eq 0 ] && echo ','
echo " \"linux-x86_64-deb\": {\"signature\": \"$linux_x64_deb_sig\", \"url\": \"$linux_x64_deb_url\"}"
first=0
fi
if [ -n "$linux_arm64_deb_url" ] && [ -n "$linux_arm64_deb_sig" ]; then
[ $first -eq 0 ] && echo ','
echo " \"linux-aarch64-deb\": {\"signature\": \"$linux_arm64_deb_sig\", \"url\": \"$linux_arm64_deb_url\"}"
first=0
fi
if [ -n "$linux_x64_rpm_url" ] && [ -n "$linux_x64_rpm_sig" ]; then
[ $first -eq 0 ] && echo ','
echo " \"linux-x86_64-rpm\": {\"signature\": \"$linux_x64_rpm_sig\", \"url\": \"$linux_x64_rpm_url\"}"
first=0
fi
if [ -n "$linux_arm64_rpm_url" ] && [ -n "$linux_arm64_rpm_sig" ]; then
[ $first -eq 0 ] && echo ','
echo " \"linux-aarch64-rpm\": {\"signature\": \"$linux_arm64_rpm_sig\", \"url\": \"$linux_arm64_rpm_url\"}"
first=0
fi
echo ' }'
echo '}'
} > "$tmp_json"
echo "Generated latest.json:" && cat "$tmp_json"
mv "$tmp_json" release-assets/latest.json
- name: Upload Release Assets
uses: softprops/action-gh-release@v3
with:
tag_name: ${{ github.ref_name }}
name: CC Switch ${{ github.ref_name }}
prerelease: true
body: |
## CC Switch ${{ github.ref_name }}
🌐 **Only Official Website / 唯一官方网站 / 唯一の公式サイト**: [ccswitch.io](https://ccswitch.io)
Claude Code 供应商切换工具
### 下载
- **macOS**: `CC-Switch-${{ github.ref_name }}-macOS.dmg`(推荐)或 `CC-Switch-${{ github.ref_name }}-macOS.zip`(解压即用)
- **Windows (x86_64)**: `CC-Switch-${{ github.ref_name }}-Windows.msi`(安装版)或 `CC-Switch-${{ github.ref_name }}-Windows-Portable.zip`(绿色版)
- **Windows (ARM64)**: `CC-Switch-${{ github.ref_name }}-Windows-arm64.msi`(安装版)或 `CC-Switch-${{ github.ref_name }}-Windows-arm64-Portable.zip`(绿色版)
- **Linux (x86_64)**: `CC-Switch-${{ github.ref_name }}-Linux-x86_64.AppImage` / `.deb` / `.rpm`
- **Linux (ARM64)**: `CC-Switch-${{ github.ref_name }}-Linux-arm64.AppImage` / `.deb` / `.rpm`
> `.tar.gz` 为 Tauri updater 自动更新专用,无需手动下载。
---
macOS 版本已通过 Apple 代码签名和公证,可直接安装使用。
files: release-assets/*
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}