Skip to content

ci(release): 按组件拆分独立 release 并按 tag 范围检测变更 #1

ci(release): 按组件拆分独立 release 并按 tag 范围检测变更

ci(release): 按组件拆分独立 release 并按 tag 范围检测变更 #1

Workflow file for this run

name: Release
on:
push:
tags:
- "v*"
permissions:
contents: write
actions: write # 允许在 release job 末尾删除旧 artifact(避免 500MB 配额耗尽)
jobs:
# ── 前置 Job: 检测上一个版本 tag → 当前 tag 之间哪些组件目录有变更 ──
# 规则:
# fluxDown/** → 浏览器扩展(extension)
# website/** → 官网(website)
# docs/**、*.md → 纯文档,不触发任何构建
# 其余任何路径 → 桌面客户端(app)
# 找不到上一个 v* tag(首个版本)时全部视为有变更。
changes:
name: Detect Changed Components
runs-on: ubuntu-latest
outputs:
app: ${{ steps.detect.outputs.app }}
extension: ${{ steps.detect.outputs.extension }}
website: ${{ steps.detect.outputs.website }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-tags: true
- name: Detect changes since previous version tag
id: detect
shell: bash
run: |
TAG="${GITHUB_REF#refs/tags/}"
# 只匹配 v0.1.2 形式的版本 tag(glob 从头锚定,
# 自动忽略 release 流程创建的 extension-v* / website-v* 组件 tag)
PREV=$(git describe --tags --abbrev=0 --match 'v[0-9]*' "${TAG}^" 2>/dev/null || true)
APP=false; EXTENSION=false; WEBSITE=false
if [ -z "$PREV" ]; then
echo "No previous version tag found — building everything"
APP=true; EXTENSION=true; WEBSITE=true
else
echo "Diff range: ${PREV}..${TAG}"
CHANGED=$(git diff --name-only "${PREV}" "${TAG}")
echo "── Changed files ──"
echo "$CHANGED"
while IFS= read -r f; do
[ -z "$f" ] && continue
case "$f" in
fluxDown/*) EXTENSION=true ;;
website/*) WEBSITE=true ;;
docs/*|*.md) ;;
*) APP=true ;;
esac
done <<< "$CHANGED"
fi
{
echo "app=$APP"
echo "extension=$EXTENSION"
echo "website=$WEBSITE"
} >> "$GITHUB_OUTPUT"
echo "::notice::Components changed in ${PREV:-<none>}..${TAG}: app=$APP extension=$EXTENSION website=$WEBSITE"
# ── 并行 Job 1: 构建 Windows 安装包(x64 + ARM64)──
build-windows:
name: Build Windows (${{ matrix.arch }})
needs: changes
if: needs.changes.outputs.app == 'true'
strategy:
fail-fast: false
matrix:
include:
- arch: x64
runner: windows-latest
- arch: arm64
runner: windows-11-arm
runs-on: ${{ matrix.runner }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Get version from tag
id: version
shell: bash
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_OUTPUT"
# ── Rust toolchain ──
- name: Setup Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Cache Rust dependencies
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: rust-${{ runner.os }}-${{ matrix.arch }}-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
rust-${{ runner.os }}-${{ matrix.arch }}-
- name: Cache Rinf CLI
id: cache-rinf
uses: actions/cache@v4
with:
path: ~/.cargo/bin/rinf.exe
key: rinf-cli-${{ runner.os }}-${{ matrix.arch }}-8.9.0
# ── Flutter SDK(x64 用预编译包,ARM64 用 git clone 原生安装)──
- name: Setup Flutter (x64)
if: matrix.arch == 'x64'
uses: subosito/flutter-action@v2
with:
channel: stable
cache: true
- name: Setup Flutter (ARM64 via git)
if: matrix.arch == 'arm64'
shell: bash
run: |
git clone https://github.com/flutter/flutter.git -b stable --depth 1 "$USERPROFILE/flutter"
echo "$USERPROFILE/flutter/bin" >> "$GITHUB_PATH"
- name: Cache Flutter (ARM64)
if: matrix.arch == 'arm64'
uses: actions/cache@v4
with:
path: ~/flutter/bin/cache
key: flutter-arm64-stable
- name: Flutter precache & version info
run: |
flutter --version
flutter precache --windows
- name: Install Flutter dependencies
run: flutter pub get
# ── Rinf code generation ──
- name: Install Rinf CLI
if: steps.cache-rinf.outputs.cache-hit != 'true'
shell: bash
env:
CARGO_HTTP_MULTIPLEXING: "false"
CARGO_NET_RETRY: "10"
run: |
for i in 1 2 3; do
cargo install rinf_cli@8.9.0 && exit 0
echo "cargo install failed (attempt $i), retrying in 15s..."
sleep 15
done
exit 1
- name: Generate Rinf bindings
run: rinf gen
# ── Build ──
- name: Pre-build Rust hub (debug cargo output)
shell: bash
run: |
cd native/hub
echo "::group::Cargo build (cdylib) verbose output"
cargo build --release --lib --verbose 2>&1 || echo "::error::Cargo build failed with exit code $?"
echo "::endgroup::"
- name: Build Flutter Windows app
# Windows 使用 native-tls(Schannel),无需 aws-lc-sys,无需任何额外 TLS 环境变量
run: flutter build windows --release --build-name=${{ steps.version.outputs.VERSION }} --build-number=1 --dart-define=APP_VERSION=${{ steps.version.outputs.VERSION }}
# ── ARM64: Flutter 在 ARM64 runner 上以 x64 仿真运行,输出到 build\windows\x64
# 但后续步骤(Inno Setup / portable zip)需要 build\windows\arm64 ──
- name: Normalize build output path for ARM64
if: matrix.arch == 'arm64'
shell: pwsh
run: |
if ((Test-Path "build\windows\x64") -and -not (Test-Path "build\windows\arm64")) {
Rename-Item -Path "build\windows\x64" -NewName "arm64"
Write-Host "Renamed build\windows\x64 -> build\windows\arm64"
}
# ── Inno Setup installer ──
# NOTE: fluxdown_nmh.exe and fluxdown_updater.exe are built automatically
# by CMake custom targets defined in windows/CMakeLists.txt during flutter build.
- name: Ensure Inno Setup is installed
if: matrix.arch == 'arm64'
shell: pwsh
run: |
if (-not (Get-Command iscc -ErrorAction SilentlyContinue)) {
choco install innosetup -y --no-progress
echo "C:\Program Files (x86)\Inno Setup 6" | Out-File -FilePath $env:GITHUB_PATH -Append
}
- name: Download Inno Setup Chinese Simplified language file
shell: pwsh
run: |
$issLangDir = "C:\Program Files (x86)\Inno Setup 6\Languages"
$url = "https://raw.githubusercontent.com/jrsoftware/issrc/main/Files/Languages/Unofficial/ChineseSimplified.isl"
Invoke-WebRequest -Uri $url -OutFile "$issLangDir\ChineseSimplified.isl"
- name: Build installer with Inno Setup
run: |
iscc /DMyAppVersion=${{ steps.version.outputs.VERSION }} /DMyAppArch=${{ matrix.arch }} installer\windows\setup.iss
# ── Compress portable version ──
- name: Create portable zip
shell: pwsh
run: |
New-Item -Path "build\windows\${{ matrix.arch }}\runner\Release\portable" -ItemType File -Force | Out-Null
Compress-Archive -Path "build\windows\${{ matrix.arch }}\runner\Release\*" -DestinationPath "build\installer\FluxDown-${{ steps.version.outputs.VERSION }}-windows-${{ matrix.arch }}-portable.zip"
# ── Upload artifacts(供 release job 使用)──
- name: Upload installer artifact
uses: actions/upload-artifact@v4
with:
name: windows-installer-${{ matrix.arch }}
path: build/installer/FluxDown-*.exe
- name: Upload portable artifact
uses: actions/upload-artifact@v4
with:
name: windows-portable-${{ matrix.arch }}
path: build/installer/FluxDown-*-portable.zip
# ── 并行 Job 2: 构建浏览器扩展 ──
build-extension:
name: Build Browser Extension
runs-on: ubuntu-latest
needs: changes
if: needs.changes.outputs.extension == 'true'
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Get version from tag
id: version
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_OUTPUT"
- name: Set extension version from tag
working-directory: fluxDown
run: |
VERSION=${{ steps.version.outputs.VERSION }}
sed -i "s/\"version\": \".*\"/\"version\": \"$VERSION\"/" package.json
echo "Updated extension version to $VERSION"
grep '"version"' package.json
- name: Install dependencies
run: npm ci
working-directory: fluxDown
- name: Build Chrome extension
run: npm run build
working-directory: fluxDown
- name: Build Firefox extension
run: npm run build:firefox
working-directory: fluxDown
- name: Package Chrome extension
run: |
VERSION=${{ steps.version.outputs.VERSION }}
mkdir -p build/extension
cd fluxDown/.output
zip -r ../../build/extension/FluxDown-${VERSION}-chrome.zip chrome-mv3/
ls -la ../../build/extension/
- name: Install web-ext
run: npm install -g web-ext
# ── Firefox 双版本策略:AMO 市场用 X.Y.Z(与 tag 一致),GitHub Release XPI 用 X.Y.Z.1
# 两个版本号不同,彻底避免同版本号重复提交冲突。
# X.Y.Z → --channel=listed → AMO 公开市场,等待人工审核
# X.Y.Z.1 → --channel=unlisted → 立即签名,XPI 附到 GitHub Release 供离线安装
# Step 1: 提交 AMO 市场版本(X.Y.Z),continue-on-error 不阻塞 Release
- name: Submit Firefox to AMO marketplace (listed, vX.Y.Z)
continue-on-error: true
run: |
web-ext sign \
--source-dir fluxDown/.output/firefox-mv2 \
--channel=listed \
--api-key=${{ secrets.AMO_JWT_ISSUER }} \
--api-secret=${{ secrets.AMO_JWT_SECRET }} \
--approval-timeout=0 \
--artifacts-dir build/extension/firefox-listed/
# Step 2: 将 manifest 版本改为 X.Y.Z.1,unlisted 签名产出 GitHub Release 用 XPI
# AMO 服务端有时返回 500,continue-on-error 避免阻断整个 Release
- name: Sign Firefox extension for GitHub Release (unlisted, vX.Y.Z.1)
continue-on-error: true
run: |
VERSION=${{ steps.version.outputs.VERSION }}
mkdir -p build/extension
python3 -c "import json;f='fluxDown/.output/firefox-mv2/manifest.json';m=json.load(open(f));m['version']='$VERSION.1';open(f,'w').write(json.dumps(m,indent=2))"
echo "Manifest version set to $VERSION.1 for unlisted signing"
web-ext sign \
--source-dir fluxDown/.output/firefox-mv2 \
--channel=unlisted \
--api-key=${{ secrets.AMO_JWT_ISSUER }} \
--api-secret=${{ secrets.AMO_JWT_SECRET }} \
--artifacts-dir build/extension/ 2>&1 | tee /tmp/amo_sign.log
if [ "${PIPESTATUS[0]}" -ne 0 ]; then
if grep -q "already exists" /tmp/amo_sign.log; then
echo "::warning::Version $VERSION.1 already on AMO (CI re-run). Downloading existing XPI..."
_amo_jwt() {
local iat exp header payload sig
iat=$(date +%s); exp=$((iat + 300))
header=$(printf '{"alg":"HS256","typ":"JWT"}' | base64 -w0 | tr '+/' '-_' | tr -d '=')
payload=$(printf '{"iss":"%s","jti":"%s","iat":%s,"exp":%s}' \
"$AMO_ISSUER" "$iat" "$iat" "$exp" | base64 -w0 | tr '+/' '-_' | tr -d '=')
sig=$(printf '%s.%s' "$header" "$payload" \
| openssl dgst -sha256 -hmac "$AMO_SECRET" -binary | base64 -w0 | tr '+/' '-_' | tr -d '=')
printf '%s.%s.%s' "$header" "$payload" "$sig"
}
VERS_DATA=$(curl -sf -H "Authorization: JWT $(_amo_jwt)" \
"https://addons.mozilla.org/api/v5/addons/addon/fluxdown%40fluxdown.app/versions/?filter=all_with_unlisted")
XPI_URL=$(echo "$VERS_DATA" | python3 -c "import sys,json;d=json.load(sys.stdin);v=next((x for x in(d.get('results')or[])if x.get('version')=='$VERSION.1'),None);sys.exit('not found') if not(v and(v.get('file')or{}).get('url')) else print(v['file']['url'])")
curl -sf -L -H "Authorization: JWT $(_amo_jwt)" \
-o "build/extension/${VERSION}.xpi" "$XPI_URL"
elif grep -q "was uploaded before and deleted" /tmp/amo_sign.log; then
echo "::error::Version $VERSION.1 was deleted from AMO. Please use a new version number."
exit 1
else
cat /tmp/amo_sign.log
exit 1
fi
fi
XPI_FILE=$(ls build/extension/*.xpi 2>/dev/null | head -1)
if [ -n "$XPI_FILE" ]; then
mv "$XPI_FILE" "build/extension/FluxDown-${VERSION}-firefox.xpi"
echo "Firefox XPI ready: FluxDown-${VERSION}-firefox.xpi (internal version $VERSION.1)"
else
echo "::error::No signed XPI found."
exit 1
fi
env:
AMO_ISSUER: ${{ secrets.AMO_JWT_ISSUER }}
AMO_SECRET: ${{ secrets.AMO_JWT_SECRET }}
VERSION: ${{ steps.version.outputs.VERSION }}
# ── Upload artifacts(供 release job 使用)──
- name: Upload Chrome extension artifact
uses: actions/upload-artifact@v4
with:
name: browser-extension-chrome
path: build/extension/FluxDown-*-chrome.zip
if-no-files-found: error
# ── Chrome Web Store 自动发布(需 CHROME_CLIENT_ID / CHROME_CLIENT_SECRET / CHROME_REFRESH_TOKEN)──
# ⚠️ Refresh Token 在 Google OAuth 同意屏幕处于 Testing 模式时 7 天过期。
# 发布前必须将同意屏幕切换到 Production,再重新走一次 OAuth 流程获取长期 token。
- name: Publish Chrome extension to Web Store
continue-on-error: true
env:
CHROME_CLIENT_ID: ${{ secrets.CHROME_CLIENT_ID }}
CHROME_CLIENT_SECRET: ${{ secrets.CHROME_CLIENT_SECRET }}
CHROME_REFRESH_TOKEN: ${{ secrets.CHROME_REFRESH_TOKEN }}
run: |
VERSION=${{ steps.version.outputs.VERSION }}
ZIP_FILE="build/extension/FluxDown-${VERSION}-chrome.zip"
EXTENSION_ID="meleenglfggcmcajknpeeeiobnpfmahc"
# 用 refresh_token 换取新的 access_token
ACCESS_TOKEN=$(curl -sf -X POST https://oauth2.googleapis.com/token \
-d "client_id=${CHROME_CLIENT_ID}" \
-d "client_secret=${CHROME_CLIENT_SECRET}" \
-d "refresh_token=${CHROME_REFRESH_TOKEN}" \
-d "grant_type=refresh_token" \
| python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])")
# 上传新版本 zip
echo "Uploading ${ZIP_FILE} to Chrome Web Store..."
UPLOAD_RESP=$(curl -sf \
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
-H "x-goog-api-version: 2" \
-X PUT -T "${ZIP_FILE}" \
"https://www.googleapis.com/upload/chromewebstore/v1.1/items/${EXTENSION_ID}")
echo "Upload response: ${UPLOAD_RESP}"
UPLOAD_STATE=$(echo "${UPLOAD_RESP}" | python3 -c \
"import sys,json; print(json.load(sys.stdin).get('uploadState','UNKNOWN'))")
if [ "${UPLOAD_STATE}" != "SUCCESS" ]; then
echo "::error::Chrome Web Store upload failed (uploadState=${UPLOAD_STATE})"
exit 1
fi
# 发布已上传的草稿版本
PUBLISH_RESP=$(curl -sf \
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
-H "x-goog-api-version: 2" \
-H "Content-Length: 0" \
-X POST \
"https://www.googleapis.com/chromewebstore/v1.1/items/${EXTENSION_ID}/publish")
echo "Publish response: ${PUBLISH_RESP}"
STATUS=$(echo "${PUBLISH_RESP}" | python3 -c \
"import sys,json; print(','.join(json.load(sys.stdin).get('status',[])))")
echo "Publish status codes: ${STATUS}"
if echo "${STATUS}" | grep -qi "error"; then
echo "::error::Chrome Web Store publish failed (status=${STATUS})"
exit 1
fi
echo "::notice::Chrome extension v${VERSION} submitted to Web Store review"
# ── Edge Add-ons 商店自动发布(需 EDGE_CLIENT_ID / EDGE_API_KEY / EDGE_PRODUCT_ID)──
# Edge 复用 Chrome MV3 zip(同为 MV3)。API v1.1:ApiKey 鉴权。
# ⚠️ API key 每 72 天过期,过期后需在 Partner Center → Publish API 重新生成并更新 EDGE_API_KEY secret。
- name: Publish Edge extension to Edge Add-ons
continue-on-error: true
env:
EDGE_CLIENT_ID: ${{ secrets.EDGE_CLIENT_ID }}
EDGE_API_KEY: ${{ secrets.EDGE_API_KEY }}
EDGE_PRODUCT_ID: ${{ secrets.EDGE_PRODUCT_ID }}
VERSION: ${{ steps.version.outputs.VERSION }}
run: |
set -uo pipefail
ZIP_FILE="build/extension/FluxDown-${VERSION}-chrome.zip"
BASE="https://api.addons.microsoftedge.microsoft.com/v1/products/${EDGE_PRODUCT_ID}"
AUTH=(-H "Authorization: ApiKey ${EDGE_API_KEY}" -H "X-ClientID: ${EDGE_CLIENT_ID}")
# 1) 上传 zip 包到 draft(202 + Location 头携带 operationID)
echo "Uploading ${ZIP_FILE} to Edge Add-ons..."
UP_HEADERS=$(curl -sS -D - -o /dev/null -X POST "${BASE}/submissions/draft/package" \
"${AUTH[@]}" -H "Content-Type: application/zip" --data-binary "@${ZIP_FILE}")
OP=$(echo "$UP_HEADERS" | tr -d '\r' | awk -F': ' 'tolower($1)=="location"{print $2}' | awk -F/ '{print $NF}')
if [ -z "$OP" ]; then
echo "::error::Edge upload did not return an operation id"; echo "$UP_HEADERS"; exit 1
fi
echo "Upload operation: $OP"
# 2) 轮询上传处理状态(InProgress → Succeeded / Failed)
STATUS="?"
for i in $(seq 1 20); do
sleep 10
RESP=$(curl -sS "${BASE}/submissions/draft/package/operations/${OP}" "${AUTH[@]}")
STATUS=$(echo "$RESP" | python3 -c "import sys,json;print(json.load(sys.stdin).get('status','?'))" 2>/dev/null || echo "?")
echo " upload status: $STATUS"
[ "$STATUS" = "Succeeded" ] && break
if [ "$STATUS" = "Failed" ]; then echo "::error::Edge upload failed: $RESP"; exit 1; fi
done
if [ "$STATUS" != "Succeeded" ]; then
echo "::error::Edge upload timed out (status=$STATUS)"; exit 1
fi
# 3) 提交发布(202 + Location 头携带发布 operationID)
echo "Publishing Edge submission..."
PUB_HEADERS=$(curl -sS -D - -o /dev/null -X POST "${BASE}/submissions" \
"${AUTH[@]}" -H "Content-Type: application/json" \
--data "{\"notes\":\"Automated release v${VERSION}\"}")
PUB_OP=$(echo "$PUB_HEADERS" | tr -d '\r' | awk -F': ' 'tolower($1)=="location"{print $2}' | awk -F/ '{print $NF}')
echo "Publish operation: ${PUB_OP:-<none>}"
echo "::notice::Edge extension v${VERSION} submitted to Edge Add-ons review"
- name: Upload Firefox extension artifact
uses: actions/upload-artifact@v4
with:
name: browser-extension-firefox
path: build/extension/FluxDown-*-firefox.xpi
if-no-files-found: warn
# ── 并行 Job 3: 构建 Linux AppImage + tar.gz(x64)──
build-linux:
name: Build Linux (x64)
runs-on: ubuntu-22.04
needs: changes
if: needs.changes.outputs.app == 'true'
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Get version from tag
id: version
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_OUTPUT"
# ── 系统依赖 ──
# 注:已切换到 rustls,无需 libssl-dev(运行时亦无 libssl 依赖)
- name: Install system dependencies
run: |
sudo apt-get update -y
sudo apt-get install -y \
cmake ninja-build clang pkg-config \
libgtk-3-dev \
libnotify-dev \
libsecret-1-dev \
patchelf \
libfuse2 \
zstd
# ── Rust toolchain ──
- name: Setup Rust toolchain
uses: dtolnay/rust-toolchain@stable
# 智能 Rust 缓存:自动管理 ~/.cargo 和 workspace target/
# Workspace Cargo.toml 在根目录,target/ 为 hub + nmh 共享目录
- name: Cache Rust dependencies
uses: Swatinem/rust-cache@v2
with:
cache-on-failure: true
- name: Cache Rinf CLI
id: cache-rinf
uses: actions/cache@v4
with:
path: ~/.cargo/bin/rinf
key: rinf-cli-${{ runner.os }}-x64-8.9.0
# ── Flutter SDK ──
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
cache: true
- name: Flutter precache & version info
run: |
flutter --version
flutter precache --linux
- name: Install Flutter dependencies
run: flutter pub get
# ── Rinf code generation ──
- name: Install Rinf CLI
if: steps.cache-rinf.outputs.cache-hit != 'true'
shell: bash
env:
CARGO_HTTP_MULTIPLEXING: "false"
CARGO_NET_RETRY: "10"
run: |
for i in 1 2 3; do
cargo install rinf_cli@8.9.0 && exit 0
echo "cargo install failed (attempt $i), retrying in 15s..."
sleep 15
done
exit 1
- name: Generate Rinf bindings
run: rinf gen
# ── Build ──
- name: Build Flutter Linux app
run: flutter build linux --release --build-name=${{ steps.version.outputs.VERSION }} --build-number=1 --dart-define=APP_VERSION=${{ steps.version.outputs.VERSION }}
# NOTE: fluxdown_nmh and fluxdown_updater are built automatically
# by CMake custom targets defined in linux/CMakeLists.txt during flutter build.
# ── Package tar.gz(命名目录后压缩,解压得 FluxDown-x.y.z-linux-x64/)──
- name: Create portable tar.gz
run: |
VERSION=${{ steps.version.outputs.VERSION }}
BUNDLE_DIR="build/linux/x64/release/bundle"
DIST_DIR="FluxDown-${VERSION}-linux-x64"
mkdir -p build/installer
mkdir -p "$DIST_DIR"
cp -a "$BUNDLE_DIR/." "$DIST_DIR/"
tar -czf "build/installer/FluxDown-${VERSION}-linux-x64.tar.gz" "$DIST_DIR"
rm -rf "$DIST_DIR"
# ── Package AppImage ──
- name: Download appimagetool
run: |
wget -qO appimagetool-x86_64.AppImage \
"https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage"
chmod +x appimagetool-x86_64.AppImage
- name: Build AppImage
run: |
VERSION=${{ steps.version.outputs.VERSION }}
BUNDLE_DIR="build/linux/x64/release/bundle"
# 创建 AppDir,顶层即 bundle 内容(flux_down、data/、lib/ 都在根)
mkdir -p AppDir
cp -a "$BUNDLE_DIR/." AppDir/
# .desktop 文件复制到 AppDir 根(AppImage 约定)
cp linux/com.fluxdown.app.desktop AppDir/
# 图标复制到 AppDir 根,文件名必须与 .desktop 中 Icon= 一致
cp assets/logo/fluxdown_logo.png AppDir/com.fluxdown.app.png
# AppRun 入口:设置 LD_LIBRARY_PATH,让 flux_down 找到 lib/ 下的共享库
{
echo '#!/bin/bash'
echo 'HERE="$(dirname "$(readlink -f "${0}")")"'
echo 'export LD_LIBRARY_PATH="${HERE}/lib:${LD_LIBRARY_PATH:-}"'
echo 'exec "${HERE}/flux_down" "$@"'
} > AppDir/AppRun
chmod +x AppDir/AppRun
# 打包 AppImage(APPIMAGE_EXTRACT_AND_RUN=1 避免 CI 无 FUSE 时失败)
ARCH=x86_64 APPIMAGE_EXTRACT_AND_RUN=1 \
./appimagetool-x86_64.AppImage AppDir \
"build/installer/FluxDown-${VERSION}-linux-x64.AppImage"
# ── Package deb(Debian/Ubuntu)──
# 安装布局:Flutter bundle → /opt/fluxdown/,wrapper 脚本 → /usr/bin/flux_down
# 保持 RPATH=$ORIGIN/lib 正确(可执行文件在 /opt/fluxdown/,lib 在 /opt/fluxdown/lib/)
# Flutter 查找 data/ 目录时用 argv[0]=/opt/fluxdown/flux_down,找 /opt/fluxdown/data/ ✓
- name: Create deb package
run: |
VERSION=${{ steps.version.outputs.VERSION }}
BUNDLE_DIR="build/linux/x64/release/bundle"
PKG_DIR="deb_pkg"
# 创建目录结构
mkdir -p "$PKG_DIR/DEBIAN"
mkdir -p "$PKG_DIR/opt/fluxdown"
mkdir -p "$PKG_DIR/usr/bin"
mkdir -p "$PKG_DIR/usr/share/applications"
mkdir -p "$PKG_DIR/usr/share/icons/hicolor/256x256/apps"
# 复制 Flutter bundle 到 /opt/fluxdown/(保持原有相对目录)
cp -a "$BUNDLE_DIR/." "$PKG_DIR/opt/fluxdown/"
# /usr/bin/flux_down 是 wrapper 脚本,将启动 /opt/fluxdown/flux_down
{
echo '#!/bin/bash'
echo 'exec /opt/fluxdown/flux_down "$@"'
} > "$PKG_DIR/usr/bin/flux_down"
chmod 755 "$PKG_DIR/usr/bin/flux_down"
# 复制 .desktop 文件和图标到标准 XDG 路径
cp linux/com.fluxdown.app.desktop "$PKG_DIR/usr/share/applications/"
cp assets/logo/fluxdown_logo.png \
"$PKG_DIR/usr/share/icons/hicolor/256x256/apps/com.fluxdown.app.png"
# 计算安装后总大小(KB,供 control 字段使用)
INSTALLED_SIZE_KB=$(du -sk "$PKG_DIR/opt/" "$PKG_DIR/usr/" \
| awk '{sum+=$1} END {print sum}')
# 生成 DEBIAN/control
{
echo "Package: fluxdown"
echo "Version: ${VERSION}"
echo "Architecture: amd64"
echo "Maintainer: FluxDown Team <contact@fluxdown.app>"
echo "Homepage: https://fluxdown.app"
echo "Section: net"
echo "Priority: optional"
echo "Installed-Size: ${INSTALLED_SIZE_KB}"
echo "Depends: libgtk-3-0, libnotify4"
echo "Description: Free IDM-alternative download manager"
echo " FluxDown is a Rust-powered, open-source download manager."
echo " Supports HTTP/HTTPS/FTP, BitTorrent magnetic links, and HLS/DASH streaming"
echo " with multi-threaded acceleration and seamless browser integration."
} > "$PKG_DIR/DEBIAN/control"
# 生成 DEBIAN/postinst(安装后刷新桌面数据库和图标缓存)
{
echo '#!/bin/bash'
echo 'set -e'
echo 'update-desktop-database -q /usr/share/applications/ 2>/dev/null || true'
echo 'gtk-update-icon-cache -q /usr/share/icons/hicolor/ 2>/dev/null || true'
} > "$PKG_DIR/DEBIAN/postinst"
chmod 755 "$PKG_DIR/DEBIAN/postinst"
# 打包 deb(--root-owner-group 将所有文件所有者设为 root:root)
# 注:NMH JSON 清单由 app 首次运行时自动写入用户目录,卸载时无法从 root 脚本定位,
# 残留的 JSON 文件仅指向不存在的二进制,浏览器会静默忽略,无需主动清理。
mkdir -p build/installer
dpkg-deb --build --root-owner-group "$PKG_DIR" \
"build/installer/FluxDown-${VERSION}-linux-x64.deb"
# ── Package Arch(.pkg.tar.zst)──
# 与 deb 相同的安装布局:/opt/fluxdown/ + /usr/bin/ wrapper
- name: Create Arch package
run: |
VERSION=${{ steps.version.outputs.VERSION }}
BUNDLE_DIR="build/linux/x64/release/bundle"
PKG_DIR="arch_pkg"
# 创建目录结构
mkdir -p "$PKG_DIR/opt/fluxdown"
mkdir -p "$PKG_DIR/usr/bin"
mkdir -p "$PKG_DIR/usr/share/applications"
mkdir -p "$PKG_DIR/usr/share/icons/hicolor/256x256/apps"
# 复制 Flutter bundle 到 /opt/fluxdown/
cp -a "$BUNDLE_DIR/." "$PKG_DIR/opt/fluxdown/"
# /usr/bin/flux_down wrapper 脚本
{
echo '#!/bin/bash'
echo 'exec /opt/fluxdown/flux_down "$@"'
} > "$PKG_DIR/usr/bin/flux_down"
chmod 755 "$PKG_DIR/usr/bin/flux_down"
# .desktop 和图标
cp linux/com.fluxdown.app.desktop "$PKG_DIR/usr/share/applications/"
cp assets/logo/fluxdown_logo.png \
"$PKG_DIR/usr/share/icons/hicolor/256x256/apps/com.fluxdown.app.png"
# 计算安装后总字节数(.PKGINFO 的 size 字段)
INSTALLED_SIZE=$(du -sb "$PKG_DIR/opt/" "$PKG_DIR/usr/" \
| awk '{sum+=$1} END {print sum}')
# 生成 .PKGINFO(pacman 安装包元数据)
{
echo "# Generated by FluxDown CI"
echo "pkgname = fluxdown"
echo "pkgver = ${VERSION}-1"
echo "pkgdesc = Free IDM-alternative download manager"
echo "url = https://fluxdown.app"
echo "builddate = $(date +%s)"
echo "packager = FluxDown CI <ci@fluxdown.app>"
echo "size = ${INSTALLED_SIZE}"
echo "arch = x86_64"
echo "license = custom"
echo "depend = gtk3"
echo "depend = libnotify"
} > "$PKG_DIR/.PKGINFO"
# 打包 .pkg.tar.zst(.PKGINFO 必须显式列在首位)
# --owner=0 --group=0:确保包内所有文件归属 root:root,避免 CI runner uid 写入包中
mkdir -p build/installer
cd "$PKG_DIR"
tar --zstd --owner=0 --group=0 -cf \
"../build/installer/FluxDown-${VERSION}-linux-x64.pkg.tar.zst" \
.PKGINFO opt/ usr/
cd ..
# ── Upload artifacts(供 release job 使用)──
- name: Upload Linux AppImage artifact
uses: actions/upload-artifact@v4
with:
name: linux-appimage
path: build/installer/FluxDown-*-linux-x64.AppImage
if-no-files-found: error
- name: Upload Linux deb artifact
uses: actions/upload-artifact@v4
with:
name: linux-deb
path: build/installer/FluxDown-*-linux-x64.deb
if-no-files-found: error
- name: Upload Linux Arch package artifact
uses: actions/upload-artifact@v4
with:
name: linux-arch
path: build/installer/FluxDown-*-linux-x64.pkg.tar.zst
if-no-files-found: error
- name: Upload Linux portable artifact
uses: actions/upload-artifact@v4
with:
name: linux-portable
path: build/installer/FluxDown-*-linux-x64.tar.gz
if-no-files-found: error
# ── 并行 Job 4: 构建 macOS DMG + tar.gz(Intel x64 + Apple Silicon arm64)──
build-macos:
name: Build macOS (${{ matrix.arch }})
needs: changes
if: needs.changes.outputs.app == 'true'
strategy:
fail-fast: false
matrix:
include:
- arch: x64
runner: macos-15-intel # Intel runner (macos-13 已废弃)
flutter_target: macos
rust_target: x86_64-apple-darwin
- arch: arm64
runner: macos-15 # Apple Silicon runner
flutter_target: macos
rust_target: aarch64-apple-darwin
runs-on: ${{ matrix.runner }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Get version from tag
id: version
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_OUTPUT"
# ── Rust toolchain ──
- name: Setup Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.rust_target }}
- name: Cache Rust dependencies
uses: Swatinem/rust-cache@v2
with:
cache-on-failure: true
- name: Cache Rinf CLI
id: cache-rinf
uses: actions/cache@v4
with:
path: ~/.cargo/bin/rinf
key: rinf-cli-${{ runner.os }}-${{ matrix.arch }}-8.9.0
# ── Flutter SDK ──
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
cache: true
- name: Flutter precache & version info
run: |
flutter --version
flutter precache --macos
- name: Install Flutter dependencies
run: flutter pub get
# ── Rinf code generation ──
- name: Install Rinf CLI
if: steps.cache-rinf.outputs.cache-hit != 'true'
shell: bash
env:
CARGO_HTTP_MULTIPLEXING: "false"
CARGO_NET_RETRY: "10"
run: |
for i in 1 2 3; do
cargo install rinf_cli@8.9.0 && exit 0
echo "cargo install failed (attempt $i), retrying in 15s..."
sleep 15
done
exit 1
- name: Generate Rinf bindings
run: rinf gen
# ── Build ──
- name: Build Flutter macOS app
run: |
flutter build macos --release \
--build-name=${{ steps.version.outputs.VERSION }} \
--build-number=1 \
--dart-define=APP_VERSION=${{ steps.version.outputs.VERSION }}
# NOTE: fluxdown_nmh and fluxdown_updater are built automatically
# by Xcode shell script build phases defined in macos/Runner.xcodeproj during flutter build.
# ── Package tar.gz portable ──
- name: Create portable tar.gz
run: |
VERSION=${{ steps.version.outputs.VERSION }}
APP_PATH="build/macos/Build/Products/Release/FluxDown.app"
mkdir -p build/installer
DIST_DIR="FluxDown-${VERSION}-macos-${{ matrix.arch }}"
mkdir -p "$DIST_DIR"
cp -R "$APP_PATH" "$DIST_DIR/"
tar -czf "build/installer/FluxDown-${VERSION}-macos-${{ matrix.arch }}.tar.gz" "$DIST_DIR"
rm -rf "$DIST_DIR"
# ── Package DMG ──
- name: Install create-dmg
run: brew install create-dmg
- name: Create DMG
run: |
VERSION=${{ steps.version.outputs.VERSION }}
APP_PATH="build/macos/Build/Products/Release/FluxDown.app"
mkdir -p build/installer
mkdir -p dmg_src
cp -R "$APP_PATH" dmg_src/
create-dmg \
--volname "FluxDown ${VERSION}" \
--window-pos 200 120 \
--window-size 660 400 \
--icon-size 100 \
--icon "FluxDown.app" 160 185 \
--hide-extension "FluxDown.app" \
--app-drop-link 500 185 \
--no-internet-enable \
"build/installer/FluxDown-${VERSION}-macos-${{ matrix.arch }}.dmg" \
dmg_src/ || \
hdiutil create \
-volname "FluxDown ${VERSION}" \
-srcfolder dmg_src \
-ov -format UDZO \
"build/installer/FluxDown-${VERSION}-macos-${{ matrix.arch }}.dmg"
# ── Upload artifacts(供 release job 使用)──
- name: Upload macOS DMG artifact
uses: actions/upload-artifact@v4
with:
name: macos-dmg-${{ matrix.arch }}
path: build/installer/FluxDown-*-macos-${{ matrix.arch }}.dmg
if-no-files-found: error
- name: Upload macOS portable artifact
uses: actions/upload-artifact@v4
with:
name: macos-portable-${{ matrix.arch }}
path: build/installer/FluxDown-*-macos-${{ matrix.arch }}.tar.gz
if-no-files-found: error
# ── 并行 Job 5: 构建官网(website/ 有变更时)──
build-website:
name: Build Website
runs-on: ubuntu-latest
needs: changes
if: needs.changes.outputs.website == 'true'
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Get version from tag
id: version
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_OUTPUT"
# website/ 无 package-lock.json,不能用 npm ci
- name: Install dependencies
run: npm install
working-directory: website
- name: Build website
run: npm run build
working-directory: website
# 打包 Node standalone 构建产物(dist/ 含 server + client)
- name: Package website dist
run: |
VERSION=${{ steps.version.outputs.VERSION }}
mkdir -p build/website
tar -czf "build/website/FluxDown-website-${VERSION}.tar.gz" -C website dist
ls -la build/website/
- name: Upload website artifact
uses: actions/upload-artifact@v4
with:
name: website-dist
path: build/website/FluxDown-website-*.tar.gz
if-no-files-found: error
# ── Release Job 1: 桌面客户端(使用推送的 v* tag,保持 GitHub latest release 语义)──
release-app:
name: Release Desktop App
runs-on: ubuntu-latest
needs: [build-windows, build-linux, build-macos]
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-tags: true
# 用 git-cliff 从 Conventional Commits 生成本版本的更新内容
# (--latest 仅包含上一个 tag 到当前 tag 之间的 commit,
# --strip header 去掉 changelog 文件头,只保留版本区块)
- name: Generate release notes with git-cliff
uses: orhun/git-cliff-action@v4
with:
args: --latest --strip header
env:
OUTPUT: RELEASE_NOTES.md
GITHUB_REPO: ${{ github.repository }}
# 兜底:若区间内没有符合规范的 commit(git-cliff 输出为空),
# 使用默认标题避免空 release body
- name: Finalize release notes
shell: bash
run: |
TAG_NAME="${GITHUB_REF#refs/tags/}"
if [ ! -s RELEASE_NOTES.md ]; then
echo "Release $TAG_NAME" > RELEASE_NOTES.md
fi
echo "── Release notes ──"
cat RELEASE_NOTES.md
# ── 下载桌面客户端构建产物 ──
- name: Download all Windows artifacts
uses: actions/download-artifact@v4
with:
pattern: windows-*
path: release-assets/
merge-multiple: true
- name: Download Linux artifacts
uses: actions/download-artifact@v4
with:
pattern: linux-*
path: release-assets/
merge-multiple: true
- name: Download macOS artifacts
uses: actions/download-artifact@v4
with:
pattern: macos-*
path: release-assets/
merge-multiple: true
- name: List release assets
run: ls -la release-assets/
# ── 创建客户端 Release 并上传所有文件 ──
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
files: release-assets/*
body_path: RELEASE_NOTES.md
generate_release_notes: false
draft: false
make_latest: "true"
# ── Release Job 2: 浏览器扩展(独立 tag extension-v*,不抢占 latest)──
release-extension:
name: Release Browser Extension
runs-on: ubuntu-latest
needs: build-extension
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-tags: true
- name: Get version from tag
id: version
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_OUTPUT"
# 只收集 fluxDown/ 目录相关的 commit 作为扩展 release notes
- name: Generate release notes with git-cliff
uses: orhun/git-cliff-action@v4
with:
args: --latest --strip header --include-path "fluxDown/**"
env:
OUTPUT: RELEASE_NOTES.md
GITHUB_REPO: ${{ github.repository }}
- name: Finalize release notes
shell: bash
run: |
if [ ! -s RELEASE_NOTES.md ]; then
echo "Browser Extension Release v${{ steps.version.outputs.VERSION }}" > RELEASE_NOTES.md
fi
echo "── Release notes ──"
cat RELEASE_NOTES.md
- name: Download browser extensions
uses: actions/download-artifact@v4
with:
pattern: browser-extension-*
path: release-assets/
merge-multiple: true
- name: List release assets
run: ls -la release-assets/
# make_latest=false:避免组件 release 成为 GitHub /releases/latest,
# 否则会打破客户端自动更新和官网下载页的最新版本判断
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: extension-v${{ steps.version.outputs.VERSION }}
name: FluxDown Extension v${{ steps.version.outputs.VERSION }}
files: release-assets/*
body_path: RELEASE_NOTES.md
generate_release_notes: false
draft: false
make_latest: "false"
# ── Release Job 3: 官网(独立 tag website-v*,不抢占 latest)──
release-website:
name: Release Website
runs-on: ubuntu-latest
needs: build-website
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-tags: true
- name: Get version from tag
id: version
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_OUTPUT"
# 只收集 website/ 目录相关的 commit 作为官网 release notes
- name: Generate release notes with git-cliff
uses: orhun/git-cliff-action@v4
with:
args: --latest --strip header --include-path "website/**"
env:
OUTPUT: RELEASE_NOTES.md
GITHUB_REPO: ${{ github.repository }}
- name: Finalize release notes
shell: bash
run: |
if [ ! -s RELEASE_NOTES.md ]; then
echo "Website Release v${{ steps.version.outputs.VERSION }}" > RELEASE_NOTES.md
fi
echo "── Release notes ──"
cat RELEASE_NOTES.md
- name: Download website artifact
uses: actions/download-artifact@v4
with:
pattern: website-dist
path: release-assets/
merge-multiple: true
- name: List release assets
run: ls -la release-assets/
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: website-v${{ steps.version.outputs.VERSION }}
name: FluxDown Website v${{ steps.version.outputs.VERSION }}
files: release-assets/*
body_path: RELEASE_NOTES.md
generate_release_notes: false
draft: false
make_latest: "false"
# ── 最终 Job: 清理 GitHub Actions Artifact,仅保留当前 tag 这一组 ──
# 原因:Free 账号 artifact 配额仅 500MB,每次 release 会上传 ~400MB
# Release 资产已独立存储在 GitHub Release,artifact 只是 CI 中间产物可安全清理
# always():无论哪些组件 release 被跳过/失败都执行清理,失败不阻断(continue-on-error)
cleanup-artifacts:
name: Cleanup CI Artifacts
runs-on: ubuntu-latest
needs: [release-app, release-extension, release-website]
if: ${{ always() && !cancelled() }}
steps:
- name: Cleanup GitHub Actions artifacts (keep current tag only)
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
CURRENT_TAG="${GITHUB_REF#refs/tags/}"
echo "🧹 Artifact cleanup: keeping only artifacts from tag ${CURRENT_TAG}"
# 列出所有 artifact,过滤掉当前 tag 的,删除其余所有
# workflow_run.head_branch 在 tag 推送时等于 tag 名(v0.1.43 这样)
DELETE_IDS=$(gh api --paginate \
repos/${{ github.repository }}/actions/artifacts \
-q ".artifacts[] | select(.workflow_run.head_branch != \"${CURRENT_TAG}\") | .id")
if [ -z "$DELETE_IDS" ]; then
echo "✅ No old artifacts to delete"
exit 0
fi
DELETE_COUNT=$(echo "$DELETE_IDS" | wc -l)
echo "Will delete ${DELETE_COUNT} old artifact(s)"
# 串行删除避免触发 GitHub TLS 限流(之前并发 8 大量 timeout)
FAIL=0
for id in $DELETE_IDS; do
if gh api -X DELETE repos/${{ github.repository }}/actions/artifacts/${id} 2>/dev/null; then
echo " 🗑️ Deleted artifact ${id}"
else
echo " ⚠️ Failed to delete artifact ${id}"
FAIL=$((FAIL+1))
fi
done
echo "✅ Artifact cleanup complete. Deleted $((DELETE_COUNT-FAIL))/${DELETE_COUNT}"
if [ "$FAIL" -gt 0 ]; then
echo "⚠️ ${FAIL} deletion(s) failed (will be retried next release)"
fi