name: Release # 发布流水线:tag `v*` → 三平台(四槽位)矩阵构建 → 单文件产物 + SHA256SUMS # → 上传 artifact + 创建 GitHub Release(pre-release 默认开)。同一 tag 重跑幂等: # Release 已存在时覆盖资产与说明,不再 `gh release create`(见 release job 末步); # 重跑**不碰** pre-release 标记,该标记只在 `inputs.prerelease` 显式给出时才被断言。 # # 契约唯一真相源:packaging/README.md(命名/目录/运行时依赖)。 # 打包本体在 packaging/make_artifacts.py(本地与 CI 同一份脚本,逐字节同布局)。 # 手动触发(workflow_dispatch)默认只跑构建不建 Release —— 用来验证矩阵仍然编得过。 # 注意 `inputs.prerelease` 只在 **tag ref** 上 dispatch 才可达:Actions 页面的 # "Run workflow" 只能选分支,要跑 tag 得 `gh workflow run release.yml --ref v0.1.0`。 # 分支 ref 的 dispatch 与 push 事件一样被 release job 的 `if: github.ref_type == 'tag'` # 挡在发布之外,所以「dispatch 只构建不发布」与「dispatch 可关 pre-release」并不矛盾。 on: push: tags: - "v*" workflow_dispatch: inputs: 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: build: name: build (${{ matrix.slot }}) strategy: fail-fast: false matrix: include: # Windows:MSVC + 静态 CRT(+crt-static)→ exe 不依赖 VC++ 可再发行包, # 双击即用的单文件形态;UCRT 本身就是 Win10+ 的系统组件。 - slot: windows-x64 os: windows-latest rustflags: -C target-feature=+crt-static # Linux:固定 ubuntu-22.04(glibc 2.35 地板)而不是 latest, # 否则产物的 glibc 底线会跟着 runner 漂移(24.04 = 2.39)。 - slot: linux-x64 os: ubuntu-22.04 # macOS:latest = arm64;Intel x64 用 macos-15-intel(macos-13 已下线)。 - slot: macos-arm64 os: macos-latest - slot: macos-x64 os: macos-15-intel runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - name: Linux 图形栈构建依赖(winit / femtovg 编译期需要) if: runner.os == 'Linux' 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 - name: cargo build --release --locked env: RUSTFLAGS: ${{ matrix.rustflags }} run: cargo build --release --locked --bin unlucky-launcher --bin ull - name: Windows 单文件自检(导入表不得出现 VC++ 可再发行 DLL) if: runner.os == 'Windows' shell: bash run: | set -euo pipefail # +crt-static 是否真的生效,只看导入表:出现 VCRUNTIME/MSVCP/msvcr/ucrtbase 说明还得装 # 可再发行包,那就不是"单文件"了,直接 fail。 # # dumpbin 定位链(三级,全不命中才 fail —— 门禁不降级,只是把"猜路径"换成官方定位): # ① vswhere:VS 官方定位器,安装器自身路径固定,与 VS 版本/版次/安装布局无关; # ② find 兜底:Program Files 与 Program Files (x86) 两个 VS 根都扫(Build Tools 落在 x86 根); # ③ PATH:镜像若已把工具链预置进 PATH 则直接可用。 # PATH 排最后是刻意的:PATH 最不可控,官方定位器优先。 VSWHERE="C:/Program Files (x86)/Microsoft Visual Studio/Installer/vswhere.exe" DUMPBIN="" VIA="" if [ -x "$VSWHERE" ]; then # -find 的模式优先用反斜杠(vswhere 官方文档即此形式),再试正斜杠以规避分隔符差异。 for pattern in 'VC\Tools\MSVC\*\bin\Hostx64\x64\dumpbin.exe' \ 'VC/Tools/MSVC/*/bin/Hostx64/x64/dumpbin.exe'; do hit=$("$VSWHERE" -latest -products '*' -find "$pattern" 2>/dev/null | head -n1 || true) if [ -n "$hit" ]; then DUMPBIN=${hit//\\//} VIA="vswhere" break fi done fi if [ -z "$DUMPBIN" ]; then # 镜像里的 VS 根形如 <年>/<版次>(2022/Enterprise、2022/BuildTools……), # 固定层数的 glob 会在 <年>/<版次> 这两级踩空,所以按路径模式扫。 for root in "C:/Program Files/Microsoft Visual Studio" \ "C:/Program Files (x86)/Microsoft Visual Studio"; do [ -d "$root" ] || continue hit=$(find "$root" -maxdepth 11 -type f \ -path '*/VC/Tools/MSVC/*/bin/Hostx64/x64/dumpbin.exe' 2>/dev/null | head -n1 || true) if [ -n "$hit" ]; then DUMPBIN=$hit VIA="find: $root" break fi done fi if [ -z "$DUMPBIN" ]; then hit=$(command -v dumpbin || true) if [ -n "$hit" ]; then DUMPBIN=$hit VIA="PATH" fi fi # 工具缺失同样 fail:windows-latest 自带 VS 工具链,基本找得到; # "找不到就跳过"等于让这道门禁在最需要它的时候静默失效。 if [ -z "$DUMPBIN" ]; then echo "::error::找不到 dumpbin,无法校验导入表(静态 CRT 门禁不降级)" exit 1 fi echo "dumpbin: $DUMPBIN(定位自 $VIA)" "$DUMPBIN" -nologo -dependents target/release/unlucky-launcher.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 }} run: | set -euo pipefail # git-bash 与 Linux/macOS runner 的 python 入口名不一致,先探一遍。 PY=$(command -v python3 || command -v python) args=() 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 if: github.ref_type == 'tag' runs-on: ubuntu-latest permissions: contents: write steps: - uses: actions/checkout@v4 - uses: actions/download-artifact@v4 with: path: dist merge-multiple: true - name: 矩阵完整性 + 重算全量 SHA256SUMS shell: bash run: | set -euo pipefail ls -l dist for slot in windows-x64 linux-x64 macos-arm64 macos-x64; do if ! compgen -G "dist/*-$slot*" >/dev/null; then echo "::error::矩阵缺产物:$slot" exit 1 fi done # 各平台任务各写一份 SHA256SUMS.txt,合并后只有一份是真的 —— 在 Release 侧重算。 cd dist rm -f SHA256SUMS.txt sha256sum unlucky-launcher-* > 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 上, # 保证每个下载平台的许可条款都随包分发。 assets=(dist/unlucky-launcher-* 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