Files
Unlucky-Launcher/.github/workflows/release.yml
T
tower 5d5011f66c ci(release): M141 arm64 体检改成逐行注解 + 缺 ARM64 SDK 时就地补装组件
- 诊断全部走 ::error:: 注解(job 日志要 token,注解不要),快照固定 4 行:
  探测根存在性 / 每个 SDK Lib 版本目录的 um 架构 / MSVC lib 架构 / 两个关键库路径。
- SDK 根放宽到 4 个((x86)、Program Files、Microsoft SDKs、VS 自带的 SDK in VS),
  kernel32.lib 探测深度 4→6;MSVC find 兜底深度 7 覆盖 lib 目录。
- 缺库时先就地补装(vs_installer modify --wait),组件 ID 按已装 SDK build 反推,
  优先补一个尚未安装的 SDK 版本(同版本重装是 no-op,补不出 um/arm64);补不上才 fail。
- 门禁不降级:PUBLISH_SLOTS 与槽位表原样(windows-arm64 仍在)。
- 为守住 400 行上限,压缩了其余纯注释块与两处等价写法,逻辑未变。
2026-11-06 12:15:10 +08:00

397 lines
23 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 四档从未真构建成功过一次、也没在真机跑过,默认一个都不跑(省配额、
# 不留红叉);整表六槽位取证走 `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'` 挡在发布之外,故「只构建不发布」与「能关 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`,故用 `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-vs2026-arm`)。M140 之前是 windows-latest 上交叉到 aarch64-pc-windows-msvc,
# **CI 从未成功过一次**(v0.2.1 首跑就红在工具链体检,M141 在此修)。原生还让 `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
# (M141)windows-arm64 跑在**原生** `windows-11-arm` 上,本步查这台机器上 ARM64 库齐不齐:
# `+crt-static` 要 MSVC 的 `lib/arm64/libcmt.lib`,链接要 SDK 的 `um/arm64/kernel32.lib`。
# **诊断走注解**:job 日志下载要 token(我们没有),check-run 注解不用;每条一行(多行会被拼成一行而截断,
# 上一轮只看到半句),error 注解上限 10 条故快照固定 4 行。门禁不降级:缺了就地补装再判,补不上才 fail。
- name: Windows ARM64 工具链体检
if: matrix.slot == 'windows-arm64'
shell: bash
run: |
set -uo pipefail # 刻意不用 -e:探测/补装失败也要把注解打完,再由显式 exit 定门禁
ann() { printf '::error::[arm64sdk] %s\n' "$*"; }
vs=("C:/Program Files/Microsoft Visual Studio" "C:/Program Files (x86)/Microsoft Visual Studio")
# SDK 根要放宽:新版装 `(x86)`、也有装 `Program Files`;老安装器把 lib 留在 Microsoft SDKs;
# VS 自己还带一份「SDK in VS」(`<VS>/Windows Kits/10/Lib`),用 vswhere 定位后追加。
sdk=("C:/Program Files (x86)/Windows Kits/10/Lib" "C:/Program Files/Windows Kits/10/Lib"
"C:/Program Files (x86)/Microsoft SDKs/Windows/v10.0A/Lib")
vswhere="C:/Program Files (x86)/Microsoft Visual Studio/Installer/vswhere.exe"
vspath=""; [ -x "$vswhere" ] && vspath=$("$vswhere" -latest -products '*' -property installationPath 2>/dev/null | head -n1)
[ -n "$vspath" ] && sdk+=("${vspath//\\//}/Windows Kits/10/Lib")
scan() { vc_lib=$(find "${vs[@]}" -maxdepth 11 -type f -path '*/VC/Tools/MSVC/*/lib/arm64/libcmt.lib' 2>/dev/null | head -n1)
sdk_lib=$(find "${sdk[@]}" -maxdepth 6 -type f -path '*/um/arm64/kernel32.lib' 2>/dev/null | head -n1); }
scan
if [ -n "$vc_lib" ] && [ -n "$sdk_lib" ]; then echo "ARM64 工具链齐: vc=$vc_lib sdk=$sdk_lib"; exit 0; fi
# 补装前的原样快照(固定 4 行):探测根 / SDK Lib 版本目录的 um 架构 / MSVC lib 架构 / 两个关键库。
ann "探测根: $(for d in "${vs[@]}" "${sdk[@]}"; do [ -d "$d" ] && printf '%s=有 ' "$d" || printf '%s=无 ' "$d"; done)"
v=""
for d in "${sdk[@]}"; do
[ -d "$d" ] || continue
for t in "$d"/*/; do [ -d "$t" ] && v+="$(basename "$t")→um:[$(ls "$t/um" 2>/dev/null | tr '\n' ',')] "; done
done
ann "SDK Lib 版本→um 架构: ${v:-<没有任何版本目录>}"
v=""
while IFS= read -r t; do v+="$(basename "$(dirname "$t")")→[$(ls "$t" 2>/dev/null | tr '\n' ',')] "; done \
< <(find "${vs[@]}" -maxdepth 7 -type d -path '*/VC/Tools/MSVC/*/lib' 2>/dev/null | head -n4)
ann "MSVC lib/ 架构: ${v:-<无 MSVC lib 目录>}"
ann "关键库: libcmt=${vc_lib:-缺失} kernel32=${sdk_lib:-缺失}"
# 补装:VS 安装器就地 modify(门禁不降级:先补再判)。组件 ID 随 VS 大版本不同;**优先补一个尚未
# 装上的 SDK 版本**——同版本重装是 no-op,补不出 arm64 子目录。补装失败不致命,重探与结论给真相。
setup="C:/Program Files (x86)/Microsoft Visual Studio/Installer/setup.exe"
build=$(ls "${sdk[@]}" 2>/dev/null | grep -oE '^10\.0\.[0-9]+' | cut -d. -f3 | sort -u | tail -n1)
id=Microsoft.VisualStudio.Component.Windows11SDK.$([ "$build" = "26100" ] && echo 22621 || echo 26100)
[ "$build" = "19041" ] && id=Microsoft.VisualStudio.Component.Windows10SDK.20348
adds=""; [ -n "$vc_lib" ] || adds+="Microsoft.VisualStudio.Component.VC.Tools.ARM64 "; [ -n "$sdk_lib" ] || adds+="$id"
if [ -x "$setup" ] && [ -n "$vspath" ]; then
ann "补装: $vspath ← ${adds}(已装 SDK build=${build:-未装})"
timeout 1200 "$setup" modify --installPath "$vspath" --quiet --norestart --nocache --wait \
--add ${adds} || ann "补装返回非 0:组件 ID 可能不被这台 VS 认(见下一条结论)"
else ann "补装跳过: 无 vs_installer 或没定位到 VS 安装路径(vspath=${vspath:-<空>})"; fi
scan
if [ -z "$vc_lib" ] || [ -z "$sdk_lib" ]; then ann "补装后结论: 仍缺(libcmt=${vc_lib:-缺失} kernel32=${sdk_lib:-缺失}),门禁不降级、不摘 arm64"; exit 1; fi
echo "补装后齐全: vc=$vc_lib sdk=$sdk_lib"
# M118(D26):**Slint 版停发** —— GUI 只有 `unlucky-launcher-iced`(`crates/app` 已被根 Cargo.toml
# `exclude`,不参与 workspace 构建)。产物名由 make_artifacts.py 的 `asset_stem()` 生成
# `<名字>_<版本号>_<平台>_<架构>`,`LAUNCHER_BIN` 只用来在产物里找文件;不传 `--target`(M140 起全原生编译)。
- 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 各起一次定位器,不赌它取哪个。反斜杠形态优先(官方文档即此形式),再试正斜杠。
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`(asset_stem),故取文件名里的「平台_架构」两段比白名单。
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`)。矩阵裁到两个槽位(M139)后这条检查依然有效:它比的是
# **下载下来的产物**,白名单里的槽位没编或编挂了,就在这里因缺产物 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 上的 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