Skip to content

0.2.48

0.2.48 #101

Workflow file for this run

name: Release
# 触发:本地 `pnpm version <major|minor|patch>` 自动改 package.json + Cargo.toml + commit + tag;
# 之后 `git push && git push --tags`,tag `v*` 推上来这条 workflow 被触发。
#
# 工作流被拆成两阶段,满足 "全部成功才发布 / 任一失败整体中止":
# 1. `build` 矩阵(6 平台并行,fail-fast: true)—— 直接调 `pnpm tauri build`,产物 upload-artifact
# 到 Actions 私有缓存。**不动 Release**。任一 platform 失败立即取消其他 jobs,避免"半成品 draft"
# 被部分 upload 出去。
# 2. `release` job(needs: build, if: success())—— 下载所有 6 个 artifact,给 installer 加平台
# 前缀,自己合成 `latest.json`,最后用 softprops/action-gh-release 一次性创建 draft Release +
# 上传所有 asset。
#
# 为什么不用 tauri-apps/tauri-action:它把 build + 创建 release + upload + 合并 latest.json 全打包
# 到一个 step,无法在多 matrix job 之间做 "全部成功才上传" 的原子切片。直接调 Tauri CLI 损失的只是
# keychain import 的便利——Tauri 2 CLI 自身已经认 `APPLE_CERTIFICATE` 等 env 自动处理 keychain。
#
# 首次启用前必做(一次性):
# 1. `pnpm tauri signer generate -w ~/.tauri/openspeech.key`(已完成)
# 2. 把输出 pubkey 贴到 src-tauri/tauri.conf.json → plugins.updater.pubkey(已完成)
# 3. GitHub Secrets:
# - TAURI_SIGNING_PRIVATE_KEY (整个 key 文件内容)
# - TAURI_SIGNING_PRIVATE_KEY_PASSWORD (生成时输入的密码,无密码留空)
# 4. **组织级权限**:OpenLoaf org → Settings → Actions → General → Workflow permissions 必须设
# "Read and write permissions"。否则 release job 创建 draft Release 时返回 "Resource not
# accessible by integration"。这是组织级配置,repo 改不了,也无法用 yml `permissions:` 提权。
#
# macOS 代码签名 + 公证(Secrets 命名跟 OpenLoaf 仓库对齐,便于跨项目复用一套 Apple 证书):
# MAC_CER_BASE64 = Developer ID Application 证书 .p12 的 base64
# MAC_CER_PASSWORD = 上述 .p12 的导出密码
# APPLE_SIGNING_IDENTITY = "Developer ID Application: 你的名字 (TEAMID)"
# APPLE_ID = Apple ID 邮箱
# APPLE_APP_SPECIFIC_PASSWORD= App-Specific Password(appleid.apple.com 生成)
# APPLE_TEAM_ID = 10 位 Team ID
# 全部为空时 Tauri CLI 自动跳过签名,回退到未签名包。
on:
push:
tags:
- 'v*'
permissions:
contents: write
jobs:
build:
strategy:
# 任一 platform 失败立即取消其他正在跑的 jobs,避免:
# 1. 部分 platform 跑一两个小时再因为已知失败而被人工 cancel,浪费配额;
# 2. release job 因为 needs: build 失败永远不会进,所以让 build 早死早超生。
fail-fast: true
matrix:
include:
# ---- macOS ----
- name: macOS ARM64
platform: macos-latest
rust-target: aarch64-apple-darwin
args: '--target aarch64-apple-darwin'
artifact: macOS-arm64
arch-tag: aarch64
# Intel 也跑在 Apple Silicon runner 上交叉编译 x86_64 —— GitHub 已把 macos-13
# (Intel)从免费 runner 池退役,留下来的 Intel runner 走付费 Larger Runner 标签,
# 现状 v0.2.0/v0.2.1 的 Intel job 永远排不到队。dtolnay/rust-toolchain 里声明
# target,cargo 自动交叉编译;OpenSpeech 的 Rust 依赖(cpal/tauri/reqwest 等)
# 没有需要 brew 的 native 依赖,纯 cross OK。OpenLoaf 仓库 publish-desktop.yml
# 同样的处理。
- name: macOS Intel
platform: macos-latest
rust-target: x86_64-apple-darwin
args: '--target x86_64-apple-darwin'
artifact: macOS-intel
arch-tag: x64
# ---- Linux ----
- name: Linux x86_64
platform: ubuntu-22.04
rust-target: ''
args: ''
artifact: Linux-x86_64
arch-tag: ''
- name: Linux ARM64
platform: ubuntu-22.04-arm
rust-target: ''
args: ''
artifact: Linux-arm64
arch-tag: ''
# ---- Windows ----
- name: Windows x86_64
platform: windows-latest
rust-target: ''
args: ''
artifact: Windows-x86_64
arch-tag: ''
- name: Windows ARM64
platform: windows-11-arm
rust-target: ''
args: ''
artifact: Windows-arm64
arch-tag: ''
name: Build · ${{ matrix.name }}
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v5
with:
version: 10
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: 20
cache: 'pnpm'
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.rust-target }}
- name: Rust cache
uses: swatinem/rust-cache@v2
with:
workspaces: './src-tauri -> target'
key: ${{ matrix.rust-target || matrix.platform }}
# Linux 打包 deb + rpm + AppImage 三件套需要的全套工具链:
# - 运行时/编译:webkit2gtk-4.1(不是 4.0)、gtk-3、ayatana appindicator、librsvg2、openssl
# - 音频:libasound2-dev(cpal/alsa-sys 找 alsa.pc)
# - 打包:patchelf / rpm / fakeroot / file / wget / libfuse2
# - **xdg-utils**:tauri-plugin-opener 在 Linux 打包阶段会校验 `/usr/bin/xdg-open` 存在,
# 缺则直接 "failed to bundle project xdg-open binary not found"。
- name: Install Linux dependencies
if: startsWith(matrix.platform, 'ubuntu-')
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
libgtk-3-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
libssl-dev \
libasound2-dev \
patchelf \
rpm \
fakeroot \
file \
wget \
libfuse2 \
xdg-utils
# Tauri bundler 内置使用的 linuxdeploy 来自 tauri-apps/binary-releases 镜像,
# 该镜像 2024-07-24 后未更新;2024 → 2026 间 Linux 工具链引入 `.relr.dyn`
# ELF section(type 0x13),老 linuxdeploy strip 时不识别 → "failed to run linuxdeploy"。
# 这里预先把上游最新 linuxdeploy(continuous tag)放到 Tauri 期望的缓存路径
# `~/.cache/tauri/linuxdeploy-<arch>.AppImage`,Tauri 检测到 cached 即跳过下载,
# 直接用上游最新版(已修 `.relr.dyn` 兼容性)。
# 等 Tauri 上游 PR #12491 (Truly portable appimage) 合并后可移除。
- name: Pre-populate latest linuxdeploy (Linux)
if: startsWith(matrix.platform, 'ubuntu-')
run: |
set -euxo pipefail
arch="$(uname -m)"
mkdir -p "$HOME/.cache/tauri"
curl -fsSL "https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-${arch}.AppImage" \
-o "$HOME/.cache/tauri/linuxdeploy-${arch}.AppImage"
chmod +x "$HOME/.cache/tauri/linuxdeploy-${arch}.AppImage"
ls -la "$HOME/.cache/tauri/"
- name: Install frontend deps
run: pnpm install --frozen-lockfile
# 历史背景:cargo-bundle 内置的 bundle_dmg.sh 用 AppleScript 让 Finder 写 .DS_Store,
# GitHub macOS runner 是 headless 的,osascript 返回 0 但 Apple Events 不实际投递,
# 导致 dmg 进了 .background/background.png 但缺 .DS_Store,挂载后 Finder 用默认外观。
# 现在的方案:tauri.conf.json 移除 dmg target,CI 自己用 appdmg 打 dmg(直接写
# .DS_Store 二进制,无需 Finder/AppleScript)。本地开发要打 dmg 也用同一条路径
# `pnpm dmg:build`,确保本地 / CI 表现一致。
# 直接调 Tauri CLI;macOS 签名 / 公证 / updater 签名都靠 env 透传给 CLI 自己处理。
# tauri-action 替你做的 keychain import 是 Tauri CLI 2.x 已经内置的能力,env 配齐即可。
- name: Tauri build
shell: bash
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
# macOS 签名+公证 env(其它平台无害;CLI 看到没填的 env 自动跳过对应步骤)
APPLE_CERTIFICATE: ${{ secrets.MAC_CER_BASE64 }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.MAC_CER_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
# 新版 linuxdeploy 自身是 AppImage 格式,启动需 FUSE;GitHub-hosted runner 上
# FUSE 不一定可用,让它先 extract 再跑。对非 Linux 平台无害。
APPIMAGE_EXTRACT_AND_RUN: '1'
# tauri.conf.json 默认 `createUpdaterArtifacts: false`(让本地 / 协作者裸跑 build 不需要 updater 私钥);
# 发版构建在这里临时 override 打开,CI 的 TAURI_SIGNING_PRIVATE_KEY 已注入。
run: pnpm tauri build ${{ matrix.args }} --config '{"bundle":{"createUpdaterArtifacts":true}}'
# macOS 上跑 appdmg 自打 DMG(绕开 Tauri 内置 AppleScript 路径)。.app 已经被 Tauri CLI
# codesign + notarize + staple 了;DMG 是外壳,里面装的 .app 是已被 Apple 认证的,
# Gatekeeper 拿到包后会检查内部 .app 的 ticket。下面再单独给 DMG 自身签名 + 公证一遍,
# 避免首次打开弹"已下载自互联网"的额外 prompt。
- name: Build DMG (appdmg)
if: startsWith(matrix.platform, 'macos')
shell: bash
env:
RUST_TARGET: ${{ matrix.rust-target }}
run: pnpm dmg:build --target "$RUST_TARGET"
# 给 DMG 本身签名。Tauri build step 创建的临时 keychain 不进 user search list,
# 跨 step 后 codesign 找不到证书;这里独立 import 一遍同样的 secrets。
- name: Sign DMG
if: startsWith(matrix.platform, 'macos') && env.APPLE_SIGNING_IDENTITY != ''
shell: bash
env:
APPLE_CERTIFICATE: ${{ secrets.MAC_CER_BASE64 }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.MAC_CER_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
RUST_TARGET: ${{ matrix.rust-target }}
run: |
set -euo pipefail
KEYCHAIN_PATH="$RUNNER_TEMP/openspeech-dmg.keychain-db"
KEYCHAIN_PASSWORD="$(uuidgen)"
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
CERT_PATH="$RUNNER_TEMP/cert.p12"
echo -n "$APPLE_CERTIFICATE" | base64 -d > "$CERT_PATH"
security import "$CERT_PATH" -k "$KEYCHAIN_PATH" -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" >/dev/null
security list-keychains -d user -s "$KEYCHAIN_PATH" $(security list-keychains -d user | sed -e 's/^[[:space:]]*//' -e 's/"//g')
rm -f "$CERT_PATH"
dmg=$(ls src-tauri/target/${RUST_TARGET}/release/bundle/dmg/OpenSpeech_*.dmg | head -1)
codesign --force --sign "$APPLE_SIGNING_IDENTITY" --timestamp --keychain "$KEYCHAIN_PATH" "$dmg"
codesign -dvvv "$dmg" 2>&1 | head -10
# 公证 DMG:把已签名 DMG 提交给 Apple 公证服务,等回执后 staple 进 DMG。
# 没配公证 secrets 时跳过——dmg 仍是签名的,只是首次打开会有 Gatekeeper 提示。
- name: Notarize DMG
if: startsWith(matrix.platform, 'macos') && env.APPLE_ID != '' && env.APPLE_PASSWORD != '' && env.APPLE_TEAM_ID != ''
shell: bash
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
RUST_TARGET: ${{ matrix.rust-target }}
run: |
set -euo pipefail
dmg=$(ls src-tauri/target/${RUST_TARGET}/release/bundle/dmg/OpenSpeech_*.dmg | head -1)
xcrun notarytool submit "$dmg" \
--apple-id "$APPLE_ID" \
--password "$APPLE_PASSWORD" \
--team-id "$APPLE_TEAM_ID" \
--wait
xcrun stapler staple "$dmg"
xcrun stapler validate "$dmg"
# 防 ad-hoc 倒灌:tauri-action / Tauri CLI 在 Apple secrets 缺失时会**自动跳签**,产物变 ad-hoc。
# ad-hoc 包跑起来每次访问 Keychain 都会弹"输入登录密码"框(每次签名指纹都变 → ACL 不认)。
# 校验逻辑:
# - 配齐了 `MAC_CER_BASE64` → 强校验 `Authority=Developer ID Application` + `TeamIdentifier`,
# 回退 ad-hoc 直接 fail,避免悄悄退化。
# - 没配 `MAC_CER_BASE64` → 跳过校验(视为"开发者尚未配齐签名 secrets,本次接受 ad-hoc 包"),
# 但仍然在日志里 echo 出 codesign 信息便于排查。
- name: Verify macOS code signature
if: startsWith(matrix.platform, 'macos')
shell: bash
env:
RUST_TARGET: ${{ matrix.rust-target }}
MAC_CER_BASE64: ${{ secrets.MAC_CER_BASE64 }}
run: |
set -euo pipefail
app="src-tauri/target/${RUST_TARGET}/release/bundle/macos/OpenSpeech.app"
out=$(codesign -dvvv "$app" 2>&1 || true)
echo "$out"
if [[ -z "${MAC_CER_BASE64:-}" ]]; then
echo "::warning::MAC_CER_BASE64 secret not configured — skipping signature verification (ad-hoc bundle accepted)."
exit 0
fi
if echo "$out" | grep -qE '^Signature=adhoc'; then
echo "::error::OpenSpeech.app 是 ad-hoc 签名 — Apple Developer ID secrets 没生效"
exit 1
fi
if echo "$out" | grep -qE '^TeamIdentifier=not set'; then
echo "::error::OpenSpeech.app 缺 TeamIdentifier — 不是 Developer ID Application 证书"
exit 1
fi
if ! echo "$out" | grep -qE '^Authority=Developer ID Application'; then
echo "::error::OpenSpeech.app 不是 Developer ID Application 签名"
exit 1
fi
# macOS .app.tar.gz 默认命名是 `OpenSpeech.app.tar.gz` —— 不带 arch!ARM64 + Intel 两个 job
# 上传同名 artifact 互不影响(按 artifact name 分组),但下载到 release job 的 staging 目录
# 时会同名覆盖。因此在 upload 之前就给它加上 arch 后缀,跟 dmg/exe/AppImage 那种"自带 arch
# 后缀"的命名风格对齐。`.sig` 同步重命名(updater 拉 .app.tar.gz 时按同名找 sig)。
- name: Tag macOS updater artifact with arch
if: startsWith(matrix.platform, 'macos')
shell: bash
env:
ARCH_TAG: ${{ matrix.arch-tag }}
RUST_TARGET: ${{ matrix.rust-target }}
run: |
set -euo pipefail
version=$(node -p "require('./package.json').version")
dir="src-tauri/target/${RUST_TARGET}/release/bundle/macos"
if [[ -f "$dir/OpenSpeech.app.tar.gz" ]]; then
mv "$dir/OpenSpeech.app.tar.gz" "$dir/OpenSpeech_${version}_${ARCH_TAG}.app.tar.gz"
mv "$dir/OpenSpeech.app.tar.gz.sig" "$dir/OpenSpeech_${version}_${ARCH_TAG}.app.tar.gz.sig"
echo "[mv] OpenSpeech.app.tar.gz -> OpenSpeech_${version}_${ARCH_TAG}.app.tar.gz"
fi
- name: Upload bundle artifacts
uses: actions/upload-artifact@v7
with:
name: bundle-${{ matrix.artifact }}
if-no-files-found: error
retention-days: 3
# 各平台只取实际产出的文件类型;nullglob 行为由 actions/upload-artifact 自己处理。
# Tauri 2 updater 产物:
# - macOS:bundle/macos/*.app.tar.gz + .sig(仍是 tarball)
# - Linux:bundle/appimage/*.AppImage + .sig(直接对 AppImage 签名,不再走 tarball)
# - Windows:bundle/nsis/*-setup.exe + .sig(直接对 NSIS exe 签名,不再走 .nsis.zip)
path: |
src-tauri/target/**/release/bundle/dmg/*.dmg
src-tauri/target/**/release/bundle/macos/*.app.tar.gz
src-tauri/target/**/release/bundle/macos/*.app.tar.gz.sig
src-tauri/target/**/release/bundle/nsis/*-setup.exe
src-tauri/target/**/release/bundle/nsis/*-setup.exe.sig
src-tauri/target/**/release/bundle/deb/*.deb
src-tauri/target/**/release/bundle/rpm/*.rpm
src-tauri/target/**/release/bundle/appimage/*.AppImage
src-tauri/target/**/release/bundle/appimage/*.AppImage.sig
release:
name: Publish draft release
needs: build
if: success()
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v6
- name: Download all bundles
uses: actions/download-artifact@v7
with:
path: artifacts
pattern: bundle-*
# staging/ 是即将上传到 Release 的扁平目录。**所有文件统一重命名为**
# `OpenSpeech-<version>-<platform>.<ext>`,包括 updater 链路:
# - macOS:.app.tar.gz + .sig
# - Linux:.AppImage + .sig(Tauri 2 直接签裸 AppImage)
# - Windows:-setup.exe + .sig(Tauri 2 直接签 NSIS exe)
# `matrix.artifact` 已经一次性表达 platform+arch(macOS-arm64 / Linux-x86_64 / Windows-arm64
# 等),不再保留 Tauri 默认名里的冗余 arch 段。
# 由于 `latest.json` 在下一个 step 里**自己生成**、url 字段直接拼新文件名,updater 客户端
# 拉到的 url ↔ 实际 asset 仍然自洽。
- name: Stage release files
shell: bash
env:
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
mkdir -p staging
version="${TAG#v}"
for art in artifacts/bundle-*; do
[[ -d "$art" ]] || continue
platform="${art##*/bundle-}" # 例:macOS-arm64 / Linux-x86_64 / Windows-arm64
base_name="OpenSpeech-${version}-${platform}"
while IFS= read -r f; do
[[ -f "$f" ]] || continue
base="$(basename "$f")"
case "$base" in
# 双扩展名 / 带 .sig 的必须排在裸扩展之前——case 顺序敏感,第一个匹上即停
*.app.tar.gz) cp "$f" "staging/${base_name}.app.tar.gz" ;;
*.app.tar.gz.sig) cp "$f" "staging/${base_name}.app.tar.gz.sig" ;;
*.AppImage.sig) cp "$f" "staging/${base_name}.AppImage.sig" ;;
*.AppImage) cp "$f" "staging/${base_name}.AppImage" ;;
*.exe.sig) cp "$f" "staging/${base_name}-setup.exe.sig" ;;
*.exe) cp "$f" "staging/${base_name}-setup.exe" ;;
*.dmg) cp "$f" "staging/${base_name}.dmg" ;;
*.deb) cp "$f" "staging/${base_name}.deb" ;;
*.rpm) cp "$f" "staging/${base_name}.rpm" ;;
*)
echo "::warning::unrecognized artifact, skipping: $f"
;;
esac
done < <(find "$art" -type f)
done
echo "--- staging/ ---"
ls -la staging/
# latest.json 是 Tauri updater 客户端 fetch 的入口(plugins.updater.endpoints 指向它)。
# 字段:version / pub_date / platforms[<platform-arch>].{signature, url}。
# signature 取 .sig 文件全文;url 写 R2 自定义域名(海外口 openspeech-r2.hexems.com);
# 客户端 update_channel.rs 在国内运行时按地区把 host 重写成 openspeech-cdn.hexems.com,
# 所以这里写哪个 host 都行——选 R2 海外是因为它本来就在 endpoints 顺序里,且 GitHub
# Release 上传一份一致的 manifest 给不会 rewrite 的客户端兜底(直接 hit R2 海外)。
- name: Generate latest.json
shell: bash
env:
TAG: ${{ github.ref_name }}
CDN_HOST: https://openspeech-r2.hexems.com
run: |
set -euo pipefail
cd staging
version="${TAG#v}"
pub_date="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
platforms='{}'
for sig in *.sig; do
[[ -f "$sig" ]] || continue
base="${sig%.sig}"
sig_content="$(cat "$sig")"
url="${CDN_HOST}/v${version}/${base}"
# stage step 已经把 platform 写进文件名(OpenSpeech-<version>-<platform>.<ext>),
# 这里直接按 -<platform>. / -<platform>- 段匹配,比凑 arch 关键字稳。
case "$base" in
OpenSpeech-*-macOS-arm64.app.tar.gz) key="darwin-aarch64" ;;
OpenSpeech-*-macOS-intel.app.tar.gz) key="darwin-x86_64" ;;
OpenSpeech-*-Linux-x86_64.AppImage) key="linux-x86_64" ;;
OpenSpeech-*-Linux-arm64.AppImage) key="linux-aarch64" ;;
OpenSpeech-*-Windows-x86_64-setup.exe) key="windows-x86_64" ;;
OpenSpeech-*-Windows-arm64-setup.exe) key="windows-aarch64" ;;
*)
echo "::warning::unknown sig pattern, skipping: $base"
continue
;;
esac
platforms="$(jq -n --argjson p "$platforms" --arg key "$key" --arg sig "$sig_content" --arg url "$url" \
'$p + {($key): {signature: $sig, url: $url}}')"
done
jq -n \
--arg version "$TAG" \
--arg pub_date "$pub_date" \
--argjson platforms "$platforms" \
'{version: $version, notes: "Auto-generated release", pub_date: $pub_date, platforms: $platforms}' \
> ../latest.json
echo "--- latest.json ---"
cat ../latest.json
# Release 正文 = changelog 原文,不再追加下载表 / 自动更新说明等内容。
# changelog SSoT = `docs/changelogs/{version}/zh.md`(缺失则回退 en.md,再缺回退占位文)。
- name: Build release notes
shell: bash
env:
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
version="${TAG#v}"
changelog_dir="docs/changelogs/${version}"
if [[ -f "${changelog_dir}/zh.md" ]]; then cp "${changelog_dir}/zh.md" release_notes.md
elif [[ -f "${changelog_dir}/en.md" ]]; then cp "${changelog_dir}/en.md" release_notes.md
else
echo "本版本暂未提供变更说明,详细改动请查看 [commit 历史](https://github.com/${GITHUB_REPOSITORY}/commits/${TAG})。" > release_notes.md
echo "::warning::缺失 ${changelog_dir}/zh.md 与 en.md,使用占位文案"
fi
echo "--- release_notes.md ---"
cat release_notes.md
# Channel 判定:tag 含 `-beta`(包括 `-beta.N`)走 beta 通道,发 prerelease。
# 其它(`v0.3.0` 这种)走 stable,正常 release。
# 注意:beta 也照常上传 latest.json 到自己的 release——`releases/latest/download/`
# 走 GitHub 的 latest 算法(自动跳过 prerelease),所以 stable 客户端不会误拉。
- name: Detect channel
id: channel
shell: bash
env:
TAG: ${{ github.ref_name }}
run: |
if [[ "$TAG" == *-beta* ]]; then
echo "is_beta=true" >> "$GITHUB_OUTPUT"
echo "prerelease=true" >> "$GITHUB_OUTPUT"
else
echo "is_beta=false" >> "$GITHUB_OUTPUT"
echo "prerelease=false" >> "$GITHUB_OUTPUT"
fi
# latest-beta.json:beta 通道的指针——内容跟 latest.json 完全一样,只是文件名不同。
# stable release 也生成它,因为 stable 版本号严格大于上一版 beta(SemVer:0.3.0 > 0.3.0-beta.2),
# beta 用户拉到这份就被升级到 stable,正好满足"关闭 beta 后只更新比 beta 大的下个版本"。
- name: Generate latest-beta.json
shell: bash
run: cp latest.json latest-beta.json
# download.json 是给"非 Tauri updater 客户端"(官网下载页 / 第三方页面 / 移动端)用的清单。
# 与 latest.json 的关键差异:url 字段指向**用户友好的安装包**(dmg / -setup.exe / AppImage),
# 而不是 updater 拉的中间产物(.app.tar.gz / .AppImage signed by tauri)。
# downloads.* 的主 url 走 R2 海外(与 latest.json 同步),mirrors 同时列出 cdn(国内 CDN
# 回源 R2)/ r2(海外 R2 直连)/ github(GitHub Release)三路给前端按地区/容灾切源。
- name: Generate download.json
shell: bash
env:
TAG: ${{ github.ref_name }}
R2_HOST: https://openspeech-r2.hexems.com
CDN_HOST: https://openspeech-cdn.hexems.com
IS_BETA: ${{ steps.channel.outputs.is_beta }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
version="${TAG#v}"
updated_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
if [[ "$IS_BETA" == "true" ]]; then channel="beta"; else channel="stable"; fi
gh_base="https://github.com/${REPO}/releases/download/${TAG}"
# 平台 → (staging 文件名, 用户友好扩展名) 映射;与 Stage release files step 命名严格对齐。
# 6 个平台与 release matrix 一一对应;缺失某平台(hot fix 单平台跳过)则 skip 该 key。
declare -A FILE_MAP=(
[mac-arm64]="OpenSpeech-${version}-macOS-arm64.dmg|dmg"
[mac-x64]="OpenSpeech-${version}-macOS-intel.dmg|dmg"
[win-x64]="OpenSpeech-${version}-Windows-x86_64-setup.exe|exe"
[win-arm64]="OpenSpeech-${version}-Windows-arm64-setup.exe|exe"
[linux-x64]="OpenSpeech-${version}-Linux-x86_64.AppImage|AppImage"
[linux-arm64]="OpenSpeech-${version}-Linux-arm64.AppImage|AppImage"
)
downloads='{}'
for key in "${!FILE_MAP[@]}"; do
entry="${FILE_MAP[$key]}"
file="${entry%|*}"
ext="${entry#*|}"
staged="staging/${file}"
if [[ ! -f "$staged" ]]; then
echo "::warning::missing $file, skipping $key in download.json"
continue
fi
size=$(stat -c %s "$staged")
r2_url="${R2_HOST}/v${version}/${file}"
cdn_url="${CDN_HOST}/v${version}/${file}"
gh_url="${gh_base}/${file}"
downloads=$(jq -n --argjson d "$downloads" --arg key "$key" \
--arg ext "$ext" --arg url "$r2_url" --arg cdn "$cdn_url" --arg r2 "$r2_url" \
--arg gh "$gh_url" --argjson size "$size" \
'$d + {($key): {ext: $ext, url: $url, sha256: null, size: $size, mirrors: {cdn: $cdn, r2: $r2, github: $gh}}}')
done
jq -n \
--arg channel "$channel" \
--arg updated_at "$updated_at" \
--arg version "$version" \
--argjson downloads "$downloads" \
--arg r2 "$R2_HOST" \
--arg cdn "$CDN_HOST" \
--arg gh_releases "https://github.com/${REPO}/releases" \
--arg gh_release "https://github.com/${REPO}/releases/tag/${TAG}" \
--arg latest_url "${R2_HOST}/latest.json" \
'{
channel: $channel,
updatedAt: $updated_at,
desktop: {
version: $version,
publishedAt: $updated_at,
downloads: $downloads,
githubReleaseUrl: $gh_release,
versionManifestUrl: $latest_url
},
mirrors: {
cdn: $cdn,
r2: $r2,
github: $gh_releases
}
}' > "download-${channel}.json"
# download-beta.json 始终生成,语义与 latest-beta.json 对齐——stable 发版时复制一份给
# beta channel 客户端,让仍在 beta 通道的用户被推到 stable(SemVer:0.3.0 > 0.3.0-beta.2)。
if [[ ! -f "download-beta.json" ]]; then
cp "download-${channel}.json" "download-beta.json"
fi
echo "--- download-${channel}.json ---"
cat "download-${channel}.json"
- name: Publish draft release
uses: softprops/action-gh-release@v3
with:
tag_name: ${{ github.ref_name }}
name: 'OpenSpeech ${{ github.ref_name }}'
draft: true
prerelease: ${{ steps.channel.outputs.prerelease }}
fail_on_unmatched_files: true
body_path: release_notes.md
# download-*.json 用 glob 而非显式枚举,因为 stable 发版只生成 download-stable.json +
# download-beta.json,beta 发版只生成 download-beta.json,glob 自适应不会因缺文件挂掉。
files: |
staging/*
latest.json
latest-beta.json
download-*.json
# channel-beta 滚动指针:每次 release(stable + beta)都把当前的 latest-beta.json +
# download-beta.json 覆盖到 `channel-beta` 这个固定 tag 的 release。前端 beta 通道直接拉
# `releases/download/channel-beta/{latest-beta,download-beta}.json` 永远拿到最新。
# 第一次跑会自动创建这个 release(标记为 prerelease,列表里折叠隐藏)。
- name: Update channel-beta pointer release
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
if ! gh release view channel-beta --repo "$REPO" >/dev/null 2>&1; then
gh release create channel-beta \
--repo "$REPO" \
--title "Channel: Beta (rolling pointer)" \
--notes "Auto-updated pointer to the latest beta-or-stable release. Do not download from here." \
--prerelease
fi
gh release upload channel-beta latest-beta.json download-beta.json --repo "$REPO" --clobber
gh release edit channel-beta --repo "$REPO" \
--notes "Latest beta-channel pointer · upstream tag: ${TAG} · updated $(date -u +%Y-%m-%dT%H:%M:%SZ)"
# ==========================================================================
# 上传产物到 Cloudflare R2(单一上传目标)
# 国内访问由腾讯云 CDN(openspeech-cdn.hexems.com) 回源 R2 自定义域(openspeech-r2.hexems.com)
# 客户端按地区分流:
# - CN → openspeech-cdn.hexems.com
# - 海外 → openspeech-r2.hexems.com
#
# R2 路径布局(保持与历史 COS 一致,方便客户端逻辑无差别):
# - r2://openspeech/v<version>/OpenSpeech-... 按版本归档,长缓存安全
# - r2://openspeech/latest.json Tauri updater stable 滚动指针(no-cache)
# - r2://openspeech/latest-beta.json Tauri updater beta 滚动指针
# - r2://openspeech/download-stable.json 官网下载页 stable 清单
# - r2://openspeech/download-beta.json 官网下载页 beta 清单
# - r2://openspeech/download.json 聚合入口:stable 优先,beta 仅在还没 stable 时占位
# 失败不阻塞 GitHub Release(已经在前面 publish 了),只是丢失这次的 R2 镜像。
#
# 注意:R2_ENDPOINT 必须**不带 bucket 段**(仅 https://<account>.r2.cloudflarestorage.com);
# 带 /openspeech 后缀会让 SDK 拼成 /openspeech/openspeech/... 直接 403。
- name: Upload artifacts to R2
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
AWS_ENDPOINT_URL: ${{ secrets.R2_ENDPOINT }}
AWS_DEFAULT_REGION: auto
R2_BUCKET: ${{ secrets.R2_BUCKET }}
TAG: ${{ github.ref_name }}
IS_BETA: ${{ steps.channel.outputs.is_beta }}
run: |
set -euo pipefail
version="${TAG#v}"
aws s3 sync staging/ "s3://${R2_BUCKET}/v${version}/" \
--no-progress --only-show-errors
# 滚动指针 manifest 必须 no-cache,否则 CDN 缓存会让用户拿不到新版本
aws s3 cp latest-beta.json "s3://${R2_BUCKET}/latest-beta.json" \
--content-type application/json --cache-control "no-cache,max-age=0"
aws s3 cp download-beta.json "s3://${R2_BUCKET}/download-beta.json" \
--content-type application/json --cache-control "no-cache,max-age=0"
if [[ "$IS_BETA" != "true" ]]; then
aws s3 cp latest.json "s3://${R2_BUCKET}/latest.json" \
--content-type application/json --cache-control "no-cache,max-age=0"
aws s3 cp download-stable.json "s3://${R2_BUCKET}/download-stable.json" \
--content-type application/json --cache-control "no-cache,max-age=0"
# stable 总是覆盖聚合入口
aws s3 cp download-stable.json "s3://${R2_BUCKET}/download.json" \
--content-type application/json --cache-control "no-cache,max-age=0"
else
# beta:仅当 R2 上还没 download-stable.json 时才让 beta 占位聚合入口
if aws s3api head-object --bucket "$R2_BUCKET" --key download-stable.json >/dev/null 2>&1; then
echo "download-stable.json already on R2, keeping it as aggregate entry"
else
echo "no stable yet, beta takes the aggregate slot"
aws s3 cp download-beta.json "s3://${R2_BUCKET}/download.json" \
--content-type application/json --cache-control "no-cache,max-age=0"
fi
fi
echo "Uploaded to R2: s3://${R2_BUCKET}/v${version}/"