From e3ee404bd576c590b2ea117187de6af5220edb8e Mon Sep 17 00:00:00 2001 From: AskaEth Date: Fri, 6 Nov 2026 19:06:38 +0800 Subject: [PATCH] =?UTF-8?q?ci(release):=20M145=20=E6=8A=8A=20release.yml?= =?UTF-8?q?=20=E6=8B=86=E6=88=90=E7=BC=96=E6=8E=92=20+=20=E4=B8=A4?= =?UTF-8?q?=E4=B8=AA=E5=8F=AF=E5=A4=8D=E7=94=A8=E5=B7=A5=E4=BD=9C=E6=B5=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit release.yml 已是 400 行整,下一处改动必撞全仓行数红线(guards.yml 的 file-size)。 拆成「触发 + 槽位表 + 编排」的主工作流与两份 `workflow_call`: - release-build.yml:原 build job 的 11 个步骤逐字搬过去,只有四个矩阵键换成同名 inputs(slot / os / build_dir / rustflags);矩阵仍由调用方 plan 生成并展开。 - release-publish.yml:原 release job 的 6 个步骤逐字搬过去(PUBLISH_SLOTS 白名单、 裁剪、重算 SHA256SUMS、渲染说明、create/edit 幂等);调用方只留 needs / tag 门禁 / contents: write 的授予,token 走显式透传的 secret,pre-release 三态改读 github.event.inputs.prerelease(dispatch 的 boolean 输入无法表达「空值」)。 on / env / 顶层 permissions / concurrency 与 plan 的槽位表一字未动。 校验:YAML 可解析、actionlint 全仓零告警、12 段 run 脚本全部 bash -n 通过、 逐步骤比对(归一化后)与拆分前完全相同;120 / 233 / 151 行。 --- .github/workflows/release-build.yml | 233 ++++++++++++++++++ .github/workflows/release-publish.yml | 151 ++++++++++++ .github/workflows/release.yml | 332 ++------------------------ 3 files changed, 410 insertions(+), 306 deletions(-) create mode 100644 .github/workflows/release-build.yml create mode 100644 .github/workflows/release-publish.yml diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml new file mode 100644 index 0000000..45a9ccd --- /dev/null +++ b/.github/workflows/release-build.yml @@ -0,0 +1,233 @@ +name: Release / build(可复用) + +# Release 流水线的**构建侧**(M145 拆分:`release.yml` 与 `packaging/README.md` 都顶到 400 行红线,下一处改动必越线)。 +# 本文件 = 拆分前 `.github/workflows/release.yml` 的 `build` job **逐字搬家**,唯一的改动是四个矩阵键换成同名入参: +# `matrix.slot` → `inputs.slot`、`matrix.os` → `inputs.os`、`matrix.build_dir` → `inputs.build_dir`、 +# `matrix.rustflags` → `inputs.rustflags`。其余 `name` / `if` / `env` / `run` / `uses` 与拆分前逐行相同。 +# 矩阵**仍由调用方** `release.yml` 的 `plan` job 生成并在外层展开(矩阵不能带 `if`,`strategy` 可读 `needs`), +# 本文件只跑**单个槽位**,不持有槽位表 —— 改槽位只改 `release.yml` 里那一处。 +# `env` 必须在被调方重复声明:**被调工作流不继承调用方的 workflow 级 `env`**(CARGO_TERM_COLOR / RUST_BACKTRACE +# 都是构建产物的实参:前者只影响日志着色,后者决定 panic 是否打回溯)。 + +on: + workflow_call: + inputs: + slot: + description: "槽位键,同 plan matrix 的 slot(如 windows-x64 / linux-arm64)" + type: string + required: true + os: + description: "runner 标签,同 plan matrix 的 os(如 windows-latest / windows-11-arm)" + type: string + required: true + build_dir: + description: "产物根,同 plan matrix 的 build_dir(六槽位一律 target/release)" + type: string + required: true + rustflags: + description: "RUSTFLAGS,同 plan matrix 的 rustflags(两个 Windows 槽位为 -C target-feature=+crt-static,其余空)" + type: string + required: false + default: "" + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + +# 与拆分前 build job 拿到的凭据范围一致(可复用工作流的权限只取调用方与本文件的**交集**,故两边都声明 read)。 +permissions: + contents: read + +jobs: + build: + name: build (${{ inputs.slot }}) + runs-on: ${{ inputs.os }} + permissions: + contents: read + 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/M142)windows-arm64 跑在**原生** `windows-11-arm` 上,本步查这台机器上 ARM64 库齐不齐:`+crt-static` + # 要 MSVC 的 `lib/arm64/libcmt.lib`,链接要 SDK 的 `um/arm64/kernel32.lib`。诊断**只走注解**(job 日志下载要 + # token 我们没有,check-run 注解不用;每条一行,多行会被拼成一行截断,每步 error 注解上限 10 条)。 + # **M142 修本步的自毁 bug**:`shell: bash` 实为 `bash -eo pipefail`,脚本里的 `set -uo pipefail` **关不掉** -e—— + # M141 死在 `vc_lib=$(find 不存在的根 | head -n1)`(pipefail 把 find 的非零带进赋值、errexit 当场退出), + # **一条注解都没打**,上一轮只剩 `Process completed with exit code 1.`。四条规矩:① 显式 `set +e`;② 每处探测 + # 自带 `|| true`;③ 问题记进 ERRORS 而不当场退出;④ 快照先打完、再由显式 `exit` 定红绿。门禁不降级(缺了就 + # 就地补装再判),但 **arm64 红不挡发布**(PUBLISH_SLOTS 已无它),本槽位只取证。 + - name: Windows ARM64 工具链体检(红不挡发布,只取证) + if: inputs.slot == 'windows-arm64' + shell: bash + run: | + set +e; set -uo pipefail; ERRORS=() # -e 由 runner 命令行注入,只能显式关 + say() { printf '::%s::[arm64sdk] %s\n' "$1" "$2"; [ "$1" = error ] && ERRORS+=("$2"); return 0; } + 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」(`/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") + inst="C:/Program Files (x86)/Microsoft Visual Studio/Installer"; vspath="" + [ -x "$inst/vswhere.exe" ] && vspath=$("$inst/vswhere.exe" -latest -products '*' -property installationPath 2>/dev/null | head -n1 || true) + [ -n "$vspath" ] && sdk+=("${vspath//\\//}/Windows Kits/10/Lib") + # 下面两个探测正是 M141 断气处(find 起点不存在 → 非 0 → `| head` 的 pipefail 带进赋值 → errexit),故处处 + # `|| true`:探不到就是「没找到」,由结论下判断,不许由它决定进程生死。 + scan() { vc_lib=$(find "${vs[@]}" -maxdepth 11 -type f -path '*/VC/Tools/MSVC/*/lib/arm64/libcmt.lib' 2>/dev/null | head -n1 || true) + sdk_lib=$(find "${sdk[@]}" -maxdepth 6 -type f -path '*/um/arm64/kernel32.lib' 2>/dev/null | head -n1 || true); } + build=$(ls "${sdk[@]}" 2>/dev/null | grep -oE '^10\.0\.[0-9]+' | cut -d. -f3 | sort -u | tail -n1 || true) + scan + # 快照固定 4 行,**无论齐不齐都先打完**:齐了用 notice(绿跑也留证据 = 探测本身没坏),缺了用 error。 + sev=error; [ -n "$vc_lib" ] && [ -n "$sdk_lib" ] && sev=notice + say "$sev" "探测根: $(for d in "${vs[@]}" "${sdk[@]}"; do [ -d "$d" ] && printf '%s=有 ' "$d" || printf '%s=无 ' "$d"; done)vswhere=$([ -x "$inst/vswhere.exe" ] && echo 有 || echo 无) vspath=${vspath:-<空>}" + 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 + say "$sev" "SDK Lib 版本→um 架构: ${v:-<没有任何版本目录>}(SDK build=${build:-未探到})" + 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) + say "$sev" "MSVC lib/ 架构: ${v:-<无 MSVC lib 目录>}" + say "$sev" "关键库: libcmt=${vc_lib:-缺失} kernel32=${sdk_lib:-缺失}" + if [ "$sev" = notice ]; then echo "ARM64 工具链齐: vc=$vc_lib sdk=$sdk_lib"; exit 0; fi + # 补装:VS 安装器就地 modify(门禁不降级:先补再判)。组件 ID 随 VS 大版本不同;**优先补一个尚未装上的 + # SDK 版本**——同版本重装是 no-op,补不出 arm64 子目录。补装失败不致命,重探与结论给真相。 + 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 "$inst/setup.exe" ] && [ -n "$vspath" ]; then + say error "补装: $vspath ← ${adds}(已装 SDK build=${build:-未装})" + timeout 1200 "$inst/setup.exe" modify --installPath "$vspath" --quiet --norestart --nocache --wait \ + --add ${adds} || say error "补装返回非 0:组件 ID 可能不被这台 VS 认(见下一条结论)" + scan + else say error "补装跳过: 无 vs_installer 或没定位到 VS 安装路径(vspath=${vspath:-<空>})"; fi + if [ -z "$vc_lib" ] || [ -z "$sdk_lib" ]; then + say error "结论: 仍缺(libcmt=${vc_lib:-缺失} kernel32=${sdk_lib:-缺失}),共记 ${#ERRORS[@]} 条探测问题;门禁红、不摘 arm64——本槽位不在 PUBLISH_SLOTS 里,x64 照发" + exit 1 + fi + echo "补装后齐全: vc=$vc_lib sdk=$sdk_lib" + + # M118(D26):**Slint 版停发** —— GUI 只有 `unlucky-launcher-iced`(`crates/app` 已 exclude,不进 workspace)。 + # 产物名由 make_artifacts.py 的 `asset_stem()` 生成 `<名字>_<版本号>_<平台>_<架构>`;不传 `--target`(M140 起全原生)。 + - name: cargo build --release --locked + shell: bash + env: + RUSTFLAGS: ${{ inputs.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 落盘时才换上。 + EXE: ${{ inputs.build_dir }}/unlucky-launcher-iced.exe + run: | + set -euo pipefail + # +crt-static 是否真生效只看导入表:出现 VCRUNTIME/MSVCP/msvcr/ucrtbase 就是还得装可再发行包,fail。 + # dumpbin 定位链(三级,全不命中才 fail —— 门禁不降级):① vswhere ② find 兜底(两个 VS 根都扫)③ PATH + # (排最后是刻意的:最不可控)。dumpbin 在 MSVC 的**宿主**工具目录里:x64 runner 是 `Hostx64/x64`,arm64 + # runner(M140)是 `Hostarm64/arm64`(反向不行——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: ${{ inputs.slot }} + BUILD_DIR: ${{ inputs.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-${{ inputs.slot }} + path: dist + if-no-files-found: error + retention-days: 14 + + # (M142)arm64 若红在别的步骤(cargo / 打包 / 导入表门禁),也要有一条注解说清「红在哪」和「不挡发布」—— + # 只剩一句 `Process completed with exit code 1.` 的教训见上一条体检步骤。 + - name: arm64 失败收尾注解(红不挡发布) + if: failure() && inputs.slot == 'windows-arm64' + shell: bash + run: echo "::error::[arm64] 本槽位已红(失败步骤见本 job 的步骤名与上面各条注解);PUBLISH_SLOTS 当前只有 windows-x64 ⇒ 本次 Release 仍照发 x64,arm64 产物不进 Release" diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml new file mode 100644 index 0000000..52906d0 --- /dev/null +++ b/.github/workflows/release-publish.yml @@ -0,0 +1,151 @@ +name: Release / publish(可复用) + +# Release 流水线的**发布侧**(M145 拆分:`release.yml` 顶到 400 行红线)。本文件 = 拆分前 `release.yml` 的 `release` +# job **逐字搬家**:裁剪白名单外的槽位产物、重算全量 `SHA256SUMS.txt`、渲染 `packaging/release-notes.md` 模板、 +# `gh release create/edit` 的同 tag 幂等,全部照旧。留在调用方 `release.yml` 的只有三件事:`needs: build`、 +# tag 门禁 `if: always() && !cancelled() && github.ref_type == 'tag'`、以及 `permissions: contents: write` 的授予。 +# 与拆分前的三处等价改写(都只涉及"从哪里读",不涉及行为): +# ① `secrets.GITHUB_TOKEN` → 本文件声明的入参 secret `PUBLISH_TOKEN`(调用方透传 `secrets.GITHUB_TOKEN`)。 +# 可复用工作流**拿不到**调用方未显式传入的 secret,写 `secrets.GITHUB_TOKEN` 会静默为空 → gh 401。 +# ② `inputs.prerelease`(原为 `workflow_dispatch` 的输入)→ `github.event.inputs.prerelease`(**字符串**形态的同一个 +# 值:`push` 触发时不存在 ⇒ 空串,dispatch 时是 `"true"`/`"false"`),下面 `case "${PRERELEASE:-}"` 的三条分支与 +# 拆分前完全一致(空值 = 不碰该标记)。为什么不用 `workflow_call` 入参转一道:dispatch 的 `inputs.prerelease` 是 +# **boolean**,而「空值 vs true/false」这条三态语义只有字符串能表达 —— 布尔入参无法取空,硬塞进字符串入参则是 +# 类型不符(`uses` 的 `with:` 会按被调方声明的类型校验;actionlint 当场报 `bool value cannot be assigned`)。 +# 取 event 上的字符串形态既保住三态,又不需要任何类型转换。 +# ③ `PUBLISH_SLOTS` / `runs-on` / `permissions` 的**内容**未动,只是被调方必须自己声明(被调方不继承调用方的 +# workflow 级 `env`;权限取调用方与本文件的交集 —— 两边都声明 `contents: write` 才拿得到写权)。 + +on: + workflow_call: + secrets: + PUBLISH_TOKEN: + description: "调用方透传 `secrets.GITHUB_TOKEN`(本 job 已声明 contents: write,`gh release` 用它发版)" + required: true + +# 权限取交集:调用方 release.yml 的 release job 与本文件都声明 contents: write,交集才是 write(不声明则被收窄成 read)。 +permissions: + contents: write + +jobs: + release: + name: GitHub Release + runs-on: ubuntu-latest + permissions: + contents: write + env: + # ── 发布白名单:唯一决定「哪几个槽位进 Release」的开关(空格分隔,键同 matrix.slot)── + # **M142 起只有 `windows-x64`**:arm64 连试四轮未走通,而下面齐全性检查是「名单内少一档就 fail」——名字挂着 + # arm64 就等于 x64 编成功也发不出去。故把它移出名单:它照留在矩阵里跑取证(诊断注解是将来修它的唯一证据), + # **红了不挡发布**。**可逆**:arm64 修好后把键加回这一行即可(齐全性检查与 SHA256SUMS 自动跟着扩),再同步 + # README §1/§9 与 release-notes.md 两张表。四档 Linux/macOS 仍未验证,默认连构建都不跑(M139)。 + PUBLISH_SLOTS: "windows-x64" + 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 不在发布白名单:四档未验证 / arm64 待修;仍留在 dist- artifact 里取证)" + rm -f "$f" + ;; + esac + done + + - name: 发布白名单齐全性 + 重算全量 SHA256SUMS + shell: bash + run: | + set -euo pipefail + ls -l dist + # 白名单内槽位必须**全齐**才发版(宁可不出 Release,也不出少一档的 Release)。每个槽位都要 GUI 与 CLI 两个 + # 资产——只按「槽位名出现过」判会被其中一个掩盖;比较用下划线形态(资产名自 D49 起是 `..._windows_x64.exe`)。 + # M142 后白名单只剩 x64 一档:arm64 缺产物不再拦发布,但 x64 自己缺 GUI 或 CLI 照样在这里 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.PUBLISH_TOKEN }} + TAG: ${{ github.ref_name }} + PRERELEASE: ${{ github.event.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 输入;空值 = 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` 是独立资产。 + 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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3368fd1..d090b33 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,6 +8,14 @@ name: Release # 契约唯一真相源 packaging/README.md,打包本体 packaging/make_artifacts.py(本地与 CI 同一份脚本)。`inputs.prerelease` # 只在 **tag ref** 上 dispatch 才可达(跑 tag 得 `gh workflow run release.yml --ref v0.2.1`);分支 ref 的 dispatch 与 # push 一样被 release job 的 `if: github.ref_type == 'tag'` 挡在发布之外。 +# +# M145 拆分(本文件与 packaging/README.md 都已顶到 400 行红线,下一处改动必越线):本文件只留**触发 + 槽位表 + 编排**, +# 两个 job 的实体各搬一份可复用工作流(`workflow_call`),步骤逐字未改: +# - `.github/workflows/release-build.yml` = 原 `build` job 全流程(Linux 依赖 / arm64 工具链体检 / cargo build / +# 静态 CRT 导入表门禁 / 打包 / 归档自检 / 上传 artifact / arm64 收尾注解),矩阵键换成同名 `inputs.*`。 +# - `.github/workflows/release-publish.yml` = 原 `release` job 全流程(`PUBLISH_SLOTS` 白名单 / 裁剪 / 重算 +# SHA256SUMS / 渲染说明 / `gh release` 幂等),`secrets.GITHUB_TOKEN` 改为显式透传的 secret。 +# `on` / `env` / `permissions` / `concurrency` 与 `plan` 的槽位表**一字未动**。 on: push: @@ -78,323 +86,35 @@ jobs: print("本次构建槽位:" + " ".join(s["slot"] for s in keep), file=sys.stderr) PY + # 构建实体在 release-build.yml(`workflow_call`)。矩阵仍由上面 `plan` 生成、**在外层展开**:`strategy.matrix` + # 必须在调用侧求值(被调方只按单个槽位的四个入参跑),所以 `matrix.*` 只出现在这个 job 的 `with:` 里。 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/M142)windows-arm64 跑在**原生** `windows-11-arm` 上,本步查这台机器上 ARM64 库齐不齐:`+crt-static` - # 要 MSVC 的 `lib/arm64/libcmt.lib`,链接要 SDK 的 `um/arm64/kernel32.lib`。诊断**只走注解**(job 日志下载要 - # token 我们没有,check-run 注解不用;每条一行,多行会被拼成一行截断,每步 error 注解上限 10 条)。 - # **M142 修本步的自毁 bug**:`shell: bash` 实为 `bash -eo pipefail`,脚本里的 `set -uo pipefail` **关不掉** -e—— - # M141 死在 `vc_lib=$(find 不存在的根 | head -n1)`(pipefail 把 find 的非零带进赋值、errexit 当场退出), - # **一条注解都没打**,上一轮只剩 `Process completed with exit code 1.`。四条规矩:① 显式 `set +e`;② 每处探测 - # 自带 `|| true`;③ 问题记进 ERRORS 而不当场退出;④ 快照先打完、再由显式 `exit` 定红绿。门禁不降级(缺了就 - # 就地补装再判),但 **arm64 红不挡发布**(PUBLISH_SLOTS 已无它),本槽位只取证。 - - name: Windows ARM64 工具链体检(红不挡发布,只取证) - if: matrix.slot == 'windows-arm64' - shell: bash - run: | - set +e; set -uo pipefail; ERRORS=() # -e 由 runner 命令行注入,只能显式关 - say() { printf '::%s::[arm64sdk] %s\n' "$1" "$2"; [ "$1" = error ] && ERRORS+=("$2"); return 0; } - 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」(`/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") - inst="C:/Program Files (x86)/Microsoft Visual Studio/Installer"; vspath="" - [ -x "$inst/vswhere.exe" ] && vspath=$("$inst/vswhere.exe" -latest -products '*' -property installationPath 2>/dev/null | head -n1 || true) - [ -n "$vspath" ] && sdk+=("${vspath//\\//}/Windows Kits/10/Lib") - # 下面两个探测正是 M141 断气处(find 起点不存在 → 非 0 → `| head` 的 pipefail 带进赋值 → errexit),故处处 - # `|| true`:探不到就是「没找到」,由结论下判断,不许由它决定进程生死。 - scan() { vc_lib=$(find "${vs[@]}" -maxdepth 11 -type f -path '*/VC/Tools/MSVC/*/lib/arm64/libcmt.lib' 2>/dev/null | head -n1 || true) - sdk_lib=$(find "${sdk[@]}" -maxdepth 6 -type f -path '*/um/arm64/kernel32.lib' 2>/dev/null | head -n1 || true); } - build=$(ls "${sdk[@]}" 2>/dev/null | grep -oE '^10\.0\.[0-9]+' | cut -d. -f3 | sort -u | tail -n1 || true) - scan - # 快照固定 4 行,**无论齐不齐都先打完**:齐了用 notice(绿跑也留证据 = 探测本身没坏),缺了用 error。 - sev=error; [ -n "$vc_lib" ] && [ -n "$sdk_lib" ] && sev=notice - say "$sev" "探测根: $(for d in "${vs[@]}" "${sdk[@]}"; do [ -d "$d" ] && printf '%s=有 ' "$d" || printf '%s=无 ' "$d"; done)vswhere=$([ -x "$inst/vswhere.exe" ] && echo 有 || echo 无) vspath=${vspath:-<空>}" - 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 - say "$sev" "SDK Lib 版本→um 架构: ${v:-<没有任何版本目录>}(SDK build=${build:-未探到})" - 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) - say "$sev" "MSVC lib/ 架构: ${v:-<无 MSVC lib 目录>}" - say "$sev" "关键库: libcmt=${vc_lib:-缺失} kernel32=${sdk_lib:-缺失}" - if [ "$sev" = notice ]; then echo "ARM64 工具链齐: vc=$vc_lib sdk=$sdk_lib"; exit 0; fi - # 补装:VS 安装器就地 modify(门禁不降级:先补再判)。组件 ID 随 VS 大版本不同;**优先补一个尚未装上的 - # SDK 版本**——同版本重装是 no-op,补不出 arm64 子目录。补装失败不致命,重探与结论给真相。 - 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 "$inst/setup.exe" ] && [ -n "$vspath" ]; then - say error "补装: $vspath ← ${adds}(已装 SDK build=${build:-未装})" - timeout 1200 "$inst/setup.exe" modify --installPath "$vspath" --quiet --norestart --nocache --wait \ - --add ${adds} || say error "补装返回非 0:组件 ID 可能不被这台 VS 认(见下一条结论)" - scan - else say error "补装跳过: 无 vs_installer 或没定位到 VS 安装路径(vspath=${vspath:-<空>})"; fi - if [ -z "$vc_lib" ] || [ -z "$sdk_lib" ]; then - say error "结论: 仍缺(libcmt=${vc_lib:-缺失} kernel32=${sdk_lib:-缺失}),共记 ${#ERRORS[@]} 条探测问题;门禁红、不摘 arm64——本槽位不在 PUBLISH_SLOTS 里,x64 照发" - exit 1 - fi - echo "补装后齐全: vc=$vc_lib sdk=$sdk_lib" - - # M118(D26):**Slint 版停发** —— GUI 只有 `unlucky-launcher-iced`(`crates/app` 已 exclude,不进 workspace)。 - # 产物名由 make_artifacts.py 的 `asset_stem()` 生成 `<名字>_<版本号>_<平台>_<架构>`;不传 `--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 落盘时才换上。 - EXE: ${{ matrix.build_dir }}/unlucky-launcher-iced.exe - run: | - set -euo pipefail - # +crt-static 是否真生效只看导入表:出现 VCRUNTIME/MSVCP/msvcr/ucrtbase 就是还得装可再发行包,fail。 - # dumpbin 定位链(三级,全不命中才 fail —— 门禁不降级):① vswhere ② find 兜底(两个 VS 根都扫)③ PATH - # (排最后是刻意的:最不可控)。dumpbin 在 MSVC 的**宿主**工具目录里:x64 runner 是 `Hostx64/x64`,arm64 - # runner(M140)是 `Hostarm64/arm64`(反向不行——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 - - # (M142)arm64 若红在别的步骤(cargo / 打包 / 导入表门禁),也要有一条注解说清「红在哪」和「不挡发布」—— - # 只剩一句 `Process completed with exit code 1.` 的教训见上一条体检步骤。 - - name: arm64 失败收尾注解(红不挡发布) - if: failure() && matrix.slot == 'windows-arm64' - shell: bash - run: echo "::error::[arm64] 本槽位已红(失败步骤见本 job 的步骤名与上面各条注解);PUBLISH_SLOTS 当前只有 windows-x64 ⇒ 本次 Release 仍照发 x64,arm64 产物不进 Release" + # 可复用工作流的权限只取「调用方 ∩ 被调方」,且不继承调用方的 `env`:两边都声明 read(与原 build job 一致)。 + permissions: + contents: read + uses: ./.github/workflows/release-build.yml + with: + slot: ${{ matrix.slot }} + os: ${{ matrix.os }} + build_dir: ${{ matrix.build_dir }} + rustflags: ${{ matrix.rustflags }} release: name: GitHub Release needs: build # `always()` + `!cancelled()`:白名单外的槽位(现在的 `windows-arm64`,以及只在 `all_slots` 取证跑里存在的四档) - # 红了**不能连坐** x64 发布 —— 真正拦发布的是下面「发布白名单齐全性」那一步:白名单里少任何一个才 fail。 + # 红了**不能连坐** x64 发布 —— 真正拦发布的是被调方「发布白名单齐全性」那一步:白名单里少任何一个才 fail。 + # tag 门禁留在这里(不在被调方):分支 ref 的 dispatch 连发布 job 都不该起。 if: always() && !cancelled() && github.ref_type == 'tag' - runs-on: ubuntu-latest + # `contents: write` 必须由**调用方**授予:可复用工作流只能收窄权限、不能提权(交集语义)。 permissions: contents: write - env: - # ── 发布白名单:唯一决定「哪几个槽位进 Release」的开关(空格分隔,键同 matrix.slot)── - # **M142 起只有 `windows-x64`**:arm64 连试四轮未走通,而下面齐全性检查是「名单内少一档就 fail」——名字挂着 - # arm64 就等于 x64 编成功也发不出去。故把它移出名单:它照留在矩阵里跑取证(诊断注解是将来修它的唯一证据), - # **红了不挡发布**。**可逆**:arm64 修好后把键加回这一行即可(齐全性检查与 SHA256SUMS 自动跟着扩),再同步 - # README §1/§9 与 release-notes.md 两张表。四档 Linux/macOS 仍未验证,默认连构建都不跑(M139)。 - PUBLISH_SLOTS: "windows-x64" - 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 不在发布白名单:四档未验证 / arm64 待修;仍留在 dist- artifact 里取证)" - rm -f "$f" - ;; - esac - done - - - name: 发布白名单齐全性 + 重算全量 SHA256SUMS - shell: bash - run: | - set -euo pipefail - ls -l dist - # 白名单内槽位必须**全齐**才发版(宁可不出 Release,也不出少一档的 Release)。每个槽位都要 GUI 与 CLI 两个 - # 资产——只按「槽位名出现过」判会被其中一个掩盖;比较用下划线形态(资产名自 D49 起是 `..._windows_x64.exe`)。 - # M142 后白名单只剩 x64 一档:arm64 缺产物不再拦发布,但 x64 自己缺 GUI 或 CLI 照样在这里 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 输入;空值 = 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` 是独立资产。 - 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 + uses: ./.github/workflows/release-publish.yml + secrets: + # 可复用工作流拿不到调用方未显式传入的 secret;`gh release` 用的就是这一枚安装令牌。 + PUBLISH_TOKEN: ${{ secrets.GITHUB_TOKEN }}