Tauri Bundles #326
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Tauri Bundles | |
| permissions: | |
| contents: read | |
| concurrency: | |
| group: tauri-bundles-${{ github.ref }} | |
| cancel-in-progress: true | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| ref: | |
| description: "Git ref to build (tag/branch/sha). Leave empty to use the workflow ref." | |
| required: false | |
| default: "" | |
| release_tag: | |
| description: "Upload artifacts to this release tag (e.g. v0.3.4). Leave empty to auto-release on master." | |
| required: false | |
| default: "" | |
| push: | |
| branches: | |
| - master | |
| tags: | |
| - "v*" | |
| schedule: | |
| - cron: "*/15 * * * *" | |
| jobs: | |
| preflight: | |
| name: Preflight Checks | |
| runs-on: ubuntu-latest | |
| outputs: | |
| build_sha: ${{ steps.resolve-build.outputs.build_sha }} | |
| release_tag: ${{ steps.resolve-build.outputs.release_tag }} | |
| release_version: ${{ steps.resolve-build.outputs.release_version }} | |
| release_enabled: ${{ steps.resolve-build.outputs.release_enabled }} | |
| release_prerelease: ${{ steps.resolve-build.outputs.release_prerelease }} | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - name: Resolve build ref & release version | |
| id: resolve-build | |
| shell: bash | |
| env: | |
| INPUT_REF: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.ref || '' }} | |
| INPUT_RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.release_tag || '' }} | |
| DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} | |
| run: | | |
| set -euo pipefail | |
| build_ref="" | |
| release_tag="" | |
| release_version="" | |
| release_enabled="false" | |
| release_prerelease="false" | |
| if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then | |
| if [[ -n "${INPUT_REF}" ]]; then | |
| build_ref="${INPUT_REF}" | |
| elif [[ -n "${INPUT_RELEASE_TAG}" ]]; then | |
| build_ref="${INPUT_RELEASE_TAG}" | |
| fi | |
| fi | |
| if [[ -n "${build_ref}" ]]; then | |
| echo "[INFO] Checking out build ref: ${build_ref}" | |
| if ! git checkout --force "${build_ref}"; then | |
| if [[ "${build_ref}" != v* ]]; then | |
| echo "[WARN] Failed to checkout '${build_ref}', retrying as 'v${build_ref}'." | |
| git checkout --force "v${build_ref}" | |
| build_ref="v${build_ref}" | |
| else | |
| echo "[ERROR] Failed to checkout build ref: ${build_ref}" >&2 | |
| exit 1 | |
| fi | |
| fi | |
| fi | |
| release_version_from_tag() { | |
| local tag="$1" | |
| if [[ "${tag}" =~ ^v([0-9]+\.[0-9]+\.[0-9]+)\.([0-9]+)$ ]]; then | |
| printf '%s-%s' "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" | |
| else | |
| printf '%s' "${tag#v}" | |
| fi | |
| } | |
| if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then | |
| if [[ -n "${INPUT_RELEASE_TAG}" ]]; then | |
| release_tag="${INPUT_RELEASE_TAG}" | |
| release_enabled="true" | |
| fi | |
| elif [[ "${GITHUB_REF}" == refs/tags/* ]]; then | |
| release_tag="${GITHUB_REF_NAME}" | |
| release_enabled="true" | |
| elif [[ "${GITHUB_EVENT_NAME}" == "schedule" || "${GITHUB_REF_NAME}" == "${DEFAULT_BRANCH}" || "${GITHUB_REF_NAME}" == "master" ]]; then | |
| base_version="$(python3 -c 'import json,re; version=json.load(open("src-tauri/tauri.conf.json", "r", encoding="utf-8"))["version"]; assert re.fullmatch(r"\d+\.\d+\.\d+", version), f"Unsupported base version for automatic branch release: {version}"; print(version)')" | |
| base_tag_regex="^v${base_version//./\\.}(\\.[0-9]+)?$" | |
| existing_auto_tag="$(git tag --points-at HEAD | grep -E "${base_tag_regex}" | head -n 1 || true)" | |
| if [[ -n "${existing_auto_tag}" ]]; then | |
| echo "[INFO] Current HEAD already has fork release tag ${existing_auto_tag}; skipping duplicate auto release." | |
| elif ! git rev-parse --verify "v${base_version}^{commit}" >/dev/null 2>&1; then | |
| release_tag="v${base_version}" | |
| release_version="${base_version}" | |
| release_enabled="true" | |
| else | |
| next_auto_index="$( | |
| git tag --list "v${base_version}.*" | awk -v prefix="v${base_version}." ' | |
| $0 ~ ("^" prefix "[0-9]+$") { | |
| value = $0 | |
| sub("^" prefix, "", value) | |
| if ((value + 0) > max) { | |
| max = value + 0 | |
| } | |
| } | |
| END { | |
| print max + 1 | |
| } | |
| ' | |
| )" | |
| release_tag="v${base_version}.${next_auto_index}" | |
| release_version="${base_version}-${next_auto_index}" | |
| release_enabled="true" | |
| fi | |
| fi | |
| normalized_tag="" | |
| version="" | |
| if [[ -n "${release_tag}" ]]; then | |
| normalized_tag="${release_tag}" | |
| if [[ "${normalized_tag}" != v* ]]; then | |
| normalized_tag="v${normalized_tag}" | |
| fi | |
| if [[ -n "${release_version}" ]]; then | |
| version="${release_version}" | |
| else | |
| version="$(release_version_from_tag "${normalized_tag}")" | |
| fi | |
| fi | |
| build_sha="$(git rev-parse HEAD)" | |
| echo "[INFO] Preflight build SHA: ${build_sha}" | |
| if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && -n "${INPUT_REF}" && -n "${INPUT_RELEASE_TAG}" ]]; then | |
| tag_for_check="${INPUT_RELEASE_TAG}" | |
| if [[ "${tag_for_check}" != v* ]]; then | |
| tag_for_check="v${tag_for_check}" | |
| fi | |
| tag_commit="$(git rev-parse --verify "${tag_for_check}^{commit}" 2>/dev/null || true)" | |
| if [[ -z "${tag_commit}" ]]; then | |
| echo "[ERROR] workflow_dispatch 同时指定 ref 与 release_tag 时,release_tag 必须已存在并指向可解析的提交:${tag_for_check}" >&2 | |
| echo "[HINT] 如需使用新版本号,请先创建并推送该 tag,或在本次构建中仅填写 ref(不填 release_tag)。" >&2 | |
| exit 1 | |
| fi | |
| if [[ "${tag_commit}" != "${build_sha}" ]]; then | |
| echo "[ERROR] workflow_dispatch 输入不一致:ref 与 release_tag 指向不同提交。" >&2 | |
| echo "[HINT] ref(${INPUT_REF}) -> ${build_sha}" >&2 | |
| echo "[HINT] release_tag(${tag_for_check}) -> ${tag_commit}" >&2 | |
| exit 1 | |
| fi | |
| echo "[INFO] ref/release_tag consistency check passed: ${build_sha}" | |
| fi | |
| if [[ -n "${version}" ]]; then | |
| if [[ "${normalized_tag}" == *-* ]]; then | |
| release_prerelease="true" | |
| fi | |
| echo "[INFO] Release tag: ${normalized_tag}" | |
| echo "[INFO] Release version: ${version}" | |
| echo "[INFO] Release enabled: ${release_enabled}" | |
| else | |
| echo "[INFO] Not a release build (no tag detected / no release_tag input)." | |
| fi | |
| echo "build_sha=${build_sha}" >> "${GITHUB_OUTPUT}" | |
| echo "release_tag=${normalized_tag}" >> "${GITHUB_OUTPUT}" | |
| echo "release_version=${version}" >> "${GITHUB_OUTPUT}" | |
| echo "release_enabled=${release_enabled}" >> "${GITHUB_OUTPUT}" | |
| echo "release_prerelease=${release_prerelease}" >> "${GITHUB_OUTPUT}" | |
| - name: Setup pnpm | |
| uses: pnpm/action-setup@v4 | |
| with: | |
| version: 10 | |
| run_install: false | |
| - name: Setup Node | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version-file: .nvmrc | |
| cache: pnpm | |
| - name: Setup Rust | |
| uses: dtolnay/rust-toolchain@stable | |
| with: | |
| components: clippy | |
| - name: Install Linux dependencies | |
| run: bash scripts/install-tauri-linux-deps.sh --preflight | |
| - name: Install dependencies | |
| run: pnpm install --frozen-lockfile | |
| - name: Frontend typecheck | |
| run: pnpm run typecheck | |
| - name: Frontend build | |
| run: pnpm run build | |
| - name: Rust clippy | |
| working-directory: src-tauri | |
| run: cargo clippy --all-targets --all-features -- -D warnings | |
| - name: Rust tests | |
| working-directory: src-tauri | |
| run: cargo test | |
| - name: Scripts regression | |
| run: bash scripts/run-regression-tests.sh | |
| bundle: | |
| name: Bundle (${{ matrix.os }}) | |
| needs: preflight | |
| if: ${{ needs.preflight.outputs.release_enabled == 'true' || github.event_name == 'workflow_dispatch' }} | |
| runs-on: ${{ matrix.os }} | |
| env: | |
| TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} | |
| TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} | |
| RELEASE_TAG: ${{ needs.preflight.outputs.release_tag }} | |
| RELEASE_VERSION: ${{ needs.preflight.outputs.release_version }} | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| os: [ubuntu-latest, windows-latest, macos-latest] | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| ref: ${{ needs.preflight.outputs.build_sha }} | |
| - name: Prepare release version override | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| if [[ -n "${RELEASE_VERSION}" ]]; then | |
| echo "{ \"version\": \"${RELEASE_VERSION}\" }" > src-tauri/tauri.release.conf.json | |
| echo "[INFO] Release tag: ${RELEASE_TAG}" | |
| echo "[INFO] Release version: ${RELEASE_VERSION}" | |
| else | |
| echo "[INFO] Not a release build (no tag detected / no release_tag input)." | |
| fi | |
| - name: Write Windows prerelease bundle override | |
| if: ${{ matrix.os == 'windows-latest' && contains(env.RELEASE_VERSION, '-') }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| cat > src-tauri/tauri.windows-prerelease.conf.json <<EOF | |
| { | |
| "bundle": { | |
| "targets": ["nsis"] | |
| } | |
| } | |
| EOF | |
| echo "[INFO] Windows prerelease build will use NSIS only." | |
| - name: Setup pnpm | |
| uses: pnpm/action-setup@v4 | |
| with: | |
| version: 10 | |
| run_install: false | |
| - name: Setup Node | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version-file: .nvmrc | |
| cache: pnpm | |
| - name: Setup Rust | |
| uses: dtolnay/rust-toolchain@stable | |
| - name: Rust cache | |
| uses: swatinem/rust-cache@v2 | |
| with: | |
| workspaces: | | |
| src-tauri -> target | |
| - name: Install Linux dependencies | |
| if: matrix.os == 'ubuntu-latest' | |
| run: bash scripts/install-tauri-linux-deps.sh --bundle | |
| - name: Prepare linuxdeploy tools | |
| if: matrix.os == 'ubuntu-latest' | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| cache_dir="$HOME/.cache/tauri" | |
| mkdir -p "$cache_dir" | |
| linuxdeploy="$cache_dir/linuxdeploy-x86_64.AppImage" | |
| plugin_appimage="$cache_dir/linuxdeploy-plugin-appimage.AppImage" | |
| if [[ ! -x "$linuxdeploy" ]]; then | |
| curl -fL \ | |
| "https://github.com/tauri-apps/binary-releases/releases/download/linuxdeploy/linuxdeploy-x86_64.AppImage" \ | |
| -o "$linuxdeploy" | |
| chmod +x "$linuxdeploy" | |
| fi | |
| if [[ ! -x "$plugin_appimage" ]]; then | |
| curl -fL \ | |
| "https://github.com/linuxdeploy/linuxdeploy-plugin-appimage/releases/download/continuous/linuxdeploy-plugin-appimage-x86_64.AppImage" \ | |
| -o "$plugin_appimage" | |
| chmod +x "$plugin_appimage" | |
| fi | |
| - name: Install Windows bundlers | |
| if: matrix.os == 'windows-latest' | |
| run: | | |
| choco install nsis -y | |
| choco install wixtoolset -y | |
| - name: Install dependencies | |
| run: pnpm install --frozen-lockfile | |
| - name: Generate app icons | |
| run: pnpm run icons:generate | |
| - name: Validate updater signing keys (tag release) | |
| if: ${{ env.RELEASE_VERSION != '' && env.TAURI_SIGNING_PRIVATE_KEY == '' }} | |
| shell: bash | |
| run: | | |
| echo "[ERROR] Missing updater signing key secrets." >&2 | |
| echo "[HINT] Configure repository secrets:" >&2 | |
| echo " - TAURI_SIGNING_PRIVATE_KEY" >&2 | |
| echo " - TAURI_SIGNING_PRIVATE_KEY_PASSWORD (optional, if your key is password-protected)" >&2 | |
| echo "" >&2 | |
| echo "[HINT] Without a stable signing key, in-app updates cannot work." >&2 | |
| exit 1 | |
| - name: Validate updater key consistency (tag release) | |
| if: ${{ matrix.os == 'ubuntu-latest' && env.RELEASE_VERSION != '' && env.TAURI_SIGNING_PRIVATE_KEY != '' }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| python3 - <<'PY' | |
| import base64 | |
| import json | |
| import subprocess | |
| import tempfile | |
| from pathlib import Path | |
| config_path = Path("src-tauri/tauri.conf.json") | |
| config = json.loads(config_path.read_text(encoding="utf-8")) | |
| pubkey_b64 = ( | |
| config.get("plugins", {}) | |
| .get("updater", {}) | |
| .get("pubkey", "") | |
| .strip() | |
| ) | |
| if not pubkey_b64: | |
| raise SystemExit("[ERROR] Missing plugins.updater.pubkey in src-tauri/tauri.conf.json") | |
| pubkey_text = base64.b64decode(pubkey_b64).decode("utf-8") | |
| pubkey_lines = [line.strip() for line in pubkey_text.splitlines() if line.strip()] | |
| if len(pubkey_lines) < 2: | |
| raise SystemExit("[ERROR] Invalid updater pubkey payload format.") | |
| pubkey_raw = base64.b64decode(pubkey_lines[1]) | |
| if len(pubkey_raw) < 10: | |
| raise SystemExit("[ERROR] Invalid updater pubkey raw payload length.") | |
| expected_key_id = pubkey_raw[2:10].hex() | |
| with tempfile.TemporaryDirectory(prefix="lessai-updater-keycheck-") as tmpdir: | |
| probe = Path(tmpdir) / "updater-key-check.txt" | |
| probe.write_text("lessai-updater-key-check\n", encoding="utf-8") | |
| subprocess.run( | |
| ["pnpm", "exec", "tauri", "signer", "sign", str(probe)], | |
| check=True, | |
| stdout=subprocess.DEVNULL, | |
| stderr=subprocess.DEVNULL, | |
| ) | |
| sig_path = Path(f"{probe}.sig") | |
| if not sig_path.exists(): | |
| raise SystemExit(f"[ERROR] Sign probe failed: signature file not found: {sig_path}") | |
| sig_b64 = sig_path.read_text(encoding="utf-8").strip() | |
| sig_text = base64.b64decode(sig_b64).decode("utf-8") | |
| sig_lines = [line.strip() for line in sig_text.splitlines() if line.strip()] | |
| if len(sig_lines) < 2: | |
| raise SystemExit("[ERROR] Invalid generated signature payload format.") | |
| sig_raw = base64.b64decode(sig_lines[1]) | |
| if len(sig_raw) < 10: | |
| raise SystemExit("[ERROR] Invalid generated signature raw payload length.") | |
| actual_key_id = sig_raw[2:10].hex() | |
| if actual_key_id != expected_key_id: | |
| raise SystemExit( | |
| "[ERROR] Updater signing key mismatch: " | |
| f"tauri.conf pubkey id={expected_key_id}, CI private key id={actual_key_id}. " | |
| "Update signatures will fail verification." | |
| ) | |
| print(f"[INFO] Updater key consistency check passed: key id={expected_key_id}") | |
| PY | |
| - name: Build bundles (with updater artifacts) | |
| if: ${{ env.TAURI_SIGNING_PRIVATE_KEY != '' }} | |
| shell: bash | |
| env: | |
| RUST_BACKTRACE: "1" | |
| run: | | |
| # linuxdeploy strip may break on modern ELF sections (.relr.dyn) in AppImage pipeline. | |
| if [[ "${{ matrix.os }}" == "ubuntu-latest" ]]; then | |
| export NO_STRIP=1 | |
| fi | |
| args=(-c src-tauri/tauri.updater.conf.json) | |
| if [ -f src-tauri/tauri.release.conf.json ]; then | |
| args+=(-c src-tauri/tauri.release.conf.json) | |
| fi | |
| if [ -f src-tauri/tauri.windows-prerelease.conf.json ]; then | |
| args+=(-c src-tauri/tauri.windows-prerelease.conf.json) | |
| fi | |
| pnpm exec tauri build "${args[@]}" | |
| - name: Build bundles (without updater artifacts) | |
| if: ${{ env.TAURI_SIGNING_PRIVATE_KEY == '' }} | |
| shell: bash | |
| env: | |
| RUST_BACKTRACE: "1" | |
| run: | | |
| # linuxdeploy strip may break on modern ELF sections (.relr.dyn) in AppImage pipeline. | |
| if [[ "${{ matrix.os }}" == "ubuntu-latest" ]]; then | |
| export NO_STRIP=1 | |
| fi | |
| args=() | |
| if [ -f src-tauri/tauri.release.conf.json ]; then | |
| args+=(-c src-tauri/tauri.release.conf.json) | |
| fi | |
| if [ -f src-tauri/tauri.windows-prerelease.conf.json ]; then | |
| args+=(-c src-tauri/tauri.windows-prerelease.conf.json) | |
| fi | |
| pnpm exec tauri build "${args[@]}" | |
| - name: Normalize Linux AppImage runtime | |
| if: matrix.os == 'ubuntu-latest' | |
| shell: bash | |
| run: | | |
| bash scripts/patch-linux-appimage.sh | |
| - name: Linux AppImage smoke test | |
| if: matrix.os == 'ubuntu-latest' | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| appimage="$(ls src-tauri/target/release/bundle/appimage/*.AppImage 2>/dev/null | head -n 1 || true)" | |
| if [ -z "${appimage}" ]; then | |
| echo "[ERROR] AppImage not found after bundle step." >&2 | |
| exit 1 | |
| fi | |
| echo "[INFO] Smoke testing ${appimage}" | |
| chmod +x "${appimage}" | |
| # Avoid fuse dependency in runner and enforce conservative graphics path for CI validation. | |
| set +e | |
| xvfb-run -a env \ | |
| APPIMAGE_EXTRACT_AND_RUN=1 \ | |
| LESSAI_LINUX_GRAPHICS_MODE=safe \ | |
| timeout 20s "${appimage}" > /tmp/lessai-appimage-smoke.log 2>&1 | |
| code=$? | |
| set -e | |
| tail -n 200 /tmp/lessai-appimage-smoke.log || true | |
| if grep -Eqi "(segmentation fault|core dumped|EGL_BAD_ALLOC|HandshakeFailure|GStreamer|appsink|gst-plugin-scanner|libgstapp)" /tmp/lessai-appimage-smoke.log; then | |
| echo "[ERROR] AppImage smoke test detected fatal runtime signature." >&2 | |
| exit 1 | |
| fi | |
| # timeout(124) means process stayed alive long enough, which is expected for GUI app. | |
| # 0 means it started and exited gracefully (still acceptable for smoke check). | |
| if [ "${code}" -ne 124 ] && [ "${code}" -ne 0 ]; then | |
| echo "[ERROR] AppImage smoke test failed with exit code ${code}." >&2 | |
| exit "${code}" | |
| fi | |
| - name: Windows runtime smoke test | |
| if: matrix.os == 'windows-latest' | |
| shell: pwsh | |
| run: | | |
| $ErrorActionPreference = "Stop" | |
| $binary = "src-tauri/target/release/lessai.exe" | |
| if (-not (Test-Path $binary)) { | |
| throw "[ERROR] Windows smoke binary not found: $binary" | |
| } | |
| $stdoutLog = Join-Path $env:RUNNER_TEMP "lessai-win-smoke.stdout.log" | |
| $stderrLog = Join-Path $env:RUNNER_TEMP "lessai-win-smoke.stderr.log" | |
| Write-Host "[INFO] Smoke testing $binary" | |
| $proc = Start-Process -FilePath $binary -PassThru -WindowStyle Hidden -RedirectStandardOutput $stdoutLog -RedirectStandardError $stderrLog | |
| Start-Sleep -Seconds 18 | |
| $exitCode = 124 | |
| if ($proc.HasExited) { | |
| $exitCode = $proc.ExitCode | |
| } else { | |
| Stop-Process -Id $proc.Id -Force | |
| Wait-Process -Id $proc.Id -Timeout 5 -ErrorAction SilentlyContinue | |
| } | |
| if (Test-Path $stdoutLog) { Get-Content $stdoutLog -Tail 200 } | |
| if (Test-Path $stderrLog) { Get-Content $stderrLog -Tail 200 } | |
| $combined = "" | |
| if (Test-Path $stdoutLog) { $combined += (Get-Content $stdoutLog -Raw) } | |
| if (Test-Path $stderrLog) { $combined += "`n" + (Get-Content $stderrLog -Raw) } | |
| if ($combined -match "(segmentation fault|core dumped|access violation|fatal error|panic)") { | |
| throw "[ERROR] Windows smoke test detected fatal runtime signature." | |
| } | |
| if ($exitCode -ne 124 -and $exitCode -ne 0) { | |
| throw "[ERROR] Windows smoke test failed with exit code $exitCode." | |
| } | |
| - name: macOS runtime smoke test | |
| if: matrix.os == 'macos-latest' | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| app_bin="$(find src-tauri/target/release/bundle/macos -type f -path '*.app/Contents/MacOS/*' | head -n 1 || true)" | |
| if [[ -z "${app_bin}" && -x src-tauri/target/release/lessai ]]; then | |
| app_bin="src-tauri/target/release/lessai" | |
| fi | |
| if [[ -z "${app_bin}" ]]; then | |
| echo "[ERROR] macOS smoke binary not found." >&2 | |
| exit 1 | |
| fi | |
| echo "[INFO] Smoke testing ${app_bin}" | |
| export LESSAI_LINUX_GRAPHICS_MODE=auto | |
| python3 - "$app_bin" <<'PY' | |
| import pathlib | |
| import re | |
| import signal | |
| import subprocess | |
| import sys | |
| import tempfile | |
| import time | |
| binary = sys.argv[1] | |
| log_path = pathlib.Path(tempfile.gettempdir()) / "lessai-macos-smoke.log" | |
| with log_path.open("wb") as log_file: | |
| proc = subprocess.Popen([binary], stdout=log_file, stderr=subprocess.STDOUT) | |
| exit_code = 124 | |
| try: | |
| proc.wait(timeout=18) | |
| exit_code = proc.returncode | |
| except subprocess.TimeoutExpired: | |
| proc.terminate() | |
| try: | |
| proc.wait(timeout=4) | |
| except subprocess.TimeoutExpired: | |
| proc.kill() | |
| proc.wait(timeout=4) | |
| content = log_path.read_text("utf-8", errors="ignore") | |
| print(content[-8000:]) | |
| if re.search(r"(segmentation fault|abort trap|core dumped|fatal error|panic)", content, re.IGNORECASE): | |
| raise SystemExit("[ERROR] macOS smoke test detected fatal runtime signature.") | |
| if exit_code not in (0, 124): | |
| raise SystemExit(f"[ERROR] macOS smoke test failed with exit code {exit_code}.") | |
| PY | |
| - name: Re-sign patched Linux AppImage | |
| if: matrix.os == 'ubuntu-latest' && env.TAURI_SIGNING_PRIVATE_KEY != '' | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| appimage="$(ls src-tauri/target/release/bundle/appimage/*.AppImage 2>/dev/null | head -n 1 || true)" | |
| if [ -z "${appimage}" ]; then | |
| echo "[ERROR] AppImage not found for re-signing." >&2 | |
| exit 1 | |
| fi | |
| rm -f "${appimage}.sig" | |
| pnpm exec tauri signer sign "${appimage}" | |
| if [ ! -f "${appimage}.sig" ]; then | |
| echo "[ERROR] Re-sign failed: signature file missing (${appimage}.sig)." >&2 | |
| exit 1 | |
| fi | |
| echo "[INFO] Re-signed patched AppImage: ${appimage}" | |
| - name: Write bundle metadata | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| arch_raw="$(uname -m || true)" | |
| arch="${arch_raw}" | |
| case "${arch_raw}" in | |
| x86_64|amd64) arch="x86_64" ;; | |
| arm64|aarch64) arch="aarch64" ;; | |
| esac | |
| mkdir -p src-tauri/target/release/bundle | |
| cat > src-tauri/target/release/bundle/ci-meta.json <<EOF | |
| { | |
| "os": "${{ matrix.os }}", | |
| "arch": "${arch}", | |
| "release_tag": "${RELEASE_TAG:-}", | |
| "release_version": "${RELEASE_VERSION:-}" | |
| } | |
| EOF | |
| echo "[INFO] Bundle meta written:" | |
| cat src-tauri/target/release/bundle/ci-meta.json | |
| - name: Upload bundles | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: bundles-${{ matrix.os }} | |
| path: src-tauri/target/release/bundle/** | |
| if-no-files-found: error | |
| release: | |
| name: Release (GitHub) | |
| if: ${{ needs.preflight.outputs.release_enabled == 'true' }} | |
| needs: [preflight, bundle] | |
| runs-on: ubuntu-latest | |
| env: | |
| RELEASE_TAG: ${{ needs.preflight.outputs.release_tag }} | |
| RELEASE_VERSION: ${{ needs.preflight.outputs.release_version }} | |
| TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} | |
| TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} | |
| permissions: | |
| contents: write | |
| steps: | |
| - name: Validate release metadata | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| if [[ -z "${RELEASE_TAG}" ]] || [[ -z "${RELEASE_VERSION}" ]]; then | |
| echo "[ERROR] Missing release metadata from preflight outputs." >&2 | |
| exit 1 | |
| fi | |
| echo "[INFO] Release tag: ${RELEASE_TAG}" | |
| echo "[INFO] Release version: ${RELEASE_VERSION}" | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 1 | |
| ref: ${{ needs.preflight.outputs.build_sha }} | |
| - name: Download bundles | |
| uses: actions/download-artifact@v4 | |
| with: | |
| path: artifacts | |
| pattern: bundles-* | |
| - name: Setup pnpm | |
| uses: pnpm/action-setup@v4 | |
| with: | |
| version: 10 | |
| run_install: false | |
| - name: Setup Node | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version-file: .nvmrc | |
| cache: pnpm | |
| - name: Install dependencies | |
| run: pnpm install --frozen-lockfile | |
| - name: Prepare release assets | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| mkdir -p release-assets | |
| for dir in artifacts/bundles-*; do | |
| os="$(basename "$dir")" | |
| echo "[INFO] Collecting files from $os" | |
| while IFS= read -r -d '' file; do | |
| base="$(basename "$file")" | |
| cp "$file" "release-assets/${os}__${base}" | |
| done < <( | |
| find "$dir" -type f \ | |
| \( \ | |
| -name "*.dmg" -o \ | |
| -name "*.pkg" -o \ | |
| -name "*.AppImage" -o \ | |
| -name "*.deb" -o \ | |
| -name "*.rpm" -o \ | |
| -name "*.msi" -o \ | |
| -name "*.exe" -o \ | |
| -name "*.zip" -o \ | |
| -name "*.tar.gz" -o \ | |
| -name "*.sig" \ | |
| \) -print0 | |
| ) | |
| done | |
| if [ -z "$(ls -A release-assets)" ]; then | |
| echo "[ERROR] No bundle files were found under artifacts/." >&2 | |
| echo "[HINT] Check Tauri output under src-tauri/target/release/bundle on each OS." >&2 | |
| exit 1 | |
| fi | |
| # ── Generate updater manifest (latest.json) ─────────────────────────── | |
| tag="${RELEASE_TAG}" | |
| version="${RELEASE_VERSION}" | |
| repo="${GITHUB_REPOSITORY}" | |
| # Prefer filenames that include the expected version if available. | |
| win_exe="$(ls release-assets/bundles-windows-latest__*${version}*.exe 2>/dev/null | head -n 1 || true)" | |
| if [ -z "$win_exe" ]; then | |
| win_exe="$(ls release-assets/bundles-windows-latest__*.exe 2>/dev/null | head -n 1 || true)" | |
| fi | |
| win_msi="$(ls release-assets/bundles-windows-latest__*${version}*.msi 2>/dev/null | head -n 1 || true)" | |
| if [ -z "$win_msi" ]; then | |
| win_msi="$(ls release-assets/bundles-windows-latest__*.msi 2>/dev/null | head -n 1 || true)" | |
| fi | |
| linux_appimage="$(ls release-assets/bundles-ubuntu-latest__*${version}*.AppImage 2>/dev/null | head -n 1 || true)" | |
| if [ -z "$linux_appimage" ]; then | |
| linux_appimage="$(ls release-assets/bundles-ubuntu-latest__*.AppImage 2>/dev/null | head -n 1 || true)" | |
| fi | |
| mac_app_tar="$(ls release-assets/bundles-macos-latest__*${version}*.app.tar.gz 2>/dev/null | head -n 1 || true)" | |
| if [ -z "$mac_app_tar" ]; then | |
| mac_app_tar="$(ls release-assets/bundles-macos-latest__*.app.tar.gz 2>/dev/null | head -n 1 || true)" | |
| fi | |
| if [ -z "$win_exe" ] || [ -z "$linux_appimage" ] || [ -z "$mac_app_tar" ]; then | |
| echo "[ERROR] Updater artifacts missing. Ensure createUpdaterArtifacts=true and signing key are configured." >&2 | |
| echo "[HINT] Expected files:" >&2 | |
| echo " - release-assets/bundles-windows-latest__*.exe" >&2 | |
| echo " - release-assets/bundles-ubuntu-latest__*.AppImage" >&2 | |
| echo " - release-assets/bundles-macos-latest__*.app.tar.gz" >&2 | |
| exit 1 | |
| fi | |
| win_sig_file="${win_exe}.sig" | |
| win_msi_sig_file="${win_msi}.sig" | |
| linux_sig_file="${linux_appimage}.sig" | |
| mac_sig_file="${mac_app_tar}.sig" | |
| if [ ! -f "$win_sig_file" ] || [ ! -f "$linux_sig_file" ] || [ ! -f "$mac_sig_file" ]; then | |
| echo "[ERROR] Signature files missing. Expected .sig files next to update bundles." >&2 | |
| echo "[HINT] Missing one of:" >&2 | |
| echo " - $win_sig_file" >&2 | |
| echo " - $linux_sig_file" >&2 | |
| echo " - $mac_sig_file" >&2 | |
| exit 1 | |
| fi | |
| win_sig="$(tr -d '\r\n' < "$win_sig_file")" | |
| win_msi_sig="" | |
| if [ -n "$win_msi" ] && [ -f "$win_msi_sig_file" ]; then | |
| win_msi_sig="$(tr -d '\r\n' < "$win_msi_sig_file")" | |
| fi | |
| linux_sig="$(tr -d '\r\n' < "$linux_sig_file")" | |
| mac_sig="$(tr -d '\r\n' < "$mac_sig_file")" | |
| win_asset="$(basename "$win_exe")" | |
| win_msi_asset="$(basename "$win_msi")" | |
| linux_asset="$(basename "$linux_appimage")" | |
| mac_asset="$(basename "$mac_app_tar")" | |
| extract_semver() { | |
| local name="$1" | |
| if [[ "$name" =~ ([0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?) ]]; then | |
| printf '%s' "${BASH_REMATCH[1]}" | |
| fi | |
| } | |
| check_asset_version() { | |
| local asset="$1" | |
| local label="$2" | |
| local found="" | |
| found="$(extract_semver "$asset" || true)" | |
| if [[ -z "${found}" ]]; then | |
| echo "[WARN] Could not detect version from ${label} asset filename: ${asset}" | |
| return 0 | |
| fi | |
| if [[ "${found}" != "${version}" ]]; then | |
| echo "[ERROR] ${label} asset filename version mismatch: expected ${version}, got ${found} (${asset})" >&2 | |
| echo "[HINT] This usually means the build did not inject the tag version into tauri config." >&2 | |
| exit 1 | |
| fi | |
| } | |
| check_asset_version "$win_asset" "windows" | |
| check_asset_version "$linux_asset" "linux" | |
| check_asset_version "$mac_asset" "macos" | |
| mac_arch="" | |
| mac_meta="$(find artifacts/bundles-macos-latest -name ci-meta.json -print -quit 2>/dev/null || true)" | |
| if [[ -n "${mac_meta}" ]]; then | |
| mac_arch="$(python3 -c 'import json,sys; from pathlib import Path; p=Path(sys.argv[1]); data=json.loads(p.read_text(\"utf-8\")); print((data.get(\"arch\") or \"\").strip())' "${mac_meta}" 2>/dev/null || true)" | |
| fi | |
| if [[ -z "${mac_arch}" ]]; then | |
| mac_arch="aarch64" | |
| mac_asset_lower="$(printf '%s' "$mac_asset" | tr '[:upper:]' '[:lower:]')" | |
| if [[ "$mac_asset_lower" == *"x86_64"* ]] || [[ "$mac_asset_lower" == *"amd64"* ]] || [[ "$mac_asset_lower" == *"x64"* ]]; then | |
| mac_arch="x86_64" | |
| fi | |
| if [[ "$mac_asset_lower" == *"aarch64"* ]] || [[ "$mac_asset_lower" == *"arm64"* ]]; then | |
| mac_arch="aarch64" | |
| fi | |
| fi | |
| echo "[INFO] macOS arch resolved to: ${mac_arch}" | |
| pub_date="$(date -u +%Y-%m-%dT%H:%M:%SZ)" | |
| win_msi_entry="" | |
| if [ -n "$win_msi_sig" ] && [ -n "$win_msi_asset" ]; then | |
| win_msi_entry=$(cat <<EOF | |
| "windows-x86_64-msi": { | |
| "url": "https://github.com/${repo}/releases/download/${tag}/${win_msi_asset}", | |
| "signature": "${win_msi_sig}" | |
| }, | |
| EOF | |
| ) | |
| fi | |
| cat > release-assets/latest.json <<EOF | |
| { | |
| "version": "${version}", | |
| "notes": "", | |
| "pub_date": "${pub_date}", | |
| "platforms": { | |
| "windows-x86_64-nsis": { | |
| "url": "https://github.com/${repo}/releases/download/${tag}/${win_asset}", | |
| "signature": "${win_sig}" | |
| }, | |
| ${win_msi_entry} | |
| "windows-x86_64": { | |
| "url": "https://github.com/${repo}/releases/download/${tag}/${win_asset}", | |
| "signature": "${win_sig}" | |
| }, | |
| "linux-x86_64-appimage": { | |
| "url": "https://github.com/${repo}/releases/download/${tag}/${linux_asset}", | |
| "signature": "${linux_sig}" | |
| }, | |
| "linux-x86_64": { | |
| "url": "https://github.com/${repo}/releases/download/${tag}/${linux_asset}", | |
| "signature": "${linux_sig}" | |
| }, | |
| "darwin-${mac_arch}-app": { | |
| "url": "https://github.com/${repo}/releases/download/${tag}/${mac_asset}", | |
| "signature": "${mac_sig}" | |
| }, | |
| "darwin-${mac_arch}": { | |
| "url": "https://github.com/${repo}/releases/download/${tag}/${mac_asset}", | |
| "signature": "${mac_sig}" | |
| } | |
| } | |
| } | |
| EOF | |
| python3 - <<'PY' | |
| import hashlib | |
| import json | |
| import subprocess | |
| from datetime import datetime, timezone | |
| from pathlib import Path | |
| root = Path("release-assets") | |
| packages = [] | |
| def detect_arch(name: str) -> str: | |
| lower = name.lower() | |
| if any(token in lower for token in ("x86_64", "amd64", "x64")): | |
| return "x86_64" | |
| if any(token in lower for token in ("aarch64", "arm64")): | |
| return "aarch64" | |
| if any(token in lower for token in ("armv7", "armhf")): | |
| return "arm" | |
| return "any" | |
| def normalize_arch(raw: str) -> str: | |
| value = (raw or "").strip().lower() | |
| if value in {"x86_64", "amd64", "x64"}: | |
| return "x86_64" | |
| if value in {"aarch64", "arm64"}: | |
| return "aarch64" | |
| if value in {"armv7", "armhf", "arm"}: | |
| return "arm" | |
| if value in {"all", "any", "noarch", "universal"}: | |
| return "any" | |
| return value or "any" | |
| def detect_arch_from_metadata(path: Path, suffix: str) -> str | None: | |
| try: | |
| if suffix == ".deb": | |
| result = subprocess.run( | |
| ["dpkg-deb", "-f", str(path), "Architecture"], | |
| capture_output=True, | |
| text=True, | |
| check=True, | |
| ) | |
| return normalize_arch(result.stdout) | |
| if suffix == ".rpm": | |
| result = subprocess.run( | |
| ["rpm", "-qp", "--qf", "%{ARCH}", str(path)], | |
| capture_output=True, | |
| text=True, | |
| check=True, | |
| ) | |
| return normalize_arch(result.stdout) | |
| except (FileNotFoundError, subprocess.CalledProcessError): | |
| return None | |
| return None | |
| for path in sorted(root.glob("*")): | |
| if not path.is_file(): | |
| continue | |
| suffix = path.suffix.lower() | |
| if suffix not in {".deb", ".rpm"}: | |
| continue | |
| kind = "deb" if suffix == ".deb" else "rpm" | |
| arch = detect_arch_from_metadata(path, suffix) | |
| if not arch: | |
| arch = detect_arch(path.name) | |
| print(f"[WARN] fallback arch detection from filename for {path.name}: {arch}") | |
| digest = hashlib.sha256(path.read_bytes()).hexdigest() | |
| packages.append( | |
| { | |
| "name": path.name, | |
| "kind": kind, | |
| "arch": arch, | |
| "sha256": digest, | |
| } | |
| ) | |
| manifest = { | |
| "schemaVersion": 1, | |
| "generatedAt": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), | |
| "packages": packages, | |
| } | |
| (root / "system-packages.json").write_text( | |
| json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", | |
| encoding="utf-8", | |
| ) | |
| print(f"[INFO] system-packages entries: {len(packages)}") | |
| PY | |
| if [[ -z "${TAURI_SIGNING_PRIVATE_KEY}" ]]; then | |
| echo "[ERROR] Missing TAURI_SIGNING_PRIVATE_KEY for system-packages manifest signing." >&2 | |
| exit 1 | |
| fi | |
| pnpm exec tauri signer sign release-assets/system-packages.json | |
| if [[ ! -f release-assets/system-packages.json.sig ]]; then | |
| echo "[ERROR] Missing generated signature: release-assets/system-packages.json.sig" >&2 | |
| exit 1 | |
| fi | |
| (cd release-assets && sha256sum * > checksums.sha256) | |
| echo "[INFO] Release assets:" | |
| ls -la release-assets | |
| - name: Delete stable assets (rerun-safe) | |
| shell: bash | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| set -euo pipefail | |
| # 允许重跑同一个 tag:先删除将要上传的同名资产,避免 422 already exists。 | |
| # 如果 release 还不存在,忽略错误即可。 | |
| if [ -d release-assets ]; then | |
| while IFS= read -r -d '' file; do | |
| asset="$(basename "$file")" | |
| gh release delete-asset "${RELEASE_TAG}" "${asset}" -y || true | |
| done < <(find release-assets -maxdepth 1 -type f -print0) | |
| fi | |
| - name: Create GitHub Release | |
| uses: softprops/action-gh-release@v2 | |
| with: | |
| tag_name: ${{ env.RELEASE_TAG }} | |
| target_commitish: ${{ needs.preflight.outputs.build_sha }} | |
| files: release-assets/* | |
| body: | | |
| ## 文档兼容边界(重要) | |
| - DOCX / PDF 当前采用“安全优先”的原文件写回策略。 | |
| - 能导入并查看,不等于一定允许写回原文件。 | |
| - 检测到结构歧义、锁定区变化或文本层定位不稳定时,会主动拒绝写回以保护原文。 | |
| - 如遇写回阻断,可先导出为新文件继续处理。 | |
| - 详细口径见:`docs/release-writeback-compatibility.md` | |
| generate_release_notes: true | |
| prerelease: ${{ needs.preflight.outputs.release_prerelease == 'true' }} |