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..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//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-` 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- artifact 里取证)" rm -f "$f" ;; esac done - name: 发布白名单齐全性 + 重算全量 SHA256SUMS shell: bash run: | set -euo pipefail ls -l dist # 白名单内槽位必须**全齐**才发版(宁可不出 Release,也不出少一档的 Release)。每个槽位都要 # GUI 与 CLI 两个资产——只按「槽位名出现过」判会被其中一个掩盖。档位比对用下划线形态: # 资产名自 D49 起是 `unlucky-launcher__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