Skip to content

fix(dev): use current Node runtime in worktrees #906

fix(dev): use current Node runtime in worktrees

fix(dev): use current Node runtime in worktrees #906

name: Build and Release Electron App
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
workflow_dispatch:
inputs:
version:
description: 'Release version (e.g., v1.0.0, leave empty for nightly)'
required: false
default: ''
# 授予 GITHUB_TOKEN 写入权限以创建 release
permissions:
contents: write
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- os: windows-2022
platform: win32
arch: x64
artifact_name: tx-5dr-windows-x64
- os: macos-latest
platform: darwin
arch: arm64
artifact_name: tx-5dr-macos-arm64
- os: macos-15-intel
platform: darwin
arch: x64
artifact_name: tx-5dr-macos-x64
- os: ubuntu-latest
platform: linux
arch: x64
artifact_name: tx-5dr-linux-x64
- os: ubuntu-24.04-arm
platform: linux
arch: arm64
artifact_name: tx-5dr-linux-arm64
runs-on: ${{ matrix.os }}
timeout-minutes: 60
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 20
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22.15.1'
- name: Enable Corepack and setup Yarn
shell: bash
run: |
corepack enable
corepack prepare yarn@4.9.1 --activate
yarn --version || {
echo "Corepack failed, installing Yarn 4.9.1 manually"
npm install -g yarn@4.9.1
yarn --version
}
- name: Verify Yarn configuration
shell: bash
run: |
echo "=== Verifying Yarn PnP is disabled ==="
if [ -f .yarnrc.yml ]; then
echo "Found .yarnrc.yml:"
cat .yarnrc.yml
if grep -q "nodeLinker: node-modules" .yarnrc.yml; then
echo "✓ nodeLinker is set to node-modules (PnP disabled)"
else
echo "⚠ Warning: nodeLinker not set to node-modules"
fi
else
echo "⚠ Warning: .yarnrc.yml not found"
fi
- name: Setup Python (for native modules)
uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Install system dependencies (Linux)
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y \
libasound2-dev libpulse-dev \
libx11-dev libxrandr-dev libxinerama-dev libxcursor-dev libxi-dev libxext-dev \
libopus-dev \
portaudio19-dev build-essential python3-dev pkg-config \
xz-utils rpm cpio
- name: Verify Yarn version
run: |
echo "Node version: $(node --version)"
echo "Yarn version: $(yarn --version)"
echo "Package manager in package.json: $(grep packageManager package.json)"
- name: Install dependencies
shell: bash
run: |
echo "=== Installing dependencies with Yarn 4 (node-modules mode) ==="
echo "Note: Using node-modules linker instead of PnP for better compatibility"
yarn install || {
echo "First install attempt failed, applying naudiodon2 fixes..."
node scripts/fix-naudiodon.js || true
echo "Clearing yarn cache and retrying..."
yarn cache clean || true
yarn install || {
echo "Second install failed, trying with network concurrency limit..."
yarn install --network-concurrency 1 || {
echo "Final fallback: installing with inline builds..."
yarn install --inline-builds
}
}
}
env:
npm_config_cache: .npm
ELECTRON_CACHE: .electron-cache
ELECTRON_BUILDER_CACHE: .electron-builder-cache
# Try to use prebuilt binaries first
npm_config_build_from_source: false
# Ensure proper build environment (only for non-Windows)
CC: ${{ matrix.platform != 'win32' && 'gcc' || '' }}
CXX: ${{ matrix.platform != 'win32' && 'g++' || '' }}
# Force Yarn to use node-modules linker (should be in .yarnrc.yml)
YARN_NODE_LINKER: node-modules
- name: Generate ICO file (Windows)
if: matrix.os == 'windows-latest'
run: node scripts/generate-ico.js
- name: Prepare desktop build metadata
shell: bash
env:
TZ: UTC
run: |
CHANNEL="release"
if [ -z "${{ github.event.inputs.version }}" ]; then
CHANNEL="nightly"
fi
COMMIT_TIMESTAMP=$(git show -s --format=%cI "${{ github.sha }}")
COMMIT_STAMP=$(git show -s --date=format-local:%Y%m%d%H%M --format=%cd "${{ github.sha }}")
node scripts/prepare-electron-release.mjs \
--channel "$CHANNEL" \
--version "${{ github.event.inputs.version }}" \
--commit "${{ github.sha }}" \
--build-timestamp "$COMMIT_TIMESTAMP" \
--build-stamp "$COMMIT_STAMP"
- name: Verify macOS signing secrets (release builds)
if: matrix.platform == 'darwin' && github.event_name != 'pull_request'
env:
MACOS_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE_BASE64 }}
MACOS_CERTIFICATE_PWD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
shell: bash
run: |
set -euo pipefail
for name in MACOS_CERTIFICATE MACOS_CERTIFICATE_PWD APPLE_ID APPLE_APP_SPECIFIC_PASSWORD APPLE_TEAM_ID; do
if [ -z "${!name:-}" ]; then
echo "Missing required macOS signing secret: ${name}" >&2
exit 1
fi
done
- name: Import Code Signing Certificate (macOS)
if: matrix.platform == 'darwin' && github.event_name != 'pull_request'
env:
MACOS_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE_BASE64 }}
MACOS_CERTIFICATE_PWD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
run: |
echo "$MACOS_CERTIFICATE" | base64 --decode > certificate.p12
security create-keychain -p actions temp.keychain
security default-keychain -s temp.keychain
security unlock-keychain -p actions temp.keychain
security import certificate.p12 -k temp.keychain -P "$MACOS_CERTIFICATE_PWD" -T /usr/bin/codesign -T /usr/bin/productbuild
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k actions temp.keychain
security set-keychain-settings -t 3600 -u temp.keychain
rm -f certificate.p12
echo "=== 验证证书导入 ==="
security find-identity -v -p codesigning temp.keychain
echo "✅ 证书导入成功"
- name: Build application
run: yarn build
env:
NODE_ENV: production
TX5DR_CLUBLOG_API_KEY: ${{ secrets.CLUBLOG_API_KEY }}
- name: Prepare portable Node runtime
shell: bash
run: |
set -euo pipefail
NODE_VERSION="22.15.1"
PLATFORM="${{ matrix.platform }}"
ARCH="${{ matrix.arch }}"
TRIPLET="${PLATFORM}-${ARCH}"
echo "Preparing portable node ${NODE_VERSION} for ${TRIPLET}"
mkdir -p resources/bin/${TRIPLET}
mkdir -p resources/licenses
if [ "${PLATFORM}" = "darwin" ]; then
PKG="node-v${NODE_VERSION}-darwin-${ARCH}.tar.xz"
URL="https://nodejs.org/dist/v${NODE_VERSION}/${PKG}"
echo "Downloading ${URL}"
curl -fsSL -o ${PKG} ${URL}
tar -xJf ${PKG}
SRC_DIR=$(echo node-v${NODE_VERSION}-darwin-${ARCH})
install -m 0755 ${SRC_DIR}/bin/node resources/bin/${TRIPLET}/node
# macOS strip -x: 只删除本地符号,保留 N-API 等导出符号(native 模块需要)
strip -x resources/bin/${TRIPLET}/node || true
cp -f ${SRC_DIR}/LICENSE resources/licenses/nodejs-LICENSE || true
# Clean up temporary archive and extracted directory to avoid packaging them
rm -rf "${PKG}" "${SRC_DIR}"
elif [ "${PLATFORM}" = "linux" ]; then
PKG="node-v${NODE_VERSION}-linux-${ARCH}.tar.xz"
URL="https://nodejs.org/dist/v${NODE_VERSION}/${PKG}"
echo "Downloading ${URL}"
curl -fsSL -o ${PKG} ${URL}
tar -xJf ${PKG}
SRC_DIR=$(echo node-v${NODE_VERSION}-linux-${ARCH})
install -m 0755 ${SRC_DIR}/bin/node resources/bin/${TRIPLET}/node
strip resources/bin/${TRIPLET}/node || true
cp -f ${SRC_DIR}/LICENSE resources/licenses/nodejs-LICENSE || true
# Clean up temporary archive and extracted directory to avoid packaging them
rm -rf "${PKG}" "${SRC_DIR}"
else
# Windows
PKG="node-v${NODE_VERSION}-win-${ARCH}.zip"
URL="https://nodejs.org/dist/v${NODE_VERSION}/${PKG}"
echo "Downloading ${URL}"
curl -fsSL -o ${PKG} ${URL}
# Use 7z to extract zip on Windows runner (tar may not handle zip)
7z x -y "${PKG}"
SRC_DIR=$(echo node-v${NODE_VERSION}-win-${ARCH})
mkdir -p resources/bin/${TRIPLET}
cp -f ${SRC_DIR}/node.exe resources/bin/${TRIPLET}/node.exe
cp -f ${SRC_DIR}/LICENSE resources/licenses/nodejs-LICENSE || true
# Bundle MSVC runtime DLLs app-locally so portable archives work on clean Windows installs.
case "${ARCH}" in
x64) REDIST_ARCH="x64" ;;
arm64) REDIST_ARCH="arm64" ;;
*)
echo "Unsupported Windows VC runtime arch: ${ARCH}" >&2
exit 1
;;
esac
# Search for VC runtime in Visual Studio 2022 (pre-installed on windows-2022 runner)
mapfile -t VC_REDIST_DIRS < <(
find \
"/c/Program Files/Microsoft Visual Studio/2022" \
"/c/Program Files (x86)/Microsoft Visual Studio/2022" \
-type d \
-path "*/VC/Redist/MSVC/*/${REDIST_ARCH}/Microsoft.VC143.CRT" \
2>/dev/null | sort -V
)
if [ "${#VC_REDIST_DIRS[@]}" -eq 0 ]; then
echo "Unable to find Visual C++ Redistributable CRT directory for ${REDIST_ARCH}." >&2
echo "Searched:" >&2
echo " /c/Program Files/Microsoft Visual Studio/2022/**/VC/Redist/MSVC/*/${REDIST_ARCH}/Microsoft.VC143.CRT" >&2
echo " /c/Program Files (x86)/Microsoft Visual Studio/2022/**/VC/Redist/MSVC/*/${REDIST_ARCH}/Microsoft.VC143.CRT" >&2
exit 1
fi
VC_REDIST_DIR="${VC_REDIST_DIRS[$((${#VC_REDIST_DIRS[@]} - 1))]}"
echo "Using VC runtime directory: ${VC_REDIST_DIR}"
VC_RUNTIME_DLLS=(
vcruntime140.dll
vcruntime140_1.dll
msvcp140.dll
msvcp140_1.dll
msvcp140_atomic_wait.dll
)
for dll in "${VC_RUNTIME_DLLS[@]}"; do
src="${VC_REDIST_DIR}/${dll}"
if [ ! -f "${src}" ]; then
echo "Required VC runtime DLL not found: ${src}" >&2
echo "Available files in ${VC_REDIST_DIR}:" >&2
ls -la "${VC_REDIST_DIR}" >&2 || true
exit 1
fi
cp -f "${src}" "resources/bin/${TRIPLET}/${dll}"
done
# Clean up temporary archive and extracted directory to avoid packaging them
rm -rf "${PKG}" "${SRC_DIR}"
fi
echo "Portable node prepared:"
ls -la resources/bin/${TRIPLET}
# macOS: fix wsjtx-lib prebuilt dylib install_names to load from its own folder
- name: Patch wsjtx-lib dylib install names (macOS)
if: matrix.platform == 'darwin'
shell: bash
run: |
set -euo pipefail
echo "Patching wsjtx-lib install names..."
ROOT=$(pwd)
ARCH="${{ matrix.arch }}"
PREBUILT_DIR="$ROOT/node_modules/wsjtx-lib/prebuilds/darwin-${ARCH}"
if [ ! -d "$PREBUILT_DIR" ]; then
echo "wsjtx-lib prebuild dir not found: $PREBUILT_DIR"
exit 0
fi
# 自动检测 Homebrew 路径(Intel: /usr/local, Apple Silicon: /opt/homebrew)
if [ -d "/opt/homebrew" ]; then
HOMEBREW_PREFIX="/opt/homebrew"
elif [ -d "/usr/local/Homebrew" ]; then
HOMEBREW_PREFIX="/usr/local"
else
echo "⚠️ Warning: Homebrew not found, trying default paths..."
HOMEBREW_PREFIX="/opt/homebrew" # 默认使用 ARM64 路径
fi
echo "✅ 使用 Homebrew 路径: $HOMEBREW_PREFIX"
# Ensure libquadmath and libgcc_s are present (copy from Homebrew GCC)
GCC_PREFIX=$(brew --prefix gcc@14 || brew --prefix gcc)
echo "GCC prefix: $GCC_PREFIX"
cp -Lv $(ls "$GCC_PREFIX"/lib/gcc/*/libquadmath.0.dylib | head -n1) "$PREBUILT_DIR" || true
cp -Lv $(ls "$GCC_PREFIX"/lib/gcc/*/libgcc_s.1.1.dylib | head -n1) "$PREBUILT_DIR" || true
pushd "$PREBUILT_DIR" >/dev/null
set +e
# Set ids to @loader_path
install_name_tool -id @loader_path/libfftw3f.3.dylib libfftw3f.3.dylib 2>/dev/null || true
install_name_tool -id @loader_path/libfftw3f_threads.3.dylib libfftw3f_threads.3.dylib 2>/dev/null || true
install_name_tool -id @loader_path/libgfortran.5.dylib libgfortran.5.dylib 2>/dev/null || true
[ -f libquadmath.0.dylib ] && install_name_tool -id @loader_path/libquadmath.0.dylib libquadmath.0.dylib || true
[ -f libgcc_s.1.1.dylib ] && install_name_tool -id @loader_path/libgcc_s.1.1.dylib libgcc_s.1.1.dylib || true
# Fix dependencies to @loader_path (支持两种路径:/opt/homebrew 和 /usr/local)
install_name_tool -change ${HOMEBREW_PREFIX}/opt/fftw/lib/libfftw3f.3.dylib @loader_path/libfftw3f.3.dylib wsjtx_lib_nodejs.node 2>/dev/null || true
install_name_tool -change ${HOMEBREW_PREFIX}/opt/fftw/lib/libfftw3f_threads.3.dylib @loader_path/libfftw3f_threads.3.dylib wsjtx_lib_nodejs.node 2>/dev/null || true
# GCC dylibs may vary by version; try both gcc@14 and generic gcc paths
install_name_tool -change ${HOMEBREW_PREFIX}/opt/gcc@14/lib/gcc/14/libgfortran.5.dylib @loader_path/libgfortran.5.dylib wsjtx_lib_nodejs.node 2>/dev/null || true
install_name_tool -change ${HOMEBREW_PREFIX}/opt/gcc/lib/gcc/*/libgfortran.5.dylib @loader_path/libgfortran.5.dylib wsjtx_lib_nodejs.node 2>/dev/null || true
install_name_tool -change ${HOMEBREW_PREFIX}/opt/gcc@14/lib/gcc/14/libquadmath.0.dylib @loader_path/libquadmath.0.dylib wsjtx_lib_nodejs.node 2>/dev/null || true
install_name_tool -change ${HOMEBREW_PREFIX}/opt/gcc/lib/gcc/*/libquadmath.0.dylib @loader_path/libquadmath.0.dylib wsjtx_lib_nodejs.node 2>/dev/null || true
# Fix secondary deps
install_name_tool -change ${HOMEBREW_PREFIX}/opt/fftw/lib/libfftw3f.3.dylib @loader_path/libfftw3f.3.dylib libfftw3f_threads.3.dylib 2>/dev/null || true
install_name_tool -change @rpath/libquadmath.0.dylib @loader_path/libquadmath.0.dylib libgfortran.5.dylib 2>/dev/null || true
install_name_tool -change @rpath/libgcc_s.1.1.dylib @loader_path/libgcc_s.1.1.dylib libgfortran.5.dylib 2>/dev/null || true
set -e
popd >/dev/null
- name: Package application
shell: bash
env:
NODE_ENV: production
PLATFORM: ${{ matrix.platform }}
ARCH: ${{ matrix.arch }}
GITHUB_TOKEN: ${{ github.token }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
IS_PULL_REQUEST: ${{ github.event_name == 'pull_request' }}
DEBUG: electron-osx-sign*
run: |
set -euo pipefail
if [ "${{ matrix.platform }}" = "darwin" ]; then
echo "🔧 增加文件描述符限制..."
ulimit -n 10240
echo "✅ 文件描述符限制已设置为: $(ulimit -n)"
fi
cleanup_macos_dmg_volume() {
if [ "${{ matrix.platform }}" != "darwin" ]; then
return 0
fi
if [ -d "/Volumes/TX-5DR" ]; then
hdiutil detach "/Volumes/TX-5DR" -force || true
fi
}
package_with_retry() {
local attempt=1
local max_attempts=2
if [ "${{ matrix.platform }}" = "darwin" ]; then
max_attempts=4
fi
while [ "$attempt" -le "$max_attempts" ]; do
echo "📦 electron-builder 打包尝试 ${attempt}/${max_attempts}..."
if "$@"; then
return 0
fi
if [ "$attempt" -ge "$max_attempts" ]; then
echo "❌ electron-builder 打包失败,已达到最大重试次数"
return 1
fi
echo "⚠️ electron-builder 打包失败,清理输出后重试..."
cleanup_macos_dmg_volume
rm -rf out/electron-builder
attempt=$((attempt + 1))
sleep $((attempt * 10))
done
}
if [ "${{ matrix.platform }}" = "darwin" ] && [ "${IS_PULL_REQUEST}" != "true" ]; then
echo "🔓 解锁钥匙串并提取证书..."
security unlock-keychain -p actions temp.keychain
CERT_IDENTITY=$(security find-identity -v -p codesigning temp.keychain | grep "Developer ID Application" | head -1 | grep -o '"[^"]*"' | tr -d '"')
if [ -z "$CERT_IDENTITY" ]; then
echo "❌ 错误: 未找到 Developer ID Application 证书"
exit 1
fi
BUILDER_IDENTITY="${CERT_IDENTITY#Developer ID Application: }"
if [ -z "$BUILDER_IDENTITY" ]; then
echo "❌ 错误: 无法解析 electron-builder 签名身份"
exit 1
fi
echo "✅ 使用证书: $CERT_IDENTITY"
echo "✅ electron-builder identity: $BUILDER_IDENTITY"
package_with_retry env TX5DR_CODESIGN_IDENTITY_FULL="$CERT_IDENTITY" APPLE_IDENTITY="$BUILDER_IDENTITY" yarn make:autoupdate
elif [ "${{ matrix.platform }}" = "darwin" ]; then
echo "📦 PR macOS build: unsigned electron-builder package"
package_with_retry env CSC_IDENTITY_AUTO_DISCOVERY=false yarn make:autoupdate
else
echo "📦 使用 electron-builder 构建 ${PLATFORM}-${ARCH} 全部桌面产物"
package_with_retry yarn make:autoupdate
fi
- name: Verify auto-update package contents
shell: bash
run: |
set -euo pipefail
PLATFORM="${{ matrix.platform }}"
ARCH="${{ matrix.arch }}"
TRIPLET="${PLATFORM}-${ARCH}"
if [ "$PLATFORM" = "darwin" ]; then
RESOURCES_DIR=$(find out/electron-builder -path "*.app/Contents/Resources" -type d | head -1)
else
APP_ROOT_CANDIDATE=$(find out/electron-builder -path "*/resources/app" -type d | head -1)
RESOURCES_DIR=$(dirname "$APP_ROOT_CANDIDATE")
fi
if [ -z "${RESOURCES_DIR:-}" ] || [ ! -d "$RESOURCES_DIR" ]; then
echo "Unable to locate electron-builder resources directory" >&2
find out/electron-builder -maxdepth 4 -type d >&2 || true
exit 1
fi
RESOURCES_DIR="$(cd "$RESOURCES_DIR" && pwd)"
APP_ROOT="$RESOURCES_DIR/app"
NODE_EXE="node"
if [ "$PLATFORM" = "win32" ]; then
NODE_EXE="node.exe"
fi
NODE_BIN="$RESOURCES_DIR/bin/$TRIPLET/$NODE_EXE"
SERVER_SCRIPT_DIR="$APP_ROOT/packages/server/dist/scripts"
required_paths=(
"$RESOURCES_DIR/app-update.yml"
"$RESOURCES_DIR/bin/$TRIPLET/$NODE_EXE"
"$RESOURCES_DIR/models/deepcw"
"$APP_ROOT/packages/electron-main/dist"
"$APP_ROOT/packages/server/dist"
"$APP_ROOT/packages/web/dist"
"$APP_ROOT/packages/client-tools/src/proxy.js"
)
for required in "${required_paths[@]}"; do
if [ ! -e "$required" ]; then
echo "Missing required packaged path: $required" >&2
echo "Resources dir: $RESOURCES_DIR" >&2
find "$RESOURCES_DIR" -maxdepth 4 -type d | sort >&2 || true
exit 1
fi
done
if [ "$PLATFORM" = "darwin" ]; then
python3 - "$APP_ROOT/node_modules" <<'PY'
import pathlib
import re
import subprocess
import sys
root = pathlib.Path(sys.argv[1])
failed = False
for binary in list(root.rglob('*.node')) + list(root.rglob('*.dylib')):
output = subprocess.check_output(['otool', '-l', str(binary)], text=True)
rpaths = re.findall(r'^\s*path\s+(.+?)\s+\(offset\s+\d+\)$', output, re.MULTILINE)
duplicates = sorted({rpath for rpath in rpaths if rpaths.count(rpath) > 1})
if duplicates:
failed = True
print(f'Duplicate LC_RPATH in {binary}: {", ".join(duplicates)}', file=sys.stderr)
if failed:
sys.exit(1)
PY
fi
if [ "$PLATFORM" = "linux" ]; then
if find "$APP_ROOT/node_modules/wsjtx-lib/prebuilds" -path "*/linux-*/libstdc++.so.6" -type f | grep -q .; then
echo "Packaged wsjtx-lib must not bundle libstdc++.so.6 on Linux Electron builds" >&2
find "$APP_ROOT/node_modules/wsjtx-lib/prebuilds" -path "*/linux-*/libstdc++.so.6" -type f >&2
exit 1
fi
export NODE_ENV=production
export APP_RESOURCES="$RESOURCES_DIR"
export NODE_PATH="$APP_ROOT/node_modules"
export LD_LIBRARY_PATH="$RESOURCES_DIR/native:${LD_LIBRARY_PATH:-}"
echo "=== Linux packaged native module preflight ==="
(cd "$SERVER_SCRIPT_DIR" && "$NODE_BIN" check-native-modules.js)
run_packaged_import_order() {
echo "=== Linux packaged native import order: $* ==="
(cd "$SERVER_SCRIPT_DIR" && "$NODE_BIN" --input-type=module -e '
for (const mod of process.argv.slice(1)) {
console.log(`import ${mod}`);
await import(mod);
}
' "$@")
}
run_packaged_import_order audify hamlib wsjtx-lib
run_packaged_import_order wsjtx-lib audify hamlib
fi
echo "=== Packaged Image Radio runtime smoke ==="
(cd "$SERVER_SCRIPT_DIR" && "$NODE_BIN" -e '
const { PNG } = require("pngjs");
if (typeof PNG.sync.read !== "function") throw new Error("pngjs unavailable");
const rasterwave = require("rasterwave-node");
if (rasterwave.sstvModes().length !== 31) throw new Error("rasterwave mode catalog incomplete");
if (typeof rasterwave.correctFaxPaper !== "function") throw new Error("radiofax correction unavailable");
for (const name of ["SstvDecoder", "FaxDecoder", "SstvEncoder"]) {
if (typeof rasterwave[name] !== "function") throw new Error(`missing ${name}`);
}
const mode = rasterwave.sstvModes().find((item) => item.mode === "robot8Bw");
const pixels = new Uint8Array(mode.width * mode.height * 3);
const encoders = [
{ enhancedPreamble: true, stationId: { kind: "fsk", callsign: "N0CALL" }, postImageGapMs: 500, endGuardMs: 300 },
{ stationId: { kind: "cw", callsign: "N0CALL", wpm: 20, toneHz: 800 }, postImageGapMs: 500, endGuardMs: 300 },
{ stationId: { kind: "none" }, endGuardMs: 300 },
].map((options) => new rasterwave.SstvEncoder(pixels, mode.mode, 12000, options));
if (encoders.some((encoder) => encoder.progress.rasterEndSample <= 0)) throw new Error("SSTV envelope progress unavailable");
Promise.all([
...encoders.map((encoder) => encoder.dispose()),
new rasterwave.SstvDecoder(12000, { outputMode: "continuousPaper", fallbackMode: "robot36", queueCapacitySamples: 24000 }, () => {}).dispose(),
new rasterwave.FaxDecoder(12000, { outputMode: "continuousPaper", continuousAuto: true, queueCapacitySamples: 24000 }, () => {}).dispose(),
rasterwave.correctFaxPaper(new Uint8Array(8), 4, 2, 0, []),
]).then(() => console.log("Packaged Image Radio runtime ok")).catch((error) => { console.error(error); process.exit(1); });
')
echo "✅ electron-builder package content verified: $RESOURCES_DIR"
- name: Validate macOS signing and notarization
if: matrix.platform == 'darwin' && github.event_name != 'pull_request'
shell: bash
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
set -euo pipefail
APP_PATH=$(find out/electron-builder -maxdepth 3 -name "*.app" -type d | head -1)
if [ -z "${APP_PATH:-}" ] || [ ! -d "$APP_PATH" ]; then
echo "Unable to locate packaged macOS app" >&2
find out/electron-builder -maxdepth 4 -type d >&2 || true
exit 1
fi
echo "=== macOS signing summary ==="
echo "App: ${APP_PATH}"
/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "${APP_PATH}/Contents/Info.plist"
/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "${APP_PATH}/Contents/Info.plist"
codesign -dv --verbose=4 "${APP_PATH}" 2>&1 | sed -E 's/(TeamIdentifier=).*/\1***/'
echo "=== Verify app code signature ==="
codesign --verify --deep --strict --verbose=2 "${APP_PATH}"
spctl --assess --type execute --verbose=4 "${APP_PATH}"
xcrun stapler validate "${APP_PATH}"
DMG_FILE=$(find out/electron-builder -maxdepth 1 -name "*.dmg" -type f | head -1)
if [ -z "${DMG_FILE:-}" ] || [ ! -f "$DMG_FILE" ]; then
echo "Unable to locate macOS DMG artifact" >&2
find out/electron-builder -maxdepth 1 -type f >&2 || true
exit 1
fi
echo "=== Notarize and staple DMG artifact ==="
xcrun notarytool submit "${DMG_FILE}" \
--apple-id "${APPLE_ID}" \
--password "${APPLE_APP_SPECIFIC_PASSWORD}" \
--team-id "${APPLE_TEAM_ID}" \
--wait
xcrun stapler staple "${DMG_FILE}"
echo "=== Verify DMG notarization ticket ==="
xcrun stapler validate "${DMG_FILE}"
ZIP_FILE=$(find out/electron-builder -maxdepth 1 -name "*.zip" -type f | head -1)
if [ -z "${ZIP_FILE:-}" ] || [ ! -f "$ZIP_FILE" ]; then
echo "Unable to locate macOS ZIP artifact" >&2
find out/electron-builder -maxdepth 1 -type f >&2 || true
exit 1
fi
ZIP_CHECK_DIR=$(mktemp -d)
unzip -q "${ZIP_FILE}" -d "${ZIP_CHECK_DIR}"
ZIP_APP_PATH=$(find "${ZIP_CHECK_DIR}" -maxdepth 2 -name "*.app" -type d | head -1)
if [ -z "${ZIP_APP_PATH:-}" ] || [ ! -d "$ZIP_APP_PATH" ]; then
echo "Unable to locate app inside macOS ZIP artifact" >&2
find "${ZIP_CHECK_DIR}" -maxdepth 3 -type d >&2 || true
exit 1
fi
echo "=== Verify ZIP app signature and notarization ticket ==="
codesign --verify --deep --strict --verbose=2 "${ZIP_APP_PATH}"
spctl --assess --type execute --verbose=4 "${ZIP_APP_PATH}"
xcrun stapler validate "${ZIP_APP_PATH}"
rm -rf "${ZIP_CHECK_DIR}"
- name: Verify Linux package metadata
if: matrix.platform == 'linux'
shell: bash
run: |
set -euo pipefail
DEB_FILE=$(find out/electron-builder -maxdepth 1 -name "*.deb" -type f | head -1)
RPM_FILE=$(find out/electron-builder -maxdepth 1 -name "*.rpm" -type f | head -1)
if [ -z "${DEB_FILE}" ] || [ -z "${RPM_FILE}" ]; then
echo "Missing Linux deb/rpm artifacts" >&2
find out/electron-builder -maxdepth 1 -type f >&2 || true
exit 1
fi
DEB_FILE=$(realpath "${DEB_FILE}")
RPM_FILE=$(realpath "${RPM_FILE}")
echo "Checking DEB metadata: ${DEB_FILE}"
[ "$(dpkg-deb -f "${DEB_FILE}" Package)" = "tx-5dr" ]
[ "$(dpkg-deb -f "${DEB_FILE}" Section)" = "utils" ]
[ "$(dpkg-deb -f "${DEB_FILE}" Priority)" = "optional" ]
dpkg-deb -f "${DEB_FILE}" Maintainer | grep -Fx 'BG5DRB <bg5drb@example.com>'
dpkg-deb -f "${DEB_FILE}" Homepage | grep -Fx 'https://tx5dr.com'
dpkg-deb -f "${DEB_FILE}" Description | head -1 | grep -Fx 'A modern digital radio station for amateur radio operators.'
dpkg-deb -f "${DEB_FILE}" Description | grep -F 'A modern digital radio station for amateur radio operators. Operate FT8, FT4, and voice modes from any web browser - anywhere, anytime.'
dpkg-deb -f "${DEB_FILE}" Depends | grep -F 'libgtk-3-0'
dpkg-deb -f "${DEB_FILE}" Recommends | grep -F 'pulseaudio | libasound2'
dpkg-deb -f "${DEB_FILE}" Suggests | grep -F 'lsb-release'
DEB_TMP=$(mktemp -d)
dpkg-deb -e "${DEB_FILE}" "${DEB_TMP}/control"
dpkg-deb -x "${DEB_FILE}" "${DEB_TMP}/root"
grep -F "update-alternatives --install '/usr/bin/tx-5dr'" "${DEB_TMP}/control/postinst"
dpkg-deb -c "${DEB_FILE}" | grep -E '/opt/TX-5DR/tx-5dr$'
DESKTOP_FILE="${DEB_TMP}/root/usr/share/applications/tx-5dr.desktop"
grep -Fx 'Name=tx-5dr' "${DESKTOP_FILE}"
grep -Fx 'Comment=A modern digital radio station for amateur radio operators. Operate FT8, FT4, and voice modes from any web browser - anywhere, anytime.' "${DESKTOP_FILE}"
grep -Fx 'GenericName=Ham Radio Application' "${DESKTOP_FILE}"
grep -Fx 'Exec=/opt/TX-5DR/tx-5dr %U' "${DESKTOP_FILE}"
grep -Fx 'Icon=tx-5dr' "${DESKTOP_FILE}"
grep -Fx 'Categories=Utility;AudioVideo;' "${DESKTOP_FILE}"
echo "Checking RPM metadata: ${RPM_FILE}"
rpm -qpi "${RPM_FILE}" | grep -F 'Name : tx-5dr'
rpm -qpi "${RPM_FILE}" | grep -F 'Summary : A modern digital radio station for amateur radio operators.'
rpm -qpi "${RPM_FILE}" | grep -F 'URL : https://tx5dr.com'
rpm -qpl "${RPM_FILE}" | grep -Fx '/usr/share/applications/tx-5dr.desktop'
rpm -qpl "${RPM_FILE}" | grep -Fx '/opt/TX-5DR/tx-5dr'
rpm -qp --scripts "${RPM_FILE}" | grep -F "update-alternatives --install '/usr/bin/tx-5dr'"
RPM_TMP=$(mktemp -d)
(cd "${RPM_TMP}" && rpm2cpio "${RPM_FILE}" | cpio -id --quiet ./usr/share/applications/tx-5dr.desktop)
RPM_DESKTOP="${RPM_TMP}/usr/share/applications/tx-5dr.desktop"
grep -Fx 'Name=tx-5dr' "${RPM_DESKTOP}"
grep -Fx 'Comment=A modern digital radio station for amateur radio operators. Operate FT8, FT4, and voice modes from any web browser - anywhere, anytime.' "${RPM_DESKTOP}"
grep -Fx 'GenericName=Ham Radio Application' "${RPM_DESKTOP}"
grep -Fx 'Exec=/opt/TX-5DR/tx-5dr %U' "${RPM_DESKTOP}"
grep -Fx 'Icon=tx-5dr' "${RPM_DESKTOP}"
grep -Fx 'Categories=Utility;AudioVideo;' "${RPM_DESKTOP}"
echo "✅ Linux deb/rpm metadata verified"
- name: Verify bundled VC runtime (Windows)
if: matrix.platform == 'win32'
shell: bash
run: |
set -euo pipefail
ARCH="${{ matrix.arch }}"
TRIPLET="win32-${ARCH}"
case "${ARCH}" in
x64) TOOL_ARCH="x64" ;;
arm64) TOOL_ARCH="arm64" ;;
*)
echo "Unsupported Windows dependency scan arch: ${ARCH}" >&2
exit 1
;;
esac
APP_DIR="out/electron-builder/win-unpacked"
if [ ! -d "${APP_DIR}" ]; then
echo "Unable to find electron-builder Windows unpacked app at ${APP_DIR}." >&2
find out/electron-builder -maxdepth 2 -type d >&2 || true
exit 1
fi
RUNTIME_DIR="${APP_DIR}/resources/bin/${TRIPLET}"
REQUIRED_DLLS=(
vcruntime140.dll
vcruntime140_1.dll
msvcp140.dll
msvcp140_1.dll
msvcp140_atomic_wait.dll
)
for dll in "${REQUIRED_DLLS[@]}"; do
if [ ! -f "${RUNTIME_DIR}/${dll}" ]; then
echo "Missing bundled VC runtime DLL: ${RUNTIME_DIR}/${dll}" >&2
ls -la "${RUNTIME_DIR}" >&2 || true
exit 1
fi
done
mapfile -t DUMPBIN_CANDIDATES < <(
find \
"/c/Program Files/Microsoft Visual Studio/2022" \
"/c/Program Files (x86)/Microsoft Visual Studio/2022" \
-type f \
-path "*/VC/Tools/MSVC/*/bin/Hostx64/${TOOL_ARCH}/dumpbin.exe" \
2>/dev/null | sort -V
)
if [ "${#DUMPBIN_CANDIDATES[@]}" -eq 0 ]; then
echo "Unable to find dumpbin.exe for ${TOOL_ARCH}; cannot verify VC dependencies." >&2
exit 1
fi
DUMPBIN="${DUMPBIN_CANDIDATES[$((${#DUMPBIN_CANDIDATES[@]} - 1))]}"
echo "Using dumpbin: ${DUMPBIN}"
DEPS_WITH_SOURCE_FILE=$(mktemp)
DEPS_FILE=$(mktemp)
while IFS= read -r binary; do
"${DUMPBIN}" //DEPENDENTS "${binary}" 2>/dev/null \
| grep -Eio '(MSVCP140[^[:space:]]*\.dll|VCRUNTIME140[^[:space:]]*\.dll)' \
| while IFS= read -r dll; do
printf '%s\t%s\n' "${dll}" "${binary}"
done || true
done < <(find "${APP_DIR}" -type f \( -iname "*.exe" -o -iname "*.dll" -o -iname "*.node" \)) \
| tr '[:upper:]' '[:lower:]' \
| sort -u > "${DEPS_WITH_SOURCE_FILE}"
cut -f1 "${DEPS_WITH_SOURCE_FILE}" | sort -u > "${DEPS_FILE}"
echo "Detected MSVC runtime dependencies:"
cat "${DEPS_FILE}"
while IFS= read -r dll; do
[ -z "${dll}" ] && continue
if [ ! -f "${RUNTIME_DIR}/${dll}" ]; then
echo "Detected VC dependency is not bundled app-locally: ${dll}" >&2
echo "Binaries requiring ${dll}:" >&2
grep -F "${dll}" "${DEPS_WITH_SOURCE_FILE}" | cut -f2- >&2 || true
exit 1
fi
done < "${DEPS_FILE}"
- name: List build outputs (Debug)
shell: bash
run: |
echo "=== Build Output Directory ==="
ls -la out/ || echo "No out/ directory found"
find out/ -type f \( -name "*.exe" -o -name "*.dmg" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.rpm" -o -name "*.zip" -o -name "*.7z" -o -name "*.blockmap" \) || echo "No installable files found"
- name: Prepare artifacts (rename for nightly if needed)
shell: bash
run: |
PLATFORM="${{ matrix.platform }}"
ARCH="${{ matrix.arch }}"
VERSION="${{ github.event.inputs.version }}"
# 创建统一的产物目录
mkdir -p release-artifacts
# 判断是否为 nightly 版本(无版本号输入时)
if [ -z "$VERSION" ]; then
echo "📦 准备 Nightly 版本产物..."
PREFIX="TX-5DR-nightly"
else
echo "📦 准备正式版本产物 ($VERSION)..."
PREFIX="TX-5DR-${VERSION}"
fi
copy_single_artifact() {
local search_dir="$1"
local pattern="$2"
local destination="$3"
local require_blockmap="${4:-false}"
local matches
matches="$(find "$search_dir" -maxdepth 1 -name "$pattern" -type f 2>/dev/null | sort)"
local count
count="$(printf '%s\n' "$matches" | sed '/^$/d' | wc -l | tr -d ' ')"
if [ "$count" -eq 0 ]; then
echo "❌ 未找到产物: ${search_dir}/${pattern}" >&2
exit 1
fi
if [ "$count" -gt 1 ]; then
echo "❌ 找到多个匹配产物,请保持输出目录唯一: ${search_dir}/${pattern}" >&2
printf '%s\n' "$matches" | sed 's/^/ - /' >&2
exit 1
fi
local file
file="$(printf '%s\n' "$matches" | sed '/^$/d' | head -1)"
cp "$file" "$destination"
if [ "$require_blockmap" = "true" ]; then
if [ ! -f "${file}.blockmap" ] && [[ "$file" == *.AppImage ]]; then
echo "ℹ️ AppImage 仅内嵌 blockmap,额外生成外置 blockmap 供 latest.json/CI 校验使用"
APP_BUILDER_BIN=$(node -p "require('app-builder-bin').appBuilderPath")
"$APP_BUILDER_BIN" blockmap --input "$file" --output "${file}.blockmap"
fi
if [ ! -f "${file}.blockmap" ]; then
echo "❌ 自动更新产物缺少 blockmap: ${file}.blockmap" >&2
exit 1
fi
cp "${file}.blockmap" "${destination}.blockmap"
fi
}
if [ "$PLATFORM" = "darwin" ]; then
copy_single_artifact "out/electron-builder" "*.dmg" "release-artifacts/${PREFIX}-macos-${ARCH}.dmg" false
echo "✅ 已准备: ${PREFIX}-macos-${ARCH}.dmg"
copy_single_artifact "out/electron-builder" "*.zip" "release-artifacts/${PREFIX}-macos-${ARCH}.zip" true
echo "✅ 已准备: ${PREFIX}-macos-${ARCH}.zip"
elif [ "$PLATFORM" = "linux" ]; then
copy_single_artifact "out/electron-builder" "*.deb" "release-artifacts/${PREFIX}-linux-${ARCH}.deb" false
echo "✅ 已准备: ${PREFIX}-linux-${ARCH}.deb"
copy_single_artifact "out/electron-builder" "*.rpm" "release-artifacts/${PREFIX}-linux-${ARCH}.rpm" false
echo "✅ 已准备: ${PREFIX}-linux-${ARCH}.rpm"
copy_single_artifact "out/electron-builder" "*.zip" "release-artifacts/${PREFIX}-linux-${ARCH}.zip" false
echo "✅ 已准备: ${PREFIX}-linux-${ARCH}.zip"
copy_single_artifact "out/electron-builder" "*.AppImage" "release-artifacts/${PREFIX}-linux-${ARCH}.AppImage" true
echo "✅ 已准备: ${PREFIX}-linux-${ARCH}.AppImage"
elif [ "$PLATFORM" = "win32" ]; then
copy_single_artifact "out/electron-builder" "*-nsis.exe" "release-artifacts/${PREFIX}-windows-${ARCH}-nsis.exe" true
echo "✅ 已准备: ${PREFIX}-windows-${ARCH}-nsis.exe ($(du -sh "release-artifacts/${PREFIX}-windows-${ARCH}-nsis.exe" | cut -f1))"
copy_single_artifact "out/electron-builder" "*.zip" "release-artifacts/${PREFIX}-windows-${ARCH}.zip" false
echo "✅ 已准备: ${PREFIX}-windows-${ARCH}.zip"
copy_single_artifact "out/electron-builder" "*.7z" "release-artifacts/${PREFIX}-windows-${ARCH}.7z" false
echo "✅ 已准备: ${PREFIX}-windows-${ARCH}.7z ($(du -sh "release-artifacts/${PREFIX}-windows-${ARCH}.7z" | cut -f1))"
fi
echo "=== 产物准备完成 ==="
ls -lh release-artifacts/
- name: Upload artifacts (attempt 1)
id: upload_artifacts_attempt_1
continue-on-error: true
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact_name }}
path: release-artifacts/*
retention-days: 30
overwrite: true
- name: Wait before artifact upload retry
if: steps.upload_artifacts_attempt_1.outcome == 'failure'
shell: bash
run: |
echo "Artifact upload failed, retrying after transient backoff..."
sleep 20
- name: Upload artifacts (attempt 2)
if: steps.upload_artifacts_attempt_1.outcome == 'failure'
id: upload_artifacts_attempt_2
continue-on-error: true
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact_name }}
path: release-artifacts/*
retention-days: 30
overwrite: true
- name: Wait before final artifact upload retry
if: steps.upload_artifacts_attempt_1.outcome == 'failure' && steps.upload_artifacts_attempt_2.outcome == 'failure'
shell: bash
run: |
echo "Artifact upload failed twice, retrying one final time..."
sleep 60
- name: Upload artifacts (final attempt)
if: steps.upload_artifacts_attempt_1.outcome == 'failure' && steps.upload_artifacts_attempt_2.outcome == 'failure'
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact_name }}
path: release-artifacts/*
retention-days: 30
overwrite: true
create-release:
needs: [build]
runs-on: ubuntu-latest
if: github.event_name != 'pull_request'
env:
OSS_ACCESS_KEY_ID: ${{ secrets.ALIYUN_ACCESS_KEY_ID }}
OSS_ACCESS_KEY_SECRET: ${{ secrets.ALIYUN_ACCESS_KEY_SECRET }}
OSS_BUCKET: ${{ secrets.OSS_BUCKET }}
OSS_ENDPOINT: ${{ secrets.OSS_ENDPOINT }}
OSS_BASE_URL: ${{ secrets.OSS_BASE_URL }}
OSS_REGION: ${{ secrets.OSS_REGION }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 20
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: dist/
- name: Prepare release assets
shell: bash
run: |
echo "=== 准备 release 产物 ==="
mkdir -p publish-assets
mkdir -p release-assets
find dist/ -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.deb" -o -name "*.rpm" -o -name "*.7z" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.blockmap" \) -exec cp {} publish-assets/ \;
find publish-assets/ -type f -exec cp {} release-assets/ \;
echo "=== Release 产物列表 ==="
ls -lh release-assets/
- name: Generate release info
id: release_info
env:
TZ: UTC
run: |
echo "timestamp=$(date +'%Y-%m-%d %H:%M:%S UTC')" >> $GITHUB_OUTPUT
echo "short_sha=$(echo ${{ github.sha }} | cut -c1-7)" >> $GITHUB_OUTPUT
echo "commit_title=$(git show -s --format=%s "${{ github.sha }}")" >> $GITHUB_OUTPUT
echo "date=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT
BASE_VERSION=$(node -p "require('./package.json').version.split('-')[0].split('+')[0]")
COMMIT_STAMP=$(git show -s --date=format-local:%Y%m%d%H%M --format=%cd "${{ github.sha }}")
if [ -z "${{ github.event.inputs.version }}" ]; then
echo "tag=nightly-app" >> $GITHUB_OUTPUT
echo "version=nightly" >> $GITHUB_OUTPUT
echo "channel=nightly" >> $GITHUB_OUTPUT
echo "channel_path=nightly" >> $GITHUB_OUTPUT
echo "app_version=${BASE_VERSION}-nightly.${COMMIT_STAMP}+g$(echo ${{ github.sha }} | cut -c1-7)" >> $GITHUB_OUTPUT
else
echo "tag=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT
echo "version=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT
echo "channel=release" >> $GITHUB_OUTPUT
echo "channel_path=release" >> $GITHUB_OUTPUT
echo "app_version=${{ github.event.inputs.version }}" | sed 's/^app_version=v/app_version=/' >> $GITHUB_OUTPUT
fi
- name: Prepare app release metadata
shell: bash
run: |
mkdir -p release-metadata
cat > release-metadata/app-release-notes.md <<EOF
Channel: ${{ steps.release_info.outputs.channel }}
Version: ${{ steps.release_info.outputs.app_version }}
Commit: ${{ steps.release_info.outputs.short_sha }}
Built at: ${{ steps.release_info.outputs.timestamp }}
EOF
node - <<'EOF' > release-metadata/recent-commits.json
const { execFileSync } = require('node:child_process');
const lines = execFileSync('git', [
'log',
'-n',
'10',
'--date=iso-strict',
'--pretty=format:%H%x09%h%x09%s%x09%cI',
process.env.GITHUB_SHA,
], { encoding: 'utf8' }).trim().split('\n').filter(Boolean);
const commits = lines.map((line) => {
const [id, shortId, title, publishedAt] = line.split('\t');
return { id, short_id: shortId, title, published_at: publishedAt };
});
process.stdout.write(JSON.stringify(commits));
EOF
node scripts/generate-oss-manifest.mjs \
--product app \
--channel "${{ steps.release_info.outputs.channel }}" \
--tag "${{ steps.release_info.outputs.tag }}" \
--version "${{ steps.release_info.outputs.app_version }}" \
--commit "${{ steps.release_info.outputs.short_sha }}" \
--commit-title "${{ steps.release_info.outputs.commit_title }}" \
--published-at "${{ steps.release_info.outputs.timestamp }}" \
--base-url "${{ secrets.OSS_BASE_URL }}" \
--object-prefix "tx-5dr/app/${{ steps.release_info.outputs.tag }}" \
--oss-base-url "${{ secrets.OSS_BASE_URL }}" \
--oss-object-prefix "tx-5dr/app/${{ steps.release_info.outputs.tag }}" \
--github-base-url "https://github.com/${{ github.repository }}/releases/download" \
--github-object-prefix "${{ steps.release_info.outputs.tag }}" \
--assets-dir "publish-assets" \
--release-notes-file "release-metadata/app-release-notes.md" \
--recent-commits-json "$(cat release-metadata/recent-commits.json)" \
--output "release-assets/latest.json"
- name: Validate app manifest
shell: bash
run: |
node - <<'EOF'
const fs = require('node:fs');
const manifest = JSON.parse(fs.readFileSync('release-assets/latest.json', 'utf8'));
if (!manifest.version) throw new Error('app manifest missing version');
if (!manifest.commit_title) throw new Error('app manifest missing commit_title');
if (!Array.isArray(manifest.recent_commits) || manifest.recent_commits.length === 0 || manifest.recent_commits.length > 10) {
throw new Error('app manifest missing recent_commits');
}
if (manifest.recent_commits[0]?.short_id !== manifest.commit) {
throw new Error('app manifest recent_commits[0] does not match top-level commit');
}
if (manifest.recent_commits[0]?.title !== manifest.commit_title) {
throw new Error('app manifest recent_commits[0] does not match top-level commit_title');
}
if (!Array.isArray(manifest.assets) || manifest.assets.length === 0) {
throw new Error('app manifest missing assets');
}
const hasMacArmDmg = manifest.assets.some((asset) => asset.platform === 'macos' && asset.arch === 'arm64' && asset.package_type === 'dmg' && asset.url_cn && asset.url_global);
if (!hasMacArmDmg) throw new Error('app manifest missing macOS arm64 dmg with dual urls');
const forbiddenMsi = manifest.assets.find((asset) => asset.package_type === 'msi' || asset.name.endsWith('.msi'));
if (forbiddenMsi) throw new Error(`app manifest must not include MSI asset: ${forbiddenMsi.name}`);
const requiredAssets = [
['windows', 'x64', 'exe'],
['windows', 'x64', 'zip'],
['windows', 'x64', '7z'],
['macos', 'arm64', 'dmg'],
['macos', 'arm64', 'zip'],
['macos', 'x64', 'dmg'],
['macos', 'x64', 'zip'],
['linux', 'x64', 'AppImage'],
['linux', 'x64', 'deb'],
['linux', 'x64', 'rpm'],
['linux', 'x64', 'zip'],
['linux', 'arm64', 'AppImage'],
['linux', 'arm64', 'deb'],
['linux', 'arm64', 'rpm'],
['linux', 'arm64', 'zip'],
];
for (const [platform, arch, packageType] of requiredAssets) {
if (!manifest.assets.some((asset) => asset.platform === platform && asset.arch === arch && asset.package_type === packageType)) {
throw new Error(`app manifest missing ${platform}-${arch}-${packageType}`);
}
}
const autoAssets = manifest.assets.filter((asset) => asset.auto_update?.supported);
for (const asset of autoAssets) {
if (!asset.size || !asset.sha512 || !asset.auto_update.sha512 || !asset.auto_update.installerFamily || !asset.auto_update.blockMapSize || !Array.isArray(asset.auto_update.files) || asset.auto_update.files.length === 0) {
throw new Error(`app manifest missing auto_update metadata for ${asset.name}`);
}
for (const file of asset.auto_update.files) {
if (!file.sha512 || !file.size || !file.blockMapSize) {
throw new Error(`app manifest missing auto_update file metadata for ${asset.name}`);
}
}
if (!fs.existsSync(`release-assets/${asset.name}.blockmap`)) {
throw new Error(`app manifest missing blockmap asset for ${asset.name}`);
}
}
for (const asset of manifest.assets) {
if (!asset.url_cn || !asset.url_global) {
throw new Error(`app manifest missing dual urls for ${asset.name}`);
}
}
EOF
# Nightly Release
- name: Create or Update Nightly Release
if: github.event.inputs.version == ''
uses: ncipollo/release-action@v1
with:
tag: nightly-app
name: "Nightly Build — Desktop App"
body: |
## TX-5DR Nightly Build — Desktop App 桌面应用
**Built at** (构建时间): ${{ steps.release_info.outputs.timestamp }}
**Commit**: [${{ steps.release_info.outputs.short_sha }}](https://github.com/${{ github.repository }}/commit/${{ github.sha }})
**Branch**: ${{ github.ref_name }}
### 📦 Downloads 下载
#### Windows (x64)
- [**NSIS Installer**](https://github.com/${{ github.repository }}/releases/download/nightly-app/TX-5DR-nightly-windows-x64-nsis.exe) (安装包)— recommended for installation and auto-update
- [**7z Archive**](https://github.com/${{ github.repository }}/releases/download/nightly-app/TX-5DR-nightly-windows-x64.7z) (压缩包,推荐)— LZMA2 compression, ~100MB smaller than ZIP, requires [7-Zip](https://www.7-zip.org/)
- [**ZIP Archive**](https://github.com/${{ github.repository }}/releases/download/nightly-app/TX-5DR-nightly-windows-x64.zip) (压缩包)— larger file size
#### macOS (ARM64 — Apple Silicon)
- [**DMG Installer**](https://github.com/${{ github.repository }}/releases/download/nightly-app/TX-5DR-nightly-macos-arm64.dmg) (安装包)— recommended
- [**ZIP Archive**](https://github.com/${{ github.repository }}/releases/download/nightly-app/TX-5DR-nightly-macos-arm64.zip) (压缩包)
#### macOS (x64 — Intel)
- [**DMG Installer**](https://github.com/${{ github.repository }}/releases/download/nightly-app/TX-5DR-nightly-macos-x64.dmg) (安装包)— recommended
- [**ZIP Archive**](https://github.com/${{ github.repository }}/releases/download/nightly-app/TX-5DR-nightly-macos-x64.zip) (压缩包)
#### Linux Desktop (x64) — Electron App
- [**DEB Package**](https://github.com/${{ github.repository }}/releases/download/nightly-app/TX-5DR-nightly-linux-x64.deb) (Ubuntu/Debian)
- [**RPM Package**](https://github.com/${{ github.repository }}/releases/download/nightly-app/TX-5DR-nightly-linux-x64.rpm) (Fedora/RHEL)
- [**AppImage**](https://github.com/${{ github.repository }}/releases/download/nightly-app/TX-5DR-nightly-linux-x64.AppImage) — supports in-app auto-update
- [**ZIP Archive**](https://github.com/${{ github.repository }}/releases/download/nightly-app/TX-5DR-nightly-linux-x64.zip) (压缩包)
#### Linux Desktop (ARM64) — Electron App
- [**DEB Package**](https://github.com/${{ github.repository }}/releases/download/nightly-app/TX-5DR-nightly-linux-arm64.deb) (Ubuntu/Debian)
- [**RPM Package**](https://github.com/${{ github.repository }}/releases/download/nightly-app/TX-5DR-nightly-linux-arm64.rpm) (Fedora/RHEL)
- [**AppImage**](https://github.com/${{ github.repository }}/releases/download/nightly-app/TX-5DR-nightly-linux-arm64.AppImage) — supports in-app auto-update
- [**ZIP Archive**](https://github.com/${{ github.repository }}/releases/download/nightly-app/TX-5DR-nightly-linux-arm64.zip) (压缩包)
> Looking for **Linux Server** (headless, no Electron)? See [nightly-server](https://github.com/${{ github.repository }}/releases/tag/nightly-server).
> 寻找 **Linux 服务器版**(无桌面环境)?请查看 [nightly-server](https://github.com/${{ github.repository }}/releases/tag/nightly-server)。
### ⚠️ Notice 注意事项
- This is a development build and may contain untested features. (开发版本,可能包含未经充分测试的功能)
- Automatically updated on every push to `main` / `develop`. (每次推送自动更新)
### 🔧 System Requirements 系统要求
- Node.js >= 22.15.1 (bundled / 已内置)
- Windows 10+ / macOS 11+ / Ubuntu 20.04+ / Fedora 35+
allowUpdates: true
removeArtifacts: true
artifacts: "release-assets/*"
token: ${{ secrets.GITHUB_TOKEN }}
prerelease: true
makeLatest: false
# Official Release
- name: Create Official Release
if: github.event.inputs.version != ''
uses: ncipollo/release-action@v1
with:
tag: ${{ github.event.inputs.version }}
name: "TX-5DR ${{ github.event.inputs.version }}"
body: |
## TX-5DR ${{ github.event.inputs.version }}
**Release Date** (发布日期): ${{ steps.release_info.outputs.date }}
**Commit**: [${{ steps.release_info.outputs.short_sha }}](https://github.com/${{ github.repository }}/commit/${{ github.sha }})
### 🚀 What's New 新功能
- See [CHANGELOG](https://github.com/${{ github.repository }}/commits/main) for details.
### 📦 Downloads 下载
#### Windows (x64)
- [**NSIS Installer**](https://github.com/${{ github.repository }}/releases/download/${{ github.event.inputs.version }}/TX-5DR-${{ github.event.inputs.version }}-windows-x64-nsis.exe) (安装包)— recommended for installation and auto-update
- [**7z Archive**](https://github.com/${{ github.repository }}/releases/download/${{ github.event.inputs.version }}/TX-5DR-${{ github.event.inputs.version }}-windows-x64.7z) (压缩包,推荐)— LZMA2 compression, ~100MB smaller than ZIP, requires [7-Zip](https://www.7-zip.org/)
- [**ZIP Archive**](https://github.com/${{ github.repository }}/releases/download/${{ github.event.inputs.version }}/TX-5DR-${{ github.event.inputs.version }}-windows-x64.zip) (压缩包)— larger file size
#### macOS (ARM64 — Apple Silicon)
- [**DMG Installer**](https://github.com/${{ github.repository }}/releases/download/${{ github.event.inputs.version }}/TX-5DR-${{ github.event.inputs.version }}-macos-arm64.dmg) (安装包)— recommended
- [**ZIP Archive**](https://github.com/${{ github.repository }}/releases/download/${{ github.event.inputs.version }}/TX-5DR-${{ github.event.inputs.version }}-macos-arm64.zip) (压缩包)
#### macOS (x64 — Intel)
- [**DMG Installer**](https://github.com/${{ github.repository }}/releases/download/${{ github.event.inputs.version }}/TX-5DR-${{ github.event.inputs.version }}-macos-x64.dmg) (安装包)— recommended
- [**ZIP Archive**](https://github.com/${{ github.repository }}/releases/download/${{ github.event.inputs.version }}/TX-5DR-${{ github.event.inputs.version }}-macos-x64.zip) (压缩包)
#### Linux Desktop (x64) — Electron App
- [**DEB Package**](https://github.com/${{ github.repository }}/releases/download/${{ github.event.inputs.version }}/TX-5DR-${{ github.event.inputs.version }}-linux-x64.deb) (Ubuntu/Debian)
- [**RPM Package**](https://github.com/${{ github.repository }}/releases/download/${{ github.event.inputs.version }}/TX-5DR-${{ github.event.inputs.version }}-linux-x64.rpm) (Fedora/RHEL)
- [**AppImage**](https://github.com/${{ github.repository }}/releases/download/${{ github.event.inputs.version }}/TX-5DR-${{ github.event.inputs.version }}-linux-x64.AppImage) — supports in-app auto-update
- [**ZIP Archive**](https://github.com/${{ github.repository }}/releases/download/${{ github.event.inputs.version }}/TX-5DR-${{ github.event.inputs.version }}-linux-x64.zip) (压缩包)
#### Linux Desktop (ARM64) — Electron App
- [**DEB Package**](https://github.com/${{ github.repository }}/releases/download/${{ github.event.inputs.version }}/TX-5DR-${{ github.event.inputs.version }}-linux-arm64.deb) (Ubuntu/Debian)
- [**RPM Package**](https://github.com/${{ github.repository }}/releases/download/${{ github.event.inputs.version }}/TX-5DR-${{ github.event.inputs.version }}-linux-arm64.rpm) (Fedora/RHEL)
- [**AppImage**](https://github.com/${{ github.repository }}/releases/download/${{ github.event.inputs.version }}/TX-5DR-${{ github.event.inputs.version }}-linux-arm64.AppImage) — supports in-app auto-update
- [**ZIP Archive**](https://github.com/${{ github.repository }}/releases/download/${{ github.event.inputs.version }}/TX-5DR-${{ github.event.inputs.version }}-linux-arm64.zip) (压缩包)
### 🔧 System Requirements 系统要求
- Node.js >= 22.15.1 (bundled / 已内置)
- Windows 10+ / macOS 11+ / Ubuntu 20.04+ / Fedora 35+
---
Built at (构建于): ${{ steps.release_info.outputs.timestamp }}
artifacts: "release-assets/*"
token: ${{ secrets.GITHUB_TOKEN }}
draft: false
prerelease: false
makeLatest: true
- name: Install ossutil
if: env.OSS_ACCESS_KEY_ID != '' && env.OSS_ACCESS_KEY_SECRET != '' && env.OSS_BUCKET != '' && env.OSS_ENDPOINT != '' && env.OSS_BASE_URL != ''
run: bash scripts/install-ossutil.sh
- name: Configure ossutil
if: env.OSS_ACCESS_KEY_ID != '' && env.OSS_ACCESS_KEY_SECRET != '' && env.OSS_BUCKET != '' && env.OSS_ENDPOINT != '' && env.OSS_BASE_URL != ''
shell: bash
run: |
oss_region="${OSS_REGION:-}"
oss_endpoint_host="${OSS_ENDPOINT#https://}"
oss_endpoint_host="${oss_endpoint_host#http://}"
oss_endpoint_host="${oss_endpoint_host%%/*}"
if [ -z "${oss_region}" ]; then
if [[ "${oss_endpoint_host}" =~ oss-([a-z0-9-]+)\.aliyuncs\.com$ ]]; then
oss_region="${BASH_REMATCH[1]}"
fi
fi
if [ -z "${oss_region}" ]; then
echo "Failed to determine OSS region from OSS_ENDPOINT=${OSS_ENDPOINT}" >&2
echo "Please configure the OSS_REGION GitHub secret, for example: cn-hangzhou" >&2
exit 1
fi
echo "Using OSS endpoint: ${OSS_ENDPOINT}"
echo "Using OSS region: ${oss_region}"
cat > "${RUNNER_TEMP}/.ossutilconfig" <<EOF
[Credentials]
language = EN
endpoint = ${OSS_ENDPOINT}
region = ${oss_region}
accessKeyID = ${OSS_ACCESS_KEY_ID}
accessKeySecret = ${OSS_ACCESS_KEY_SECRET}
EOF
- name: Generate OSS app manifest
if: env.OSS_ACCESS_KEY_ID != '' && env.OSS_ACCESS_KEY_SECRET != '' && env.OSS_BUCKET != '' && env.OSS_ENDPOINT != '' && env.OSS_BASE_URL != ''
run: |
mkdir -p oss-metadata/app
node scripts/generate-oss-manifest.mjs \
--product app \
--channel "${{ steps.release_info.outputs.channel }}" \
--tag "${{ steps.release_info.outputs.tag }}" \
--version "${{ steps.release_info.outputs.app_version }}" \
--commit "${{ steps.release_info.outputs.short_sha }}" \
--commit-title "${{ steps.release_info.outputs.commit_title }}" \
--published-at "${{ steps.release_info.outputs.timestamp }}" \
--base-url "${OSS_BASE_URL}" \
--object-prefix "tx-5dr/app/${{ steps.release_info.outputs.tag }}" \
--oss-base-url "${OSS_BASE_URL}" \
--oss-object-prefix "tx-5dr/app/${{ steps.release_info.outputs.tag }}" \
--github-base-url "https://github.com/${{ github.repository }}/releases/download" \
--github-object-prefix "${{ steps.release_info.outputs.tag }}" \
--assets-dir "publish-assets" \
--release-notes-file "release-metadata/app-release-notes.md" \
--recent-commits-json "$(cat release-metadata/recent-commits.json)" \
--output "oss-metadata/app/latest.json"
- name: Validate OSS app manifest
if: env.OSS_ACCESS_KEY_ID != '' && env.OSS_ACCESS_KEY_SECRET != '' && env.OSS_BUCKET != '' && env.OSS_ENDPOINT != '' && env.OSS_BASE_URL != ''
shell: bash
run: |
node - <<'EOF'
const fs = require('node:fs');
const manifest = JSON.parse(fs.readFileSync('oss-metadata/app/latest.json', 'utf8'));
if (!Array.isArray(manifest.recent_commits) || manifest.recent_commits.length === 0 || manifest.recent_commits.length > 10) {
throw new Error('oss app manifest missing recent_commits');
}
if (manifest.recent_commits[0]?.short_id !== manifest.commit) {
throw new Error('oss app manifest recent_commits[0] does not match top-level commit');
}
if (manifest.recent_commits[0]?.title !== manifest.commit_title) {
throw new Error('oss app manifest recent_commits[0] does not match top-level commit_title');
}
const forbiddenMsi = (manifest.assets || []).find((asset) => asset.package_type === 'msi' || asset.name.endsWith('.msi'));
if (forbiddenMsi) throw new Error(`oss app manifest must not include MSI asset: ${forbiddenMsi.name}`);
const autoAssets = (manifest.assets || []).filter((asset) => asset.auto_update?.supported);
for (const asset of autoAssets) {
if (!asset.size || !asset.sha512 || !asset.auto_update.sha512 || !asset.auto_update.installerFamily || !asset.auto_update.blockMapSize || !Array.isArray(asset.auto_update.files) || asset.auto_update.files.length === 0) {
throw new Error(`oss app manifest missing auto_update metadata for ${asset.name}`);
}
for (const file of asset.auto_update.files) {
if (!file.sha512 || !file.size || !file.blockMapSize) {
throw new Error(`oss app manifest missing auto_update file metadata for ${asset.name}`);
}
}
if (!fs.existsSync(`publish-assets/${asset.name}.blockmap`)) {
throw new Error(`oss app manifest missing blockmap asset for ${asset.name}`);
}
}
EOF
- name: Upload app assets to OSS
if: env.OSS_ACCESS_KEY_ID != '' && env.OSS_ACCESS_KEY_SECRET != '' && env.OSS_BUCKET != '' && env.OSS_ENDPOINT != '' && env.OSS_BASE_URL != ''
run: |
for file in publish-assets/*; do
ossutil cp "$file" "oss://${OSS_BUCKET}/tx-5dr/app/${{ steps.release_info.outputs.tag }}/$(basename "$file")" --force -c "${RUNNER_TEMP}/.ossutilconfig"
done
ossutil cp "oss-metadata/app/latest.json" "oss://${OSS_BUCKET}/tx-5dr/app/${{ steps.release_info.outputs.tag }}/latest.json" --force -c "${RUNNER_TEMP}/.ossutilconfig"
ossutil cp "oss-metadata/app/latest.json" "oss://${OSS_BUCKET}/tx-5dr/app/${{ steps.release_info.outputs.channel_path }}/latest.json" --force -c "${RUNNER_TEMP}/.ossutilconfig"