Skip to content

feat: sync latest workspace changes #289

feat: sync latest workspace changes

feat: sync latest workspace changes #289

Workflow file for this run

name: Release
on:
push:
tags:
- "v*"
paths-ignore:
- "docs/**"
- "*.md"
- ".github/workflows/deploy-docs.yml"
- ".github/workflows/update-homebrew.yml"
workflow_dispatch:
inputs:
version:
description: "Version to release (e.g., v0.1.0)"
required: true
default: "v0.1.0"
source_ref:
description: "Git ref to build from (e.g., v0.96.0 or main)"
required: false
default: ""
permissions:
contents: write
packages: write
actions: read
env:
CARGO_NET_RETRY: 10
RUSTUP_MAX_RETRIES: 10
CARGO_TERM_COLOR: always
jobs:
prepare_release:
name: Prepare GitHub release
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.event.inputs.source_ref || github.ref }}
- name: Ensure GitHub release exists
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
TAG="${{ github.event.inputs.version || github.ref_name }}"
TARGET_REF="${{ github.event.inputs.source_ref || github.sha }}"
NOTES_FILE="$RUNNER_TEMP/release-notes.md"
if [[ "$TAG" == *-* ]]; then
IS_PRERELEASE="true"
else
IS_PRERELEASE="false"
fi
if [ -f RELEASE_NOTES.md ]; then
cp RELEASE_NOTES.md "$NOTES_FILE"
else
PREV_TAG="$(git tag --sort=-v:refname | grep -v "^${TAG}$" | head -n 1)"
if [ -z "$PREV_TAG" ]; then
CHANGELOG="$(git log --pretty=format:"- %s (%h)" "${TAG}" 2>/dev/null || git log --pretty=format:"- %s (%h)")"
else
CHANGELOG="$(git log --pretty=format:"- %s (%h)" "${PREV_TAG}..${TAG}" 2>/dev/null || git log --pretty=format:"- %s (%h)" "${PREV_TAG}..HEAD")"
fi
{
echo "## Lime ${TAG}"
echo
echo "### Changes"
echo "${CHANGELOG}"
} > "$NOTES_FILE"
fi
if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
echo "Release $TAG already exists"
exit 0
fi
create_args=(
release create "$TAG"
--repo "$GITHUB_REPOSITORY"
--target "$TARGET_REF"
--title "Lime $TAG"
--notes-file "$NOTES_FILE"
)
if [ "$IS_PRERELEASE" = "true" ]; then
create_args+=(--prerelease)
fi
gh "${create_args[@]}" || gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null
build:
needs: prepare_release
strategy:
fail-fast: false
matrix:
include:
- platform: macos-latest
target: aarch64-apple-darwin
name: macOS-arm64
build_cli: true
- platform: macos-15-intel
target: x86_64-apple-darwin
name: macOS-x64
build_cli: true
- platform: windows-2022
target: x86_64-pc-windows-msvc
name: Windows-x64
build_cli: true
- platform: ubuntu-22.04
target: x86_64-unknown-linux-gnu
name: Linux-x64-cli
build_cli: true
runs-on: ${{ matrix.platform }}
env:
LIME_UPDATER_PUBLIC_KEY: ${{ secrets.LIME_UPDATER_PUBLIC_KEY }}
TAURI_SIGNING_PRIVATE_KEY_RAW: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
NODE_OPTIONS: --max-old-space-size=8192
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.event.inputs.source_ref || github.ref }}
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "pnpm"
cache-dependency-path: "pnpm-lock.yaml"
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Setup sccache
uses: mozilla-actions/sccache-action@v0.0.9
- name: Setup Rust cache
uses: Swatinem/rust-cache@v2
with:
workspaces: src-tauri
shared-key: "rust-${{ matrix.target }}"
cache-on-failure: true
cache-all-crates: true
save-if: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/') }}
- name: Resolve updater release mode
id: updater_mode
shell: bash
run: |
if [ -n "${LIME_UPDATER_PUBLIC_KEY}" ] && [ -n "${TAURI_SIGNING_PRIVATE_KEY_RAW}" ] && [ -n "${TAURI_SIGNING_PRIVATE_KEY_PASSWORD}" ]; then
echo "enabled=true" >> "$GITHUB_OUTPUT"
echo "LIME_ENABLE_UPDATER_ARTIFACTS=true" >> "$GITHUB_ENV"
echo "Updater artifacts enabled"
else
echo "enabled=false" >> "$GITHUB_OUTPUT"
echo "LIME_ENABLE_UPDATER_ARTIFACTS=false" >> "$GITHUB_ENV"
echo "LIME_UPDATER_PUBLIC_KEY=" >> "$GITHUB_ENV"
echo "TAURI_SIGNING_PRIVATE_KEY_RAW=" >> "$GITHUB_ENV"
echo "TAURI_SIGNING_PRIVATE_KEY=" >> "$GITHUB_ENV"
echo "TAURI_SIGNING_PRIVATE_KEY_PATH=" >> "$GITHUB_ENV"
echo "TAURI_SIGNING_PRIVATE_KEY_PASSWORD=" >> "$GITHUB_ENV"
echo "::warning::Updater signing secrets are missing. Release will skip updater artifacts and fall back to manual-download updates."
fi
- name: Sync version from tag to tauri configs
shell: bash
run: |
VERSION="${{ github.event.inputs.version || github.ref_name }}"
VERSION="${VERSION#v}"
echo "Syncing version to $VERSION"
cd src-tauri
node -e "
const fs = require('fs');
for (const file of ['tauri.conf.json', 'tauri.conf.headless.json']) {
const conf = JSON.parse(fs.readFileSync(file, 'utf8'));
conf.version = '$VERSION';
conf.bundle = conf.bundle || {};
conf.bundle.createUpdaterArtifacts = process.env.LIME_ENABLE_UPDATER_ARTIFACTS === 'true';
fs.writeFileSync(file, JSON.stringify(conf, null, 2) + '\n');
}
"
echo "tauri.conf.json version is now: $(node -e "console.log(JSON.parse(require('fs').readFileSync('tauri.conf.json','utf8')).version)")"
echo "tauri.conf.headless.json version is now: $(node -e "console.log(JSON.parse(require('fs').readFileSync('tauri.conf.headless.json','utf8')).version)")"
echo "createUpdaterArtifacts: $(node -e "console.log(JSON.parse(require('fs').readFileSync('tauri.conf.json','utf8')).bundle.createUpdaterArtifacts)")"
- name: Resolve release channel
id: release_meta
shell: bash
run: |
TAG="${{ github.event.inputs.version || github.ref_name }}"
if [[ "$TAG" == *-* ]]; then
echo "is_prerelease=true" >> "$GITHUB_OUTPUT"
echo "Release channel: prerelease"
else
echo "is_prerelease=false" >> "$GITHUB_OUTPUT"
echo "Release channel: stable"
fi
- name: Load release notes
shell: bash
run: |
if [ -f RELEASE_NOTES.md ]; then
{
echo "RELEASE_BODY<<RELEASE_BODY_EOF"
cat RELEASE_NOTES.md
echo ""
echo "RELEASE_BODY_EOF"
} >> "$GITHUB_ENV"
else
# fallback: 从 git log 生成简单 changelog
TAG="${{ github.event.inputs.version || github.ref_name }}"
PREV_TAG=$(git tag --sort=-v:refname | grep -v "^${TAG}$" | head -n 1)
if [ -z "$PREV_TAG" ]; then
CHANGELOG=$(git log --pretty=format:"- %s (%h)" ${TAG} 2>/dev/null || git log --pretty=format:"- %s (%h)")
else
CHANGELOG=$(git log --pretty=format:"- %s (%h)" ${PREV_TAG}..${TAG} 2>/dev/null || git log --pretty=format:"- %s (%h)" ${PREV_TAG}..HEAD)
fi
{
echo "RELEASE_BODY<<RELEASE_BODY_EOF"
echo "## Lime ${TAG}"
echo ""
echo "### Changes"
echo "${CHANGELOG}"
echo "RELEASE_BODY_EOF"
} >> "$GITHUB_ENV"
fi
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile
- name: Ensure Tauri CLI native binding
if: startsWith(matrix.platform, 'macos')
shell: bash
run: |
set -euo pipefail
if node -e "require('@tauri-apps/cli')"; then
echo "Tauri CLI native binding is ready"
exit 0
fi
echo "::warning::Tauri CLI native binding missing after initial install; retrying clean install."
rm -rf node_modules
pnpm install --frozen-lockfile --force
node -e "require('@tauri-apps/cli')"
- name: Normalize updater signing key
if: steps.updater_mode.outputs.enabled == 'true'
shell: bash
run: |
python - <<'PY'
import base64
import os
from pathlib import Path
raw = os.environ.get("TAURI_SIGNING_PRIVATE_KEY_RAW", "")
if not raw.strip():
raise SystemExit("TAURI_SIGNING_PRIVATE_KEY secret is empty")
candidates = []
def add(value: str) -> None:
normalized = value.replace("\r\n", "\n")
if normalized and normalized not in candidates:
candidates.append(normalized)
add(raw)
if "\\n" in raw and "\n" not in raw:
add(raw.replace("\\r\\n", "\n").replace("\\n", "\n"))
for candidate in list(candidates):
compact = candidate.strip()
if not compact:
continue
padding = (-len(compact)) % 4
compact = compact + ("=" * padding)
try:
decoded = base64.b64decode(compact, validate=True).decode("utf-8")
except Exception:
continue
add(decoded)
if "\\n" in decoded and "\n" not in decoded:
add(decoded.replace("\\r\\n", "\n").replace("\\n", "\n"))
normalized_key = next(
(
candidate
for candidate in candidates
if candidate.lstrip().startswith("untrusted comment:")
and "\n" in candidate
),
"",
)
if not normalized_key:
normalized_key = next(
(
candidate
for candidate in candidates
if candidate.lstrip().startswith("untrusted comment:")
),
"",
)
if not normalized_key:
raise SystemExit(
"Unable to normalize TAURI_SIGNING_PRIVATE_KEY into minisign secret key content. "
"Expected raw multiline key, literal \\\\n escaped key, or base64 encoded key."
)
key_path = Path(os.environ["RUNNER_TEMP"]) / "tauri-updater.key"
key_path.write_text(normalized_key.rstrip("\n") + "\n", encoding="utf-8")
key_path.chmod(0o600)
with open(os.environ["GITHUB_ENV"], "a", encoding="utf-8") as env_file:
env_file.write(f"TAURI_SIGNING_PRIVATE_KEY_PATH={key_path}\n")
env_file.write("TAURI_SIGNING_PRIVATE_KEY<<__TAURI_SIGNING_PRIVATE_KEY__\n")
env_file.write(normalized_key.rstrip("\n") + "\n")
env_file.write("__TAURI_SIGNING_PRIVATE_KEY__\n")
print(f"Normalized updater key written to {key_path}")
PY
- name: Validate updater signing key
if: steps.updater_mode.outputs.enabled == 'true'
shell: bash
run: |
PROBE_FILE="$RUNNER_TEMP/tauri-signing-probe.txt"
printf 'lime-release-signing-probe\n' > "$PROBE_FILE"
npx tauri signer sign \
-f "$TAURI_SIGNING_PRIVATE_KEY_PATH" \
-p "$TAURI_SIGNING_PRIVATE_KEY_PASSWORD" \
"$PROBE_FILE" >/dev/null
# macOS 签名证书设置
- name: Import Apple Certificate
if: startsWith(matrix.platform, 'macos')
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
run: |
# 创建临时 keychain
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
KEYCHAIN_PASSWORD=${{ secrets.KEYCHAIN_PASSWORD }}
# 解码证书
echo -n "$APPLE_CERTIFICATE" | base64 --decode > $RUNNER_TEMP/certificate.p12
# 创建 keychain
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
# 导入证书
security import $RUNNER_TEMP/certificate.p12 -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security list-keychain -d user -s $KEYCHAIN_PATH
- name: Validate macOS notarization prerequisites
if: startsWith(matrix.platform, 'macos')
shell: bash
env:
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
set -euo pipefail
missing=()
[ -n "${APPLE_SIGNING_IDENTITY:-}" ] || missing+=("APPLE_SIGNING_IDENTITY")
[ -n "${APPLE_ID:-}" ] || missing+=("APPLE_ID")
[ -n "${APPLE_PASSWORD:-}" ] || missing+=("APPLE_PASSWORD")
[ -n "${APPLE_TEAM_ID:-}" ] || missing+=("APPLE_TEAM_ID")
if [ "${#missing[@]}" -gt 0 ]; then
printf 'Missing macOS notarization secrets: %s\n' "${missing[*]}" >&2
exit 1
fi
- name: Build Tauri app (Windows offline, direct release upload)
if: matrix.platform == 'windows-2022' && steps.updater_mode.outputs.enabled == 'true'
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# 禁用 LTO 加速编译(正式发布可改为 thin)
CARGO_PROFILE_RELEASE_LTO: "off"
# 增加并行编译单元
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: 32
CARGO_INCREMENTAL: 0
SCCACHE_GHA_ENABLED: "true"
RUSTC_WRAPPER: sccache
with:
tauriScript: npx tauri
projectPath: src-tauri
tagName: ${{ github.event.inputs.version || github.ref_name }}
releaseName: "Lime ${{ github.event.inputs.version || github.ref_name }}"
releaseBody: ${{ env.RELEASE_BODY }}
releaseDraft: false
prerelease: ${{ steps.release_meta.outputs.is_prerelease == 'true' }}
includeUpdaterJson: ${{ steps.updater_mode.outputs.enabled == 'true' }}
# tauri-action@v0 使用 assetNamePattern,离线包优先上传并保留独立名称
assetNamePattern: "[name]_[version]_[arch]-offline[setup][ext]"
# 默认不启用 voice feature(包含 whisper-rs,编译很慢)
args: --target ${{ matrix.target }} --config tauri.windows.conf.json
- name: Build Tauri app (Windows offline, stage only)
id: build_windows_offline_stage
if: matrix.platform == 'windows-2022' && steps.updater_mode.outputs.enabled == 'false'
uses: tauri-apps/tauri-action@v0
env:
# 禁用 LTO 加速编译(正式发布可改为 thin)
CARGO_PROFILE_RELEASE_LTO: "off"
# 增加并行编译单元
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: 32
CARGO_INCREMENTAL: 0
SCCACHE_GHA_ENABLED: "true"
RUSTC_WRAPPER: sccache
with:
tauriScript: npx tauri
projectPath: src-tauri
# 默认不启用 voice feature(包含 whisper-rs,编译很慢)
args: --target ${{ matrix.target }} --config tauri.windows.conf.json
- name: Stage Windows offline release asset
if: matrix.platform == 'windows-2022' && steps.updater_mode.outputs.enabled == 'false'
shell: bash
env:
ARTIFACT_PATHS_JSON: ${{ steps.build_windows_offline_stage.outputs.artifactPaths }}
RELEASE_TAG: ${{ github.event.inputs.version || github.ref_name }}
TARGET_TRIPLE: ${{ matrix.target }}
STAGING_DIR: release-assets/${{ matrix.target }}
run: |
set -euo pipefail
node - <<'NODE'
const fs = require('fs');
const path = require('path');
const raw = (process.env.ARTIFACT_PATHS_JSON || '').trim();
if (!raw) {
throw new Error('Windows offline artifactPaths 输出为空');
}
let artifacts;
try {
artifacts = JSON.parse(raw);
} catch (error) {
throw new Error(`Windows offline artifactPaths 不是合法 JSON: ${raw}\n${error}`);
}
if (!Array.isArray(artifacts) || artifacts.length === 0) {
throw new Error(`Windows offline artifactPaths 为空数组: ${raw}`);
}
const installerPath = artifacts.find(
(item) => typeof item === 'string' && item.toLowerCase().endsWith('.exe')
);
if (!installerPath) {
throw new Error(`Windows offline 构建未找到 .exe 安装包: ${raw}`);
}
const version = (process.env.RELEASE_TAG || '').replace(/^v/, '');
if (!version) {
throw new Error('RELEASE_TAG 为空,无法生成 Windows release 文件名');
}
const targetTriple = process.env.TARGET_TRIPLE || '';
const archLabel =
targetTriple.startsWith('x86_64-') ? 'x64' :
targetTriple.startsWith('aarch64-') ? 'aarch64' :
targetTriple.replace(/[^A-Za-z0-9._-]+/g, '-');
const stagingDir = process.env.STAGING_DIR;
fs.mkdirSync(stagingDir, { recursive: true });
const stagedPath = path.join(
stagingDir,
`Lime_${version}_${archLabel}-offline-setup.exe`
);
fs.copyFileSync(installerPath, stagedPath);
console.log(`Staged Windows offline installer: ${installerPath} -> ${stagedPath}`);
NODE
- name: Build Tauri app (Windows online, direct release upload)
if: matrix.platform == 'windows-2022' && steps.updater_mode.outputs.enabled == 'true'
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# 禁用 LTO 加速编译(正式发布可改为 thin)
CARGO_PROFILE_RELEASE_LTO: "off"
# 增加并行编译单元
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: 32
CARGO_INCREMENTAL: 0
SCCACHE_GHA_ENABLED: "true"
RUSTC_WRAPPER: sccache
with:
tauriScript: npx tauri
projectPath: src-tauri
tagName: ${{ github.event.inputs.version || github.ref_name }}
releaseName: "Lime ${{ github.event.inputs.version || github.ref_name }}"
releaseBody: ${{ env.RELEASE_BODY }}
releaseDraft: false
prerelease: ${{ steps.release_meta.outputs.is_prerelease == 'true' }}
includeUpdaterJson: ${{ steps.updater_mode.outputs.enabled == 'true' }}
# tauri-action@v0 使用 assetNamePattern,在线包作为体积更小的备选
assetNamePattern: "[name]_[version]_[arch]-online[setup][ext]"
# 默认不启用 voice feature(包含 whisper-rs,编译很慢)
args: --target ${{ matrix.target }} --config tauri.windows.online.conf.json
- name: Build Tauri app (Windows online, stage only)
id: build_windows_online_stage
if: matrix.platform == 'windows-2022' && steps.updater_mode.outputs.enabled == 'false'
uses: tauri-apps/tauri-action@v0
env:
# 禁用 LTO 加速编译(正式发布可改为 thin)
CARGO_PROFILE_RELEASE_LTO: "off"
# 增加并行编译单元
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: 32
CARGO_INCREMENTAL: 0
SCCACHE_GHA_ENABLED: "true"
RUSTC_WRAPPER: sccache
with:
tauriScript: npx tauri
projectPath: src-tauri
# 默认不启用 voice feature(包含 whisper-rs,编译很慢)
args: --target ${{ matrix.target }} --config tauri.windows.online.conf.json
- name: Stage Windows online release asset
if: matrix.platform == 'windows-2022' && steps.updater_mode.outputs.enabled == 'false'
shell: bash
env:
ARTIFACT_PATHS_JSON: ${{ steps.build_windows_online_stage.outputs.artifactPaths }}
RELEASE_TAG: ${{ github.event.inputs.version || github.ref_name }}
TARGET_TRIPLE: ${{ matrix.target }}
STAGING_DIR: release-assets/${{ matrix.target }}
run: |
set -euo pipefail
node - <<'NODE'
const fs = require('fs');
const path = require('path');
const raw = (process.env.ARTIFACT_PATHS_JSON || '').trim();
if (!raw) {
throw new Error('Windows online artifactPaths 输出为空');
}
let artifacts;
try {
artifacts = JSON.parse(raw);
} catch (error) {
throw new Error(`Windows online artifactPaths 不是合法 JSON: ${raw}\n${error}`);
}
if (!Array.isArray(artifacts) || artifacts.length === 0) {
throw new Error(`Windows online artifactPaths 为空数组: ${raw}`);
}
const installerPath = artifacts.find(
(item) => typeof item === 'string' && item.toLowerCase().endsWith('.exe')
);
if (!installerPath) {
throw new Error(`Windows online 构建未找到 .exe 安装包: ${raw}`);
}
const version = (process.env.RELEASE_TAG || '').replace(/^v/, '');
if (!version) {
throw new Error('RELEASE_TAG 为空,无法生成 Windows release 文件名');
}
const targetTriple = process.env.TARGET_TRIPLE || '';
const archLabel =
targetTriple.startsWith('x86_64-') ? 'x64' :
targetTriple.startsWith('aarch64-') ? 'aarch64' :
targetTriple.replace(/[^A-Za-z0-9._-]+/g, '-');
const stagingDir = process.env.STAGING_DIR;
fs.mkdirSync(stagingDir, { recursive: true });
const stagedPath = path.join(
stagingDir,
`Lime_${version}_${archLabel}-online-setup.exe`
);
fs.copyFileSync(installerPath, stagedPath);
console.log(`Staged Windows online installer: ${installerPath} -> ${stagedPath}`);
NODE
- name: Build macOS app (attempt 1, verbose)
id: build_macos_primary
if: startsWith(matrix.platform, 'macos')
continue-on-error: true
shell: bash
env:
# 禁用 LTO 加速编译(正式发布可改为 thin)
CARGO_PROFILE_RELEASE_LTO: "off"
# 增加并行编译单元
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: 32
CARGO_INCREMENTAL: 0
CARGO_TARGET_DIR: src-tauri/target
SCCACHE_GHA_ENABLED: "true"
RUSTC_WRAPPER: sccache
# Apple 签名环境变量
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
# Apple 公证环境变量(解决 Gatekeeper 警告)
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
set -euxo pipefail
echo "macOS target: ${{ matrix.target }}"
echo "bundle candidates:"
echo " - src-tauri/target/${{ matrix.target }}/release/bundle"
echo " - src-tauri/src-tauri/target/${{ matrix.target }}/release/bundle"
echo " - target/${{ matrix.target }}/release/bundle"
pnpm tauri build --target "${{ matrix.target }}"
- name: Inspect macOS outputs after attempt 1
if: startsWith(matrix.platform, 'macos') && always()
shell: bash
run: |
set -euo pipefail
TARGET_TRIPLE="${{ matrix.target }}"
bundle_dirs=()
while IFS= read -r bundle_dir; do
[ -n "$bundle_dir" ] || continue
bundle_dirs+=("$bundle_dir")
done < <(find . -type d -path "*/target/${TARGET_TRIPLE}/release/bundle" | sort)
if [ "${#bundle_dirs[@]}" -eq 0 ]; then
echo "Bundle dir not created yet for ${TARGET_TRIPLE}"
exit 0
fi
printf 'Detected macOS bundle dirs after attempt 1:\n'
printf ' - %s\n' "${bundle_dirs[@]}"
for bundle_dir in "${bundle_dirs[@]}"; do
find "$bundle_dir" -maxdepth 4 -type f | sort
done
- name: Build macOS app (retry, verbose)
id: build_macos_retry
if: startsWith(matrix.platform, 'macos') && steps.build_macos_primary.outcome == 'failure'
continue-on-error: true
shell: bash
env:
CARGO_PROFILE_RELEASE_LTO: "off"
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: 32
CARGO_INCREMENTAL: 0
CARGO_TARGET_DIR: src-tauri/target
SCCACHE_GHA_ENABLED: "true"
RUSTC_WRAPPER: sccache
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
set -euxo pipefail
echo "Retry macOS target: ${{ matrix.target }}"
pnpm tauri build --target "${{ matrix.target }}"
- name: Inspect macOS outputs after retry
if: startsWith(matrix.platform, 'macos') && steps.build_macos_primary.outcome == 'failure' && always()
shell: bash
run: |
set -euo pipefail
TARGET_TRIPLE="${{ matrix.target }}"
bundle_dirs=()
while IFS= read -r bundle_dir; do
[ -n "$bundle_dir" ] || continue
bundle_dirs+=("$bundle_dir")
done < <(find . -type d -path "*/target/${TARGET_TRIPLE}/release/bundle" | sort)
if [ "${#bundle_dirs[@]}" -eq 0 ]; then
echo "Bundle dir not created yet for ${TARGET_TRIPLE}"
exit 0
fi
printf 'Detected macOS bundle dirs after retry:\n'
printf ' - %s\n' "${bundle_dirs[@]}"
for bundle_dir in "${bundle_dirs[@]}"; do
find "$bundle_dir" -maxdepth 4 -type f | sort
done
- name: Fail when macOS notarized build is unavailable
if: startsWith(matrix.platform, 'macos') && steps.build_macos_primary.outcome == 'failure' && steps.build_macos_retry.outcome == 'failure'
shell: bash
run: |
set -euo pipefail
echo "::error::macOS notarization failed twice. Blocking release instead of publishing a signed-only artifact."
exit 1
- name: Resolve macOS bundle dir
id: resolve_macos_bundle_dir
if: startsWith(matrix.platform, 'macos') && (steps.build_macos_primary.outcome == 'success' || steps.build_macos_retry.outcome == 'success')
shell: bash
run: |
set -euo pipefail
TARGET_TRIPLE="${{ matrix.target }}"
candidates=(
"src-tauri/target/${TARGET_TRIPLE}/release/bundle"
"src-tauri/src-tauri/target/${TARGET_TRIPLE}/release/bundle"
"target/${TARGET_TRIPLE}/release/bundle"
)
bundle_dir=""
for candidate in "${candidates[@]}"; do
if [ -d "$candidate" ]; then
bundle_dir="$candidate"
break
fi
done
if [ -z "$bundle_dir" ]; then
bundle_dir="$(find . -type d -path "*/target/${TARGET_TRIPLE}/release/bundle" | sort | head -n 1)"
fi
if [ -z "$bundle_dir" ]; then
echo "Unable to resolve macOS bundle dir for ${TARGET_TRIPLE}" >&2
find . -type d -path "*/target/${TARGET_TRIPLE}/release*" | sort || true
exit 1
fi
echo "Resolved macOS bundle dir: $bundle_dir"
echo "bundle_dir=$bundle_dir" >> "$GITHUB_OUTPUT"
- name: Verify notarized macOS app bundle
if: startsWith(matrix.platform, 'macos') && (steps.build_macos_primary.outcome == 'success' || steps.build_macos_retry.outcome == 'success')
shell: bash
run: |
set -euo pipefail
BUNDLE_DIR="${{ steps.resolve_macos_bundle_dir.outputs.bundle_dir }}"
if [ -z "$BUNDLE_DIR" ] || [ ! -d "$BUNDLE_DIR" ]; then
echo "Expected bundle dir missing: $BUNDLE_DIR" >&2
exit 1
fi
app_bundle="$(find "$BUNDLE_DIR" -type d -name "*.app" | sort | head -n 1)"
if [ -z "$app_bundle" ] || [ ! -d "$app_bundle" ]; then
echo "No .app bundle found under $BUNDLE_DIR" >&2
find "$BUNDLE_DIR" -maxdepth 4 | sort || true
exit 1
fi
echo "Validating notarized app bundle: $app_bundle"
spctl -a -vv "$app_bundle"
xcrun stapler validate "$app_bundle"
- name: Inspect final macOS outputs
if: startsWith(matrix.platform, 'macos') && always()
shell: bash
run: |
set -euo pipefail
BUNDLE_DIR="${{ steps.resolve_macos_bundle_dir.outputs.bundle_dir }}"
if [ -z "$BUNDLE_DIR" ]; then
echo "Bundle dir missing for ${{ matrix.target }}"
exit 0
fi
if [ -d "$BUNDLE_DIR" ]; then
echo "Resolved bundle dir: $BUNDLE_DIR"
find "$BUNDLE_DIR" -maxdepth 4 -type f | sort
else
echo "Bundle dir missing: $BUNDLE_DIR"
fi
- name: Stage macOS release assets
if: startsWith(matrix.platform, 'macos') && (steps.build_macos_primary.outcome == 'success' || steps.build_macos_retry.outcome == 'success')
shell: bash
run: |
set -euo pipefail
BUNDLE_DIR="${{ steps.resolve_macos_bundle_dir.outputs.bundle_dir }}"
STAGING_DIR="release-assets/${{ matrix.target }}"
if [ -z "$BUNDLE_DIR" ] || [ ! -d "$BUNDLE_DIR" ]; then
echo "Expected bundle dir missing: $BUNDLE_DIR" >&2
exit 1
fi
assets=()
while IFS= read -r asset; do
[ -n "$asset" ] || continue
assets+=("$asset")
done < <(
find "$BUNDLE_DIR" -type f \
\( -name "*.dmg" -o -name "*.app.tar.gz" -o -name "*.app.tar.gz.sig" -o -name "latest*.json" \) \
| sort
)
if [ "${#assets[@]}" -eq 0 ]; then
echo "No macOS release assets found under $BUNDLE_DIR" >&2
find "$BUNDLE_DIR" -maxdepth 4 -type f | sort || true
exit 1
fi
mkdir -p "$STAGING_DIR"
printf 'Staging macOS assets:\n'
printf ' - %s\n' "${assets[@]}"
for asset in "${assets[@]}"; do
cp "$asset" "$STAGING_DIR/$(basename "$asset")"
done
printf 'Staged macOS assets under %s:\n' "$STAGING_DIR"
find "$STAGING_DIR" -maxdepth 1 -type f | sort
- name: Build lime-cli release binary
if: matrix.build_cli && (matrix.platform == 'windows-2022' || matrix.platform == 'ubuntu-22.04' || (startsWith(matrix.platform, 'macos') && (steps.build_macos_primary.outcome == 'success' || steps.build_macos_retry.outcome == 'success')))
shell: bash
env:
CARGO_PROFILE_RELEASE_LTO: "off"
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: 32
CARGO_INCREMENTAL: 0
CARGO_TARGET_DIR: src-tauri/target
SCCACHE_GHA_ENABLED: "true"
RUSTC_WRAPPER: sccache
run: |
set -euxo pipefail
cargo build --manifest-path src-tauri/Cargo.toml -p lime-cli --release --target "${{ matrix.target }}"
- name: Package lime-cli release asset
id: package_lime_cli
if: matrix.build_cli && (matrix.platform == 'windows-2022' || matrix.platform == 'ubuntu-22.04' || (startsWith(matrix.platform, 'macos') && (steps.build_macos_primary.outcome == 'success' || steps.build_macos_retry.outcome == 'success')))
shell: bash
run: |
set -euxo pipefail
VERSION="${{ github.event.inputs.version || github.ref_name }}"
VERSION="${VERSION#v}"
metadata="$(
node packages/lime-cli-npm/scripts/build-release.js \
--target-triple "${{ matrix.target }}" \
--version "$VERSION" \
--out-dir "packages/lime-cli-npm/dist" \
--json
)"
echo "$metadata"
asset_path="$(node -e 'const data = JSON.parse(process.argv[1]); process.stdout.write(data.archivePath);' "$metadata")"
echo "asset_path=$asset_path" >> "$GITHUB_OUTPUT"
- name: Stage lime-cli release asset
if: matrix.build_cli && (matrix.platform == 'windows-2022' || matrix.platform == 'ubuntu-22.04' || (startsWith(matrix.platform, 'macos') && (steps.build_macos_primary.outcome == 'success' || steps.build_macos_retry.outcome == 'success')))
shell: bash
run: |
set -euxo pipefail
ASSET_PATH="${{ steps.package_lime_cli.outputs.asset_path }}"
STAGING_DIR="release-assets/${{ matrix.target }}"
if [ -z "$ASSET_PATH" ] || [ ! -f "$ASSET_PATH" ]; then
echo "lime-cli asset missing: $ASSET_PATH" >&2
exit 1
fi
node -e "
const fs = require('fs');
const path = require('path');
const src = process.argv[1];
const destDir = process.argv[2];
fs.mkdirSync(destDir, { recursive: true });
const dest = path.join(destDir, path.basename(src));
fs.copyFileSync(src, dest);
process.stdout.write(dest);
" "$ASSET_PATH" "$STAGING_DIR"
echo
- name: Upload staged release assets artifact
if: always()
uses: actions/upload-artifact@v4
with:
name: release-assets-${{ matrix.target }}
path: release-assets
if-no-files-found: ignore
retention-days: 7
# 注意:移除了 Post-build cleanup 步骤
# 之前的清理会删除 deps/build/incremental 目录,导致缓存无法复用
# 保留这些文件可以让 rust-cache 更好地工作
- name: Show sccache stats
run: sccache --show-stats
# 清理 keychain
- name: Cleanup Keychain
if: startsWith(matrix.platform, 'macos') && always()
run: |
security delete-keychain $RUNNER_TEMP/app-signing.keychain-db || true
publish_release_assets:
name: Publish staged release assets
needs: build
if: needs.build.result == 'success'
runs-on: ubuntu-22.04
steps:
- name: Download staged release assets
uses: actions/download-artifact@v4
with:
pattern: release-assets-*
path: release-assets
merge-multiple: true
- name: Inspect staged release assets
shell: bash
run: |
set -euo pipefail
if [ ! -d "release-assets" ]; then
echo "release-assets directory is missing" >&2
exit 1
fi
assets=()
while IFS= read -r asset; do
[ -n "$asset" ] || continue
assets+=("$asset")
done < <(find "release-assets" -type f | sort)
if [ "${#assets[@]}" -eq 0 ]; then
echo "No staged release assets found" >&2
exit 1
fi
printf 'Downloaded staged release assets:\n'
printf ' - %s\n' "${assets[@]}"
- name: Upload staged release assets to GitHub Release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
TAG="${{ github.event.inputs.version || github.ref_name }}"
assets=()
while IFS= read -r asset; do
[ -n "$asset" ] || continue
assets+=("$asset")
done < <(find "release-assets" -type f | sort)
if [ "${#assets[@]}" -eq 0 ]; then
echo "No staged release assets found" >&2
exit 1
fi
printf 'Uploading staged release assets:\n'
printf ' - %s\n' "${assets[@]}"
gh release upload "$TAG" "${assets[@]}" \
--repo "$GITHUB_REPOSITORY" \
--clobber