Skip to content

Tauri Bundles

Tauri Bundles #326

Workflow file for this run

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' }}