Files
Unlucky-Launcher/.github/workflows/release.yml
T
AskaEth 5678683561 fix(release): M140 r1 复审三条 p2 —— vswhere -find 单值、根因措辞、SDK 双根
P2-1(真缺陷):`-find` 是单值参数,老写法 `... "${find_args[@]}"` 一次传 4 个
`-find`,官方定位器只认其中一个(复现:last-wins 下取到 Hostx64),
所以 round-1 新加的「ARM64 优先 → Hostarm64」在 vswhere 分支上从未执行。
改成每个 pattern 各起一次定位器,不赌取值规则;注释同步为真话。

P2-2(措辞):README §4 不再声称 v0.2.1 那次是「x64 镜像缺库」——windows-2025
镜像清单里 VC.Tools.ARM64 与 SDK 26100 都在,根因未定论,更像自检的路径探测;
§7.4 的 dumpbin 门禁补一句覆盖 windows-latest 与 windows-11-arm 两个镜像。

P2-3(放宽):arm64 库体检的 SDK 侧也改成两个安装根(Program Files (x86) 与
Program Files),把「路径猜错 → fail-closed 误报」这个正是本次事故的形态再降一档。

行数:release.yml 400 / README 400(均 ≤400);PUBLISH_SLOTS 未动。
2026-11-06 11:59:31 +08:00

401 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 **各扫两个安装根**,SDK 全版本找。
- 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 同样两个根:老安装器落在 `(x86)`,别只猜一个 —— 猜错路径就是这次事故的形态(fail-closed 误报)。
sdk_roots=("C:/Program Files (x86)/Windows Kits/10/Lib" "C:/Program Files/Windows Kits/10/Lib")
echo "── 探测的根目录 ──"
for r in "${vs_roots[@]}" "${sdk_roots[@]}"; do
if [ -d "$r" ]; then echo " 有 $r"; else echo " 无 $r"; fi
done
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_roots[@]}" -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_roots[@]}" -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 那族兜底,靠模拟层能跑);反向不行——x64 上挑 Hostarm64 是跑不起来的二进制。
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` 是**单值**参数:一次传多个 pattern 只有一个生效(3.x 取最后一个 —— 老写法里 Hostarm64
# 因此永远轮不到)。所以每个 pattern 各起一次定位器,不赌它取哪个。
# 反斜杠形态优先(vswhere 官方文档即此形式),再试正斜杠以规避分隔符差异。
for p in "${pats[@]}"; do
for pat in "${p//\//\\}" "$p"; do
hit=$("$VSWHERE" -latest -products '*' -find "$pat" 2>/dev/null | head -n1 || true)
if [ -n "$hit" ]; then DUMPBIN=${hit//\\//}; VIA="vswhere"; break 2; fi
done
done
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