merge: 解 origin/dev 三处冲突(M182 / M184+M190 / README §14),两边都留
冲突(都来自别人后来改同一批文件): - crates/app-iced/src/platform/mod.rs:保留 M182 的 HostOs / host·linux·macos 分册表 / plan_corner_for,同时保留 M196 的 `[`win32::console`]` 落点说明;把 M182 措辞里已经不 成立的「唯一 unsafe 落点 / 只此一处」改成「两处落点(transparency + console)」 (unsafe-scope 门禁自 M196 起期望集合就是两处,不改这句文档就是错的)。 注意:`pub mod console;` 属于 `platform/win32/mod.rs`,本次解冲突时一度被误加到 `platform/mod.rs`(`cargo check` 立刻报 E0583 文件不存在),已修回。 - .github/workflows/release-build.yml:M190 的「PE 版本资源读回」步、M184 的 linux-x64 xvfb 冒烟步与首帧上传步**全部逐字保留**;M196 的 PE 子系统门禁作为**兄弟步** 插在导入表门禁之后(两道 PE 门禁互不替代)。按块比对:dev 的 10 个步骤逐字未变, 合并后 11 个步骤。 - packaging/README.md:保留 M184 的 §11 补充与 §12 行、M190 的 §14 签名段;§12 里 check_publish_slots 用 dev 的 11 条版本、另加 M196 的 check_pe_subsystem 行;§15 归位在 §14 之后。 行数:release-build.yml 合并后 413 行会撞 400 行红线(dev 侧本来 393),故把 M196 那一步 压到 6 行 + 1 空行(说明改由步骤名指路 packaging/README.md §15 与脚本 docstring 承载), 最终正好 400 行。 验证见 mission 报告:cargo check/test/fmt 全绿,两个 PE 门禁在同一批产物上都 exit 0 (子系统 GUI=2 / CLI=3,版本资源的 subsystem 读数同为 2 / 3)。
This commit is contained in:
commit
1764301cdf
290 files changed
+25890
-2228
No files matched your search
+20
-1
@@ -45,6 +45,10 @@
|
||||
- 闸门下限 ≈「至少画出一条标题栏」(review 实测切片仍 pass):证明「场景被真实渲染过」,**不证明界面元素齐全,
|
||||
更不证明视觉正确**——视觉靠人眼看 artifact。
|
||||
- 只覆盖 Linux x64;macOS 是 dispatch-only 探针(无实测证据),Windows 已有本机人眼验收。
|
||||
- (M184)`linux-x64` 已进发布名单,所以这条**同类**判据也常驻在发布流水线里:`release-build.yml` 的
|
||||
`Linux x64 运行时冒烟` 步用同一个 `check_screenshot.py` 判 release 产物的首帧,外加 `[theme]` /
|
||||
`[backend] final=… result=started` 两行日志断言。证据账(含真机 WSLg 实测输出)在
|
||||
[`packaging/docs/linux-evidence.md`](docs/linux-evidence.md)。
|
||||
|
||||
**本地复跑**:`bash packaging/linux-smoke-docker.sh . D:/tmp/ull-smoke`——脚本逐步复刻这个 job(同样的 apt 依赖、
|
||||
`--bin unlucky-launcher-iced`、同样的 `xvfb-run … --software-renderer --screenshot --data-dir`);输出目录别写 `/tmp`,
|
||||
@@ -64,11 +68,12 @@ Git Bash 下会静默落进 Docker VM(脚本内部用 `cygpath -m` 归一)
|
||||
| `packaging/make_artifacts.py` | 打包与校验(本地 + CI 共用) | ≤400 |
|
||||
| `packaging/release-notes.md` | GitHub Release 说明模板(`{{VERSION}}` / `{{SUMS}}` 占位) | ≤400 |
|
||||
| `packaging/check_screenshot.py` | 截图非空白判定(CI 门禁 + 本地手动跑,零第三方依赖) | ≤400 |
|
||||
| `packaging/check_publish_slots.py` | 发布名单守卫(M164):名单 ⇄ 产物两个方向;`--self-test` 注入 8 条回归 | ≤400 |
|
||||
| `packaging/check_publish_slots.py` | 发布名单守卫(M164/M184):名单 ⇄ 产物两个方向 + `mode: ci` 槽位无产物的警告;`--self-test` 注入 11 条回归 | ≤400 |
|
||||
| `packaging/check_pe_subsystem.py` | PE 子系统守卫(M196):**读产物 PE 头的 `Subsystem`**(GUI 必须 2 / CLI 必须 3),非 2 即红;`--self-test` 注入 8 条回归(红 6 / 绿 2)(见 §15) | ≤400 |
|
||||
| `packaging/check_test_source_off.py` | 测试通道守门(M169):workflow / 打包脚本 / `Cargo.toml default` 都不许打开 `update-test-source`;归属按 `jobs.*.steps[]` 列表项判、默认拒绝;`--self-test` 按 `tests/guard_vectors.json` 注入 | ≤400 |
|
||||
| `packaging/tests/guard_vectors.json` | 守门自测向量(基线 + 32 条:26 红 / 6 绿),数据与代码分开 | ≤400 |
|
||||
| `packaging/ci-runtime-smoke.md` | §11 的细节:断言口径/阈值依据/失效模式/复跑 | ≤400 |
|
||||
| `packaging/docs/linux-evidence.md` | Linux 槽位的证据账(M184):现有证据盘点、WSLg 真机实测输出、xvfb 复跑、CI 增量估算、未验清单 | ≤400 |
|
||||
| `packaging/linux-smoke-docker.sh` | 本地复跑 `runtime-smoke`(Docker + xvfb) | ≤400 |
|
||||
| `packaging/linux-check-docker.sh` | 本地复跑 `ci.yml` 的两条 ubuntu 门禁(clippy / test,Docker) | ≤400 |
|
||||
| `.github/workflows/release.yml` | tag 触发 → 选槽位(`plan`)→ 调下面两个可复用工作流 | ≤400 |
|
||||
@@ -143,6 +148,20 @@ Git Bash 下会静默落进 Docker VM(脚本内部用 `cygpath -m` 归一)
|
||||
`portable-exe`;Linux/macOS 的真实产物是 `.tar.gz` + `portable-zip`,在本模块会被直接拒)。
|
||||
**不验**真 GitHub 下载、GUI 点击与重启替换(需人工)。
|
||||
|
||||
## 14. 产物署名与清单签名(M190)
|
||||
|
||||
这一节只放「去哪看」的三句话,手册本体在 **`packaging/docs/signing.md`**:
|
||||
|
||||
- **PE 版本资源**(Windows「属性 → 详细信息」里的发布者/产品/版权/四段式版本):口径唯一来源
|
||||
`packaging/pe_version.rs`,被 `crates/cli/build.rs` 与 `crates/app-iced/build.rs` 各 `include!` 一次;
|
||||
判据是**读产物字节**的 `packaging/check_pe_version.py`(CI:`release-build.yml` 的 Windows 槽位)。
|
||||
- **清单签名**:`manifest.json.sig` 与 `manifest.json` 并列上传,是分离的 Ed25519 签名文件(签的是清单
|
||||
的**原始字节**,不是某个规范化后的 JSON)。信任锚是 `crates/core/src/update/trust.rs` 里的
|
||||
**编译期公钥数组**(keyring);源里没有 `.sig`、钥不在表里、签名对不上,一律 `signature_*` 分类拒装,
|
||||
**不可重试**。签名脚本 `packaging/sign_manifest.py`(零第三方依赖,`--self-test` 跑 RFC 8032 向量)。
|
||||
- **不得混淆**:项目自有签名解决的是「更新器怎么判断这份清单是不是我们发的」,**不解决 SmartScreen**
|
||||
——用户双击 exe 时看到的「未知发布者」要等 Authenticode 证书,那是另一条线。
|
||||
|
||||
## 15. 产物 PE 子系统(M196:GUI 必须 `WINDOWS_GUI`=2)
|
||||
|
||||
群友 2026-11-08 实测发布构建「运行时会带一个控制台窗口在底下」。根因:产物 PE 头的 `Subsystem` 仍是
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Assert the PE **version resource is really inside the artifact** (M190).
|
||||
|
||||
Writing `CompanyName` into `build.rs` proves nothing — this reads the produced `.exe` bytes and
|
||||
looks for the `VS_VERSIONINFO` structure (`FIXEDFILEINFO.dwSignature = 0xFEEF04BD`, key strings in
|
||||
UTF-16LE) plus the exact values Windows "Properties → Details" must show:
|
||||
|
||||
CompanyName Unlucky Launcher Project
|
||||
ProductName Unlucky Launcher
|
||||
FileDescription Unlucky Launcher
|
||||
LegalCopyright © 2026 Unlucky Launcher Project · Apache-2.0
|
||||
FileVersion <Cargo.toml version, four parts>
|
||||
|
||||
It is deliberately a *byte* check, not a "did the build script run" check: a resource that got lost
|
||||
in linking still fails here. `--expect-version` also compares `dwFileVersionMS/LS` with the crate
|
||||
version, so "version drifted from Cargo.toml" is caught.
|
||||
|
||||
python3 packaging/check_pe_version.py <exe>... --expect-version 0.2.3
|
||||
python3 packaging/check_pe_version.py --self-test
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import struct
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PE_MAGIC = bytes([0x50, 0x45, 0, 0]) # "PE" + two NUL
|
||||
FIXED_SIGNATURE = b"\xbd\x04\xef\xfe" # FIXEDFILEINFO.dwSignature = 0xFEEF04BD
|
||||
PE_VERSION_SOURCE = Path(__file__).resolve().parent / "pe_version.rs"
|
||||
|
||||
# 承诺的署名(与 packaging/pe_version.rs 同一份口径;`--self-test` 解析那个文件对拍)。
|
||||
EXPECTED_STRINGS = {
|
||||
"CompanyName": "Unlucky Launcher Project",
|
||||
"ProductName": "Unlucky Launcher",
|
||||
"FileDescription": "Unlucky Launcher",
|
||||
"LegalCopyright": "© 2026 Unlucky Launcher Project · Apache-2.0",
|
||||
}
|
||||
|
||||
|
||||
def utf16(text: str) -> bytes:
|
||||
return text.encode("utf-16-le")
|
||||
|
||||
|
||||
def four_part(version: str) -> tuple[int, int, int, int]:
|
||||
"""`v0.2.3` / `0.2.3-beta.1` → `(0, 2, 3, 0)`(与 `pe_version.rs::four_part` 同规则)。"""
|
||||
numeric = version.split("-")[0].split("+")[0].strip().lstrip("v")
|
||||
parts = [0, 0, 0, 0]
|
||||
for index, piece in enumerate(numeric.split(".")[:4]):
|
||||
try:
|
||||
parts[index] = min(int(piece), 0xFFFF)
|
||||
except ValueError:
|
||||
parts[index] = 0
|
||||
return (parts[0], parts[1], parts[2], parts[3])
|
||||
|
||||
|
||||
def pe_subsystem(data: bytes) -> int | None:
|
||||
"""可选头里的 `Subsystem`(2 = GUI,3 = 控制台);PE 头不对时返回 None。"""
|
||||
if len(data) < 0x40 or data[:2] != b"MZ":
|
||||
return None
|
||||
offset = struct.unpack_from("<I", data, 0x3C)[0]
|
||||
if offset + 0x5C > len(data) or data[offset : offset + 4] != PE_MAGIC:
|
||||
return None
|
||||
return struct.unpack_from("<H", data, offset + 24 + 0x44)[0]
|
||||
|
||||
|
||||
def check(path: Path, expect_version: str | None) -> list[str]:
|
||||
"""返回没过的判据(空 = 通过)。"""
|
||||
failures: list[str] = []
|
||||
data = path.read_bytes()
|
||||
if len(data) < 0x100:
|
||||
return [f"{path}: 文件太小({len(data)} 字节),不像 PE"]
|
||||
|
||||
subsystem = pe_subsystem(data)
|
||||
if subsystem is None:
|
||||
return [f"{path}: 不是可解析的 PE(MZ / PE 头不对)"]
|
||||
|
||||
has_resource = FIXED_SIGNATURE in data
|
||||
if not has_resource:
|
||||
failures.append(f"{path}: 找不到 FIXEDFILEINFO 签名 0xFEEF04BD —— 这个 exe 里**没有版本资源**")
|
||||
|
||||
for key in ("VS_VERSION_INFO", "StringFileInfo", "VarFileInfo", "Translation"):
|
||||
if utf16(key) not in data:
|
||||
failures.append(f"{path}: 版本资源里缺结构键 `{key}`(UTF-16LE 未命中)")
|
||||
|
||||
for field, value in EXPECTED_STRINGS.items():
|
||||
if utf16(field) not in data:
|
||||
failures.append(f"{path}: 缺字段名 `{field}`")
|
||||
if utf16(value) not in data:
|
||||
failures.append(f"{path}: 字段 `{field}` 的值不是 `{value}`")
|
||||
|
||||
if utf16(f"{path.stem}.exe") not in data:
|
||||
failures.append(f"{path}: `OriginalFilename` 不是 `{path.stem}.exe`")
|
||||
|
||||
if expect_version:
|
||||
parts = four_part(expect_version)
|
||||
dotted = ".".join(str(part) for part in parts)
|
||||
if utf16(dotted) not in data:
|
||||
failures.append(f"{path}: 版本字符串 `{dotted}` 不在资源里(与 Cargo.toml 不同源?)")
|
||||
index = data.find(FIXED_SIGNATURE)
|
||||
if index >= 0 and index + 16 <= len(data):
|
||||
ms, ls = struct.unpack_from("<II", data, index + 8)
|
||||
actual = (ms >> 16, ms & 0xFFFF, ls >> 16, ls & 0xFFFF)
|
||||
if actual != parts:
|
||||
failures.append(f"{path}: 数值版本 {actual} 与期望 {parts} 不一致(dwFileVersion)")
|
||||
else:
|
||||
failures.append(f"{path}: FIXEDFILEINFO 之后读不到 dwFileVersion")
|
||||
|
||||
print(
|
||||
f"{path.name}: {len(data)} 字节 / subsystem={subsystem}(2=GUI 3=控制台)"
|
||||
f" / 版本资源={'有' if has_resource else '无'}"
|
||||
)
|
||||
return failures
|
||||
|
||||
|
||||
def self_test() -> int:
|
||||
"""口径表与 `pe_version.rs` 对拍 + 版本换算 + 「假 PE 必须红」。"""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
failures = 0
|
||||
source = PE_VERSION_SOURCE.read_text(encoding="utf-8")
|
||||
for field, value in (
|
||||
("COMPANY_NAME", EXPECTED_STRINGS["CompanyName"]),
|
||||
("PRODUCT_NAME", EXPECTED_STRINGS["ProductName"]),
|
||||
("COPYRIGHT", EXPECTED_STRINGS["LegalCopyright"]),
|
||||
):
|
||||
if f'pub const {field}: &str = "{value}";' not in source:
|
||||
print(f"FAIL: pe_version.rs 里找不到 {field} = {value!r}", file=sys.stderr)
|
||||
failures += 1
|
||||
|
||||
for case, expect in (("0.2.3", (0, 2, 3, 0)), ("v1.2.3-beta.4", (1, 2, 3, 0)), ("1.2.3.4", (1, 2, 3, 4))):
|
||||
if four_part(case) != expect:
|
||||
print(f"FAIL: four_part({case!r}) = {four_part(case)},期望 {expect}", file=sys.stderr)
|
||||
failures += 1
|
||||
|
||||
# 判据本身要能变红:造一个「像 PE 但没有版本资源」的最小文件,check() 必须报错
|
||||
# (否则这个脚本对任何输入都会「绿」,门禁等于没有)。
|
||||
fake = bytearray(0x200)
|
||||
fake[0:2] = b"MZ"
|
||||
struct.pack_into("<I", fake, 0x3C, 0x40)
|
||||
fake[0x40:0x44] = PE_MAGIC
|
||||
struct.pack_into("<H", fake, 0x40 + 24 + 0x44, 2)
|
||||
with tempfile.NamedTemporaryFile(suffix=".exe", delete=False) as handle:
|
||||
handle.write(bytes(fake))
|
||||
name = handle.name
|
||||
problems = check(Path(name), "0.2.3")
|
||||
os.unlink(name)
|
||||
if not problems:
|
||||
print("FAIL: 没有版本资源的假 PE 竟然通过了(判据失效)", file=sys.stderr)
|
||||
failures += 1
|
||||
|
||||
print("self-test:", "ok" if failures == 0 else f"{failures} 处失败")
|
||||
return 0 if failures == 0 else 1
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="读产物字节断言 PE 版本资源(M190)")
|
||||
parser.add_argument("artifacts", nargs="*", type=Path, help="要检查的 exe")
|
||||
parser.add_argument("--expect-version", help="与 Cargo.toml 同源的版本号(如 0.2.3)")
|
||||
parser.add_argument("--self-test", action="store_true", help="自检(口径表 + 版本换算 + 假 PE 必红)")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.self_test:
|
||||
return self_test()
|
||||
if not args.artifacts:
|
||||
parser.print_help()
|
||||
return 2
|
||||
|
||||
failures: list[str] = []
|
||||
for artifact in args.artifacts:
|
||||
if not artifact.is_file():
|
||||
failures.append(f"{artifact}: 文件不存在")
|
||||
continue
|
||||
failures.extend(check(artifact, args.expect_version))
|
||||
|
||||
for line in failures:
|
||||
print(f"error: {line}", file=sys.stderr)
|
||||
if failures:
|
||||
print(f"FAIL:{len(failures)} 条判据没过(版本资源没进产物就是没进,构建日志不算证据)", file=sys.stderr)
|
||||
return 1
|
||||
print(f"ok: {len(args.artifacts)} 个产物的版本资源齐全")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -17,8 +17,12 @@
|
||||
3. **名单里每一项的 `mode` 都必须是 `ci`**:`mode: probe` 的槽位默认构建根本不跑,写进发布名单等于
|
||||
每一轮都缺一档 —— 一个自己写死要红的组合。
|
||||
4. **产物 ⇄ 名单两个方向**(`--dist`,只在发布侧有产物时跑):
|
||||
- 名单声明了但没有 GUI 或 CLI 产物 ⇒ `::error::` + exit 1(少一档不发版,这条现在是脚本、本地可跑);
|
||||
- 有产物但名单没声明 ⇒ `::warning::` 明说「未发布、会被裁掉」,**不许静默**;
|
||||
- 名单声明了但没有 GUI 或 CLI 产物 ⇒ `::error::` + exit 1(少一档不发版)。声明为发布的多半就是 `mode: ci`
|
||||
的槽位,所以这条同时是「**声明为 ci 的槽位构建红了必须挡住发布**」的判据 —— 红在 build 矩阵里的那一档不会
|
||||
让发布跳过(`release` job 是 `always()`),挡住它的就是这里;
|
||||
- 有产物但名单没声明 ⇒ `::warning::` 明说「未发布、会被裁掉」,**不许静默**(M184 起 `linux-arm64` 正好是
|
||||
这一档:它 `mode: ci`、每轮都编,但运行时未验所以不在名单,产物留着取证);
|
||||
- `mode: ci` 却本次没有产物(且不在名单)⇒ 也打 `::warning::`:那一档的构建很可能红了,不能只表现为「表里少一行」;
|
||||
- 无论哪种,最后都打一张全槽位表:发布 / 未发布(无产物)/ 未发布(名单没声明)。
|
||||
|
||||
用法(零第三方依赖,本地与 CI 同一份代码):
|
||||
@@ -28,7 +32,8 @@
|
||||
python3 packaging/check_publish_slots.py --self-test # 注入回归,断言每条都变红
|
||||
|
||||
`--self-test` 是这条守门的自测证据:把两个 workflow 复制进临时目录、造合成 dist,逐项注入真实回归
|
||||
(漏一个资产 / 名单拼错 / 名单写了 probe 档 / 名单声明了但产物缺 / 名单没声明却有产物),断言守门
|
||||
(漏一个资产 / 名单拼错 / 名单写了 probe 档 / 名单声明了但产物缺 / 名单没声明却有产物 /
|
||||
`mode: ci` 却没产物 / 声明的 `linux-x64` 构建红了 / 提前把 `linux-arm64` 写进名单),断言守门
|
||||
该红的红、该只警告的不红。守门抓不住回归时它自己先红。
|
||||
"""
|
||||
|
||||
@@ -49,7 +54,7 @@ PUBLISH_YML = Path(".github/workflows/release-publish.yml")
|
||||
#: 槽位表条目(`plan` job 里那份):`dict(slot="windows-x64", ..., mode="ci")`,一条可能跨两行。
|
||||
SLOT_RE = re.compile(r'dict\(\s*slot="(?P<slot>[^"]+)"(?P<rest>.*?)\)', re.S)
|
||||
MODE_RE = re.compile(r'mode="(?P<mode>[^"]+)"')
|
||||
#: 发布白名单那一行(yaml 里是 `PUBLISH_SLOTS: "windows-x64"`)。
|
||||
#: 发布白名单那一行(yaml 里是 `PUBLISH_SLOTS: "windows-x64 linux-x64"`)。
|
||||
PUBLISH_RE = re.compile(r'^\s*PUBLISH_SLOTS:\s*"(?P<value>[^"]*)"', re.M)
|
||||
|
||||
#: 每个槽位必须**两样都有**:GUI 与 CLI 各自一个资产(只按「槽位名出现过」判会被其中一个掩盖)。
|
||||
@@ -150,7 +155,12 @@ def check_dist(dist: Path, declared: list[str], matrix: dict[str, str]) -> tuple
|
||||
if is_declared:
|
||||
missing = [role for prefix, role in ASSET_PREFIXES if not found[prefix]]
|
||||
for role in missing:
|
||||
errors.append(f"发布白名单缺产物:{slot} 的 {role}(`{prefix_for(role)}_*_{stem(slot)}*`)")
|
||||
suffix = (
|
||||
"(它是 mode=ci 的槽位:构建红了就必须挡住本次发布,别把缺一档的 Release 发出去)"
|
||||
if matrix.get(slot) == "ci"
|
||||
else ""
|
||||
)
|
||||
errors.append(f"发布白名单缺产物:{slot} 的 {role}(`{prefix_for(role)}_*_{stem(slot)}*`){suffix}")
|
||||
verdict = "发布" if not missing else "缺产物 ⇒ 不发版"
|
||||
wanted.discard(slot)
|
||||
elif present:
|
||||
@@ -160,6 +170,14 @@ def check_dist(dist: Path, declared: list[str], matrix: dict[str, str]) -> tuple
|
||||
"若这是修复后的首次成功构建,把槽位加进名单即可(齐全性检查与 SHA256SUMS 自动跟着扩)"
|
||||
)
|
||||
verdict = "未发布(名单没声明)"
|
||||
elif matrix.get(slot) == "ci":
|
||||
# 声明为「默认在 CI 里构建」的槽位本次却没产物:那一档的构建红了(或矩阵被改窄了)。它不在名单里
|
||||
# ⇒ **不该**让发布变红(arm64 的策略就是「红了不挡发布」),但也不能静默:否则「又红了」只剩翻 job 列表。
|
||||
warnings.append(
|
||||
f"{slot} 是 mode=ci 的槽位(tag 推送默认就编)却本次没有产物:那一档的构建红了或被裁了,"
|
||||
"本次未发布。它不在 PUBLISH_SLOTS 里,故不影响本次发布"
|
||||
)
|
||||
verdict = "未发布(ci 槽位本次无产物)"
|
||||
else:
|
||||
verdict = "未发布(本次没有产物)"
|
||||
rows.append(f"{slot:<20} {'声明' if is_declared else '未声明':<8} {'、'.join(present) or '无':<15} {verdict}")
|
||||
@@ -269,11 +287,12 @@ def self_test() -> int:
|
||||
set_declared(typo, "windows-aarch64")
|
||||
cases.append(("名单拼错成 windows-aarch64", typo, fake_dist(Path(tmp) / "c4d", ("windows-aarch64",)), None, True))
|
||||
|
||||
# 5. 名单写了 probe 档(默认不构建)⇒ 必须红。
|
||||
# 5. 名单写了 probe 档(默认不构建)⇒ 必须红。**要挑仍是 probe 的槽位**:`linux-x64` 已翻成 `mode: ci`,
|
||||
# 再拿它当样例,这条用例就从「必须红」变成「本来该绿」——自测跟着矩阵走,否则它自己先变成假证据。
|
||||
probe = Path(tmp) / "c5"
|
||||
snapshot(probe)
|
||||
set_declared(probe, "windows-x64 linux-x64")
|
||||
cases.append(("名单写了 mode=probe 的槽位", probe, fake_dist(Path(tmp) / "c5d", ("windows-x64", "linux-x64")), None, True))
|
||||
set_declared(probe, "windows-x64 macos-arm64")
|
||||
cases.append(("名单写了 mode=probe 的槽位", probe, fake_dist(Path(tmp) / "c5d", ("windows-x64", "macos-arm64")), None, True))
|
||||
|
||||
# 6. 运行期名单与文件里那一行不一致 ⇒ 必须红(防步内覆盖)。
|
||||
cases.append(("运行期名单与文件不一致", base, fake_dist(Path(tmp) / "c6", tuple(declared)), ["windows-arm64"], True))
|
||||
@@ -284,6 +303,20 @@ def self_test() -> int:
|
||||
set_declared(flip, " ".join([*declared, "windows-arm64"]))
|
||||
cases.append(("名单加上 windows-arm64 且产物齐全(下一 tag 的翻牌预演)", flip, fake_dist(Path(tmp) / "c7d", (*declared, "windows-arm64")), None, False))
|
||||
|
||||
# 8. (M184 核心)名单里的 `linux-x64` 构建红了(没有它的产物)⇒ **必须挡住发布**:`release` job 是
|
||||
# `always()`,构建矩阵里红一档不会把发布跳过;挡住它的只能是这条。
|
||||
dist = fake_dist(Path(tmp) / "c8", tuple(s for s in declared if s != "linux-x64") + ("linux-arm64",))
|
||||
cases.append(("名单声明的 linux-x64 构建红了(缺产物)", base, dist, None, True))
|
||||
|
||||
# 9. (M184 真实场景)`linux-arm64` 构建成功但不在名单 ⇒ 只警告、不挡发布(它是 `mode: ci` 的槽位,每轮都编)。
|
||||
cases.append(("linux-arm64 构建成功但不在名单(只警告)", base, fake_dist(Path(tmp) / "c9", (*declared, "linux-arm64")), None, False))
|
||||
|
||||
# 10. 反过来:有人把 `linux-arm64` 提前写进名单、而它没构建出来 ⇒ 必须红(解锁条件还没满足)。
|
||||
early = Path(tmp) / "c10"
|
||||
snapshot(early)
|
||||
set_declared(early, " ".join([*declared, "linux-arm64"]))
|
||||
cases.append(("名单提前声明 linux-arm64 但缺产物", early, fake_dist(Path(tmp) / "c10d", tuple(declared)), None, True))
|
||||
|
||||
for label, root, dist, expected, want_red in cases:
|
||||
errors, warnings, _rows = run_checks(root, dist, expected)
|
||||
got_red = bool(errors)
|
||||
@@ -295,13 +328,29 @@ def self_test() -> int:
|
||||
else:
|
||||
print(f"OK: {label} → {'红' if got_red else '绿'}({len(warnings)} 条未发布提示)")
|
||||
|
||||
# 第 3 条还要断言**确实打了警告**:不静默才是这条规则的重点。
|
||||
_e, warnings, _r = run_checks(base, fake_dist(Path(tmp) / "c8", (*declared, "windows-arm64")), None)
|
||||
if not any("windows-arm64" in w for w in warnings):
|
||||
print("::error::自测失败:产物在但名单没声明时没有针对 windows-arm64 的 ::warning::", file=sys.stderr)
|
||||
# 第 3 / 9 条还要断言**确实打了警告**:不静默才是这两条规则的重点。
|
||||
for ghost in ("windows-arm64", "linux-arm64"):
|
||||
_e, warnings, _r = run_checks(base, fake_dist(Path(tmp) / f"c-warn-{ghost}", (*declared, ghost)), None)
|
||||
if not any(ghost in w for w in warnings):
|
||||
print(f"::error::自测失败:产物在但名单没声明时没有针对 {ghost} 的 ::warning::", file=sys.stderr)
|
||||
failures += 1
|
||||
else:
|
||||
print(f"OK: 产物在但名单没声明 → 有 {ghost} 的 ::warning::")
|
||||
|
||||
# 第 9 条的另一半:`mode: ci` 的槽位**连产物都没有**(构建红了)时也要有注解,不能只在表里少一行。
|
||||
ci_slots = [slot for slot, mode in matrix_slots((base / MATRIX_YML).read_text(encoding="utf-8")).items() if mode == "ci"]
|
||||
missing_ci = [slot for slot in ci_slots if slot not in declared]
|
||||
if not missing_ci:
|
||||
print("::error::自测失败:没有「mode=ci 但不在名单」的槽位,这条用例失去意义(矩阵是不是被改窄了?)", file=sys.stderr)
|
||||
failures += 1
|
||||
else:
|
||||
print("OK: 产物在但名单没声明 → 有 windows-arm64 的 ::warning::")
|
||||
_e, warnings, _r = run_checks(base, fake_dist(Path(tmp) / "c-ci-red", tuple(declared)), None)
|
||||
for slot in missing_ci:
|
||||
if not any(slot in w and "ci" in w for w in warnings):
|
||||
print(f"::error::自测失败:{slot} 是 mode=ci 却没产物时没有 ::warning::(红在哪都不知道)", file=sys.stderr)
|
||||
failures += 1
|
||||
else:
|
||||
print(f"OK: {slot} 是 mode=ci 却无产物 → 有 ::warning::")
|
||||
|
||||
if failures:
|
||||
return 1
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Parse every workflow file with a **real YAML parser** (M190 r1 复审 P1-1).
|
||||
|
||||
Why this exists: a raw 0x01 byte slipped into `.github/workflows/release-build.yml` (a `sed` replacement
|
||||
string that should have been `\\1`). GitHub refuses to parse such a file, so the whole run would not even
|
||||
start — and no other guard noticed, because they all read workflow files as *text* (`check_release_semantics.py`
|
||||
does `yaml_scalars()`, the file-size job only counts lines). A text scanner cannot see a control byte that
|
||||
a YAML 1.2 reader rejects outright.
|
||||
|
||||
So: hand every `.github/workflows/*.yml` to PyYAML. Two checks, both cheap:
|
||||
① it parses (real error message with position, not a mystery red);
|
||||
② no C0 control character other than TAB/LF/CR is present (YAML 1.2 forbids them; parsers differ in
|
||||
whether they reject, so this is the belt-and-braces half).
|
||||
|
||||
PyYAML is preinstalled on GitHub-hosted runners; if it is not, this script installs it. If that fails too
|
||||
the gate **fails** rather than degrading to a text scan ("工具缺失也 fail" — same rule as the other guards).
|
||||
|
||||
python3 packaging/check_workflows_yaml.py
|
||||
python3 packaging/check_workflows_yaml.py --self-test
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
WORKFLOW_DIR = Path(__file__).resolve().parent.parent / ".github/workflows"
|
||||
ALLOWED_CONTROL = {0x09, 0x0A, 0x0D}
|
||||
|
||||
|
||||
def load_yaml():
|
||||
"""Import PyYAML, installing it once if the runner lacks it."""
|
||||
try:
|
||||
import yaml # noqa: PLC0415
|
||||
|
||||
return yaml
|
||||
except ImportError:
|
||||
print("PyYAML 缺失,尝试安装一次(pip install --quiet pyyaml)…", file=sys.stderr)
|
||||
done = subprocess.run(
|
||||
[sys.executable, "-m", "pip", "install", "--quiet", "pyyaml"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if done.returncode != 0:
|
||||
print(
|
||||
"error: 装不上 PyYAML,YAML 门禁无法运行(门禁不降级)。输出:\n" + done.stdout + done.stderr,
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise SystemExit(2) from None
|
||||
import yaml # noqa: PLC0415
|
||||
|
||||
return yaml
|
||||
|
||||
|
||||
def control_characters(data: bytes) -> list[tuple[int, int]]:
|
||||
"""`[(位置, 字节)]`:YAML 1.2 不许出现的 C0 控制字符(TAB/LF/CR 除外)。"""
|
||||
return [(index, byte) for index, byte in enumerate(data) if byte < 0x20 and byte not in ALLOWED_CONTROL]
|
||||
|
||||
|
||||
def check_file(path: Path, yaml) -> list[str]:
|
||||
"""返回该文件没过的判据(空 = 通过)。"""
|
||||
failures: list[str] = []
|
||||
data = path.read_bytes()
|
||||
for index, byte in control_characters(data):
|
||||
line = data[:index].count(b"\n") + 1
|
||||
failures.append(
|
||||
f"::error file={path},line={line}::第 {line} 行含裸控制字符 {byte:#04x}(YAML 1.2 禁止;"
|
||||
"常见成因是 sed/printf 把 `\\1` 写成了 0x01)"
|
||||
)
|
||||
try:
|
||||
text = data.decode("utf-8")
|
||||
except UnicodeDecodeError as error:
|
||||
failures.append(f"::error file={path}::不是合法 UTF-8({error})")
|
||||
return failures
|
||||
try:
|
||||
yaml.safe_load(text)
|
||||
except Exception as error: # noqa: BLE001 - 解析器的异常类型随版本变,这里只关心「没通过」
|
||||
first = str(error).splitlines()[0] if str(error) else repr(error)
|
||||
failures.append(f"::error file={path}::YAML 解析失败:{first}")
|
||||
return failures
|
||||
|
||||
|
||||
def self_test() -> int:
|
||||
"""注入一枚 0x01 到临时副本,断言门禁**会红**(否则这条门禁对任何输入都绿)。"""
|
||||
import tempfile
|
||||
|
||||
yaml = load_yaml()
|
||||
failures = 0
|
||||
sample = "name: x\non: push\njobs:\n a:\n runs-on: ubuntu-latest\n steps:\n - run: |\n echo hi\n"
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
good = Path(tmp) / "good.yml"
|
||||
good.write_text(sample, encoding="utf-8")
|
||||
if check_file(good, yaml):
|
||||
print("FAIL: 合法 YAML 被判红(误杀)", file=sys.stderr)
|
||||
failures += 1
|
||||
bad = Path(tmp) / "bad.yml"
|
||||
bad.write_bytes(sample.replace('"/\\1/p"', "").encode("utf-8") + b'x\n v=$(s/"' + bytes([1]) + b'/p)\n')
|
||||
if not check_file(bad, yaml):
|
||||
print("FAIL: 含裸 0x01 的 YAML 竟然通过(判据失效)", file=sys.stderr)
|
||||
failures += 1
|
||||
# 只让 PyYAML 判的那一半也要能红:把控制字节换成合法字符,但结构坏掉。
|
||||
broken = Path(tmp) / "broken.yml"
|
||||
broken.write_text("a: [1, 2\nb: 3\n", encoding="utf-8")
|
||||
if not check_file(broken, yaml):
|
||||
print("FAIL: 结构坏的 YAML 竟然通过", file=sys.stderr)
|
||||
failures += 1
|
||||
print("self-test:", "ok" if failures == 0 else f"{failures} 处失败")
|
||||
return 0 if failures == 0 else 1
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="用真 YAML 解析器 parse 全部 workflow(M190 r1 P1-1)")
|
||||
parser.add_argument("--self-test", action="store_true", help="自检:注入 0x01 / 坏结构必须红")
|
||||
parser.add_argument("--dir", type=Path, default=WORKFLOW_DIR, help="workflow 目录(默认 .github/workflows)")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.self_test:
|
||||
return self_test()
|
||||
|
||||
yaml = load_yaml()
|
||||
files = sorted(args.dir.glob("*.yml")) + sorted(args.dir.glob("*.yaml"))
|
||||
if not files:
|
||||
print(f"error: {args.dir} 里一个 workflow 都没有(路径错了?门禁不降级)", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
failures: list[str] = []
|
||||
for path in files:
|
||||
failures.extend(check_file(path, yaml))
|
||||
|
||||
for line in failures:
|
||||
print(line, file=sys.stderr)
|
||||
if failures:
|
||||
print(f"FAIL:{len(failures)} 条判据没过({len(files)} 个文件)", file=sys.stderr)
|
||||
return 1
|
||||
print(f"ok: {len(files)} 个 workflow 全部能被真 YAML 解析器 parse,且无裸控制字符")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -9,9 +9,9 @@
|
||||
| 槽位 | 本版发布 | 产物文件名 | 形态 | 归档内容 |
|
||||
|------|---------|-----------|------|---------|
|
||||
| `windows-x64` | ✅ 发布 | `unlucky-launcher_<ver>_windows_x64.exe` | **裸 exe**(双击即用) | exe 单文件;三份合规文本作为并列 Release 资产 |
|
||||
| `windows-arm64` | ❌ 不发布(构建取证中) | `unlucky-launcher_<ver>_windows_arm64.exe` | **裸 exe**(同 x64 策略) | 同上;CI 在**原生** `windows-11-arm`(Windows 11 Arm64 + VS2026 镜像)上构建,**M164 起编译前体检只取证、不再判定红绿**(此前五轮全红其实全红在那个误判的体检步),**红了不挡 x64 发布**(M142,见 §3) |
|
||||
| `linux-x64` | ❌ 不构建(未验证) | `unlucky-launcher_<ver>_linux_x64.tar.gz` | 单二进制归档 | `unlucky-launcher` + `LICENSE` + `NOTICE` + `README.md` |
|
||||
| `linux-arm64` | ❌ 不构建(未验证) | `unlucky-launcher_<ver>_linux_arm64.tar.gz` | 单二进制归档(原生 ARM64 runner 构建) | 同上 |
|
||||
| `linux-x64` | ✅ 发布 | `unlucky-launcher_<ver>_linux_x64.tar.gz` | 单二进制归档 | `unlucky-launcher` + `LICENSE` + `NOTICE` + `README.md`;CI 在原生 `ubuntu-22.04`(glibc 2.35 地板)上构建,**并有同槽位的 xvfb 运行时门禁**(真渲一帧 + `[theme]` / `[backend] final` 日志断言 + 像素判定);真机验收证据见 `docs/linux-evidence.md` |
|
||||
| `windows-arm64` | ❌ 不发布(构建取证中) | `unlucky-launcher_<ver>_windows_arm64.exe` | **裸 exe**(同 x64 策略) | 同上;CI 在**原生** `windows-11-arm`(Windows 11 Arm64 + VS2026 镜像)上构建,**M164 起编译前体检只取证、不再判定红绿**(此前五轮全红其实全红在那个误判的体检步),**红了不挡发布**(M142,见 §3) |
|
||||
| `linux-arm64` | ❌ 已构建、不发布(运行时零证据) | `unlucky-launcher_<ver>_linux_arm64.tar.gz` | 单二进制归档(原生 `ubuntu-22.04-arm` 构建,与 x64 同 glibc 地板) | 同上;M184 起**默认就编**(`mode: ci`),但没有任何 ARM64 真机跑过一帧 ⇒ 不进 `PUBLISH_SLOTS`。解锁条件 = 真机渲出一帧 + 主题日志 |
|
||||
| `macos-arm64` | ❌ 不构建(未验证) | `unlucky-launcher_<ver>_macos_arm64.tar.gz` | 单二进制归档 | 同上 |
|
||||
| `macos-x64` | ❌ 不构建(未验证) | `unlucky-launcher_<ver>_macos_x64.tar.gz` | 单二进制归档 | 同上 |
|
||||
| 每槽位 | 随所属槽位 | `ull_<ver>_<平台>_<架构>[.exe]` | **裸 CLI 文件**(独立资产,不进 tar.gz) | 零 UI 依赖的 `ull` 本体,进同一份 `SHA256SUMS.txt` |
|
||||
@@ -20,10 +20,12 @@
|
||||
|
||||
约定细节:
|
||||
|
||||
- **「不构建」= CI 默认根本不跑**(M139):那四档未验证,产物不给用户、构建配额也不花;要整表六槽位取证走
|
||||
`gh workflow run release.yml -f all_slots=true`(§3)。发布白名单**只有 `windows-x64`**(M142:arm64 照构建、不发布)。
|
||||
(M164)名单 ⇄ 产物两个方向都有守卫 `packaging/check_publish_slots.py`:声明了缺产物 ⇒ 红;有产物但没声明 ⇒
|
||||
`::warning::` 明说「未发布、会被裁掉」——arm64 真出包之后把键加回名单是一行改动,守卫与 `SHA256SUMS.txt` 自动跟着扩。
|
||||
- **「不构建」= CI 默认根本不跑**(M139):macOS 那两档未验证,产物不给用户、构建配额也不花;要整表六槽位取证走
|
||||
`gh workflow run release.yml -f all_slots=true`(§3)。发布白名单**是 `windows-x64` + `linux-x64`**(M184)。
|
||||
「已构建但不发布」是独立的一档(`linux-arm64`、`windows-arm64`):它们 `mode: ci`、每轮都编,产物留在
|
||||
`dist-<slot>` artifact 里取证,裁剪步会把它们剔出 Release。
|
||||
(M164/M184)名单 ⇄ 产物两个方向都有守卫 `packaging/check_publish_slots.py`:声明了缺产物 ⇒ 红;有产物但没声明 ⇒
|
||||
`::warning::` 明说「未发布、会被裁掉」;`mode: ci` 却本次没产物 ⇒ 也打 `::warning::`(那一档红了要看得见)。
|
||||
- **产物命名**:`<名字>_<版本号>_<平台>_<架构>`,下划线分段、**不带实现细节字样**(不出现 `iced`);真相源是 `make_artifacts.py::asset_stem()`。
|
||||
- **"单文件"指可执行文件本身零运行时依赖**(无 WebView、无 JRE、无 sidecar、无 DLL 旁挂);归档里的三份合规文本是许可义务、不是运行时依赖,Windows 因为"一个 exe 装不下文本",
|
||||
把 `LICENSE` / `NOTICE` / `README.md` 作为 Release 的并列资产分发,条款同样随下载走。
|
||||
|
||||
@@ -32,15 +32,15 @@ python packaging/make_artifacts.py --slot windows-arm64 \
|
||||
|
||||
`--slot` 与宿主槽位不一致时,`ull --version` 冒烟会**自动跳过并打印原因**(arm64 产物在 x64
|
||||
宿主机上根本执行不了;拿"跳过"当通过是假证据,所以跳过永远带原因)。**CI 不走交叉**(M140):
|
||||
`windows-arm64` 在原生 `windows-11-arm` 上编,宿主=产物架构 → 冒烟**真跑**;`linux-arm64` 的同类
|
||||
冒烟只在 `all_slots=true` 的取证跑里出现(`ubuntu-22.04-arm` 原生 runner 上真跑 `ull --version`)。
|
||||
`windows-arm64` 在原生 `windows-11-arm` 上编,宿主=产物架构 → 冒烟**真跑**;`linux-arm64` 也是原生
|
||||
`ubuntu-22.04-arm` 构建 → 冒烟在 runner 上真跑(M184 起它默认就编,不再只在 `all_slots=true` 的取证跑里出现)。
|
||||
|
||||
### CI(默认只跑两个 Windows 槽位;`all_slots=true` 才整表六槽位)
|
||||
### CI(默认跑四个槽位:Windows 两档 + Linux 两档;`all_slots=true` 才整表六槽位)
|
||||
|
||||
`.github/workflows/release.yml`:
|
||||
|
||||
- 触发:`push` tag `v*`;`workflow_dispatch` 只构建不发布,且多一个 `all_slots` 开关(**默认 false =
|
||||
只跑两个 Windows 槽位**,true = 整表六槽位都编一遍取证)。`inputs.prerelease` **只在 tag ref 上 dispatch
|
||||
只跑四个槽位**,true = 整表六槽位都编一遍取证)。`inputs.prerelease` **只在 tag ref 上 dispatch
|
||||
才可达**——Actions 页面的 "Run workflow" 只能选分支,要跑 tag 得 `gh workflow run release.yml --ref v0.2.3`。
|
||||
分支 ref 的 dispatch 与 push 一样被 `release` job 的 `if: github.ref_type == 'tag'` 挡在发布之外,所以
|
||||
「分支 ref 的 dispatch 只构建不发布」与「tag ref 上 dispatch 能选标记」是两条互不冲突的规则。
|
||||
@@ -48,8 +48,15 @@ python packaging/make_artifacts.py --slot windows-arm64 \
|
||||
不能带 `if`,而 `strategy` 可读 `needs`),槽位表只在该 job 里有一份:`windows-latest` x64 与
|
||||
**原生** `windows-11-arm` arm64(M140;后者是公开仓免费的标准 arm64 runner,4C/16G)——两者都是 MSVC +
|
||||
`-C target-feature=+crt-static`、产物都在 `target/release`,标 `mode: ci`(默认编);
|
||||
`ubuntu-22.04`(glibc 2.35 地板)/ `ubuntu-22.04-arm`(原生 ARM64,同地板)/ `macos-latest` /
|
||||
`macos-15-intel` 标 `mode: probe`——**未验证,默认一个都不编**,只有 `all_slots=true` 才进矩阵。
|
||||
(M184)`ubuntu-22.04`(glibc 2.35 地板)与 `ubuntu-22.04-arm`(原生 ARM64,同地板)**也标 `mode: ci`** ——
|
||||
tag 推送默认就编这两档;只剩 `macos-latest` / `macos-15-intel` 是 `mode: probe`,**未验证、默认一个都不编**,
|
||||
只有 `all_slots=true` 才进矩阵。
|
||||
- **运行时证据决定发布,构建绿了不够**:`linux-x64` 进 `PUBLISH_SLOTS` 的依据是「release 产物真开窗渲出一帧」
|
||||
(真机 WSLg + 截图 + `[theme]` / `[backend] final` 日志,实测输出见 `packaging/docs/linux-evidence.md`),
|
||||
所以 `release-build.yml` 给**这个槽位**配了一条 xvfb 运行时冒烟步(`if: inputs.slot == 'linux-x64'`):
|
||||
装 xvfb → `xvfb-run … --software-renderer --screenshot` 起 **release** 产物 → 断言 `[theme] source=` 与
|
||||
`[backend] final=… result=started` 两行 → `check_screenshot.py` 判像素 → 上传首帧图。
|
||||
`linux-arm64` **刻意没有**这条判据:arm64 上这条路一次都没验过,给它加一条没验证过的门禁就是造红。
|
||||
- 每个槽位 `cargo build --release --locked`(**不传 `--target`**:每槽原生编译,交叉槽位没了)。arm64 槽位在编译前
|
||||
**把工具链显式备齐**(M164):`rustup target add aarch64-pc-windows-msvc`、用微软自己的 `vcvarsall.bat arm64` 取
|
||||
`LIB`/`INCLUDE`(写进本步环境与 `$GITHUB_ENV`)、用 vswhere 定位 arm64 `link.exe` 后显式导出
|
||||
@@ -64,16 +71,25 @@ python packaging/make_artifacts.py --slot windows-arm64 \
|
||||
自相矛盾(`um/arm64` 明明在)。现在那一步只打印 vswhere 组件查询、各目录 `.lib` **计数**与 `link.exe` 候选,
|
||||
永不 `exit 1`;**红绿只由编译器 / 链接器 / 静态 CRT 导入表门禁决定**。runner 实况:`windows-11-arm` 自 2026-09
|
||||
是 Windows 11 Arm64 + **Visual Studio 2026** 镜像,`VC.Tools.ARM64` 与 `Windows11SDK.22621/26100` 都已装好。
|
||||
- **发布白名单(`PUBLISH_SLOTS`,唯一开关)**:只有白名单里的槽位进 Release 与 `SHA256SUMS.txt`。**M142 起只有
|
||||
`windows-x64`**:齐全性检查是「名单内少一档就 fail」,挂着没验证过的 arm64 就等于 x64 编成功也发不出去。
|
||||
(M164)arm64 那五轮红其实全红在误判的体检步(上一条),门禁拆掉后「arm64 能不能出 PE」只能由**下一 tag 的 CI**
|
||||
证明,所以名单这轮仍只列 x64 —— 不拿 x64 的发布去赌(tower M164 裁决)。**可逆且只是一行**:那次 CI 真出包后把
|
||||
键加回 `release-publish.yml` 那一行,再同步 §1/§9 与 `release-notes.md`。四档未验证的(默认连构建都不跑)依旧不推给用户。
|
||||
- **名单 ⇄ 产物两个方向都有守卫(M164)**:`packaging/check_publish_slots.py`,同一份代码两处跑 —— PR 阶段
|
||||
- **发布白名单(`PUBLISH_SLOTS`,唯一开关)**:只有白名单里的槽位进 Release 与 `SHA256SUMS.txt`。**M184 起是
|
||||
`windows-x64 linux-x64`**:齐全性检查是「名单内少一档就 fail」,所以 `linux-x64` 进来之后它的构建红了就挡住整次
|
||||
发布 —— 这正是「声明为 ci 的槽位失败必须阻断发布」那条要求,判据落在 `check_publish_slots.py` 的 `--dist` 模式里
|
||||
(`release` job 是 `always()`,构建矩阵里红一档不会让它跳过,挡住它的只能是那道守卫)。
|
||||
`linux-arm64` 本轮**只构建不发布**:它没有任何运行时证据(没有 ARM64 真机跑过一帧,也没有 arm64 的 xvfb 冒烟),
|
||||
**解锁条件 = 真机上真跑出一帧 + 主题日志**,之后才把键加进 `release-publish.yml` 那一行,再同步 §1/§9 与
|
||||
`release-notes.md`。`windows-arm64` 同理仍未进名单(M164:它的五轮红全红在误判的体检步,等下一 tag 的 CI 证)。
|
||||
**可逆且只是一行**:翻牌只改 `PUBLISH_SLOTS` 那一行,齐全性与 SHA256SUMS 自动跟着扩。
|
||||
- **耗时(M184 估算,2 槽位 → 4 槽位)**:多出的两档是**并行**跑的(矩阵 `fail-fast: false`),所以墙钟增量 ≈
|
||||
「`linux-x64` 那一档的时长(含新增 xvfb 冒烟步约 1~2 分钟)」与「`linux-arm64` 那一档的时长」取大者,而不是两者相加;
|
||||
真正增加的是**并发作业数与总机时**(约 +2× 单槽位机时,公开仓 arm64 Linux runner 不额外计费)。
|
||||
发布侧只多两个资产的裁剪/摘要/上传(秒级)。细节与逐项拆解见 `packaging/docs/linux-evidence.md`。
|
||||
- **名单 ⇄ 产物两个方向都有守卫(M164,M184 扩)**:`packaging/check_publish_slots.py`,同一份代码两处跑 —— PR 阶段
|
||||
(`.github/workflows/release-guard.yml`)断言「名单非空 / 每项都是槽位表里 `mode: ci` 的档」;发布侧
|
||||
(`release-publish.yml` 的 `--dist` 模式,跑在**裁剪前**的全量集上)断言「声明了就得有 GUI 与 CLI 两个产物,
|
||||
缺一个就红」,并把「有产物但名单没声明」打成 `::warning::` 明说未发布 —— M142 之后那个方向是静的(裁剪步只
|
||||
`echo drop:`),一个真编出来的 arm64 会被就地删掉而没人知道。`--self-test` 注入 8 条回归,本地一条命令可跑。
|
||||
`echo drop:`),一个真编出来的 arm64 会被就地删掉而没人知道。M184 补第三条:`mode: ci` 的槽位**本次连产物都没有**
|
||||
(那一档构建红了)也打 `::warning::`,别只在表里少一行。`--self-test` 注入 11 条回归(含「声明的 `linux-x64` 构建红了
|
||||
⇒ 必须红」「`linux-arm64` 有产物但没声明 ⇒ 只警告」「提前把 `linux-arm64` 写进名单 ⇒ 缺产物必红」),本地一条命令可跑。
|
||||
- `release` job:下载全部 artifact → **发布名单守卫**(断言 + 未发布提示)→ 按白名单裁剪 → 重算 `SHA256SUMS.txt` →
|
||||
用 `packaging/release-notes.md` 模板渲染支持矩阵 → 生成更新清单 `manifest.json` → `gh release create`
|
||||
(**push tag 默认建稳定版**:不带任何标记)。
|
||||
@@ -106,8 +122,9 @@ python packaging/make_artifacts.py --slot windows-arm64 \
|
||||
后来只有 M163 动过 `on` 里 `prerelease` 的默认值(`true` → `false`,见上面那张语义表),M164 改过
|
||||
`release-build.yml` 的 arm64 体检步与 `cargo build` 步、并在 `release-publish.yml` 裁剪前插了「发布名单守卫」
|
||||
(于是本分支 8 步:checkout / download / **守卫** / 裁剪 / 重算 SHA256SUMS / manifest / 说明 / `gh release`;
|
||||
搬家时 6 步、主线 7 步)。改槽位仍只改 `release.yml` 那一处(`PUBLISH_SLOTS` 白名单在 `release-publish.yml`,
|
||||
翻牌时要改的仍是那两处)。
|
||||
搬家时 6 步、主线 7 步)。M184 又在 `release-build.yml` 里给 `linux-x64` 插了「运行时冒烟 + 上传首帧图」两步
|
||||
(该文件 11 → 13 步,发布侧仍是 8 步)。改槽位仍只改 `release.yml` 那一处(`PUBLISH_SLOTS` 白名单在
|
||||
`release-publish.yml`,翻牌时要改的仍是那两处)。
|
||||
- **M149 微软登录 id 的烘焙**:`release-build.yml` 的 `cargo build` 步带 `env.ULL_MSA_CLIENT_ID`(值
|
||||
`${{ secrets.ULL_MSA_CLIENT_ID }}`),`option_env!` 把它烘进二进制,于是发布产物装完即可登录。id **不进
|
||||
git 文件**:真值放仓库 Secret,由 `release.yml` 的 `build` job 按名显式透传(可复用工作流拿不到未显式传入的
|
||||
|
||||
@@ -21,25 +21,32 @@
|
||||
| 平台 | 本版发布 | 级别 | 运行时要求 |
|
||||
|------|---------|------|-----------|
|
||||
| Windows 10/11 x64 | ✅ | 一等 | 无(UCRT 系统自带) |
|
||||
| Linux x64 X11 | ✅ | 一等 | glibc 2.35+(`ubuntu-22.04` 原生构建即地板)、xkbcommon、fontconfig、libGL(或软渲染)、xcb |
|
||||
| Linux x64 Wayland | ✅(同一归档;**Wayland 路径未验**) | 支持 | 上述 + wayland-client(XWayland 应用仍需 X11 那套) |
|
||||
| Windows 11 ARM64 | ❌ 不发布(构建取证中) | 一等(启动器产物) | 无额外库;启动器原生运行,游戏本体走系统 x64 模拟层 + x64 JDK |
|
||||
| Linux arm64 | ❌ 已构建、不发布(**运行时零证据**) | 一等(未真机验证) | glibc 2.35+(`ubuntu-22.04-arm` 原生构建,**与 x64 同地板**)、xkbcommon、fontconfig、libGL、xcb / wayland-client |
|
||||
| macOS 12+(arm64 / x64) | ❌ 不构建 | 一等(未真机验证) | 无第三方库;未签名需去 quarantine |
|
||||
| Linux x64 X11 | ❌ 不构建 | 一等(未真机验证) | glibc 2.35+、xkbcommon、fontconfig、libGL、xcb |
|
||||
| Linux x64 Wayland | ❌ 不构建 | 支持 | 上述 + wayland-client |
|
||||
| Linux arm64 X11 | ❌ 不构建 | 一等(未真机验证) | glibc 2.35+(`ubuntu-22.04-arm` 原生构建,**与 x64 同地板**)、xkbcommon、fontconfig、libGL、xcb |
|
||||
| Linux arm64 Wayland | ❌ 不构建 | 支持 | 上述 + wayland-client |
|
||||
| Windows 8.1/8/7 | — | 不支持 | 基线已上调(D21) |
|
||||
| Android Termux + X11 | — | 试验级(不承诺) | CLI 与 GUI 均需源码自建;GUI 已在单机真机验证两条路径各一次(CPU 软渲染 + 自编 Mesa/turnip GPU 加速,配方见 `packaging/docs/termux-glibc-build.md`、证据见 `README.termux.md`),不在本发布矩阵内 |
|
||||
|
||||
`packaging/release-notes.md` 的 "Support matrix" 表与上表逐行对应(英文版同样带 published / not
|
||||
published 一列,Termux 行标注 experimental)——改一边必须同步另一边,否则标题里的"一致"就是假话。
|
||||
|
||||
两个 arm64 槽位都是**正式矩阵槽位**(不是 Termux 那种源码自建旁路),但本版两个都**不发布**:`windows-arm64` 照构建取证
|
||||
(红了不挡 x64 发布),`linux-arm64` 连构建都不跑;发布开关是 `release-publish.yml` 的 `PUBLISH_SLOTS`(当前只有 `windows-x64`)。
|
||||
**M184 的翻牌(Linux x64 进发布名单,arm64 只构建)**:发布开关仍是 `release-publish.yml` 的 `PUBLISH_SLOTS`,
|
||||
现在是 `windows-x64 linux-x64`;`release.yml` 槽位表里 `linux-x64` 与 `linux-arm64` 都是 `mode: ci`(tag 推送默认就编)。
|
||||
两档的唯一差别是**运行时证据**:
|
||||
|
||||
(M164)`windows-arm64` 为什么还没进名单:它 v0.2.1~v0.2.3 的红**全部**红在一个编译前体检步(`find` 猜 SDK 的
|
||||
- `linux-x64` 有:release 产物在 Linux 真机(Ubuntu 22.04 + WSLg 真窗口)渲出一帧、`[theme]` 与
|
||||
`[backend] final=… result=started` 两行齐全、像素判定通过;`release-build.yml` 里还有一条**同槽位**的 xvfb
|
||||
运行时门禁,每轮发布都重跑(证据与实测输出:`packaging/docs/linux-evidence.md`)。
|
||||
- `linux-arm64` 没有:没有任何 ARM64 机器跑过它一帧,也没有 arm64 的 xvfb 冒烟。**解锁条件 = 真机(或原生
|
||||
ARM64 runner)上真跑出一帧 + 主题日志**,之后才把键加进 `PUBLISH_SLOTS`。构建绿了不算解锁,别拿没跑过的包发人。
|
||||
|
||||
`windows-arm64` 为什么还没进名单(M164 遗留):它 v0.2.1~v0.2.3 的红**全部**红在一个编译前体检步(`find` 猜 SDK 的
|
||||
`um/arm64/kernel32.lib`),`cargo build` 一次都没跑过;M164 把那个门禁降级为只取证、判定权交回编译器,但「能不能
|
||||
出 PE」只能由**下一 tag 的 CI** 证明。名单 ⇄ 产物的两个方向现在都有守卫(`packaging/check_publish_slots.py`):
|
||||
声明了缺产物 ⇒ 红,有产物没声明 ⇒ `::warning::` 明说未发布 —— 所以那次 CI 真出包之后,翻一行名单即可。
|
||||
出 PE」只能由**下一 tag 的 CI** 证明。名单 ⇄ 产物的两个方向都有守卫(`packaging/check_publish_slots.py`):
|
||||
声明了缺产物 ⇒ 红(这正是「声明为 ci 的槽位构建红了就挡住发布」那条闸),有产物没声明 ⇒ `::warning::` 明说未发布,
|
||||
`mode: ci` 却本次没产物 ⇒ 也打 `::warning::`(红了要看得见)—— 所以那次 CI 真出包之后,翻一行名单即可。
|
||||
|
||||
## 10. 已知边界 / 后续
|
||||
|
||||
@@ -57,3 +64,9 @@ published 一列,Termux 行标注 experimental)——改一边必须同步
|
||||
`windows-arm64` / `linux-arm64` 的**真实产物只能由 CI 出**(前者靠 `dumpbin -dependents` 的静态 CRT
|
||||
门禁 + 架构校验,后者在 `ubuntu-22.04-arm` 上真跑 `ull --version` 冒烟);本机能做且已做的是脚本层
|
||||
取证(§7.5:宿主判定注入、可执行文件头识别、把真 exe 的 PE 机器码改成 ARM64 当 stub 走完全流程)。
|
||||
8. **Linux 上自更新只"能查不能装"**(M184 发布 Linux x64 之后才有意义):清单现在会带 `linux-x64` 的
|
||||
`portable-zip` 产物,而 `ull update apply` 只做便携单文件 `.exe` 的自替换
|
||||
(`crates/core/src/update/job.rs::ensure_self_replaceable`)——Linux 上它会带具名原因**拒绝**
|
||||
("发布里的 linux-x64 产物是 portable-zip 形态,不能原地替换启动器 … 压缩包请解压后再用"),
|
||||
不会半装坏。Linux 的更新方式仍是「下载新归档覆盖旧副本」。真要做 Linux 自更新(换目录里的文件再重启)
|
||||
是另一件事,未规划。
|
||||
@@ -0,0 +1,208 @@
|
||||
# Linux 槽位的证据账(M184)
|
||||
|
||||
本文是「Linux 到底验过什么、没验什么」的**唯一账本**。M184 把 `linux-x64` 写进了 `PUBLISH_SLOTS`(发布),
|
||||
把 `linux-arm64` 留在「构建但不发布」,两者都不是凭感觉:下面按「已有哪些证据 → 本轮补的实测 → CI 增量 →
|
||||
仍然没验的部分 → 解锁条件」逐条记账,任何一条被推翻都要回来改这里与 `compliance-support-boundaries.md` §9。
|
||||
|
||||
## 1. M184 之前的证据盘点
|
||||
|
||||
| 证据 | 位置 | 覆盖了什么 | 没覆盖什么 |
|
||||
|------|------|-----------|-----------|
|
||||
| xvfb 运行时冒烟(每次 PR/main) | `.github/workflows/ci.yml` 的 `runtime-smoke` | `ubuntu-latest`(x64)上装 xvfb + 软渲染依赖 + 字体,`xvfb-run … --software-renderer --screenshot` 真渲一帧,`packaging/check_screenshot.py` 对**像素本身**断言(唯一色数 / 亮度 σ / 强边缘占比),截图作为 artifact 留证 | 只有 x64;用的是 **debug** 档(`cargo build --bin unlucky-launcher-iced --locked`),不是发出去的那个 release 产物;不跑 GPU 通路;不验 Wayland;不校验 `[theme]` / `[backend] final` 日志行;没有 arm64 |
|
||||
| 本地复跑脚本(开发机) | `packaging/linux-smoke-docker.sh` | 在 `rust:1-bookworm`(**Debian 12 / glibc 2.36**)里逐步复刻上面那个 job;M131 真跑过一次(2026-11-05):`exit=0`、截图 1180×760、`[ok] 截图非空白` | 基础镜像是 Debian 而不是 runner 的 Ubuntu;仍是 debug 档;同样没有 arm64 |
|
||||
| 编译期门禁复跑 | `packaging/linux-check-docker.sh` | 在 Linux 上跑 `clippy --workspace --all-targets -- -D warnings` 与 `test --workspace --all-targets`(M144 那两条只能在 Linux 复现的红) | 与运行时无关,只证明「编得过、测得过」 |
|
||||
| 阈值依据 | `packaging/ci-runtime-smoke.md` | 真实帧与纯色帧的实测数值(唯一色 342~2352、σ 19.45~27.46、强边缘 0.0076~0.0433)都在这里,阈值取在两者之间 | 标定样本来自 x64 软渲染 |
|
||||
| `ull --version` 冒烟 | `packaging/make_artifacts.py::smoke_version` | 槽位与宿主一致时**机制上会真跑** CLI 并断言版本串(`slot != host_slot()` 才跳过)。`linux-x64` 侧本轮**真跑过**(见 §2 `smoke: ull --version -> 0.2.3 (ok)`) | **机制存在 ≠ 有过一次运行**:截至本分支,仓库里找不到任何一次 `linux-arm64` 成功出包的记录(`git grep -rn "all_slots" DevDocs/` 零命中),所以 **arm64 侧运行时证据为零** —— 连这条 CLI 冒烟都没在 arm64 上真跑过。GUI 一帧更没有 |
|
||||
|
||||
**glibc 地板怎么定的**:槽位表把 Linux 两档钉在 `ubuntu-22.04` / `ubuntu-22.04-arm`,**不跟 `latest` 走**
|
||||
(`release.yml` 的槽位表注释:跟 latest 走地板就会随镜像漂移)。Ubuntu 22.04 ⇒ glibc **2.35**,这就是
|
||||
`README` / §9 写的「glibc 2.35+」的来源,也是本轮真机验收为什么选 Ubuntu 22.04 的 WSL 发行版:**验证环境的地板
|
||||
与发布产物的地板一致**,在更新的发行版上验等于放宽了条件。
|
||||
|
||||
**一处诚实记录**:M184 的任务描述里提到「曾用过 WSLg 开真窗口跑首帧」。本仓库里查不到那次留下的任何文档或
|
||||
脚本痕迹(`grep -rn WSLg` 在本轮改动之前零命中),所以下面这一节是**重新做了一遍并留证**的,不是转述。
|
||||
|
||||
## 2. 本轮实测(WSLg 真机,linux-x64 release 产物)
|
||||
|
||||
**环境**(2026-11-08,`wsl.exe -v` / 发行版内自报,逐字取自实测输出):
|
||||
|
||||
```
|
||||
Linux 6.6.114.1-microsoft-standard-WSL2 x86_64 # WSL 2.7.3.0,WSLg 1.0.73
|
||||
PRETTY_NAME="Ubuntu 22.04.5 LTS" # == 发布槽位 ubuntu-22.04 的同一代
|
||||
ldd (Ubuntu GLIBC 2.35-0ubuntu3.15) 2.35 # == 文档写的 glibc 2.35 地板
|
||||
DISPLAY=:0 WAYLAND_DISPLAY=wayland-0
|
||||
rustc 1.99.1 / cargo 1.99.1(rust-toolchain.toml 的 stable)
|
||||
```
|
||||
|
||||
**构建**(与 CI 同一条命令,12 核 WSL,crates 走 rsproxy 镜像;镜像只影响下载速度,不影响产物):
|
||||
|
||||
```
|
||||
cargo build --release --locked --bin unlucky-launcher-iced --bin ull
|
||||
Finished `release` profile [optimized] target(s) in 9m 38s # BUILD_WALL=579.19s
|
||||
-rwxr-xr-x 19571328 unlucky-launcher-iced
|
||||
-rwxr-xr-x 7534848 ull
|
||||
```
|
||||
|
||||
**打包**(CI 的同一份脚本 `packaging/make_artifacts.py`,`--slot linux-x64`):
|
||||
|
||||
```
|
||||
arch: unlucky-launcher-iced -> x64 (ok for linux-x64)
|
||||
smoke: ull --version -> 0.2.3 (ok) # 真跑,不是跳过(宿主 == 产物)
|
||||
arch: ull -> x64 (ok for linux-x64)
|
||||
artifact: unlucky-launcher_0.2.3_linux_x64.tar.gz 8.05 MiB sha256=c78e9f6cbab44e0fa76912f5d3608b911d89e2de2adc473339341d8ac3b60c13
|
||||
artifact: ull_0.2.3_linux_x64 7.19 MiB sha256=42ff569152f1af89899b0925b4a7495bfcaeb66988889a0348722aa2d6ae2369
|
||||
sums: SHA256SUMS.txt
|
||||
```
|
||||
|
||||
归档解开就是用户拿到的东西(`unlucky-launcher` 0755;`ELF 64-bit LSB pie executable, x86-64, stripped`):
|
||||
|
||||
```
|
||||
-rw-r--r-- LICENSE 11345 / NOTICE 4721 / README.md 30542
|
||||
-rwxr-xr-x unlucky-launcher 19571328
|
||||
$ ./unlucky-launcher --version
|
||||
unlucky-launcher 0.2.3(unlucky-cli)
|
||||
os: Ubuntu 22.04.5 LTS · 6.6.114.1-microsoft-standard-WSL2(x86_64)· 满足最低要求
|
||||
```
|
||||
|
||||
**首帧(`--screenshot` 通道,真窗口在 WSLg 的真显示服务里开出来再渲)**,`exit=0`,关键日志行逐字:
|
||||
|
||||
```
|
||||
[probe] adapters=0 hardware=false :: request=cpu -> ICED_BACKEND=tiny-skia
|
||||
[font] platform=linux text="Noto Sans CJK SC" display="Noto Sans CJK SC" mono="DejaVu Sans Mono" | … source=env cjk=not-needed
|
||||
[shot] 未指定主题,按 Light 出图(D30 可复现;CI 冒烟依赖这条默认值)
|
||||
[theme] source=default-light flavor=light follows_system=false
|
||||
[window] requested size=1180x760 min=880x560 resizable=true decorations=false transparent=false scale_requested=system size_source=default title="Unlucky Launcher"
|
||||
[boot] first frame in 485 ms(run() 开始 → 首次 view,含后端探测与字体扫描)
|
||||
[shot] 1180x760 scale=1 -> …/wslg-first-frame.png
|
||||
[shot] screen=shell theme=light renderer=tiny-skia route=Detail("survival")
|
||||
[backend] final=tiny-skia result=started elapsed=1333ms evidence=probe-single-item
|
||||
```
|
||||
|
||||
像素判定(`packaging/check_screenshot.py --json`,图 132637 字节):
|
||||
|
||||
```
|
||||
{"width": 1180, "height": 760, "pixels": 896800, "unique_colors": 1858,
|
||||
"top_color": "#f6faff", "top_color_share": 0.5564, "luma_mean": 241.18,
|
||||
"luma_stddev": 24.06, "edge_ratio": 0.0372, "problems": []}
|
||||
[ok] 截图非空白
|
||||
```
|
||||
|
||||
**真的开了一个窗口**(交互模式,不加 `--screenshot`):进程 12 秒后仍存活,WSLg 侧出现
|
||||
`msrdc` 窗口 **"Unlucky Launcher (cyrene-wsl)"**,`GetWindowRect` 读回 **1180×760**(正是它请求的尺寸),
|
||||
从 Windows 侧对这块矩形截屏后**人眼可见**:自绘标题栏三键 + 左侧导航轨(Instances / Versions / Logs /
|
||||
Settings / Assistant)+ "No instance selected" 空态 + 左下 "Not signed in"。这张图已入库:
|
||||
[`docs/images/linux-x64-wslg-window.png`](../../docs/images/linux-x64-wslg-window.png)
|
||||
(1180×760,43084 字节,`check_screenshot.py`: 唯一色 871、σ 14.4、强边缘 0.0137 ⇒ 非空白 —— 比进程内那张低,
|
||||
因为桌面截屏是**已经合成到 Windows 桌面**的结果,抗锯齿被放大过一次;两张都远在阈值之上)。
|
||||
交互模式的日志另有两行值得记:
|
||||
|
||||
```
|
||||
[probe] adapters=1 [D3D12 (AMD Radeon(TM) Graphics) [Other]] hardware=false :: request=auto -> ICED_BACKEND=tiny-skia
|
||||
[probe] 探测耗时 1527 ms(wgpu 实例 + 全后端适配器枚举)
|
||||
[boot] first frame in 2273 ms(…)
|
||||
[theme] source=system flavor=pending follows_system=true
|
||||
[theme] 系统主题 = None
|
||||
[appearance] hwnd=none(拿不到窗口句柄:整窗 α 无法下发)
|
||||
[corner] boot corner=policy=system source=square os=unknown excluded_reason=unknown-os radius=system pref=none readback=n/a window=none frame=? layered=?
|
||||
```
|
||||
|
||||
读法(别过度解读):① WSLg 能枚举到一个 D3D12 适配器但 `hardware=false`,所以 auto 也落到软渲染
|
||||
(`tiny-skia`)—— **GPU 通路在 Linux 上仍然没有证据**;② WSLg 没有"系统主题",所以 `follows_system=true`
|
||||
而 `系统主题 = None`(真桌面上的系统主题跟随仍未验);③ **整窗透明与圆角在 Linux 上本来就是 no-op**:日志自己说
|
||||
`hwnd=none`、`corner=os=unknown`(用户令:这两件事交给桌面合成器,我们不自绘)—— 所以它们不是"未验",而是
|
||||
**在 Linux 上没有这条代码路径**。
|
||||
|
||||
**一处 WSLg 的坑**(写在这里免得下次重复踩):WSLg 的 X 侧只有 Weston 的代理窗口,
|
||||
`import -window root` / `xwd -root` 一律 `X_GetImage … BadMatch`,**拿不到桌面级截屏**;
|
||||
真窗口像素要么走进程内 `--screenshot`(CI 就是这么做的),要么从 Windows 侧对 `msrdc` 窗口矩形截屏(本节采用后者)。
|
||||
|
||||
|
||||
## 3. 本轮实测(Xvfb,同一个 release 产物)
|
||||
|
||||
同一条命令在无头 X 里再跑一遍(Ubuntu 22.04 的 Xvfb,**release 产物**,判据与 CI 新增那步逐字一致):
|
||||
|
||||
```
|
||||
xvfb-run -a --server-args="-screen 0 1280x1024x24" ./unlucky-launcher --software-renderer \
|
||||
--screenshot …/xvfb-first-frame.png --data-dir …/data-xvfb
|
||||
launcher exit=0
|
||||
[theme] source=default-light flavor=light follows_system=false
|
||||
[boot] first frame in 35 ms(run() 开始 → 首次 view,含后端探测与字体扫描)
|
||||
[shot] 1180x760 scale=1 -> …/xvfb-first-frame.png
|
||||
[backend] final=tiny-skia result=started elapsed=1147ms evidence=probe-single-item
|
||||
{"width": 1180, "height": 760, "pixels": 896800, "unique_colors": 1858, "top_color": "#f6faff",
|
||||
"top_color_share": 0.5564, "luma_mean": 241.18, "luma_stddev": 24.06, "edge_ratio": 0.0372, "problems": []}
|
||||
[ok] 截图非空白
|
||||
```
|
||||
|
||||
软渲染是确定性的:WSLg 与 Xvfb 两张图的统计量**逐个相同**(1858 / 24.06 / 0.0372),也正是这一点让
|
||||
`check_screenshot.py` 的阈值(唯一色 ≥64、亮度 σ ≥3.0、强边缘 ≥0.001)有 **8~37 倍**余量
|
||||
(唯一色 1858/64≈29×、强边缘 0.0372/0.001≈37×、**亮度 σ 只有 24.06/3.0≈8×** —— 与 `check_screenshot.py` 自己
|
||||
写的「阈值取在实测下限的 1/5~1/8」一致,三者里最紧的是亮度 σ):CI 里这条门禁不会因为"某次抗锯齿抖了"而假红。
|
||||
|
||||
本机 Docker 复跑(`packaging/linux-smoke-docker.sh`,Debian 12 容器 + **debug** 档,缓存卷是热的):
|
||||
|
||||
```
|
||||
$ bash packaging/linux-smoke-docker.sh . C:/Temp/ull-m184/smoke-out
|
||||
rustc 1.99.1 (58cf8f9c3 2026-10-13) / cargo 1.99.1 / 12.15 # 12.15 = Debian 版本号
|
||||
Finished `dev` profile [unoptimized + debuginfo] target(s) in 1m 26s
|
||||
[font] platform=linux text="DejaVu Sans" display="DejaVu Sans" … source=default cjk=not-needed
|
||||
[theme] source=default-light flavor=light follows_system=false
|
||||
[boot] first frame in 69 ms(run() 开始 → 首次 view,含后端探测与字体扫描)
|
||||
[backend] final=tiny-skia result=started elapsed=1736ms evidence=probe-single-item
|
||||
exit=0
|
||||
/out/runtime-smoke.png: 1180x760 唯一色 1756 亮度σ 23.71 强边缘 0.03366 主色 #f6faff(55.7%)
|
||||
[ok] 截图非空白
|
||||
```
|
||||
|
||||
两条差异值得写下来(说明"为什么容器绿了还不够"):① 容器里没有中文字体,字体解析落到 `DejaVu Sans`,而 WSL 里是
|
||||
`Noto Sans CJK SC` —— **字体选择随环境变**,观感类验收只能在真桌面上做;② 基础镜像是 Debian 12(glibc 2.36),
|
||||
比发布地板 2.35 高一档,所以它能证明"这条运行时通路通",**不能**代替 `ubuntu-22.04` 上的证据(后者见 §2/§3 前面那段)。
|
||||
|
||||
单次启动的墙钟(`/usr/bin/time`,含 `xvfb-run` 起 X 的开销):**WALL=1.96s**,日志里 `launch elapsed=1188ms`。
|
||||
|
||||
|
||||
## 4. CI 增量:2 个槽位 → 4 个槽位
|
||||
|
||||
默认矩阵从 **2 档变 4 档**(`windows-x64` / `windows-arm64` / `linux-x64` / `linux-arm64`),多出来的两档是**并行**作业
|
||||
(`fail-fast: false`),所以:
|
||||
|
||||
| 项 | 变化 | 依据 |
|
||||
|----|------|------|
|
||||
| 并发作业数 | 2 → 4 | `release.yml` 的 `plan` 输出(本地跑那段 python:默认 4 档、`all_slots=true` 6 档) |
|
||||
| 每档新增步骤 | `linux-x64` 多 **2 步**(xvfb 运行时冒烟 + 上传首帧图);`linux-arm64` 多 **0 步** | `release-build.yml`:11 步 → 13 步(本地 YAML 解析计数) |
|
||||
| `linux-x64` 那 2 步的耗时 | apt 装 xvfb ≈ 30~50 s,跑一帧 ≈ 2 s,日志/像素断言 ≈ 1 s,上传 ≈ 3 s ⇒ **≈ 1~2 分钟** | 本地 Xvfb 实测单次启动 WALL=1.96s(`/usr/bin/time`) |
|
||||
| 单档 release 构建时长(**估算**) | 冷缓存 ≈ 20~30 min;`Swatinem/rust-cache` 命中后 ≈ 8~15 min | 本机 12 核实测 **9m38s**;官方 `ubuntu-22.04` runner 是 4 vCPU,编译墙钟按 2.5~3× 估,再乘缓存命中折扣 |
|
||||
| 总机时(**估算**) | 每 tag **+约 40 分钟**机时(2 档 × ≈20 min) | 同上 |
|
||||
| 墙钟(**估算**) | 增量 ≈ `max(0, linux 档时长 − 现有最慢档)`,量级 **0~10 分钟**;发布侧多两个资产,秒级 | 四档并行,墙钟由最慢那档决定 |
|
||||
| 配额风险 | `ubuntu-22.04-arm` 的可用性**本机无法核实**(无 `gh` CLI、无 Actions API 凭据) | 见 §5 最后一条:它不在发布名单里,起不来也不会挡住那两档发布 |
|
||||
|
||||
诚实边界:单档构建时长是**估算**(本机不是 runner,核数、缓存命中率、网络都不同)。下一次 tag 的真实数字应该回来
|
||||
把这张表换成实测——**别让估算一直冒充实测**。
|
||||
|
||||
|
||||
## 5. 仍然没验的部分(如实划出)
|
||||
|
||||
- **真实桌面发行版的观感**:拖动 / 缩放 / 字体度量与中文整形,一个都没在真桌面(GNOME/KDE 的可交互窗口)上
|
||||
人眼验收过。本轮做的是「起窗 + 渲出一帧 + 断言像素与日志」,它证明「能跑出画面」,不证明「观感正确」。
|
||||
(**整窗透明度与圆角不在这张清单里**:Linux 上它们没有代码路径 —— 见 §2 的三条读法,别把「不适用」写成「未验」。)
|
||||
- **GPU 通路**:所有 Linux 证据都是软件渲染(`--software-renderer` / tiny-skia)。wgpu→Vulkan/GL 那条路在 Linux
|
||||
上一次都没跑过。
|
||||
- **Wayland 原生**:WSLg 同时提供 X11 与 Wayland,本轮只按 `DISPLAY` 走(见 §2 的实测环境),没单独验
|
||||
`WAYLAND_DISPLAY` 那条路。
|
||||
- **linux-arm64 运行时完全未验**:GUI 进程在 ARM64 上一次都没启动过。连 `make_artifacts.py` 那条 `ull --version`
|
||||
冒烟也**只是机制上会真跑**(`mode: ci` + 宿主==产物),**截至本分支没有一次 arm64 成功出包的记录**,所以 arm64
|
||||
侧一份运行时证据都没有 —— 别把「这条冒烟以后会跑」读成「它跑过了」。
|
||||
- **`ubuntu-22.04-arm` runner 的可用性未在本机核实**:本机没有 `gh` CLI,也没有到 Actions API 的可用凭据,
|
||||
所以「这档 runner 在这个仓库上真能起来」只能由下一次 CI 证明。风险是有界的:它**不在** `PUBLISH_SLOTS` 里,
|
||||
起不来/红了不会挡住 `windows-x64` + `linux-x64` 的发布(只会让那一条 job 红,外加一条 `::warning::`)。
|
||||
|
||||
## 6. `linux-arm64` 的解锁条件(写死在这里,别凭感觉放行)
|
||||
|
||||
1. 在**真 ARM64 机器**(或原生 arm64 runner)上把 release 产物跑出一个非空白帧,`packaging/check_screenshot.py`
|
||||
通过;
|
||||
2. 同一份日志里有 `[theme] source=…` 与 `[backend] final=… result=started` 两行(证明主题来源与渲染后端都按预期);
|
||||
3. 把上面两条的原始输出(时间、命令、图尺寸、日志行)贴进本文 §2 的位置;
|
||||
4. 然后把 `linux-arm64` 加进 `release-publish.yml` 的 `PUBLISH_SLOTS`(一行),并同步 §9 与 `release-notes.md`。
|
||||
|
||||
构建绿了**不算**解锁,`ull --version` 跑得过也**不算** —— 那两条只证明 CLI 与编译器,用户拿到的那个 `.tar.gz` 里
|
||||
装的是 GUI。(何况截至本分支 arm64 上连那条 CLI 冒烟都还没真跑过一次。)
|
||||
@@ -124,5 +124,5 @@ libxcb 条目数 = 0,只把 fontconfig 那 7 个 `.so` 放进 `LD_LIBRARY_PATH
|
||||
`um/arm64/kernel32.lib`,猜缺就 `exit 1`,而 `cargo build` 永远 skipped);M164 把它降级为只取证(打印 vswhere 组件、
|
||||
目录 `.lib` 计数、`link.exe` 候选),红绿交回编译器。闭环标准不是「两槽位都进 SHA256SUMS」(M142 起只有 x64 进),
|
||||
而是 arm64 的 `cargo build` 真跑绿 + 把键加回 `PUBLISH_SLOTS`(守卫会自动跟着扩,并在产物有了却没声明时报 `::warning::`)。
|
||||
本机能补的只有脚本层证据:`python packaging/check_publish_slots.py --self-test`(8 条注入回归)与
|
||||
本机能补的只有脚本层证据:`python packaging/check_publish_slots.py --self-test`(11 条注入回归)与
|
||||
`cargo check --target aarch64-pc-windows-msvc`(依赖图对该 target 的类型检查,**不链接、不产出 PE**)。
|
||||
@@ -0,0 +1,154 @@
|
||||
# 产物签名与更新验签(M190)
|
||||
|
||||
本文件是**发布侧手册**:私钥从哪来、怎么用、怎么轮换、哪一步失败必须让发布变红。
|
||||
用户面的说明(README 里「为什么第一个包要手动下」那类话)不在本文件范围。
|
||||
|
||||
## 0. 先说清这件事**不**解决什么
|
||||
|
||||
| 有 / 没有 | 状态 |
|
||||
|---|---|
|
||||
| 更新器验「清单是不是我们发的」 | **有**(Ed25519 清单签名 + 二进制里钉住的公钥) |
|
||||
| Windows 上「未知发布者」提示消失 | **没有**。项目自有签名与 Authenticode 是两件事 |
|
||||
| SmartScreen 信誉 | **没有**。只有买了 OV/EV 证书并真签名才可能逐步积累 |
|
||||
|
||||
> 一句话:**项目自有签名保护的是自更新这条链,不是 Windows 的信任判断。** 用户双击 exe 时看到的
|
||||
> 「未知发布者」来自 SmartScreen,与本文件的方案无关;买证书是另一条线(见 M190 报告里的
|
||||
> 「后续要用户拍板的两件事」)。
|
||||
|
||||
## 1. 威胁模型(为什么要签名)
|
||||
|
||||
清单里的 `sha256` 只保证「包体与清单对得上」。**能改 Release 的人可以同时改包和摘要**,所以摘要
|
||||
本身不是信任根。签名的对象因此是**清单文件的原始字节**:改了任意一个字段(哪怕只改版本号里一个数字),
|
||||
签名立刻对不上。
|
||||
|
||||
```
|
||||
发布侧 用户机器
|
||||
------ --------
|
||||
manifest.json(字节)──签名──> manifest.json.sig
|
||||
│ │
|
||||
└── 挂到同一个 Release ─────────────┤
|
||||
├─ 取清单 + .sig
|
||||
├─ 用**编译进二进制**的公钥验签(trust.rs)
|
||||
├─ 过了 → 再解析、再比 sha256、再装
|
||||
└─ 不过 → 五类签名错误,**什么都不会被安装**
|
||||
```
|
||||
|
||||
## 2. 信任根在哪:`crates/core/src/update/trust.rs`
|
||||
|
||||
* 公钥是**编译期常量数组**(`PRODUCTION`),不是用户机器上的文件 —— 没有环境变量、没有设置键、
|
||||
没有「清单旁边的 keyring.json」这类运行期口子(`tests/signature_cases.rs::no_runtime_keyring_override`
|
||||
守着这条)。
|
||||
* 是**数组**而不是一把:换签名方时新旧钥可以并存(见 §5)。
|
||||
* `key_id` 只是查表标签,**判据永远是公钥字节**:把 `key_id` 伪造成钉住的那个也过不了
|
||||
(`key_id_spoofing_does_not_help`)。
|
||||
|
||||
### 空表 = fail-closed
|
||||
|
||||
正式表当前**是空的**:私钥只能来自仓库 Secret,任何代码仓库都不该自己生成并落盘私钥。
|
||||
空表的效果是「任何正式构建都拒装任何清单」,同时 `--check-pinned-keyring` 让发布流水线当场红。
|
||||
**这是有意的**:宁可不出 Release,也不出未签名的 Release。
|
||||
|
||||
## 3. 一次性录入(只做一次)
|
||||
|
||||
```bash
|
||||
# ① 生成:打印一段 Secret JSON + 一行可以直接贴进 trust.rs 的 PinnedKey
|
||||
python3 packaging/sign_manifest.py --generate-key --key-id ull-release-2026a
|
||||
```
|
||||
|
||||
* 打印出来的 **第一行 JSON**(含 `seed_hex`)= 私钥,**只**贴进仓库 Secret:
|
||||
Settings → Secrets and variables → Actions → New repository secret,名字
|
||||
**`ULL_MANIFEST_SIGNING_KEY`**。
|
||||
* 打印出来的 **`PinnedKey { … }` 那一行**贴进 `crates/core/src/update/trust.rs` 的
|
||||
`>>> PINNED-KEYS >>>` 与 `<<< PINNED-KEYS <<<` 标记之间(标记本身不要动,脚本按它解析)。
|
||||
* 提交这次改动(公钥进 git 是对的:它是信任锚,本来就该公开可审计)。
|
||||
|
||||
### 私钥纪律(违反任何一条都算事故)
|
||||
|
||||
1. **只**存在于仓库 Secret `ULL_MANIFEST_SIGNING_KEY`。本地需要签测试产物时,走 `--key <文件>`
|
||||
并把那个文件放在**仓库外**(gitignore 不保险,别放仓库里)。
|
||||
2. **不得**出现在日志、产物、提交、issue、聊天记录里。CI 里任何 `echo`/`cat`/`set -x` 都不许碰到它
|
||||
(工作流里的签名步骤显式 `set +x` 且只回显**公钥**)。
|
||||
3. 一旦怀疑泄露:立即按 §5 轮换,并在下一个 Release 的说明里点名旧 `key_id` 作废。
|
||||
4. 私钥文件/Secret 的备份不归仓库管;**丢了 = 旧钥作废**,用户机器上的旧二进制只能靠
|
||||
「先手动装一次新版本」恢复(`trust.rs` 里的旧条目那时才可以删)。
|
||||
|
||||
## 4. 发布时发生什么(`.github/workflows/release-publish.yml`)
|
||||
|
||||
在 `manifest.json` 生成之后、`gh release create/upload` 之前,多了一步:
|
||||
|
||||
```bash
|
||||
python3 packaging/sign_manifest.py --self-test # ① RFC 8032 向量自检
|
||||
python3 packaging/sign_manifest.py --check-pinned-keyring # ② 密钥 ↔ trust.rs 必须对得上
|
||||
python3 packaging/sign_manifest.py --sign dist/manifest.json # ③ 产出 dist/manifest.json.sig
|
||||
python3 packaging/sign_manifest.py --verify-pinned dist/manifest.json # ④ 用钉住的公钥回验产出
|
||||
```
|
||||
|
||||
第 ④ 步复现「客户端会怎么判」:它**不碰私钥**,只拿 `trust.rs` 里那把公钥去验刚落盘的 `.sig`。
|
||||
因此「签名脚本自己说签好了」不构成证据,证据是「按客户端的判据能验过」。
|
||||
|
||||
四条命令任何一条非零退出 ⇒ **这一步红 ⇒ 整个 Release job 红 ⇒ 本次不出 Release**。常见红法:
|
||||
|
||||
| 现象 | 原因 | 怎么补 |
|
||||
|---|---|---|
|
||||
| `没有签名密钥` | Secret `ULL_MANIFEST_SIGNING_KEY` 没配 / 名字拼错 | 按 §3 录入 |
|
||||
| `信任表是空的` | `trust.rs` 里还没钉任何公钥 | 把 §3 打印的 `PinnedKey` 那行贴进标记之间 |
|
||||
| `签名钥(…)不在信任表里` | Secret 与 `trust.rs` 是**两把不同的钥** | 用同一把钥重跑 §3,或回滚 `trust.rs` 的改动 |
|
||||
| `签出来的签名自己验不过` | 实现/环境异常 | 不要发布;把 `--self-test` 输出贴进 issue |
|
||||
|
||||
`dist/manifest.json.sig` 与 `dist/manifest.json` 一起作为 Release 资产上传(`assets=(...)` 那一行)。
|
||||
|
||||
## 5. 轮换(换钥 / 旧钥泄露 / 接托管签名)
|
||||
|
||||
**纪律:旧钥签新钥。** 不允许在没有旧钥签名的情况下替换信任表 —— 那等于让「能改仓库的人」变成
|
||||
「能改信任根的人」。
|
||||
|
||||
1. 生成新钥(§3),得到 `key_id = ull-release-2027a`。
|
||||
2. `trust.rs` 里**保留**旧条目、**追加**新条目(数组顺序无关;`key_id` 不能重名)。
|
||||
3. 在一次发布里,让清单**同时带两把钥的签名**(两条 `signatures`,客户端「任意一条通过即放行」):
|
||||
用旧钥签一次、新钥签一次,把两个信封的 `signatures` 数组拼起来放进 `manifest.json.sig`。
|
||||
这一步让「装着旧二进制的用户」和「装着新二进制的用户」都过得去。
|
||||
4. 之后至少**一个完整发布周期**保持双签名,再观察下载/更新失败率。
|
||||
5. 确认新钥已被广泛接受后,用新钥单独签发下一次清单,并从 `trust.rs` 删掉旧条目
|
||||
(删条目的那个版本必须**只**由新钥签)。
|
||||
6. changelog 里点名:新增/作废的 `key_id` 与公钥指纹。
|
||||
|
||||
托管签名(例如 SignPath Foundation)作为**后续可替换路径**:它的产物签名由 CI 侧调用其服务完成,
|
||||
`trust.rs` 的数组形状不用变(多一条 `PinnedKey` 即可);清单签名这一层保持本项目自有钥不动。
|
||||
**不允许**为了接托管签名把判据改成「只认证书」。
|
||||
|
||||
## 6. 三条守门(都能单独变红)
|
||||
|
||||
| 守门 | 脚本 / 用例 | 变红意味着 |
|
||||
|---|---|---|
|
||||
| 版本资源真的在产物里 | `packaging/check_pe_version.py <exe> --expect-version <版本>` | 读回产物字节找不到 `VS_VERSIONINFO` / 字段值不对(构建日志绿也没用) |
|
||||
| 篡改清单 ⇒ 验签失败且不安装 | `cargo test -p unlucky-core --lib update::tests::signature_cases` | 改字段/只改签名/漏签名/陌生钥 任一仍能装 |
|
||||
| 签名失败阻断发布 | `release-publish.yml` 的签名步 + `--check-pinned-keyring` | 没钥/钥没钉/钥不匹配时仍发出 Release |
|
||||
|
||||
另外 CI 的构建槽位会跑 `check_pe_version.py`(`release-build.yml`):Windows 槽位上直接读
|
||||
`target/release/unlucky-launcher-iced.exe` 与 `ull.exe` 的字节,断言 `CompanyName` 等字符串在。
|
||||
|
||||
## 7. 实现分工(M190 r1 复审后)
|
||||
|
||||
| 哪一半 | 在哪 | 用什么 |
|
||||
|---|---|---|
|
||||
| **验签**(进二进制) | `crates/core/src/update/ed25519.rs` | `ed25519-dalek::verify_strict`(在非 strict 的基础上额外拒**小阶公钥**与**小阶 `R`**;`S` 规范性是非 strict 的 `verify` 也查的) |
|
||||
| **签名**(只在发布侧) | `packaging/ed25519_ref.py` + `packaging/sign_manifest.py` | 纯 Python 参考实现,零第三方依赖(CI 不装 pip 包) |
|
||||
|
||||
两套实现互相独立,`--self-test` 用 RFC 8032 §7.1 的同一组向量把两边各自钉住。**第一版曾把验签写成
|
||||
仓内自写**(5x51 素域 + 扩展坐标);r1 复审裁定不安全——信任锚的微妙错误后果是「验签通过但本不该通过」,
|
||||
因此整段删除,改为官方实现。`crates/core/src/update/tests/ed25519_cases.rs` 现在钉的是
|
||||
**dalek 的 strict 语义**:谁把 `verify_strict` 换成 `verify`,`small_order_public_key_is_rejected` 与
|
||||
`small_order_r_is_rejected_only_by_strict` 会红(这是 r2 实测的结论——其余几条拒绝类用例在非 strict
|
||||
下仍绿,别把它们当 strict 的守卫)。
|
||||
|
||||
## 8. 本地自测(不需要私钥)
|
||||
|
||||
```bash
|
||||
python3 packaging/sign_manifest.py --self-test # RFC 8032 向量 + 标记解析
|
||||
python3 packaging/check_pe_version.py --self-test # 口径表与 pe_version.rs 对拍
|
||||
cargo test -p unlucky-core --lib update::tests::signature_cases
|
||||
cargo test -p unlucky-core --lib update::tests::ed25519_cases
|
||||
```
|
||||
|
||||
带 `update-test-source` 的验收构建里另有一把**公开的测试钥**(种子写在测试夹具里):它只用于
|
||||
「本地目录源 + 真二进制」的端到端验收,**不在**正式信任表里,签不出任何正式版本能装的清单。
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Ed25519 的**参考实现**(RFC 8032),只给发布侧签名脚本用(M190)。
|
||||
|
||||
为什么这里自写一套:发布侧签名必须**零第三方依赖**(CI 不装 pip 包),而仓里 `crates/core` 的验签
|
||||
用的是 `ed25519-dalek`。两套实现互相独立,`sign_manifest.py --self-test` 用 RFC 8032 §7.1 的同一组
|
||||
向量把两边各自钉住 —— 任何一边坏了,向量那一半会红;两边都「一致地错」的概率极低。
|
||||
|
||||
⚠ 这里的私钥路径(`sign`)**只在发布侧**(`packaging/sign_manifest.py --sign`)跑,不进任何产物;
|
||||
二进制里只有验签(`ed25519-dalek::verify_strict`)。扩展坐标公式与 dalek/ref10 同源,向量自检兜底。
|
||||
|
||||
import ed25519_ref as ref
|
||||
seed = bytes.fromhex("…") # 32 字节种子
|
||||
pk = ref.public_key(seed)
|
||||
sig = ref.sign(seed, b"message") # 64 字节
|
||||
assert ref.verify(pk, b"message", sig)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
|
||||
ALG = "ed25519"
|
||||
|
||||
Q = 2**255 - 19
|
||||
L = 2**252 + 27742317777372353535851937790883648493
|
||||
|
||||
def _inv(x: int) -> int:
|
||||
return pow(x, Q - 2, Q)
|
||||
|
||||
D = -121665 * _inv(121666) % Q
|
||||
_D2 = 2 * D % Q
|
||||
_SQRT_M1 = pow(2, (Q - 1) // 4, Q)
|
||||
|
||||
def _recover_x(y: int, sign: int) -> int | None:
|
||||
u = (y * y - 1) % Q
|
||||
v = (D * y * y + 1) % Q
|
||||
v3 = pow(v, 3, Q)
|
||||
v7 = pow(v, 7, Q)
|
||||
x = u * v3 % Q * pow(u * v7 % Q, (Q - 5) // 8, Q) % Q
|
||||
if (v * x * x - u) % Q != 0:
|
||||
x = x * _SQRT_M1 % Q
|
||||
if (v * x * x - u) % Q != 0:
|
||||
return None
|
||||
if x % 2 != sign:
|
||||
x = (Q - x) % Q
|
||||
return x
|
||||
|
||||
def _decompress(data: bytes) -> tuple[int, int, int, int] | None:
|
||||
if len(data) != 32:
|
||||
return None
|
||||
y = int.from_bytes(data, "little")
|
||||
sign = (y >> 255) & 1
|
||||
y &= (1 << 255) - 1
|
||||
if y >= Q:
|
||||
return None
|
||||
x = _recover_x(y, sign)
|
||||
if x is None:
|
||||
return None
|
||||
return (x, y, 1, x * y % Q)
|
||||
|
||||
def _add(p: tuple[int, int, int, int], r: tuple[int, int, int, int]):
|
||||
x1, y1, z1, t1 = p
|
||||
x2, y2, z2, t2 = r
|
||||
a = (y1 - x1) * (y2 - x2) % Q
|
||||
b = (y1 + x1) * (y2 + x2) % Q
|
||||
c = t1 * _D2 * t2 % Q
|
||||
d = 2 * z1 * z2 % Q
|
||||
e, f, g, h = (b - a) % Q, (d - c) % Q, (d + c) % Q, (b + a) % Q
|
||||
return (e * f % Q, g * h % Q, f * g % Q, e * h % Q)
|
||||
|
||||
def _double(p: tuple[int, int, int, int]):
|
||||
x1, y1, z1, _ = p
|
||||
a = x1 * x1 % Q
|
||||
b = y1 * y1 % Q
|
||||
c = 2 * z1 * z1 % Q
|
||||
d = -a % Q
|
||||
e = ((x1 + y1) * (x1 + y1) - a - b) % Q
|
||||
g = (d + b) % Q
|
||||
f = (g - c) % Q
|
||||
h = (d - b) % Q
|
||||
return (e * f % Q, g * h % Q, f * g % Q, e * h % Q)
|
||||
|
||||
def _scalarmult(p, e: int):
|
||||
acc = (0, 1, 1, 0)
|
||||
for bit in bin(e)[2:] if e else "":
|
||||
acc = _double(acc)
|
||||
if bit == "1":
|
||||
acc = _add(acc, p)
|
||||
return acc
|
||||
|
||||
def _encode(p) -> bytes:
|
||||
x, y, z, _ = p
|
||||
zi = _inv(z)
|
||||
x, y = x * zi % Q, y * zi % Q
|
||||
return (y | ((x & 1) << 255)).to_bytes(32, "little")
|
||||
|
||||
def _base():
|
||||
encoded = (4 * _inv(5) % Q).to_bytes(32, "little")
|
||||
return _decompress(encoded)
|
||||
|
||||
def _hint(data: bytes) -> int:
|
||||
return int.from_bytes(hashlib.sha512(data).digest(), "little") % L
|
||||
|
||||
def public_key(seed: bytes) -> bytes:
|
||||
h = hashlib.sha512(seed).digest()
|
||||
a = int.from_bytes(_clamp(bytearray(h[:32])), "little")
|
||||
return _encode(_scalarmult(_base(), a))
|
||||
|
||||
def _clamp(scalar: bytearray) -> bytes:
|
||||
scalar[0] &= 248
|
||||
scalar[31] &= 127
|
||||
scalar[31] |= 64
|
||||
return bytes(scalar)
|
||||
|
||||
def sign(seed: bytes, message: bytes) -> bytes:
|
||||
if len(seed) != 32:
|
||||
raise SystemExit("error: Ed25519 种子必须 32 字节")
|
||||
h = hashlib.sha512(seed).digest()
|
||||
a = int.from_bytes(_clamp(bytearray(h[:32])), "little")
|
||||
prefix = h[32:]
|
||||
r = _hint(prefix + message)
|
||||
r_encoded = _encode(_scalarmult(_base(), r))
|
||||
pk = _encode(_scalarmult(_base(), a))
|
||||
k = _hint(r_encoded + pk + message)
|
||||
s = (r + k * a) % L
|
||||
return r_encoded + s.to_bytes(32, "little")
|
||||
|
||||
def verify(pk: bytes, message: bytes, signature: bytes) -> bool:
|
||||
if len(pk) != 32 or len(signature) != 64:
|
||||
return False
|
||||
r_bytes, s_bytes = signature[:32], signature[32:]
|
||||
if int.from_bytes(s_bytes, "little") >= L:
|
||||
return False
|
||||
a = _decompress(pk)
|
||||
r_point = _decompress(r_bytes)
|
||||
if a is None or r_point is None:
|
||||
return False
|
||||
k = _hint(r_bytes + pk + message)
|
||||
left = _scalarmult(_base(), int.from_bytes(s_bytes, "little"))
|
||||
return _encode(left) == _encode(_add(r_point, _scalarmult(a, k)))
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
// PE 版本资源(VERSIONINFO)的唯一口径:发布者/产品/版权/版本号。
|
||||
//
|
||||
// 这个文件被 `crates/cli/build.rs` 与 `crates/app-iced/build.rs` 各 `include!` 一次——两个产物
|
||||
// (`ull` 与 `unlucky-launcher-iced`)的署名必须逐字相同,复制两份表迟早会漂。
|
||||
//
|
||||
// # 字段口径(M190,塔已拍板)
|
||||
//
|
||||
// | 字段 | 值 | 为什么 |
|
||||
// |---|---|---|
|
||||
// | `CompanyName` | `Unlucky Launcher Project` | 发布者。**不得出现个人真实姓名**:这个名字随每个下载的 exe 传播,将来买 Authenticode 证书时 SmartScreen 显示的是证书主体,二者要对得上 |
|
||||
// | `ProductName` / `FileDescription` | `Unlucky Launcher` | Windows「属性 → 详细信息」里最显眼的两行 |
|
||||
// | `LegalCopyright` | `© 2026 Unlucky Launcher Project · Apache-2.0` | 版权 + 许可(仓库是 Apache-2.0) |
|
||||
// | `FileVersion` / `ProductVersion` | `CARGO_PKG_VERSION` 的四段式 | 与 `Cargo.toml` **同源**:不另写一份版本号 |
|
||||
// | `InternalName` / `OriginalFilename` | bin 名 / `<bin>.exe` | 供「这个 exe 到底叫什么」的取证 |
|
||||
// | `Comments` | 仓库地址 | 项目署名 `AskaEth` 的可读落点(GitHub 命名空间,不是个人姓名) |
|
||||
//
|
||||
// # 只在 windows 目标上真跑
|
||||
//
|
||||
// 版本资源是 PE 概念:`build.rs` 先看 `CARGO_CFG_TARGET_OS`,不是 `windows` 就整段返回,
|
||||
// Linux/macOS 构建一个字节都不多编译(它们的发布产物本来也没有 PE 头)。
|
||||
//
|
||||
// # 为什么不用 `windows_subsystem` 之外的资源
|
||||
//
|
||||
// 只要 VERSIONINFO 一块:图标、清单(DPI/权限)另有归属,本 mission 不碰(加了会改变运行时行为)。
|
||||
//
|
||||
// # 守门
|
||||
//
|
||||
// `packaging/check_pe_version.py` 是「写进源码不算,读回产物才算」的那道判据:它直接读 exe 字节,
|
||||
// 断言 `VS_VERSION_INFO` 结构在、上述字符串(UTF-16LE)在。CI 的构建槽位跑它。
|
||||
|
||||
/// 发布者(CompanyName)。**改它等于改署名**:同时要改 Authenticode 证书主体与 README 的署名口径。
|
||||
pub const COMPANY_NAME: &str = "Unlucky Launcher Project";
|
||||
|
||||
/// 产品名(`ProductName` 与 `FileDescription`)。
|
||||
pub const PRODUCT_NAME: &str = "Unlucky Launcher";
|
||||
|
||||
/// 版权行(`LegalCopyright`)。
|
||||
pub const COPYRIGHT: &str = "© 2026 Unlucky Launcher Project · Apache-2.0";
|
||||
|
||||
/// 项目署名(GitHub 命名空间;`Comments` 里随仓库地址一起出现)。
|
||||
pub const PROJECT_HANDLE: &str = "AskaEth";
|
||||
|
||||
/// 仓库地址(`Comments`)。
|
||||
pub const REPOSITORY: &str = "https://github.com/AskaEth/Unlucky-Launcher";
|
||||
|
||||
/// 把版本号补成四段式的数值(`0.2.3` → `0.2.3.0`;预发布段丢掉,因为 PE 的数值字段只认数字)。
|
||||
///
|
||||
/// 返回 `(四段元组, 打包成 FILEVERSION 用的 u64)`;任一数字大于 65535 时按 65535 截断
|
||||
/// (PE 的每个分量只有 16 位,真实版本号远小于它)。
|
||||
#[must_use]
|
||||
pub fn four_part(version: &str) -> ([u64; 4], u64) {
|
||||
// `0.2.3-beta.1` 的数值部分只有前三段;`+build` 元数据同理不计入版本资源。
|
||||
let numeric = version.split(['-', '+']).next().unwrap_or(version).trim();
|
||||
let mut parts = [0_u64; 4];
|
||||
for (index, piece) in numeric.split('.').take(4).enumerate() {
|
||||
parts[index] = piece.parse::<u64>().unwrap_or(0).min(0xFFFF);
|
||||
}
|
||||
let packed = (parts[0] << 48) | (parts[1] << 32) | (parts[2] << 16) | parts[3];
|
||||
(parts, packed)
|
||||
}
|
||||
|
||||
/// 给一个 bin 名(`ull` / `unlucky-launcher-iced`)写出版本资源。
|
||||
///
|
||||
/// 由两个 crate 的 `build.rs` 调用;非 windows 目标直接返回。
|
||||
pub fn emit_pe_version_resource(binary_name: &str) {
|
||||
println!("cargo:rerun-if-changed=build.rs");
|
||||
println!("cargo:rerun-if-env-changed=CARGO_PKG_VERSION");
|
||||
// 共享口径文件改了也要重编(`include!` 不被 cargo 追踪)。
|
||||
println!("cargo:rerun-if-changed=../../packaging/pe_version.rs");
|
||||
|
||||
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {
|
||||
// Linux/macOS:没有 PE 头,整段不跑(发布产物的形状不变)。
|
||||
return;
|
||||
}
|
||||
|
||||
let version = std::env::var("CARGO_PKG_VERSION").unwrap_or_else(|_| "0.0.0".to_string());
|
||||
let (parts, packed) = four_part(&version);
|
||||
let dotted = format!("{}.{}.{}.{}", parts[0], parts[1], parts[2], parts[3]);
|
||||
|
||||
let mut resource = winresource::WindowsResource::new();
|
||||
resource.set("CompanyName", COMPANY_NAME);
|
||||
resource.set("ProductName", PRODUCT_NAME);
|
||||
resource.set("FileDescription", PRODUCT_NAME);
|
||||
resource.set("LegalCopyright", COPYRIGHT);
|
||||
resource.set("InternalName", binary_name);
|
||||
resource.set("OriginalFilename", &format!("{binary_name}.exe"));
|
||||
resource.set("Comments", REPOSITORY);
|
||||
// 字符串形态(「属性」页显示的)与数值形态(资源管理器/安装器排序用的)都要给。
|
||||
resource.set("FileVersion", &dotted);
|
||||
resource.set("ProductVersion", &dotted);
|
||||
resource.set_version_info(winresource::VersionInfo::FILEVERSION, packed);
|
||||
resource.set_version_info(winresource::VersionInfo::PRODUCTVERSION, packed);
|
||||
|
||||
if let Err(error) = resource.compile() {
|
||||
// 资源编不出来就**中断构建**:宁可不出产物,也不出一个「属性里什么都没有」的 exe
|
||||
// (那正是 M190 要修的病)。
|
||||
panic!("写 PE 版本资源失败({binary_name} {dotted}):{error}");
|
||||
}
|
||||
println!("cargo:warning=PE VERSIONINFO: {binary_name} {dotted} / {COMPANY_NAME}");
|
||||
let _ = PROJECT_HANDLE;
|
||||
}
|
||||
+78
-27
@@ -6,6 +6,11 @@ No WebView, no bundled runtime, no JRE download needed to start the launcher its
|
||||
|
||||
## What's new in {{VERSION}}
|
||||
|
||||
- **Linux x64 is a published platform.** The release workflow now builds `linux-x64` on a native `ubuntu-22.04`
|
||||
runner by default (not only in manual all-slots runs) and publishes it: `unlucky-launcher_{{VERSION}}_linux_x64.tar.gz`
|
||||
plus the bare `ull_{{VERSION}}_linux_x64`. The artifact itself was started on a Linux machine under a real display
|
||||
server, rendered a frame and logged the expected theme/backend lines before this decision, and the same check is
|
||||
now a per-slot CI gate. Linux ARM64 is built too, but stays unpublished until an ARM64 machine runs it.
|
||||
- **{{VERSION}} is the first release published as a stable release.** GitHub only points
|
||||
`releases/latest` at the newest **non-prerelease** release, so `0.2.2` — kept as a historical
|
||||
pre-release, untouched — never appeared there. A pushed tag now creates a stable release by default;
|
||||
@@ -32,6 +37,7 @@ Asset names are `<name>_<version>_<platform>_<arch>`, underscore separated — t
|
||||
| Platform | Asset | Notes |
|
||||
| --- | --- | --- |
|
||||
| Windows 10/11 x64 | `unlucky-launcher_{{VERSION}}_windows_x64.exe` | single exe, double-click to run; this is also what you run on ARM64 Windows (x64 emulation) |
|
||||
| Linux x64 | `unlucky-launcher_{{VERSION}}_linux_x64.tar.gz` | one archive, one top-level directory holding the binary plus `LICENSE` / `NOTICE` / `README.md`; needs glibc 2.35+ and the usual X11/Wayland + fontconfig stack |
|
||||
|
||||
**Windows ARM64 is not part of this release.** CI still builds it on a native `windows-11-arm` runner (Windows 11
|
||||
Arm64 with Visual Studio 2026), but the build has not delivered an artifact yet, so
|
||||
@@ -40,21 +46,25 @@ Arm64 with Visual Studio 2026), but the build has not delivered an artifact yet,
|
||||
so far happened in a **pre-build toolchain probe** that guessed whether the SDK's ARM64 import libraries were
|
||||
present and aborted before `cargo build` ever ran — the compiler and linker have not been heard from yet. That
|
||||
probe is now evidence-only (it prints what it sees and never decides the job), so the next CI run is the first
|
||||
one that can actually answer the question. `windows-x64` is the only published slot; ARM64 rejoins this table as
|
||||
soon as a run produces its artifact (one line in the workflow's `PUBLISH_SLOTS`, guarded on both sides by
|
||||
`packaging/check_publish_slots.py`).
|
||||
one that can actually answer the question. ARM64 rejoins this table as soon as a run produces its artifact (one
|
||||
line in the workflow's `PUBLISH_SLOTS`, guarded on both sides by `packaging/check_publish_slots.py`).
|
||||
|
||||
**This release ships Windows only, and CI builds Windows only.** The Linux (x64 / arm64) and macOS
|
||||
(Apple Silicon / Intel) slots are not built by the release workflow at all — they have not been verified
|
||||
on real machines yet, so nothing unverified is handed out and no build minutes are spent on them. They
|
||||
stay out of the download list and out of `SHA256SUMS.txt` until that verification happens; a manual
|
||||
workflow run can build all six slots for that purpose, but the four extra slots are never published. The
|
||||
support matrix below marks those slots accordingly.
|
||||
**Linux ARM64 is built but not published, and it has no runtime evidence yet.** `linux-arm64` joined the default
|
||||
build matrix in this release (native `ubuntu-22.04-arm`, same glibc 2.35 floor as x64), so the artifact exists as
|
||||
build evidence, but nothing has ever run it on ARM64 hardware — not one frame. It stays out of the download list
|
||||
and out of `SHA256SUMS.txt` until an ARM64 machine renders a frame and the theme/backend log lines come out as
|
||||
expected; that run is the single unlock condition, and copying a build nobody executed is exactly what this list
|
||||
is meant to prevent. The publish job prunes that artifact with an explicit "not published" warning rather than
|
||||
dropping it quietly.
|
||||
|
||||
**macOS is not built by the release workflow at all.** The Apple Silicon and Intel slots never got past a manual
|
||||
probe, so no build minutes are spent on them and nothing unverified is handed out. They stay out of the download
|
||||
list and out of `SHA256SUMS.txt`; a manual workflow run can build all six slots for evidence, but the probe slots
|
||||
are never published. The support matrix below marks those slots accordingly.
|
||||
|
||||
The Windows asset is a bare `.exe`, so `LICENSE`, `NOTICE` and `README.md` are attached to this release
|
||||
next to it: the license terms travel with every download. Slots that ship an archive (Linux / macOS, once
|
||||
published) unpack to one top-level directory of the same name holding the binary plus those three
|
||||
documents.
|
||||
next to it: the license terms travel with every download. The Linux archive unpacks to one top-level directory
|
||||
of the same name holding the binary plus those three documents.
|
||||
|
||||
## Command line
|
||||
|
||||
@@ -65,6 +75,10 @@ Each published slot also ships the command line as its own small bare asset,
|
||||
.\ull_{{VERSION}}_windows_x64.exe instances list # servers and CI: no graphics stack needed
|
||||
```
|
||||
|
||||
```bash
|
||||
./ull_{{VERSION}}_linux_x64 instances list # same binary shape on Linux, no display needed
|
||||
```
|
||||
|
||||
`ull` runs `instances`, `install`, `pack import`, `launch`, `accounts`, `sessions`, `logs`, `diag`,
|
||||
`config`, `java` and `agent`, and links no graphics library at all. The GUI binary accepts the same set
|
||||
before it ever opens a window, so a desktop machine does not need `ull`.
|
||||
@@ -84,22 +98,29 @@ Published = shipped as a download of **this** release, listed in `SHA256SUMS.txt
|
||||
| Platform | Status in this release | Level | Runtime requirements |
|
||||
| --- | --- | --- | --- |
|
||||
| Windows 10/11 x64 | **published** | first class | nothing beyond the OS (UCRT is part of Win10+) |
|
||||
| Linux x64, X11 | **published** | first class | glibc 2.35+ (`libxkbcommon`, `fontconfig`, `libGL` or the software renderer, `libxcb`) |
|
||||
| Linux x64, Wayland | **published** (same archive, Wayland path unverified) | supported | the same archive; `wayland-client` + `libxkbcommon`, plus the X11 set for XWayland apps |
|
||||
| Windows 11 ARM64 | not published (build evidence only) | first class (launcher artifact) | nothing extra; the launcher runs natively, the game itself runs through the OS x64 emulation layer with an x64 JDK |
|
||||
| Linux arm64 | built, not published (no runtime evidence) | first class (unverified) | glibc 2.35+ (native `ubuntu-22.04-arm`, same floor as x64), `libxkbcommon`, `fontconfig`, `libGL`, `libxcb` / `wayland-client` |
|
||||
| macOS 12+ Apple Silicon | not built (unverified) | first class (unverified) | nothing; unsigned build, needs the Gatekeeper workaround |
|
||||
| macOS 12+ Intel | not built (unverified) | first class (unverified) | same as above |
|
||||
| Linux x64, X11 | not built (unverified) | first class (unverified) | `libxkbcommon`, `fontconfig`, `libGL` (or software renderer), `libxcb` |
|
||||
| Linux x64, Wayland | not built (unverified) | supported (unverified) | `wayland-client`, `libxkbcommon`, plus the X11 set for XWayland apps |
|
||||
| Linux arm64, X11 | not built (unverified) | first class (unverified) | glibc 2.35+, `libxkbcommon`, `fontconfig`, `libGL`, `libxcb` |
|
||||
| Linux arm64, Wayland | not built (unverified) | supported (unverified) | the above plus `wayland-client` |
|
||||
| Windows 8.1/8/7 | not built | unsupported | the baseline is Windows 10 |
|
||||
| Android — Termux + X11 | not built (no CI slot) | experimental, not promised | CLI and GUI from source only; GUI verified on-device twice: CPU software rendering and self-built Mesa/turnip GPU acceleration (see `packaging/docs/termux-glibc-build.md` and `README.termux.md`) |
|
||||
|
||||
`windows-x64` is the only published slot this release; Windows ARM64 stays out until a CI run actually produces
|
||||
its artifact (see Downloads above — so far every ARM64 failure was the pre-build probe, never the compiler).
|
||||
Both arm64 rows are formal matrix slots, not the experimental Termux path, and Linux arm64 is not built by
|
||||
default — someone has to run it on real hardware (via a manual all-slots run) before it joins this list. The
|
||||
rows above and the matrix table in `packaging/docs/compliance-support-boundaries.md` §9 are the same list —
|
||||
change one, change the other.
|
||||
Two slots are published this release: `windows-x64` and `linux-x64`. Linux x64 is not a claim taken on faith — the
|
||||
release artifact itself was started on a Linux machine, opened a real window under a real display server, rendered a
|
||||
frame (PNG asserted non-blank) and printed the expected `[theme]` and `[backend] final=… result=started` lines, and
|
||||
the release build now carries the same check as a per-slot CI gate (`packaging/docs/linux-evidence.md` has the raw
|
||||
output). What that does **not** cover: dragging/resizing and font metrics / CJK shaping on a real desktop session.
|
||||
Whole-window transparency and rounded corners have no code path on Linux at all (transparency needs a native window
|
||||
handle the app does not get there; corners are delegated to the desktop compositor), so they are not "unverified" —
|
||||
they are not applicable. Nothing at all is known about Linux ARM64: no ARM64 machine has run the binary yet, which is
|
||||
why that row stays unpublished.
|
||||
|
||||
Both arm64 rows are formal matrix slots, not the experimental Termux path, and Linux arm64 is not published until
|
||||
someone runs it on real hardware — a manual all-slots run builds it today, and one real frame on an ARM64 machine is
|
||||
the unlock. The rows above and the matrix table in `packaging/docs/compliance-support-boundaries.md` §9 are the same
|
||||
list — change one, change the other.
|
||||
|
||||
## Install
|
||||
|
||||
@@ -108,9 +129,26 @@ release, and it is also what you run on ARM64 Windows (the OS runs it through it
|
||||
The first run creates `.ULL/` and `.minecraft/` next to the exe (portable is the default data mode); the
|
||||
per-user `%APPDATA%\ULL` plus `%APPDATA%\.minecraft`, or a custom root, are selectable instead.
|
||||
|
||||
Linux / macOS are not part of this release; their asset names are already fixed by the same rule
|
||||
(`unlucky-launcher_{{VERSION}}_linux_x64.tar.gz`, `_linux_arm64`, `_macos_arm64`, `_macos_x64`) so the
|
||||
links will not change shape when those slots open.
|
||||
**Linux x64** — unpack the archive and run the binary inside it; nothing is installed system-wide:
|
||||
|
||||
```bash
|
||||
tar -xzf unlucky-launcher_{{VERSION}}_linux_x64.tar.gz
|
||||
cd unlucky-launcher_{{VERSION}}_linux_x64
|
||||
./unlucky-launcher # X11 or Wayland; --software-renderer forces the CPU rasteriser
|
||||
```
|
||||
|
||||
It needs glibc 2.35+ (the slot is built on Ubuntu 22.04, so that is the floor), `libxkbcommon`, `fontconfig`
|
||||
and `libGL` — or `--software-renderer` if there is no usable GPU adapter. On Debian/Ubuntu:
|
||||
|
||||
```bash
|
||||
sudo apt-get install libxkbcommon0 libfontconfig1 libgl1 libx11-6 libxcursor1 libxrandr2 libxi6
|
||||
```
|
||||
|
||||
The bare `ull_{{VERSION}}_linux_x64` next to the archive runs on a machine with no display at all.
|
||||
|
||||
Linux arm64 and macOS are not part of this release; their asset names are already fixed by the same rule
|
||||
(`unlucky-launcher_{{VERSION}}_linux_arm64.tar.gz`, `_macos_arm64`, `_macos_x64`) so the links will not change
|
||||
shape when those slots open.
|
||||
|
||||
## No GPU? Software renderer
|
||||
|
||||
@@ -119,8 +157,8 @@ adapter is present; `--software-renderer` (or its equivalent `--renderer=cpu`) f
|
||||
|
||||
## Verify the download
|
||||
|
||||
`SHA256SUMS.txt` lists exactly the assets attached to this release (Windows only), and `sha256sum -c`
|
||||
checks the ones you actually downloaded:
|
||||
`SHA256SUMS.txt` lists exactly the assets attached to this release (Windows x64 and Linux x64, GUI + `ull` each), and
|
||||
`sha256sum -c` checks the ones you actually downloaded:
|
||||
|
||||
```bash
|
||||
sha256sum -c SHA256SUMS.txt --ignore-missing # Linux / WSL
|
||||
@@ -146,6 +184,19 @@ Get-FileHash .\unlucky-launcher_{{VERSION}}_windows_x64.exe -Algorithm SHA256
|
||||
id.
|
||||
- Binaries are not code-signed / notarized yet: Windows SmartScreen and macOS Gatekeeper warn on first
|
||||
launch. The checksums above are the integrity reference for now.
|
||||
- **Linux x64 is verified, not "finished".** The published archive was built on Ubuntu 22.04 and started under a
|
||||
real display server (WSLg) where it opened a window, rendered a non-blank first frame and printed its theme and
|
||||
backend lines; CI repeats that with Xvfb on every release. What nobody has looked at yet on a real desktop
|
||||
distribution: dragging and resizing, font metrics and CJK shaping. Whole-window transparency and rounded corners
|
||||
are not "unverified" on Linux — they are not code paths there: transparency needs a native window handle the app
|
||||
does not get (`hwnd=none`), and corners are delegated to the desktop compositor (`corner=policy=system`).
|
||||
- **On Linux the in-app update does not install itself.** The published Linux asset is a `.tar.gz`, and
|
||||
`ull update apply` only self-replaces the portable single-file `.exe` form — on Linux it refuses with an explicit
|
||||
message naming `portable-zip` instead of half-installing (see `crates/core/src/update/job.rs`). Updating on Linux
|
||||
means downloading the new archive and unpacking it over the old copy; the update check itself (version + channel)
|
||||
works normally.
|
||||
- **Linux ARM64 has no runtime evidence at all.** It compiles, and that is the whole story; the artifact is not
|
||||
attached to this release.
|
||||
- When the macOS slot ships it is the bare binary (single-file promise), not a `.app` bundle: no Dock
|
||||
icon or Finder double-click — launch it from a terminal.
|
||||
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Sign / verify the update manifest, and check the pinned keyring (M190).
|
||||
|
||||
Why Ed25519 + a **detached** `.sig`: the manifest's `sha256` only says "the package matches the
|
||||
manifest"; it says nothing about who wrote the manifest. A signature over the manifest's *exact
|
||||
bytes* answers that, and a separate file avoids JSON canonicalisation games (any byte change
|
||||
breaks the signature).
|
||||
|
||||
Key discipline: the **private key** lives only in the repository Secret
|
||||
`ULL_MANIFEST_SIGNING_KEY` (never in git, logs or artifacts); the **public key** is pinned
|
||||
*inside the binary* — `crates/core/src/update/trust.rs`, between the `>>> PINNED-KEYS >>>` /
|
||||
`<<< PINNED-KEYS <<<` markers. `--check-pinned-keyring` fails the release when the secret's key
|
||||
is not pinned (a release signed with a key clients do not trust is worse than no release).
|
||||
Manual: `packaging/docs/signing.md`.
|
||||
|
||||
Usage: `--generate-key [--key-id K]` / `--sign dist/manifest.json`(钥从环境变量
|
||||
`ULL_MANIFEST_SIGNING_KEY` 取)/ `--check-pinned-keyring`(钥必须已钉住)/ `--verify-pinned
|
||||
dist/manifest.json`(用 trust.rs 里钉的公钥回验产出,复现客户端判据)/ `--self-test`。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
FORMAT = 1
|
||||
ALG = "ed25519"
|
||||
KEY_ENV = "ULL_MANIFEST_SIGNING_KEY"
|
||||
SIG_SUFFIX = ".sig"
|
||||
TRUST_RS = Path(__file__).resolve().parent.parent / "crates/core/src/update/trust.rs"
|
||||
PIN_BEGIN = ">>> PINNED-KEYS >>>"
|
||||
PIN_END = "<<< PINNED-KEYS <<<"
|
||||
KID_RE = re.compile(r'key_id:\s*"([^"]+)"\s*,\s*public_key_hex:\s*"([0-9a-fA-F]{64})"')
|
||||
# Ed25519 的参考实现搬到了 `packaging/ed25519_ref.py`(M190 r1:本文件贴着 400 行红线)。
|
||||
# 目的与路径都写在那边的文件头:发布侧签名必须零第三方依赖,而仓里验签用的是 ed25519-dalek ——
|
||||
# 两套实现互相独立,`--self-test` 用 RFC 8032 的同一组向量把两边各自钉住。
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from ed25519_ref import ALG, sign, verify, public_key # noqa: E402
|
||||
|
||||
def load_key(text: str) -> tuple[str, bytes]:
|
||||
"""Parse the secret JSON (`{"key_id":…,"seed_hex":…}`) or a bare 64-char hex seed."""
|
||||
text = text.strip()
|
||||
try:
|
||||
payload = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
payload = None
|
||||
if isinstance(payload, dict):
|
||||
key_id = str(payload.get("key_id", "")).strip()
|
||||
seed_hex = str(payload.get("seed_hex", "")).strip()
|
||||
else:
|
||||
key_id, seed_hex = "", text
|
||||
if not key_id:
|
||||
raise SystemExit("error: 密钥缺少 `key_id`(清单签名要靠它查信任表)")
|
||||
try:
|
||||
seed = bytes.fromhex(seed_hex)
|
||||
except ValueError as error:
|
||||
raise SystemExit(f"error: `seed_hex` 不是十六进制({error})") from error
|
||||
if len(seed) != 32:
|
||||
raise SystemExit(f"error: `seed_hex` 必须 32 字节(实际 {len(seed)})")
|
||||
return key_id, seed
|
||||
|
||||
def key_from_env_or_arg(path: str | None) -> tuple[str, bytes]:
|
||||
if path:
|
||||
secret = Path(path).read_text(encoding="utf-8")
|
||||
else:
|
||||
secret = os.environ.get(KEY_ENV, "")
|
||||
if not secret.strip():
|
||||
raise SystemExit(
|
||||
f"error: 没有签名密钥:设环境变量 {KEY_ENV}(内容 = --generate-key 打出来的那一行 JSON),"
|
||||
"或显式给 --key <文件>。**绝不**把私钥写进仓库/日志/产物。"
|
||||
)
|
||||
return load_key(secret)
|
||||
|
||||
def pinned_keys(trust_text: str) -> list[tuple[str, str]]:
|
||||
"""Extract `(key_id, public_key_hex)` pairs between the PINNED-KEYS markers."""
|
||||
begin = trust_text.find(PIN_BEGIN)
|
||||
end = trust_text.find(PIN_END)
|
||||
if begin < 0 or end < 0 or end < begin:
|
||||
raise SystemExit(f"error: {TRUST_RS} 里找不到 {PIN_BEGIN} / {PIN_END} 标记")
|
||||
return KID_RE.findall(trust_text[begin:end])
|
||||
|
||||
|
||||
|
||||
def do_generate_key(key_id: str) -> int:
|
||||
seed = os.urandom(32)
|
||||
pk = public_key(seed)
|
||||
pk_hex = pk.hex()
|
||||
if not verify(pk, b"self-check", sign(seed, b"self-check")):
|
||||
print("error: 自己签自己验不过——不要用这把钥", file=sys.stderr)
|
||||
return 1
|
||||
secret = json.dumps({"key_id": key_id, "seed_hex": seed.hex()}, ensure_ascii=False)
|
||||
print("# ① 私钥(**只**贴进仓库 Secret " + KEY_ENV + ",别写进任何文件/日志/提交):")
|
||||
print(secret)
|
||||
print("# ② 公钥(贴进 crates/core/src/update/trust.rs 的 PINNED-KEYS 标记之间):")
|
||||
print(f' PinnedKey {{ key_id: "{key_id}", public_key_hex: "{pk_hex}" }},')
|
||||
print(f"# 公钥十六进制:{pk_hex}")
|
||||
return 0
|
||||
|
||||
def do_sign(manifest: Path, key_path: str | None, out: Path | None, key_id_override: str | None) -> int:
|
||||
if not manifest.is_file():
|
||||
print(f"error: 没有清单文件 {manifest}", file=sys.stderr)
|
||||
return 1
|
||||
raw = manifest.read_bytes()
|
||||
# 签的必须是**能解析**的清单:签一串垃圾只会把事故推迟到用户机器上。
|
||||
try:
|
||||
parsed = json.loads(raw.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
||||
print(f"error: {manifest} 不是合法 JSON({error})", file=sys.stderr)
|
||||
return 1
|
||||
if not isinstance(parsed, dict) or "channel" not in parsed or "releases" not in parsed:
|
||||
print(f"error: {manifest} 不像发布清单(缺 channel/releases)", file=sys.stderr)
|
||||
return 1
|
||||
key_id, seed = key_from_env_or_arg(key_path)
|
||||
if key_id_override:
|
||||
key_id = key_id_override
|
||||
signature = sign(seed, raw)
|
||||
if not verify(public_key(seed), raw, signature):
|
||||
print("error: 签出来的签名自己验不过(实现有问题),拒绝落盘", file=sys.stderr)
|
||||
return 1
|
||||
envelope = {
|
||||
"format": FORMAT,
|
||||
"signatures": [
|
||||
{
|
||||
"alg": ALG,
|
||||
"key_id": key_id,
|
||||
"sig": signature.hex(),
|
||||
"manifest_sha256": hashlib.sha256(raw).hexdigest(),
|
||||
}
|
||||
],
|
||||
}
|
||||
out = out or manifest.with_name(manifest.name + SIG_SUFFIX)
|
||||
out.write_text(json.dumps(envelope, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", newline="\n")
|
||||
print(f"signed: {manifest} → {out}")
|
||||
print(f" key_id = {key_id}")
|
||||
print(f" public_key = {public_key(seed).hex()}")
|
||||
print(f" manifest_sha256 = {envelope['signatures'][0]['manifest_sha256']}")
|
||||
return 0
|
||||
|
||||
def do_verify(manifest: Path, sig: Path, public_key_hex: str) -> int:
|
||||
raw = manifest.read_bytes()
|
||||
envelope = json.loads(sig.read_text(encoding="utf-8"))
|
||||
pk = bytes.fromhex(public_key_hex)
|
||||
for item in envelope.get("signatures", []):
|
||||
if item.get("alg") != ALG:
|
||||
continue
|
||||
if verify(pk, raw, bytes.fromhex(item.get("sig", ""))):
|
||||
print(f"ok: {sig} 里的签名(key_id={item.get('key_id')})通过")
|
||||
return 0
|
||||
print("error: 没有任何一条签名能验过", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
def do_check_pinned(key_path: str | None) -> int:
|
||||
key_id, seed = key_from_env_or_arg(key_path)
|
||||
pk_hex = public_key(seed).hex()
|
||||
keys = pinned_keys(TRUST_RS.read_text(encoding="utf-8"))
|
||||
if not keys:
|
||||
# 「说人话的红」:因为什么、缺什么、怎么补,一行一行写清楚(不涉密,只回显公钥)。
|
||||
print("error: 信任表是空的(fail-closed):客户端一把公钥都没有,任何清单都验不过,所以拒绝发布。", file=sys.stderr)
|
||||
print(f"error: 本次签名钥 = key_id {key_id} / 公钥 {pk_hex}", file=sys.stderr)
|
||||
print("error: 补法:把这行贴进 crates/core/src/update/trust.rs 的 PINNED-KEYS 标记之间 ——", file=sys.stderr)
|
||||
print(f'error: PinnedKey {{ key_id: "{key_id}", public_key_hex: "{pk_hex}" }},', file=sys.stderr)
|
||||
print("error: 手册:packaging/docs/signing.md §3", file=sys.stderr)
|
||||
return 1
|
||||
for pinned_id, pinned_hex in keys:
|
||||
if pinned_hex.lower() == pk_hex:
|
||||
print(f"ok: 签名钥 {key_id} 已被钉住(trust.rs 里叫 {pinned_id})")
|
||||
if pinned_id != key_id:
|
||||
print(
|
||||
f"warning: 同一个公钥在 trust.rs 里叫 {pinned_id}、密钥文件里叫 {key_id};"
|
||||
"清单会带后者,客户端按**公钥字节**验签,所以两种写法都能过(但建议改名对齐)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 0
|
||||
pinned_ids = [k[0] for k in keys]
|
||||
print(
|
||||
f"error: 签名钥(key_id={key_id} / 公钥={pk_hex})不在信任表里 —— 签出来的清单没有任何客户端会认,所以拒绝发布。",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(f"error: trust.rs 现在钉的是:{pinned_ids}", file=sys.stderr)
|
||||
for pinned_id, pinned_hex in keys:
|
||||
print(f"error: {pinned_id} = {pinned_hex}", file=sys.stderr)
|
||||
print("error: 二选一:", file=sys.stderr)
|
||||
print("error: ① 用已钉住的那把钥签(换回对应的 Secret);或", file=sys.stderr)
|
||||
print(
|
||||
"error: ② 换钥:把上面的公钥按「旧钥签新钥」贴进 crates/core/src/update/trust.rs 的 PINNED-KEYS 之间,"
|
||||
"并让本次清单同时带旧钥签名",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print("error: 手册:packaging/docs/signing.md §3/§5", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
def do_verify_pinned(manifest: Path) -> int:
|
||||
"""用 trust.rs 里钉住的公钥回验 `<manifest>.sig` —— 复现「客户端会怎么判」,不需要私钥。"""
|
||||
keys = pinned_keys(TRUST_RS.read_text(encoding="utf-8"))
|
||||
if not keys:
|
||||
print("error: 信任表是空的,无从回验(先钉公钥,见 --check-pinned-keyring)", file=sys.stderr)
|
||||
return 1
|
||||
sig_path = manifest.with_name(manifest.name + SIG_SUFFIX)
|
||||
if not sig_path.is_file():
|
||||
print(f"error: 没有签名文件 {sig_path}", file=sys.stderr)
|
||||
return 1
|
||||
raw = manifest.read_bytes()
|
||||
envelope = json.loads(sig_path.read_text(encoding="utf-8"))
|
||||
for item in envelope.get("signatures", []):
|
||||
if item.get("alg") != ALG:
|
||||
continue
|
||||
try:
|
||||
signature = bytes.fromhex(item.get("sig", ""))
|
||||
except ValueError:
|
||||
continue
|
||||
for pinned_id, pinned_hex in keys:
|
||||
if verify(bytes.fromhex(pinned_hex), raw, signature):
|
||||
print(f"ok: {sig_path.name} 用钉住的公钥 {pinned_id} 能验过(客户端会接受)")
|
||||
return 0
|
||||
print(
|
||||
"error: 拿 trust.rs 里钉住的公钥回验不过 —— 这份 Release 的用户会全部拒装。别发版",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
|
||||
def do_self_test() -> int:
|
||||
vectors = [
|
||||
("9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60", "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a", "",
|
||||
"e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e065224901555fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b"),
|
||||
("4ccd089b28ff96da9db6c346ec114e0f5b8a319f35aba624da8cf6ed4fb8a6fb", "3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c", "72",
|
||||
"92a009a9f0d4cab8720e820b5f642540a2b27b5416503f8fb3762223ebdb69da085ac1e43e15996e458f3613d0f11d8c387b2eaeb4302aeeb00d291612bb0c00"),
|
||||
("c5aa8df43f9f837bedb7442f31dcb7b166d38535076f094b85ce3a2e0b4458f7", "fc51cd8e6218a1a38da47ed00230f0580816ed13ba3303ac5deb911548908025", "af82",
|
||||
"6291d657deec24024827e69c3abe01a30ce548a284743a445e3680d7db5ac3ac18ff9b538d16f290ae67f760984dc6594a7c15e9716ed28dc027beceea1ec40a"),
|
||||
]
|
||||
failures = 0
|
||||
for index, (seed_hex, pk_hex, message_hex, sig_hex) in enumerate(vectors, 1):
|
||||
seed, pk, message = bytes.fromhex(seed_hex), bytes.fromhex(pk_hex), bytes.fromhex(message_hex)
|
||||
if public_key(seed).hex() != pk_hex:
|
||||
print(f"FAIL vector {index}: public key mismatch", file=sys.stderr)
|
||||
failures += 1
|
||||
if sign(seed, message).hex() != sig_hex:
|
||||
print(f"FAIL vector {index}: signature mismatch", file=sys.stderr)
|
||||
failures += 1
|
||||
if not verify(pk, message, bytes.fromhex(sig_hex)):
|
||||
print(f"FAIL vector {index}: verify rejected", file=sys.stderr)
|
||||
failures += 1
|
||||
tampered = bytearray(message)
|
||||
tampered.append(0)
|
||||
if verify(pk, bytes(tampered), bytes.fromhex(sig_hex)):
|
||||
print(f"FAIL vector {index}: tampered message still verifies", file=sys.stderr)
|
||||
failures += 1
|
||||
# 标记解析:信任表解析器必须在示例上命中一对,且在空表上返回空。
|
||||
sample = f'{PIN_BEGIN}\n PinnedKey {{ key_id: "k1", public_key_hex: "{"ab" * 32}" }},\n{PIN_END}'
|
||||
if pinned_keys(sample) != [("k1", "ab" * 32)]:
|
||||
print("FAIL: pinned_keys() 没解析出示例条目", file=sys.stderr)
|
||||
failures += 1
|
||||
if pinned_keys(f"{PIN_BEGIN}\n{PIN_END}") != []:
|
||||
print("FAIL: pinned_keys() 在空表上应返回空", file=sys.stderr)
|
||||
failures += 1
|
||||
print("self-test:", "ok" if failures == 0 else f"{failures} 处失败")
|
||||
return 0 if failures == 0 else 1
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="更新清单的 Ed25519 签名/验签与信任表检查(M190)")
|
||||
parser.add_argument("--generate-key", action="store_true", help="生成一把新钥(打印 Secret 与 PinnedKey 片段)")
|
||||
parser.add_argument("--key-id", default="ull-release", help="生成/签名时用的 key_id")
|
||||
parser.add_argument("--sign", metavar="MANIFEST", type=Path, help="签这份清单(产出 <清单>.sig)")
|
||||
parser.add_argument("--out", metavar="SIG", type=Path, help="签名输出路径(默认 <清单>.sig)")
|
||||
parser.add_argument("--key", metavar="FILE", help=f"密钥文件(不给则读环境变量 {KEY_ENV})")
|
||||
parser.add_argument("--verify", nargs=2, metavar=("MANIFEST", "SIG"), help="手工验一份签名")
|
||||
parser.add_argument("--public-key", metavar="HEX", help="--verify 用的公钥")
|
||||
parser.add_argument("--check-pinned-keyring", action="store_true", help="检查密钥是否已被钉进 trust.rs")
|
||||
parser.add_argument("--verify-pinned", metavar="MANIFEST", type=Path, help="用 trust.rs 里钉的公钥回验产出")
|
||||
parser.add_argument("--self-test", action="store_true", help="跑 RFC 8032 向量与解析器自检")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.self_test:
|
||||
return do_self_test()
|
||||
if args.generate_key:
|
||||
return do_generate_key(args.key_id)
|
||||
if args.sign:
|
||||
return do_sign(args.sign, args.key, args.out, args.key_id if args.key_id != "ull-release" else None)
|
||||
if args.verify:
|
||||
if not args.public_key:
|
||||
print("error: --verify 需要 --public-key", file=sys.stderr)
|
||||
return 1
|
||||
return do_verify(Path(args.verify[0]), Path(args.verify[1]), args.public_key)
|
||||
if args.check_pinned_keyring:
|
||||
return do_check_pinned(args.key)
|
||||
if args.verify_pinned:
|
||||
return do_verify_pinned(args.verify_pinned)
|
||||
parser.print_help()
|
||||
return 2
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in new issue
Block a user