feat(packaging): 签名就绪度播报 + 操作手册(M255)
M254 查出 `trust.rs` 的 `PRODUCTION` 表在 PINNED-KEYS 标记之间是空的 ⇒ 任何正式二进制都验不过 任何清单(fail-closed)。工具链早就齐了,缺的是操作步骤与可见性;本条补的就是这两样,不生成钥、 不改 Rust、不碰 release-publish 的判据。 - `packaging/check_signing_ready.py`(只读、零第三方依赖):三行读数 ① 信任表空不空 ② 环境有没有钥 (顺手对账「这把钥在不在表里」,种子一个字节都不打印)③ 最近一次发布的 .sig/清单痕迹(取不到写 「未知」)。退出码 0 就绪 / 1 未就绪(打印 fail-closed 后果 + 一行修复命令)/ 2 用法错。 - `packaging/tests/signing_ready_selftest.py`:判据自测,含**两个方向的变异重放**(退出码永远 0 ⇒ 判据①必须红;永远 1 ⇒ 判据②必须红)与接线判据(没人 uses: 它、没有 error 级注解、就绪度那步恒 exit 0)。 - `packaging/docs/signing-runbook.md`:人类照做的 5 步 + 每步判据 + 11 条已知坑 + 没验的部分(无任何密钥值)。 - `.github/workflows/signing-status.yml`:把三行搬进 job summary 的**播报** workflow。guards.yml 已 399/400,与 gpu-evidence.yml 同先例另开文件;刻意不声明 workflow_call:,未就绪用 ::warning::、 就绪度那步恒 exit 0,明确「不应被设为 required」。 - `packaging/README.md` / `packaging/docs/signing.md`:索引与指针。
This commit is contained in:
1 parent
fbb663c87c
commit
bff48e27fa
6 files changed
+828
No files matched your search
@@ -0,0 +1,339 @@
|
||||
#!/usr/bin/env python3
|
||||
"""`packaging/check_signing_ready.py` 的判据自测(M255):让「三行读数 + 0/1/2 退出码」变成可执行的事实。
|
||||
|
||||
为什么它必须存在:这个脚本的全部价值是「说清今天差什么」。它一旦退化成「永远 exit 0」,
|
||||
它就从**播报**变成了本仓栽过四次的**假绿尺子** —— 而且没人会当场发现(输出看起来一模一样)。
|
||||
所以这里的第一类用例判的是退出码语义,第二类用例判的是**把退出码改绿之后自测会红**(变异重放)。
|
||||
|
||||
钉住的判据(每一条都对应 mission 的验收项):
|
||||
|
||||
| 判据 | 用例 | 抓什么 |
|
||||
|---|---|---|
|
||||
| ① 表空 ⇒ `exit 1` 且三行都打印 | `empty_table` | 今天的状态(`PRODUCTION` 空表)必须读成「未就绪」,且不许少打印第 ③ 行 |
|
||||
| ② 副本夹一条假 `PinnedKey` ⇒ `exit 0` | `pinned_and_key` | 就绪的正路真的存在(**不碰真实 `trust.rs`**:全部走 `--trust <临时副本>`) |
|
||||
| 变异:把退出码改成永远 0 ⇒ 用例必须红 | `mutation_always_zero` | 上面 ① 的期望值不被「永远绿」满足 ⇒ 自测当场报红 |
|
||||
| 三行之外的读数 | `key_mismatch` / `no_key` / `release_*` | ② 的对账(钥不在表里)、③ 的有/无/未知 |
|
||||
| 不泄密 | `no_seed_leak` | 种子的十六进制在 stdout/stderr 里一次都不出现 |
|
||||
| 用法错 = 2 | `markers_missing` / `usage` | 标记丢了、参数写错 ⇒ 2,不是 1 |
|
||||
| 接线不是门禁 | `wiring` | 没有其它 workflow `uses:` 它;文件里没有 `::error::`;就绪度那步 `exit 0` |
|
||||
|
||||
**不覆盖(如实记)**:本文件自己也是「守门人住在被守对象旁边」——整个 `signing-status.yml` 被删掉后
|
||||
没有任何东西会跑这份自测(与 `gpu_shot_ci_selftest.py` 同一条边界)。它也不判「用户机器上真的能更新」,
|
||||
那是 `cargo test -p unlucky-core --lib update::tests::signature_cases` 与一次真 Release 的事。
|
||||
|
||||
公开的测试种子:`DUMMY_SEED` 是**由一句固定文本哈希出来的**假种子(同 `trust.rs` 里 `TEST_ONLY` 的那把
|
||||
公开测试钥同一路数),只用来造读数,签不出任何正式清单;文档与本文件都不含任何真实私钥。
|
||||
|
||||
python3 packaging/tests/signing_ready_selftest.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
SCRIPT = ROOT / "packaging/check_signing_ready.py"
|
||||
WORKFLOW_DIR = ROOT / ".github/workflows"
|
||||
WORKFLOW = WORKFLOW_DIR / "signing-status.yml"
|
||||
KEY_ENV = "ULL_MANIFEST_SIGNING_KEY"
|
||||
FAIL_CLOSED = "这会让已安装用户永远验不过(fail-closed)"
|
||||
LABELS = ("① 信任表 PRODUCTION", "② 环境签名钥", "③ 最近一次发布痕迹")
|
||||
|
||||
sys.path.insert(0, str(ROOT / "packaging"))
|
||||
from ed25519_ref import public_key # noqa: E402
|
||||
|
||||
DUMMY_SEED = hashlib.sha256(b"ull-m255-signing-ready-selftest-not-a-real-key").hexdigest()
|
||||
DUMMY_PUB = public_key(bytes.fromhex(DUMMY_SEED)).hex()
|
||||
OTHER_SEED = hashlib.sha256(b"ull-m255-selftest-the-other-dummy-key").hexdigest()
|
||||
|
||||
TRUST_TEMPLATE = """\
|
||||
pub(crate) const PRODUCTION: &[PinnedKey] = &[
|
||||
// >>> PINNED-KEYS >>>
|
||||
{body} // <<< PINNED-KEYS <<<
|
||||
];
|
||||
"""
|
||||
|
||||
|
||||
def secret_json(seed: str = DUMMY_SEED, key_id: str = "selftest-key") -> str:
|
||||
return '{"key_id": "%s", "seed_hex": "%s"}' % (key_id, seed)
|
||||
|
||||
|
||||
def write(path: Path, text: str) -> Path:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(text, encoding="utf-8", newline="\n")
|
||||
return path
|
||||
|
||||
|
||||
def run(args: list[str], env_key: str | None, summary: Path | None = None, script: Path = SCRIPT) -> tuple[int, str]:
|
||||
"""跑一次脚本,返回 `(退出码, stdout+stderr)`。`env_key` = 塞进 `KEY_ENV` 的内容(`None` = 不设)。"""
|
||||
env = {k: v for k, v in os.environ.items() if k != KEY_ENV}
|
||||
env.pop("GITHUB_STEP_SUMMARY", None)
|
||||
# 子进程与父进程的编码显式对齐成 UTF-8:本机 Windows 默认 gbk,中文读数会 decode 失败,
|
||||
# 而 CI(ubuntu)本来就是 UTF-8。这条不是洁癖 —— 编码不一致会让整份自测在 Windows 上假红/假绿。
|
||||
env["PYTHONIOENCODING"] = "utf-8"
|
||||
if env_key is not None:
|
||||
env[KEY_ENV] = env_key
|
||||
if summary is not None:
|
||||
env["GITHUB_STEP_SUMMARY"] = str(summary)
|
||||
done = subprocess.run(
|
||||
[sys.executable, str(script), *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
env=env,
|
||||
cwd=str(ROOT),
|
||||
check=False,
|
||||
)
|
||||
return done.returncode, done.stdout + done.stderr
|
||||
|
||||
|
||||
def case_empty_table(work: Path, failures: list[str], script: Path = SCRIPT) -> None:
|
||||
"""① 今天的状态:空表 ⇒ exit 1,三行都要打印,且必须点明 fail-closed 后果与修复命令。
|
||||
|
||||
`script` 可换:变异用例把**同一组断言**原样套在「退出码永远 0」的副本上,断言集合必须报红。
|
||||
"""
|
||||
trust = write(work / "empty/trust.rs", TRUST_TEMPLATE.format(body=""))
|
||||
rc, out = run(["--trust", str(trust)], None, script=script)
|
||||
if rc != 1:
|
||||
failures.append(f"判据①:空表应 exit 1,实际 {rc}\n{out}")
|
||||
missing = [label for label in LABELS if label not in out]
|
||||
if missing:
|
||||
failures.append(f"判据①:空表时没打印这三行中的 {missing}\n{out}")
|
||||
if FAIL_CLOSED not in out:
|
||||
failures.append(f"判据①:未就绪时必须写明「{FAIL_CLOSED}」(后果,不是术语)\n{out}")
|
||||
if "修复:" not in out:
|
||||
failures.append(f"判据①:未就绪时必须给一行修复命令\n{out}")
|
||||
|
||||
|
||||
def case_pinned_and_key(work: Path, failures: list[str], script: Path = SCRIPT) -> None:
|
||||
"""② 副本里夹一条假 PinnedKey + 环境里是同一把钥 ⇒ exit 0(真实 trust.rs 一个字节都不碰)。"""
|
||||
body = f' PinnedKey {{ key_id: "selftest-key", public_key_hex: "{DUMMY_PUB}" }},\n'
|
||||
trust = write(work / "pinned/trust.rs", TRUST_TEMPLATE.format(body=body))
|
||||
rc, out = run(["--trust", str(trust)], secret_json(), script=script)
|
||||
if rc != 0:
|
||||
failures.append(f"判据②:表非空 + 钥同源应 exit 0,实际 {rc}\n{out}")
|
||||
missing = [label for label in LABELS if label not in out]
|
||||
if missing:
|
||||
failures.append(f"判据②:就绪时也要打印三行,缺 {missing}\n{out}")
|
||||
if "就绪" not in out:
|
||||
failures.append(f"判据②:就绪时必须明说「就绪」\n{out}")
|
||||
if FAIL_CLOSED in out:
|
||||
failures.append(f"判据②:就绪时不该出现 fail-closed 那段警告\n{out}")
|
||||
# 不泄密:种子的十六进制在输出里一次都不许出现(公钥可以出现,它是公开物)。
|
||||
if DUMMY_SEED in out:
|
||||
failures.append(f"判据②:输出里出现了**种子**(私钥)—— 这是泄密\n{out}")
|
||||
|
||||
|
||||
def case_key_mismatch(work: Path, failures: list[str]) -> None:
|
||||
"""② 的对账:表里钉的是 A,环境里是 B ⇒ 未就绪(发布时 --check-pinned-keyring 会红)。"""
|
||||
body = f' PinnedKey {{ key_id: "selftest-key", public_key_hex: "{DUMMY_PUB}" }},\n'
|
||||
trust = write(work / "mismatch/trust.rs", TRUST_TEMPLATE.format(body=body))
|
||||
rc, out = run(["--trust", str(trust)], secret_json(OTHER_SEED, "other-key"))
|
||||
if rc != 1:
|
||||
failures.append(f"②对账:钥不在表里应 exit 1,实际 {rc}\n{out}")
|
||||
if "不在" not in out:
|
||||
failures.append(f"②对账:必须明说这把钥不在信任表里\n{out}")
|
||||
if DUMMY_SEED in out or OTHER_SEED in out:
|
||||
failures.append(f"②对账:输出里出现了种子\n{out}")
|
||||
|
||||
|
||||
def case_no_key(work: Path, failures: list[str]) -> None:
|
||||
"""② 没有钥:即便表是好的,也算未就绪(签不出任何清单)。"""
|
||||
body = f' PinnedKey {{ key_id: "selftest-key", public_key_hex: "{DUMMY_PUB}" }},\n'
|
||||
trust = write(work / "nokey/trust.rs", TRUST_TEMPLATE.format(body=body))
|
||||
rc, out = run(["--trust", str(trust)], None)
|
||||
if rc != 1:
|
||||
failures.append(f"②无钥:应 exit 1,实际 {rc}\n{out}")
|
||||
if KEY_ENV not in out:
|
||||
failures.append(f"②无钥:必须点名环境变量 {KEY_ENV}(换个人也知道该配什么)\n{out}")
|
||||
|
||||
|
||||
def case_release_evidence(work: Path, failures: list[str]) -> None:
|
||||
"""③ 只取证:有留档 / 有清单没签名 / 什么都没给 = 未知;且它不影响退出码。"""
|
||||
body = f' PinnedKey {{ key_id: "selftest-key", public_key_hex: "{DUMMY_PUB}" }},\n'
|
||||
trust = write(work / "release/trust.rs", TRUST_TEMPLATE.format(body=body))
|
||||
signed = work / "release/signed"
|
||||
write(signed / "manifest.json", '{"channel": "stable", "releases": []}\n')
|
||||
write(signed / "manifest.json.sig", '{"format": 1, "signatures": []}\n')
|
||||
rc, out = run(["--trust", str(trust), "--dist", str(signed)], secret_json())
|
||||
if rc != 0 or "有 ——" not in out:
|
||||
failures.append(f"③有:留档齐全时应写「有」且退出码仍是 0,实际 rc={rc}\n{out}")
|
||||
|
||||
bare = work / "release/bare"
|
||||
write(bare / "manifest.json", '{"channel": "stable", "releases": []}\n')
|
||||
rc, out = run(["--trust", str(trust), "--dist", str(bare)], secret_json())
|
||||
if "没有" not in out:
|
||||
failures.append(f"③无:只有 manifest.json 时必须点明「没有 .sig」\n{out}")
|
||||
|
||||
nowhere = work / "release/absent"
|
||||
rc, out = run(["--trust", str(trust), "--dist", str(nowhere)], secret_json())
|
||||
if "未知" not in out:
|
||||
failures.append(f"③未知:取不到证据时必须写「未知」,不许写成「无」(本地没留档 ≠ 上次没签)\n{out}")
|
||||
if rc != 0:
|
||||
failures.append(f"③未知:③ 不参与就绪判定,退出码应仍是 0,实际 {rc}\n{out}")
|
||||
|
||||
|
||||
def case_usage(work: Path, failures: list[str]) -> None:
|
||||
"""用法错 = 2:参数写错、标记丢了(脚本形不成判断)。绝不能混进 1(那是「未就绪」的读数)。"""
|
||||
rc, out = run(["--nonsense"], None)
|
||||
if rc != 2:
|
||||
failures.append(f"用法错:未知参数应 exit 2,实际 {rc}\n{out}")
|
||||
broken = write(work / "broken/trust.rs", "pub(crate) const PRODUCTION: &[PinnedKey] = &[];\n")
|
||||
rc, out = run(["--trust", str(broken)], None)
|
||||
if rc != 2:
|
||||
failures.append(f"用法错:标记缺失应 exit 2(不是 1),实际 {rc}\n{out}")
|
||||
rc, out = run(["--trust", str(work / "no-such-dir/trust.rs")], None)
|
||||
if rc != 2:
|
||||
failures.append(f"用法错:trust.rs 不存在应 exit 2,实际 {rc}\n{out}")
|
||||
|
||||
|
||||
def case_summary(work: Path, failures: list[str]) -> None:
|
||||
"""`--line`:CI 用,把三行搬进 $GITHUB_STEP_SUMMARY;且必须写明「不是门禁」。"""
|
||||
trust = write(work / "summary/trust.rs", TRUST_TEMPLATE.format(body=""))
|
||||
summary = work / "summary-step.md"
|
||||
rc, out = run(["--trust", str(trust), "--line"], None, summary=summary)
|
||||
if rc != 1:
|
||||
failures.append(f"summary:空表仍应 exit 1,实际 {rc}\n{out}")
|
||||
if not summary.is_file():
|
||||
failures.append("summary:给了 $GITHUB_STEP_SUMMARY 却没写文件")
|
||||
return
|
||||
text = summary.read_text(encoding="utf-8")
|
||||
missing = [label for label in LABELS if label not in text]
|
||||
if missing:
|
||||
failures.append(f"summary:三行没全部进 summary,缺 {missing}\n{text}")
|
||||
if "不代表签名就绪" not in text:
|
||||
failures.append(f"summary:必须写清「绿色不代表签名就绪」(播报 ≠ 门禁)\n{text}")
|
||||
|
||||
|
||||
TARGET_LINE = " return 0 if ready else 1\n"
|
||||
|
||||
|
||||
def mutant(work: Path, name: str, replacement: str) -> Path | None:
|
||||
"""造一份「只改了退出码那一行」的副本。副本自成一体(模块一起拷):脚本按 `__file__` 上一级找
|
||||
`packaging/`,不靠 PYTHONPATH(实测本机 3.12 根本不把 PYTHONPATH 放进 sys.path)。"""
|
||||
source = SCRIPT.read_text(encoding="utf-8")
|
||||
if source.count(TARGET_LINE) != 1:
|
||||
return None
|
||||
root = work / name
|
||||
path = write(root / "packaging/check_signing_ready_mutated.py", source.replace(TARGET_LINE, replacement))
|
||||
for module in ("sign_manifest.py", "ed25519_ref.py"):
|
||||
write(root / f"packaging/{module}", (ROOT / f"packaging/{module}").read_text(encoding="utf-8"))
|
||||
write(root / "trust.rs", TRUST_TEMPLATE.format(body=""))
|
||||
return path
|
||||
|
||||
|
||||
def case_stray_hint(work: Path, failures: list[str]) -> None:
|
||||
"""① 的「标记之外还有条目」提示:只**点名**,不替人断言「贴错了」。
|
||||
|
||||
实测抓到的误报:真实 `trust.rs` 里 `TEST_ONLY` 那一条**本来就在标记之外**(那是它的正确位置),
|
||||
上一版措辞「另有 N 条写在标记之外(贴错位置,等于没钉)」会对今天的状态喊狼来了。
|
||||
"""
|
||||
outside = 'pub(crate) const TEST_ONLY: &[PinnedKey] = &[PinnedKey { key_id: "test-only-key", public_key_hex: "%s" }];\n'
|
||||
trust = write(
|
||||
work / "stray/trust.rs",
|
||||
outside % ("ab" * 32) + TRUST_TEMPLATE.format(body=""),
|
||||
)
|
||||
rc, out = run(["--trust", str(trust)], None)
|
||||
if rc != 1:
|
||||
failures.append(f"①提示:标记外有条目时表仍是空的 ⇒ 应 exit 1,实际 {rc}\n{out}")
|
||||
if "test-only-key" not in out:
|
||||
failures.append(f"①提示:标记之外的条目必须**点名**(否则人不知道贴漏了哪一条)\n{out}")
|
||||
if "TEST_ONLY" not in out:
|
||||
failures.append(f"①提示:必须说清 `TEST_ONLY` 本来就在标记之外(否则这就是一句误报)\n{out}")
|
||||
|
||||
|
||||
def case_mutation(work: Path, failures: list[str]) -> None:
|
||||
"""变异重放:改坏被测对象,**同一组断言原样套上去必须报红**(两个方向都试)。
|
||||
|
||||
这是本 mission 唯一认可的形状 —— 不是「我写了断言」,而是「把对象改坏,断言真的会红」。
|
||||
"""
|
||||
# ── 变异 A:退出码永远 0(「未就绪」伪装成「就绪」)⇒ 判据① 必须红 ──
|
||||
always_zero = mutant(work, "mutant-zero", " return 0\n")
|
||||
if always_zero is None:
|
||||
failures.append(f"变异 A:{SCRIPT.name} 里找不到唯一的 `{TARGET_LINE.strip()}`,变异无处下手(脚本形状变了就更新这里)")
|
||||
else:
|
||||
trust = work / "mutant-zero/trust.rs"
|
||||
probe: list[str] = []
|
||||
case_empty_table(work / "mutant-zero", probe, script=always_zero)
|
||||
# 副本必须真的「跑得起来、只有退出码变了」:崩溃(rc=2)也会让 probe 非空,那就变成假绿了。
|
||||
rc, out = run(["--trust", str(trust)], None, script=always_zero)
|
||||
if rc != 0:
|
||||
failures.append(f"变异 A:副本在空表上应返回 0(这就是变异本身),实际 {rc}\n{out}")
|
||||
missing = [label for label in LABELS if label not in out]
|
||||
if missing:
|
||||
failures.append(f"变异 A:副本连三行读数都没打印(缺 {missing}),它不是在测退出码那一条\n{out}")
|
||||
if not probe:
|
||||
failures.append(f"变异 A:「退出码永远 0」的副本竟然让判据①全过 —— 断言抓不住改绿(副本空表返回 {rc})")
|
||||
elif not any("exit 1" in item for item in probe):
|
||||
failures.append(f"变异 A:副本被判红,但不是因为退出码那一条 —— 变异用例没对准判据①:{probe}")
|
||||
|
||||
# ── 变异 B:退出码永远 1(「就绪」伪装成「未就绪」)⇒ 判据② 必须红 ──
|
||||
# 反向也要试:只测一个方向的话,「就绪的正路根本不存在」这种回归仍然全绿。
|
||||
always_one = mutant(work, "mutant-one", " return 1\n")
|
||||
if always_one is None:
|
||||
failures.append(f"变异 B:{SCRIPT.name} 里找不到唯一的 `{TARGET_LINE.strip()}`")
|
||||
else:
|
||||
probe = []
|
||||
case_pinned_and_key(work / "mutant-one", probe, script=always_one)
|
||||
if not probe:
|
||||
failures.append("变异 B:「退出码永远 1」的副本竟然让判据②全过 —— 就绪的正路没被断言(假绿的反面)")
|
||||
elif not any("exit 0" in item for item in probe):
|
||||
failures.append(f"变异 B:副本被判红,但不是因为退出码那一条 —— 变异用例没对准判据②:{probe}")
|
||||
|
||||
|
||||
def case_wiring(failures: list[str]) -> None:
|
||||
"""接线判据:它**只是播报**。别人 `uses:` 它 = 它成了某条线的必需检查 ⇒ 红。"""
|
||||
if not WORKFLOW.is_file():
|
||||
failures.append(f"接线:{WORKFLOW} 不存在")
|
||||
return
|
||||
text = WORKFLOW.read_text(encoding="utf-8")
|
||||
for phrase in ("播报,不是门禁", "不应被设为 required"):
|
||||
if phrase not in text:
|
||||
failures.append(f"接线:文件头必须写清「{phrase}」(不阻断要是可核的事实,不是靠没接线)")
|
||||
if "::error::" in text:
|
||||
failures.append("接线:播报 workflow 里不许出现 `::error::`(那会让人以为构建挂了,与播报定位冲突)")
|
||||
if "rc=$?" not in text or "exit 0" not in text:
|
||||
failures.append("接线:就绪度那一步必须抓 `rc=$?` 后 `exit 0`(未就绪不许让 job 变红)")
|
||||
wired: list[str] = []
|
||||
for path in sorted(WORKFLOW_DIR.glob("*.y*ml")):
|
||||
if path.resolve() == WORKFLOW.resolve():
|
||||
continue
|
||||
for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
|
||||
if re.match(r"^\s*uses:\s*", line) and WORKFLOW.name in line:
|
||||
wired.append(f"{path.relative_to(ROOT).as_posix()}:{number}: {line.strip()}")
|
||||
if wired:
|
||||
failures.append(
|
||||
"接线:有 workflow `uses:` 了 " + WORKFLOW.name + " ⇒ 播报变成了别人的必需检查:\n " + "\n ".join(wired)
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
failures: list[str] = []
|
||||
with tempfile.TemporaryDirectory(prefix="ull-signing-ready-") as temp:
|
||||
work = Path(temp)
|
||||
case_empty_table(work, failures)
|
||||
case_pinned_and_key(work, failures)
|
||||
case_key_mismatch(work, failures)
|
||||
case_no_key(work, failures)
|
||||
case_release_evidence(work, failures)
|
||||
case_stray_hint(work, failures)
|
||||
case_usage(work, failures)
|
||||
case_summary(work, failures)
|
||||
case_mutation(work, failures)
|
||||
case_wiring(failures)
|
||||
|
||||
for line in failures:
|
||||
print(f"FAIL {line}", file=sys.stderr)
|
||||
print("signing-ready selftest:", "ok" if not failures else f"{len(failures)} 处失败")
|
||||
return 0 if not failures else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in new issue
Block a user