Files
Unlucky-Launcher/.github/workflows/release.yml
T
AskaEth b27e86c65b ci(release): M140 windows-arm64 改用原生 windows-11-arm runner
交叉编译路线在 CI 上从未成功过一次:v0.2.1 首次真跑即红在
「Windows ARM64 交叉工具链体检」(windows-latest 上探不到 VC/SDK 的
ARM64 库),故按文档标准 runner 表把 arm64 槽位改成原生
windows-11-arm(公开仓免费,4C/16G),产物回到 target/release。

- 槽位表:windows-arm64 os=windows-11-arm、build_dir=target/release;
  无交叉槽位后 target 键与 rustup target add / cargo --target 一并删掉
- 体检步骤重写为「Windows ARM64 工具链体检」:失败时打印实际探测到的
  VS 根、VC arm64 lib 目录、SDK 的 um/arm64 目录;SDK 不再只认
  tail -n1 挑出的单一版本(全版本里找)
- dumpbin 定位链补 Hostarm64/arm64 分支,按 RUNNER_ARCH 选宿主工具族
  (x64 上绝不挑 arm64 二进制),vswhere/find/PATH 三级与硬门禁不变
- packaging/README.md §1/§3/§4/§7.5 同步原生化与冒烟真跑的说法
2026-11-06 11:51:01 +08:00

400 lines
22 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
name: Release
# 发布流水线:tag `v*` → **只构建两个 Windows 槽位**(各自原生:`windows-latest` / `windows-11-arm`)→ 单文件产物 + SHA256SUMS → artifact + GitHub Release
# (pre-release 默认开;同 tag 重跑幂等 = 覆盖资产与说明而不 create;重跑不碰该标记,只在显式 inputs.prerelease 时才断言)
#
# **构建 ≠ 发布**:只有 release job 的 `PUBLISH_SLOTS` 白名单里的槽位才会进 Release 与 SHA256SUMS。
# **CI 只跑两个 Windows 槽位**(M139):Linux x64/arm64、macOS arm64/x64 四档从未真构建成功过一次、
# 也没在真机跑过,默认**一个都不跑**(省 CI 配额、不留没意义的红叉);要整表六槽位取证走手动入口
# `gh workflow run release.yml -f all_slots=true`(槽位表在 `plan` job 里,唯一真相源)。
#
# 契约唯一真相源:packaging/README.md(命名/目录/运行时依赖/发布白名单);打包本体在
# packaging/make_artifacts.py(本地与 CI 同一份脚本,逐字节同布局)。`inputs.prerelease` 只在 **tag ref**
# 上 dispatch 才可达(Actions 页面只能选分支,跑 tag 得 `gh workflow run release.yml --ref v0.2.1`);
# 分支 ref 的 dispatch 与 push 一样被 release job 的 `if: github.ref_type == 'tag'` 挡在发布之外,所以
# 「dispatch 只构建不发布」与「dispatch 能关 pre-release」并不矛盾。
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
all_slots:
description: "构建全部六个槽位(含未验证的 Linux/macOS 四档);默认只跑两个 Windows"
type: boolean
default: false
prerelease:
description: "标记为 pre-release(仅在 tag ref 上 dispatch 时才生效)"
type: boolean
default: true
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
permissions:
contents: read
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
jobs:
# M139:矩阵不写死在 `build` 里,改由本 job 按 `inputs.all_slots` 生成 —— 矩阵不能带 `if`,而
# `jobs.<id>.strategy` 可读 `needs`,所以「默认两个 Windows / 手动整表六槽位」用
# `fromJSON(needs.plan.outputs.matrix)` 实现。**槽位表只在下面这一处**,改槽位只改这里。
plan:
name: 选槽位(默认两个 Windows)
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.pick.outputs.matrix }}
steps:
- name: 生成 build 矩阵(`all_slots=true` 才整表六槽位)
id: pick
shell: bash
env:
ALL_SLOTS: ${{ inputs.all_slots }}
run: |
set -euo pipefail
python3 - "$ALL_SLOTS" >> "$GITHUB_OUTPUT" <<'PY'
import json, sys
# 槽位表(唯一真相源)。mode:"ci" = 默认在 CI 里构建;"probe" = 未验证,仅 all_slots=true 时构建。
# **没有一个槽位交叉编译**(M140):每槽都在**自己的架构**上原生构建,故不设 `target` 键、产物一律
# 在 target/release。哪天再加交叉槽位,把 `target` 键、`build_dir=target/<triple>/release` 与
# 「`rustup target add` + cargo 的 `--target`」两处一起加回来(现在它们已随最后一个交叉槽位删掉)。
SLOTS = [
# Windows x64:MSVC + 静态 CRT(+crt-static)→ exe 不依赖 VC++ 可再发行包,双击即用的单文件。
dict(slot="windows-x64", os="windows-latest", build_dir="target/release", rustflags="-C target-feature=+crt-static", mode="ci"),
# Windows arm64:**原生** `windows-11-arm`(4C/16G,公开仓库免费的标准 arm64 runner,GitHub
# 文档的 Standard runners 表明列 `windows-11-arm` / `windows-11-vs2026-arm`)。M140 之前是
# windows-latest 上交叉编译到 aarch64-pc-windows-msvc,**CI 从未成功过一次**(v0.2.1 首次
# 真跑就红在「ARM64 工具链体检」),那条惯性理由不再成立;原生还让 `ull --version` 冒烟
# 真跑(宿主=产物架构,不再被跳过)。产物在 target/release,dumpbin 走 Hostarm64 分支。
dict(slot="windows-arm64", os="windows-11-arm", build_dir="target/release",
rustflags="-C target-feature=+crt-static", mode="ci"),
# ↓ 四档**未验证,不纳入 CI**:从未真构建成功过一次、也没在真机跑过,默认一个都不编;
# `all_slots=true` 时照编(将来验证 Linux/macOS 的唯一入口,别删)。真机验证通过后:把该行
# mode 改成 "ci" + 槽位键加进 PUBLISH_SLOTS + 同步 README §1/§9 与 release-notes.md。
# Linux 固定 22.04 而非 latest:否则 glibc 地板跟着 runner 漂移(依据见 README §4)。
dict(slot="linux-x64", os="ubuntu-22.04", build_dir="target/release", rustflags="", mode="probe"),
dict(slot="linux-arm64", os="ubuntu-22.04-arm", build_dir="target/release", rustflags="", mode="probe"),
# macOS:latest = arm64;Intel x64 用 macos-15-intel(macos-13 已下线)。
dict(slot="macos-arm64", os="macos-latest", build_dir="target/release", rustflags="", mode="probe"),
dict(slot="macos-x64", os="macos-15-intel", build_dir="target/release", rustflags="", mode="probe"),
]
keep = [s for s in SLOTS if s["mode"] == "ci" or sys.argv[1] == "true"]
print("matrix=" + json.dumps({"include": keep}, separators=(",", ":")))
print("本次构建槽位:" + " ".join(s["slot"] for s in keep), file=sys.stderr)
PY
build:
name: build (${{ matrix.slot }})
needs: plan
strategy:
fail-fast: false
matrix: ${{ fromJSON(needs.plan.outputs.matrix) }}
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Linux 图形栈构建依赖(winit / wgpu 编译期需要)
if: runner.os == 'Linux'
shell: bash
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libxkbcommon-dev libfontconfig1-dev libgl1-mesa-dev libegl1-mesa-dev \
libwayland-dev libxcb1-dev
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
# (M140)windows-arm64 跑在**原生** `windows-11-arm` 上,所以本步查的是"这台机器上的 ARM64 库齐不齐":
# `+crt-static` 要 VC 的 `lib/arm64/libcmt.lib`,链接要 SDK 的 `um/arm64/kernel32.lib`。
# 门禁不降级(缺了就 fail),但**失败必须能自己定位**:把实际探测到的根目录 / lib 目录全打出来
# ——原来只报"缺失",2026-11-06 第一次真跑就卡在这里,而单看日志无法判断是镜像没装还是探测写错了。
# 两个探测还都改宽容了:VS 两个根都扫;SDK 不再只认 `tail -n1` 挑出的那一个版本,全部版本里找。
- name: Windows ARM64 工具链体检
if: matrix.slot == 'windows-arm64'
shell: bash
run: |
set -euo pipefail
vs_roots=("C:/Program Files/Microsoft Visual Studio" "C:/Program Files (x86)/Microsoft Visual Studio")
sdk_root="C:/Program Files (x86)/Windows Kits/10/Lib"
echo "── 探测的根目录 ──"
for r in "${vs_roots[@]}"; do
if [ -d "$r" ]; then echo " 有 $r"; else echo " 无 $r"; fi
done
if [ -d "$sdk_root" ]; then echo " 有 $sdk_root"; else echo " 无 $sdk_root"; fi
vc_dirs=$(find "${vs_roots[@]}" -maxdepth 9 -type d -path '*/VC/Tools/MSVC/*/lib/arm64' 2>/dev/null || true)
echo "── VC 的 arm64 lib 目录(*/VC/Tools/MSVC/*/lib/arm64)──"
echo "${vc_dirs:- <无>}"
sdk_dirs=$(find "$sdk_root" -maxdepth 3 -type d -path '*/um/arm64' 2>/dev/null || true)
echo "── SDK 的 um/arm64 目录 ──"
echo "${sdk_dirs:- <无>}"
vc_lib=$(find "${vs_roots[@]}" -maxdepth 11 -type f -path '*/VC/Tools/MSVC/*/lib/arm64/libcmt.lib' 2>/dev/null | head -n1 || true)
sdk_lib=$(find "$sdk_root" -maxdepth 4 -type f -path '*/um/arm64/kernel32.lib' 2>/dev/null | head -n1 || true)
echo "libcmt.lib: ${vc_lib:-<缺失>}"
echo "kernel32.lib: ${sdk_lib:-<缺失>}"
if [ -z "$vc_lib" ] || [ -z "$sdk_lib" ]; then
echo "::error::windows-11-arm 上缺 ARM64 库(vc=${vc_lib:-缺失} / sdk=${sdk_lib:-缺失}),上面就是全部探测结果;给 VS 补 Microsoft.VisualStudio.Component.VC.Tools.ARM64 与 Windows 11 SDK"
exit 1
fi
# M118(D26):**Slint 版停发** —— GUI 来源只有 `unlucky-launcher-iced`(`crates/app` 已被根
# Cargo.toml `exclude`,不参与任何 workspace 构建)。对外产物名由 make_artifacts.py 的
# `asset_stem()` 生成 `<名字>_<版本号>_<平台>_<架构>`;`LAUNCHER_BIN` 只用来在产物里找文件。
# 不传 `--target`:M140 起每个槽位都在自己的架构上原生编译,host triple 就是产物 triple。
- name: cargo build --release --locked
shell: bash
env:
RUSTFLAGS: ${{ matrix.rustflags }}
run: |
set -euo pipefail
cargo build --release --locked --bin unlucky-launcher-iced --bin ull
- name: Windows 单文件自检(导入表不得出现 VC++ 可再发行 DLL)
if: runner.os == 'Windows'
shell: bash
env:
# 校验的是**构建产物**(iced bin 原始名);对外名 `unlucky-launcher` 由 make_artifacts.py 落盘时
# 才换上。产物在 `matrix.build_dir`(M140 起六个槽位都是 target/release)。
EXE: ${{ matrix.build_dir }}/unlucky-launcher-iced.exe
run: |
set -euo pipefail
# +crt-static 是否真生效只看导入表:出现 VCRUNTIME/MSVCP/msvcr/ucrtbase 就是还得装可再发行包,fail。
#
# dumpbin 定位链(三级,全不命中才 fail —— 门禁不降级,只是把"猜路径"换成官方定位):
# ① vswhere(VS 官方定位器,安装器路径固定)② find 兜底(两个 VS 根都扫,Build Tools 在
# x86 根)③ PATH(镜像预置则直接可用;排最后是刻意的:PATH 最不可控,官方定位器优先)。
# dumpbin 在 MSVC 的**宿主**工具目录里:x64 runner 是 `Hostx64/x64`,arm64 runner(M140)是
# `Hostarm64/arm64`(x64 那族作为兜底,ARM64 Windows 靠模拟层跑得起来)。反向不成立 ——
# 在 x64 runner 上挑 `Hostarm64` 只会拿到跑不起来的 arm64 二进制,所以按 RUNNER_ARCH 选。
pats=('VC/Tools/MSVC/*/bin/Hostx64/x64/dumpbin.exe')
if [ "${RUNNER_ARCH:-X64}" = "ARM64" ]; then
pats=('VC/Tools/MSVC/*/bin/Hostarm64/arm64/dumpbin.exe' "${pats[@]}")
fi
VSWHERE="C:/Program Files (x86)/Microsoft Visual Studio/Installer/vswhere.exe"
DUMPBIN=""
VIA=""
if [ -x "$VSWHERE" ]; then
# -find 的模式先用反斜杠(vswhere 官方文档即此形式),再试正斜杠以规避分隔符差异。
find_args=()
for p in "${pats[@]}"; do find_args+=(-find "${p//\//\\}" -find "$p"); done
hit=$("$VSWHERE" -latest -products '*' "${find_args[@]}" 2>/dev/null | head -n1 || true)
if [ -n "$hit" ]; then DUMPBIN=${hit//\\//}; VIA="vswhere"; fi
fi
if [ -z "$DUMPBIN" ]; then
# VS 根形如 <年>/<版次>(2022/Enterprise、2022/BuildTools……),固定层数的 glob 会踩空,按模式扫。
find_pat=()
for p in "${pats[@]}"; do find_pat+=(-o -path "*/$p"); done
DUMPBIN=$(find "C:/Program Files/Microsoft Visual Studio" \
"C:/Program Files (x86)/Microsoft Visual Studio" \
-maxdepth 11 -type f \( "${find_pat[@]:1}" \) 2>/dev/null | head -n1 || true)
if [ -n "$DUMPBIN" ]; then VIA="find"; fi
fi
if [ -z "$DUMPBIN" ]; then
hit=$(command -v dumpbin || true)
if [ -n "$hit" ]; then
DUMPBIN=$hit
VIA="PATH"
fi
fi
# 工具缺失同样 fail:"找不到就跳过"等于让这道门禁在最需要它的时候静默失效。
if [ -z "$DUMPBIN" ]; then
echo "::error::找不到 dumpbin,无法校验导入表(静态 CRT 门禁不降级)"
exit 1
fi
echo "dumpbin: $DUMPBIN(定位自 $VIA)"
ls -l "$EXE"
"$DUMPBIN" -nologo -dependents "$EXE" | tee dependents.txt
if grep -Eiq 'VCRUNTIME|MSVCP|MSVCR|ucrtbase\.dll' dependents.txt; then
echo "::error::静态 CRT 未生效:exe 仍动态依赖 VC++ 可再发行包"
exit 1
fi
- name: 打包(产物 + SHA256SUMS)
shell: bash
env:
# 不把 `${{ }}` 直接拼进 bash 脚本:git ref 名允许 `$`/反引号/`"`,一律经 env 传入再展开。
REF_TYPE: ${{ github.ref_type }}
TAG: ${{ github.ref_name }}
SLOT: ${{ matrix.slot }}
BUILD_DIR: ${{ matrix.build_dir }}
run: |
set -euo pipefail
# git-bash 与 Linux/macOS runner 的 python 入口名不一致,先探一遍。
PY=$(command -v python3 || command -v python)
# 槽位与产物根都从矩阵来(M140 起六个槽位都是 target/release),脚本据此定位产物并回读
# 可执行文件头校验架构:arm64 槽位现在是**真跑** `ull --version`,「宿主≠产物就跳过」只留给本地交叉。
args=(--slot "$SLOT" --build-dir "$BUILD_DIR")
if [ "$REF_TYPE" = "tag" ]; then
args+=(--version "$TAG") # 与 Cargo.toml 版本交叉校验
fi
"$PY" packaging/make_artifacts.py "${args[@]}" --out dist
ls -l dist
- name: 归档内容自检(二进制定位 + 合规文本)
shell: bash
run: |
set -euo pipefail
for archive in dist/*.tar.gz; do
[ -e "$archive" ] || continue
listing=$(tar -tzf "$archive")
for required in unlucky-launcher LICENSE NOTICE README.md; do
if ! grep -q "/$required$" <<<"$listing"; then
echo "::error file=$archive::$archive 缺少 $required"
exit 1
fi
done
echo "OK: $archive 含二进制与 LICENSE/NOTICE/README.md"
done
if ls dist/*.exe >/dev/null 2>&1; then
echo "Windows 单 exe 形态:LICENSE/NOTICE/README.md 作为 Release 资产并列分发"
fi
- uses: actions/upload-artifact@v4
with:
name: dist-${{ matrix.slot }}
path: dist
if-no-files-found: error
retention-days: 14
release:
name: GitHub Release
needs: build
# `always()` + `!cancelled()`:白名单外的槽位(只有 `all_slots` 取证跑里才存在)红了**不能连坐**
# Windows 发布。真正拦发布的是下面「发布白名单齐全性」那一步:白名单里少任何一个就 fail。
if: always() && !cancelled() && github.ref_type == 'tag'
runs-on: ubuntu-latest
permissions:
contents: write
env:
# ── 发布白名单:唯一决定「哪几个槽位进 Release」的开关(空格分隔,键同 matrix.slot)──
# 为什么只有两个 Windows 槽位:Linux x64/arm64、macOS arm64/x64 这四档**从未真构建成功过一次、
# 更没在真机上跑过**,未验证的产物不推给用户;它们默认连构建都不跑(M139),只有 `all_slots=true`
# 的取证跑里才有产物,留在本次运行的 `dist-<slot>` artifact 里可下载验证,不进 Release/SHA256SUMS。
# 将来某槽位真机验证通过后「打开」它需同时改三处(缺一即是假话):① `plan` 槽位表里把它的 mode
# 改成 `ci`;② 把槽位键加进这一行(齐全性检查与 SHA256SUMS 自动跟着扩);③ packaging/README.md
# §1/§9 与 packaging/release-notes.md 的 Downloads / Support matrix。
PUBLISH_SLOTS: "windows-x64 windows-arm64"
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
path: dist
merge-multiple: true
- name: 裁掉白名单外的槽位产物(构建取证 ≠ 发布)
shell: bash
run: |
set -euo pipefail
# 槽位键 `windows-x64` 落到文件名是 `windows_x64`(make_artifacts.py::asset_stem),故取文件名里的
# 「平台_架构」两段跟白名单比:`<名字>_<版本>_<平台>_<架构>[.exe]`。
keep=" "
for slot in $PUBLISH_SLOTS; do keep+="${slot//-/_} "; done
for f in dist/*; do
base=${f##*/}
[ "$base" = "SHA256SUMS.txt" ] && continue
platform_arch=${base%.exe}; platform_arch=${platform_arch%.tar.gz}; platform_arch=${platform_arch#*_*_}
case "$keep" in
*" $platform_arch "*)
echo "publish: $base"
;;
*)
echo "drop: $base($platform_arch 未在真机验证,不进 Release;仍留在 dist-<slot> artifact 里取证)"
rm -f "$f"
;;
esac
done
- name: 发布白名单齐全性 + 重算全量 SHA256SUMS
shell: bash
run: |
set -euo pipefail
ls -l dist
# 白名单内槽位必须**全齐**才发版(宁可不出 Release,也不出少一档的 Release)。每个槽位都要
# GUI 与 CLI 两个资产——只按「槽位名出现过」判会被其中一个掩盖。档位比对用下划线形态:
# 资产名自 D49 起是 `unlucky-launcher_<ver>_windows_x64.exe`,旧写法 `dist/$prefix-*-$slot*`
# (连字符)对现行命名永远匹配不上,已一并修正。
# 矩阵裁到两个槽位(M139)后这条检查依然有效:它比的是**下载下来的产物**,不看矩阵大小 ——
# 白名单里的槽位没编(比如忘了改 plan 里的 mode)或编挂了,就在这里因缺产物 fail,不会永远过。
for slot in $PUBLISH_SLOTS; do
stem=${slot//-/_}
for prefix in unlucky-launcher ull; do
if ! compgen -G "dist/${prefix}_*_${stem}*" >/dev/null; then
echo "::error::发布白名单缺产物:$prefix / $slot"
exit 1
fi
done
done
# 各槽位各写一份 SHA256SUMS.txt,合并后只有一份是真的 —— 在 Release 侧按已发布的资产重算。
cd dist
rm -f SHA256SUMS.txt
sha256sum unlucky-launcher_* ull_* > SHA256SUMS.txt
sha256sum -c SHA256SUMS.txt
cat SHA256SUMS.txt
- name: 生成 Release 说明(支持矩阵模板)
shell: bash
env:
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
python3 - <<'PY'
import os, pathlib
version = os.environ["TAG"].lstrip("v")
template = pathlib.Path("packaging/release-notes.md").read_text(encoding="utf-8")
sums = pathlib.Path("dist/SHA256SUMS.txt").read_text(encoding="utf-8").strip()
body = template.replace("{{VERSION}}", version).replace("{{SUMS}}", sums)
pathlib.Path("release-body.md").write_text(body, encoding="utf-8", newline="\n")
print(body[:400])
PY
- name: 创建或更新 GitHub Release(同 tag 重跑幂等)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ github.ref_name }}
PRERELEASE: ${{ inputs.prerelease }}
shell: bash
run: |
set -euo pipefail
base_flags=(--title "Unlucky Launcher $TAG" --notes-file release-body.md)
# pre-release 标记只在**显式要求**时才断言:
# PRERELEASE=true / false —— 来自 tag ref 上的 workflow_dispatch 输入;
# PRERELEASE 空 —— push 触发的运行或它的 Re-run,此时**不碰**该标记,
# 否则会把用户手动提升为 stable 的 Release 静默翻回 pre-release。
# 首次创建(create)仍保持「pre-release 默认开」的老契约:空值按 true 处理。
create_mark=()
edit_mark=()
case "${PRERELEASE:-}" in
true)
create_mark=(--prerelease)
edit_mark=(--prerelease)
;;
false)
edit_mark=(--prerelease=false)
;;
"")
create_mark=(--prerelease)
;;
*)
echo "::error::非预期的 inputs.prerelease 取值:$PRERELEASE"
exit 1
;;
esac
# Windows 单 exe 装不下 LICENSE/NOTICE/README.md,这三份与产物并列挂到 Release 上,
# 保证每个下载平台的许可条款都随包分发。
# `ull` 是独立资产(不在 tar.gz 里):headless 用户下一个小文件直接跑,不用解档。
assets=(dist/unlucky-launcher_* dist/ull_* dist/SHA256SUMS.txt LICENSE NOTICE README.md)
if gh release view "$TAG" >/dev/null 2>&1; then
# 同 tag 重跑(补丁 / 重编):Release 已存在 → 覆盖资产 + 刷新说明,
# 不再 `create`(那会因已存在而失败,把整条流水线在最后一步染红)。
gh release upload "$TAG" "${assets[@]}" --clobber
gh release edit "$TAG" "${base_flags[@]}" "${edit_mark[@]}"
else
gh release create "$TAG" "${assets[@]}" "${base_flags[@]}" "${create_mark[@]}"
fi