Release #81
Workflow file for this run
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: 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}/" | |