feat: 模板门禁加 version 检查、生成器落版本脚本与 CHANGELOG 骨架
templates/ci-gate.py:新增必需检查 version(跑 check-changelog.py --tags),并与语言识别解耦(认不出语言也照查版本记录);策略版本 generic-3。templates/check-changelog.py 与 release-preflight.py 为模板版。生成器落 scripts/check_changelog.py、scripts/release_preflight.py 与 CHANGELOG.md 骨架(版本权威来源声明 + Unreleased 四节模板)。AGENTS 骨架新增 §6 版本与发版(必须人批准才打 tag/建 Release)。
This commit is contained in:
+35
-1
@@ -69,7 +69,41 @@ cp "$TPL/ci_gate.py" .gitea/scripts/ci_gate.py # 提交信息闸门(
|
||||
cp "$TPL/ci_report.py" .gitea/scripts/ci_report.py # 核对报告 + 上报(--verify-only 可单独核对)
|
||||
cp "$TPL/ci_preview.py" .gitea/scripts/ci_preview.py # **先核对完整报告,再发预览**
|
||||
cp "$TPL/ci_checkout.sh" .gitea/scripts/ci_checkout.sh # 检出:固定事件 SHA、协议不降级、URL 不放凭据
|
||||
chmod +x scripts/ci_checks.py scripts/check_secrets.py .gitea/scripts/*.py .gitea/scripts/*.sh
|
||||
cp "$TPL/check-changelog.py" scripts/check_changelog.py # 版本记录检查(正本 §6.6,门禁的 version 项调它)
|
||||
cp "$TPL/release-preflight.py" scripts/release_preflight.py # 发版预检(**只读**;批准后才 --execute)
|
||||
chmod +x scripts/ci_checks.py scripts/check_secrets.py scripts/check_changelog.py \
|
||||
scripts/release_preflight.py .gitea/scripts/*.py .gitea/scripts/*.sh
|
||||
|
||||
# 变更记录骨架:**版本号的唯一权威来源就是本文件顶部那条已发布版本**(正本 §6.1)。
|
||||
# 首次发版前只有 `[Unreleased]` 是正常的(检查会放行;构建拿不到版本号则是另一回事)。
|
||||
cat > CHANGELOG.md <<'CL'
|
||||
# 变更记录
|
||||
|
||||
> 版本号的**唯一权威来源** = 本文件顶部那条已发布版本(正本 §6.1)。
|
||||
> 日常改动先记在 `## [Unreleased]` 的 `### 改了什么` 里;正式发版时把它改成
|
||||
> `## [x.y.z] - YYYY-MM-DD`,并补齐四节:改了什么 / 兼容性与迁移影响 / 怎么验收 / 已知问题。
|
||||
> 首次发版之前没有"已发布版本"是正常的。
|
||||
> 发版流程见规范仓 `07-发版操作说明(tag与Release).md`;本仓预检:`python3 scripts/release_preflight.py`。
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 改了什么
|
||||
|
||||
- (在这里记这次改了什么)
|
||||
|
||||
### 兼容性与迁移影响
|
||||
|
||||
- 无
|
||||
|
||||
### 怎么验收
|
||||
|
||||
1. (命令 → 期望现象)
|
||||
|
||||
### 已知问题
|
||||
|
||||
- 无
|
||||
CL
|
||||
echo " CHANGELOG.md(版本权威来源骨架)"
|
||||
|
||||
# 测试范围声明:门禁的 tests 项要读它("允许跳过哪些用例""前端用什么跑测试")。
|
||||
# 给一份**合法但空**的骨架 —— 空声明不等于"随便跳",必需用例被跳过照样红。
|
||||
|
||||
@@ -105,4 +105,25 @@ python3 ~/.hermes/scripts/gitea_api.py GET "/api/v1/repos/<ns>/<repo>/actions/ta
|
||||
- 提交信息不要用反引号 / `$()`(会触发命令替换):用 `git commit -F -` + heredoc。
|
||||
- 推之前跑完第 2 节;推送后用第 3 节确认流水线,失败就**先读 trace 再改代码**。
|
||||
- 生产发布**不走 push**:它是独立的 `deploy-production` workflow,只由人手动发起(见规范 §5.2)。
|
||||
|
||||
## 6. 版本与发版(**CI 不代劳,必须人批准**)
|
||||
|
||||
- **开工前先读系统级规范的正本 §6**(`~/eng-standards/01-规范-Git与CI-CD(Gitea版).md`)
|
||||
与 `07-发版操作说明(tag与Release).md`。
|
||||
- 版本号的**唯一权威来源**:本仓 `CHANGELOG.md` 顶部那条已发布版本(正本 §6.1)。
|
||||
禁止再放 `VERSION` 文件、禁止写进源码常量、禁止在 CI 里用 `git describe`(检出取不到 tag)。
|
||||
- **完成一个功能**时,把改动写进 `CHANGELOG.md` 的 `## [Unreleased]`(**不要求每个提交升版本**)。
|
||||
- **准备发版**时,先给用户这五样,批准后才动:
|
||||
1. **版本建议**(`python3 scripts/release_preflight.py` 给的候选值);
|
||||
2. **变更说明**(四节:改了什么 / 兼容性与迁移影响 / 怎么验收 / 已知问题);
|
||||
3. **准确提交的完整 40 位 SHA**;
|
||||
4. 那次提交的 **CI 成功证据**(status context + run 链接)—— 不是"本地跑过了";
|
||||
5. **未验证项与遗留风险**。
|
||||
- ⛔ **未获用户明确批准,不创建、不推送正式 tag,不发布 Gitea Release。**
|
||||
CI **不**自动升版本、**不**打 tag、**不**发布。
|
||||
- 正式发版用**注释 tag**(`v<版本号>`)指向验收过的提交;Gitea Release 关联该 tag,
|
||||
说明**来自 CHANGELOG**,不另写一份。
|
||||
- 自查命令:`python3 scripts/check_changelog.py --tags`(结构 + tag 一致性);
|
||||
`python3 scripts/release_preflight.py`(**只读**预检,不给"可以发"就是不能发)。
|
||||
- ⚠️ **开发版本没打 tag 是正常的**:日常 CI 不许因此变红。
|
||||
````
|
||||
@@ -0,0 +1,366 @@
|
||||
#!/usr/bin/env python3
|
||||
# 阅读时注意:默认的版本号权威来源是**仓库根 `CHANGELOG.md` 顶部那条已发布版本**(正本 §6.1)。
|
||||
# 某个项目要用别的来源(例如别的文件),必须在它的 `AGENTS.md` 里写明,并让构建与本脚本都读那一个来源。
|
||||
"""CHANGELOG / 版本记录检查 —— 把"版本规则"变成退出码(正本 §6)。
|
||||
|
||||
判据(每条都能被 --self-test 证明会红):
|
||||
A. 结构(默认就跑)
|
||||
1. 至少一个已发布版本条目;`[Unreleased]` 段若有,必须在最上面、可以只有 `改了什么`;
|
||||
2. 已发布版本标题 `## [x.y.z] - YYYY-MM-DD`:三段数字、**不许前导零**、日期必备;
|
||||
3. 每个已发布版本块必须有**四节**:`改了什么`、`兼容性与迁移影响`、`怎么验收`、`已知问题`
|
||||
("已知问题"可以写“无”,但不能整节空着 —— 空节等于没写);
|
||||
4. 版本号不重复、按出现顺序严格递减(最新在最上面);
|
||||
5. 已发布版本块本身不为空。
|
||||
B. tag 一致性(`--tags` / CI 里跑)
|
||||
6. 正式 tag 形如 `v<版本号>`,且必须是**注释 tag**(轻量 tag 不合格);
|
||||
7. 每个正式 tag 指到的那个提交里,`CHANGELOG.md` 顶部的已发布版本 == 该 tag 的版本
|
||||
(防止"tag 指着的提交从没被记进变更记录");
|
||||
8. **没有 tag 是正常的**:开发版本不打 tag 绝不能让日常 CI 变红;
|
||||
看不到远端 tag 时(缺 `.ci-remote-tags`)明说"没核对远端",不假绿。
|
||||
|
||||
用法:
|
||||
python3 scripts/check_changelog.py # 只查结构
|
||||
python3 scripts/check_changelog.py --tags # 结构 + 本地 tag 一致性
|
||||
python3 scripts/check_changelog.py --self-test # 反向自检(含造 tag 的夹具)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
HEADING = re.compile(r"^##\s*\[([^\]]+)\](\s*-\s*(\d{4}-\d{2}-\d{2}))?\s*$", re.MULTILINE)
|
||||
SEMVER = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$") # 不许前导零(01.2.3 不合格)
|
||||
TAG_RE = re.compile(r"^v(\d+\.\d+\.\d+)$")
|
||||
SEC_CHANGE = "### 改了什么"
|
||||
SEC_COMPAT = "### 兼容性与迁移影响"
|
||||
SEC_ACCEPT = "### 怎么验收"
|
||||
SEC_KNOWN = "### 已知问题"
|
||||
REQUIRED_SECS = (SEC_CHANGE, SEC_COMPAT, SEC_ACCEPT, SEC_KNOWN)
|
||||
REMOTE_TAGS = ROOT / ".ci-remote-tags"
|
||||
|
||||
|
||||
def _blocks(text: str):
|
||||
out = []
|
||||
matches = list(HEADING.finditer(text))
|
||||
for i, m in enumerate(matches):
|
||||
end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
|
||||
out.append((m.group(0), m.group(1), m.group(3), text[m.end():end]))
|
||||
return out
|
||||
|
||||
|
||||
def _sec_body(body: str, sec: str) -> str:
|
||||
tail = body.split(sec, 1)[1]
|
||||
return re.split(r"^###\s", tail, maxsplit=1, flags=re.MULTILINE)[0]
|
||||
|
||||
|
||||
def check(path: Path | None = None) -> list[str]:
|
||||
path = path or (ROOT / "CHANGELOG.md")
|
||||
if not path.is_file():
|
||||
return [f"找不到 {path}"]
|
||||
text = path.read_text(encoding="utf-8")
|
||||
blocks = _blocks(text)
|
||||
released = [b for b in blocks if b[1] != "Unreleased"]
|
||||
if not released:
|
||||
# ⚠️ "还没有发过版"不是错误:新工程只有 `[Unreleased]` 是正常的 ——
|
||||
# 首次发版时把 Unreleased 改成 `## [0.1.0] - YYYY-MM-DD` 并补齐四节即可。
|
||||
# (真一个段都没有,才是"没有版本记录"。)
|
||||
if any(b[1] == "Unreleased" for b in blocks):
|
||||
return []
|
||||
return ["没有已发布版本条目(形如 `## [0.1.0] - 2026-10-10`)—— 也没有 `[Unreleased]` 段"]
|
||||
|
||||
problems: list[str] = []
|
||||
# [Unreleased] 必须在最上面(日常改动先记这里)
|
||||
if blocks and blocks[0][1] == "Unreleased":
|
||||
pass
|
||||
elif any(b[1] == "Unreleased" for b in blocks):
|
||||
problems.append("[Unreleased] 段必须在最上面(日常改动先记这里,发版时把它改成版本号)")
|
||||
|
||||
seen: list[str] = []
|
||||
for heading, ver, date, body in released:
|
||||
if ver == "Unreleased":
|
||||
continue
|
||||
if not SEMVER.match(ver):
|
||||
problems.append(f"{heading} 的版本号不合法(要 `主.次.修订` 三段十进制、不许前导零):{ver!r}")
|
||||
continue
|
||||
seen.append(ver)
|
||||
if not date:
|
||||
problems.append(f"{heading} 缺少日期(要写成 `## [{ver}] - YYYY-MM-DD`)")
|
||||
for sec in REQUIRED_SECS:
|
||||
if sec not in body:
|
||||
problems.append(f"[{ver}] 缺少小节 `{sec}` —— 正式版本必须记录"
|
||||
f"改了什么 / 兼容性与迁移影响 / 怎么验收 / 已知问题")
|
||||
continue
|
||||
if not [l for l in _sec_body(body, sec).splitlines()
|
||||
if l.strip() and not l.strip().startswith("```")][:1]:
|
||||
problems.append(f"[{ver}] 的 `{sec}` 下面是空的(不知道就写“无”,但不能空着)")
|
||||
dupes = {v for v in seen if seen.count(v) > 1}
|
||||
if dupes:
|
||||
problems.append(f"版本号重复:{sorted(dupes)}")
|
||||
nums = [tuple(int(x) for x in v.split(".")) for v in seen]
|
||||
if nums != sorted(nums, reverse=True):
|
||||
problems.append(f"版本号没有按出现顺序严格递减(最新应在最上面):{seen}")
|
||||
return problems
|
||||
|
||||
|
||||
def _git(*args, cwd=None) -> tuple[int, str]:
|
||||
p = subprocess.run(["git", *args], cwd=cwd or ROOT, capture_output=True, text=True)
|
||||
return p.returncode, (p.stdout if p.returncode == 0 else p.stderr)
|
||||
|
||||
|
||||
def remote_tag_lines() -> tuple[list[str], str]:
|
||||
"""tag 清单,每行「对象sha + refs/tags/<名>」。
|
||||
|
||||
取数顺序:① 检出步骤写的 `.ci-remote-tags`(CI 用,能证明远端有哪些 tag);
|
||||
② 本地 `for-each-ref refs/tags`(**没核对远端**,来源里必须写明);③ `git ls-remote`。
|
||||
"""
|
||||
if REMOTE_TAGS.is_file():
|
||||
return [l for l in REMOTE_TAGS.read_text(encoding="utf-8").splitlines() if l.strip()], \
|
||||
f"远端清单 {REMOTE_TAGS.name}"
|
||||
import subprocess as _sp
|
||||
q = _sp.run(["git", "for-each-ref", "--format=%(objectname) %(refname)", "refs/tags"],
|
||||
cwd=ROOT, capture_output=True, text=True)
|
||||
if q.returncode == 0 and q.stdout.strip():
|
||||
return [l for l in q.stdout.splitlines() if l.strip()], "本地 refs/tags(**只看得到本机取到的 tag,没核对远端**)"
|
||||
rc, out = _git("ls-remote", "--tags", "origin")
|
||||
if rc == 0:
|
||||
return [l for l in out.splitlines() if l.strip()], "git ls-remote --tags origin(本机实时查)"
|
||||
return [], "拿不到任何 tag 信息(本机没有 tag,也查不了远端)"
|
||||
|
||||
|
||||
def check_tags(changelog: Path | None = None) -> list[str]:
|
||||
"""正式 tag 的版本必须与**它指到的那个提交里**的版本记录一致。没有 tag ⇒ 不是问题。"""
|
||||
changelog = changelog or (ROOT / "CHANGELOG.md")
|
||||
problems: list[str] = []
|
||||
lines, source = remote_tag_lines()
|
||||
formal, notes = [], []
|
||||
for line in lines:
|
||||
parts = line.split()
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
sha, ref = parts[0], parts[-1]
|
||||
name = ref.split("refs/tags/", 1)[-1]
|
||||
if name.endswith("^{}"): # annotated tag 的解引用行,跳过
|
||||
continue
|
||||
m = TAG_RE.match(name)
|
||||
(formal if m else notes).append((name, sha, m.group(1) if m else ""))
|
||||
print(f"-- tag 核对:来源 {source}|正式 tag {len(formal)} 个"
|
||||
f"{'|非正式(不判版本一致性):' + ', '.join(n for n, _, _ in notes) if notes else ''}")
|
||||
|
||||
for name, sha, ver in formal:
|
||||
rc, commit = _git("rev-parse", sha + "^{commit}")
|
||||
if rc != 0 or not commit.strip():
|
||||
problems.append(f"tag {name}({sha[:12]})指向的提交在本机取不到 —— "
|
||||
f"没法核对它的版本记录(检出步骤应当把 tag 取下来)")
|
||||
continue
|
||||
commit = commit.strip()
|
||||
rc, kind = _git("cat-file", "-t", sha)
|
||||
if rc == 0 and kind.strip() == "commit":
|
||||
problems.append(f"tag {name} 是**轻量 tag**(不是注释 tag)—— 正式发布必须用注释 tag")
|
||||
rc, blob = _git("show", commit + ":CHANGELOG.md")
|
||||
if rc != 0:
|
||||
problems.append(f"tag {name} 指到的提交里没有 CHANGELOG.md")
|
||||
continue
|
||||
tops = [b[1] for b in _blocks(blob) if b[1] != "Unreleased" and SEMVER.match(b[1])]
|
||||
if not tops:
|
||||
problems.append(f"tag {name} 指到的提交里 CHANGELOG.md 没有已发布版本条目")
|
||||
elif tops[0] != ver:
|
||||
problems.append(f"tag {name} 的版本 {ver} != 该提交里 CHANGELOG 顶部的版本 {tops[0]}"
|
||||
f"(tag 指着的提交没有把这次发版记进变更记录)")
|
||||
else:
|
||||
print(f" ✅ {name} → {commit[:12]}:提交内顶部版本 {tops[0]} 与 tag 一致")
|
||||
if not formal:
|
||||
print(" (没有正式 tag —— 开发版本不打 tag 是正常的,不判红)")
|
||||
return problems
|
||||
|
||||
|
||||
GOOD = """# 变更记录
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 改了什么
|
||||
|
||||
- 还没发布的东西
|
||||
|
||||
## [0.2.0] - 2026-02-01
|
||||
|
||||
### 改了什么
|
||||
|
||||
- 加了 X
|
||||
|
||||
### 兼容性与迁移影响
|
||||
|
||||
- 无(向后兼容)
|
||||
|
||||
### 怎么验收
|
||||
|
||||
1. 跑 `curl /health` 期望 200
|
||||
|
||||
### 已知问题
|
||||
|
||||
- 无
|
||||
|
||||
## [0.1.0] - 2026-01-01
|
||||
|
||||
### 改了什么
|
||||
|
||||
- 初版
|
||||
|
||||
### 兼容性与迁移影响
|
||||
|
||||
- 首次发布,无迁移
|
||||
|
||||
### 怎么验收
|
||||
|
||||
1. 起容器,打开首页
|
||||
|
||||
### 已知问题
|
||||
|
||||
- 集成用例需要 TEST_DATABASE_URL,CI 里跳过(已声明)
|
||||
"""
|
||||
|
||||
BAD = """# 变更记录
|
||||
|
||||
## [0.1.0]
|
||||
|
||||
### 改了什么
|
||||
|
||||
- 没有日期、缺两节、而且版本顺序还倒着
|
||||
|
||||
## [0.2.0] - 2026-02-01
|
||||
|
||||
### 改了什么
|
||||
|
||||
- 上面那条比这条旧,顺序错了(版本号合法,但比上面的大)
|
||||
"""
|
||||
|
||||
|
||||
def _seed_repo(d: Path, changelog_text: str, tag: str, annotated: bool, tag_ver: str) -> str:
|
||||
"""造一个最小 git 仓库:一个提交 + 一个 tag(指向该提交,tag 名与提交内版本可刻意不一致)。"""
|
||||
env = {"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t",
|
||||
"GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t",
|
||||
"GIT_CONFIG_COUNT": "1", "GIT_CONFIG_KEY_0": "core.hooksPath",
|
||||
"GIT_CONFIG_VALUE_0": str(d / "no-hooks")}
|
||||
(d / "no-hooks").mkdir(exist_ok=True)
|
||||
for args in (["init", "-q", "-b", "main"],):
|
||||
subprocess.run(["git", *args], cwd=d, env={**dict(**__import__("os").environ), **env}, check=True)
|
||||
(d / "CHANGELOG.md").write_text(changelog_text, encoding="utf-8")
|
||||
for args in (["add", "-A"], ["commit", "-q", "-m", "chore(release): 夹具提交"],
|
||||
["tag", "-a", tag, "-m", f"release {tag}"] if annotated else ["tag", tag]):
|
||||
subprocess.run(["git", *args], cwd=d, env={**dict(**__import__("os").environ), **env}, check=True)
|
||||
return subprocess.run(["git", "rev-parse", "HEAD"], cwd=d, capture_output=True,
|
||||
text=True).stdout.strip()
|
||||
|
||||
|
||||
def self_test() -> int:
|
||||
import os
|
||||
problems: list[str] = []
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
g, b = Path(d) / "good.md", Path(d) / "bad.md"
|
||||
g.write_text(GOOD, encoding="utf-8")
|
||||
b.write_text(BAD, encoding="utf-8")
|
||||
if check(g):
|
||||
problems.append(f"合规夹具竟然报红:{check(g)}")
|
||||
# 首次发版前(只有 Unreleased)必须放行 —— 否则每个新工程一开箱就红
|
||||
(Path(d) / "new.md").write_text(
|
||||
"# 变更记录\n\n## [Unreleased]\n\n### 改了什么\n\n- 还没发布\n", encoding="utf-8")
|
||||
if check(Path(d) / "new.md"):
|
||||
problems.append(f"只有 Unreleased 的新工程竟然报红:{check(Path(d) / 'new.md')}")
|
||||
(Path(d) / "none.md").write_text("# 变更记录\n\n(什么都没有)\n", encoding="utf-8")
|
||||
if not check(Path(d) / "none.md"):
|
||||
problems.append("完全没有版本记录的文件竟然没报红")
|
||||
joined = " / ".join(check(b))
|
||||
for want in ["缺少日期", f"缺少小节 `{SEC_COMPAT}`", f"缺少小节 `{SEC_KNOWN}`", "严格递减"]:
|
||||
if want not in joined:
|
||||
problems.append(f"违规夹具没有报出 `{want}` —— 这条判据是死的")
|
||||
|
||||
# 前导零版本号必须报(B 夹具里已含,单独再钉一次)
|
||||
(Path(d) / "zero.md").write_text(
|
||||
"# 变更记录\n\n## [Unreleased]\n\n## [01.0.0] - 2026-01-01\n\n" + "".join(
|
||||
f"{s}\n\n- x\n\n" for s in REQUIRED_SECS), encoding="utf-8")
|
||||
if not any("不合法" in p for p in check(Path(d) / "zero.md")):
|
||||
problems.append("带前导零的版本号没有判红")
|
||||
|
||||
# 空节必须报
|
||||
(Path(d) / "empty.md").write_text(
|
||||
"# 变更记录\n\n## [Unreleased]\n\n## [0.1.0] - 2026-01-01\n\n"
|
||||
+ f"{SEC_CHANGE}\n\n- x\n\n{SEC_COMPAT}\n\n- 无\n\n{SEC_ACCEPT}\n\n- 无\n\n{SEC_KNOWN}\n\n",
|
||||
encoding="utf-8")
|
||||
if not any("下面是空的" in p for p in check(Path(d) / "empty.md")):
|
||||
problems.append("空的『已知问题』节没有判红")
|
||||
|
||||
# ── tag 一致性(造真仓库)──
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
repo_ok = Path(d) / "ok"
|
||||
repo_ok.mkdir()
|
||||
_seed_repo(repo_ok, GOOD, "v0.2.0", annotated=True, tag_ver="0.2.0")
|
||||
globals()["REMOTE_TAGS"] = repo_ok / "absent" # 走本地 ls-remote 失败 → tag -l 分支
|
||||
globals()["ROOT"] = repo_ok
|
||||
try:
|
||||
if check_tags(repo_ok / "CHANGELOG.md"):
|
||||
problems.append(f"tag 一致时竟然报红:{check_tags(repo_ok / 'CHANGELOG.md')}")
|
||||
|
||||
repo_bad = Path(d) / "bad"
|
||||
repo_bad.mkdir()
|
||||
_seed_repo(repo_bad, GOOD, "v9.9.9", annotated=True, tag_ver="9.9.9")
|
||||
globals()["ROOT"] = repo_bad
|
||||
if not any("!=" in p for p in check_tags(repo_bad / "CHANGELOG.md")):
|
||||
problems.append("tag 版本与提交内版本不一致时没有判红")
|
||||
|
||||
repo_light = Path(d) / "light"
|
||||
repo_light.mkdir()
|
||||
_seed_repo(repo_light, GOOD, "v0.2.0", annotated=False, tag_ver="0.2.0")
|
||||
globals()["ROOT"] = repo_light
|
||||
if not any("轻量 tag" in p for p in check_tags(repo_light / "CHANGELOG.md")):
|
||||
problems.append("轻量 tag 没有判红")
|
||||
|
||||
repo_none = Path(d) / "none"
|
||||
repo_none.mkdir()
|
||||
_seed_repo(repo_none, GOOD, "v0.2.0", annotated=True, tag_ver="0.2.0")
|
||||
subprocess.run(["git", "tag", "-d", "v0.2.0"], cwd=repo_none, capture_output=True)
|
||||
globals()["ROOT"] = repo_none
|
||||
if check_tags(repo_none / "CHANGELOG.md"):
|
||||
problems.append("没有 tag 时竟然报红(开发版本不打 tag 是正常的)")
|
||||
finally:
|
||||
globals()["REMOTE_TAGS"] = REMOTE_TAGS
|
||||
globals()["ROOT"] = ROOT
|
||||
_ = os
|
||||
|
||||
if problems:
|
||||
print("❌ 反向自检失败:", file=sys.stderr)
|
||||
for p in problems:
|
||||
print(f" · {p}", file=sys.stderr)
|
||||
return 1
|
||||
print("✅ 反向自检通过:合规夹具全绿;缺日期/缺小节/前导零/空节/顺序倒置/tag 版本不符/"
|
||||
"轻量 tag/无记录逐条报红;无 tag 与「只有 Unreleased」不报红")
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
if "--self-test" in argv:
|
||||
return self_test()
|
||||
target = next((a for a in argv if not a.startswith("-")), None)
|
||||
path = Path(target) if target else None
|
||||
problems = check(path)
|
||||
if "--tags" in argv or "--tag-check" in argv:
|
||||
problems += check_tags(path)
|
||||
if problems:
|
||||
print("❌ CHANGELOG / 版本记录不合规:", file=sys.stderr)
|
||||
for p in problems:
|
||||
print(f" · {p}", file=sys.stderr)
|
||||
print("\n正式版本条目写成:\n"
|
||||
" ## [x.y.z] - YYYY-MM-DD\n\n ### 改了什么\n - …\n\n"
|
||||
" ### 兼容性与迁移影响\n - …(看不清就写“无”,但不能空着)\n\n"
|
||||
" ### 怎么验收\n 1. 命令 → 期望现象\n\n ### 已知问题\n - …\n",
|
||||
file=sys.stderr)
|
||||
return 1
|
||||
print("✅ CHANGELOG / 版本记录合规" + ("(含 tag 一致性)" if ("--tags" in argv or "--tag-check" in argv) else ""))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
+29
-4
@@ -38,7 +38,7 @@ import time
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
POLICY_REVISION = "ci-checks/generic-2"
|
||||
POLICY_REVISION = "ci-checks/generic-3"
|
||||
SUCCESS, FAILURE, SKIPPED, NOT_RUN = "success", "failure", "skipped", "not_run"
|
||||
FULL_SHA = re.compile(r"^[0-9a-f]{40}$")
|
||||
GOVULNCHECK = "golang.org/x/vuln/cmd/govulncheck@v1.1.4" # 锁版本:不静默升级工具
|
||||
@@ -46,6 +46,9 @@ MANIFESTS = ["go.mod", "go.sum", "package.json", "package-lock.json", "pnpm-lock
|
||||
"yarn.lock", "requirements.txt", "pyproject.toml", "uv.lock"]
|
||||
SCOPE_FILE = ROOT / "scripts" / "test-scope.json"
|
||||
|
||||
# 这些检查**与语言无关**:认不出语言时它们照样要跑(版本记录、清单改写核对)
|
||||
LANG_FREE = {"version", "manifests-locked"}
|
||||
|
||||
# 必需检查清单的声明(id → 在哪些档案下必需)。汇总拿它对账,不遍历"跑过的项"。
|
||||
CHECK_MANIFEST: dict[str, tuple[str, ...]] = {
|
||||
"identity": ("ci",),
|
||||
@@ -54,6 +57,7 @@ CHECK_MANIFEST: dict[str, tuple[str, ...]] = {
|
||||
"static": ("local", "ci"),
|
||||
"build": ("local", "ci"),
|
||||
"tests": ("local", "ci"),
|
||||
"version": ("local", "ci"),
|
||||
"security": ("local", "ci"),
|
||||
"manifests-locked": ("local", "ci"),
|
||||
}
|
||||
@@ -338,7 +342,7 @@ def self_test() -> int:
|
||||
return 1
|
||||
print(f"✅ 门禁自检通过:汇总 {len(cases)} 分支 + 前端 {len(fe)} 判据"
|
||||
f"(含「进程非零必红」「不写死 vitest」)+ pytest 退出码语义 + 完整 SHA + 清单比对"
|
||||
f" + Python 锁定/隔离(缺锁失败、从不用 --system)")
|
||||
f" + Python 锁定/隔离(缺锁失败、从不用 --system) + 版本记录检查")
|
||||
return 0
|
||||
|
||||
|
||||
@@ -592,6 +596,25 @@ def c_tests() -> tuple[str, str]:
|
||||
return FAILURE, ";".join(problems) + "|" + "|".join(notes)
|
||||
|
||||
|
||||
def c_version() -> tuple[str, str]:
|
||||
"""CHANGELOG 与版本记录(正本 §6.6):格式 / 四节齐备 / 正式 tag 与提交内版本一致。
|
||||
|
||||
三条要点:**没有 tag 不算失败**(开发版本不打 tag 是常态);检查器缺失即红;
|
||||
项目没声明版本来源(默认就是 CHANGELOG 顶部)也判红 —— 不许"没有来源所以跳过"。
|
||||
"""
|
||||
script = ROOT / "scripts" / "check_changelog.py"
|
||||
if not script.is_file():
|
||||
return FAILURE, "找不到 scripts/check_changelog.py(版本记录检查器):缺失即红,不跳过"
|
||||
if not (ROOT / "CHANGELOG.md").is_file():
|
||||
return FAILURE, ("没有 CHANGELOG.md —— 每个项目必须声明版本号权威来源"
|
||||
"(默认是 CHANGELOG 顶部那条已发布版本,正本 §6.1)")
|
||||
rc, out, err = run([sys.executable or "python3", str(script), "--tags"])
|
||||
if rc:
|
||||
return FAILURE, f"版本记录检查未通过:{(out or err).strip()[-300:]}"
|
||||
tail = [l for l in (out or "").strip().splitlines() if l.strip()][-1:]
|
||||
return SUCCESS, f"版本记录合规|{(tail[0] if tail else '')[:90]}"
|
||||
|
||||
|
||||
def c_security() -> tuple[str, str]:
|
||||
"""密钥扫描 + 依赖漏洞。工具缺失 / 库不可达 ⇒ 失败(不许伪装成"没有漏洞")。"""
|
||||
langs, web = detect()
|
||||
@@ -679,7 +702,7 @@ def main(argv: list[str]) -> int:
|
||||
|
||||
steps = [("identity", None), ("deps-install", c_deps), ("format", c_format),
|
||||
("static", c_static), ("build", c_build), ("tests", c_tests),
|
||||
("security", c_security)]
|
||||
("version", c_version), ("security", c_security)]
|
||||
want = [x for x in args.only.split(",") if x] if args.only else None
|
||||
if want:
|
||||
unknown = [x for x in want if x not in CHECK_MANIFEST]
|
||||
@@ -699,7 +722,9 @@ def main(argv: list[str]) -> int:
|
||||
stopped = status == FAILURE
|
||||
print(f" {reason}")
|
||||
continue
|
||||
if not langs:
|
||||
if not langs and cid not in LANG_FREE:
|
||||
# 与语言无关的检查(版本记录、清单改写核对)**不因为"认不出语言"而停** ——
|
||||
# 它们查的是仓库级的记录,跟有没有 go.mod 无关。
|
||||
status, reason = FAILURE, "识别不到任何语言入口(go.mod / pyproject.toml / package.json)"
|
||||
record(cid, status, reason)
|
||||
stopped = True
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
#!/usr/bin/env python3
|
||||
# 阅读时注意:默认的版本号权威来源是**仓库根 `CHANGELOG.md` 顶部那条已发布版本**(正本 §6.1)。
|
||||
# 某个项目要用别的来源(例如别的文件),必须在它的 `AGENTS.md` 里写明,并让构建与本脚本都读那一个来源。
|
||||
"""发版预检 —— **只读**,把"要不要发、发什么、有没有证据"一次列清楚。
|
||||
|
||||
设计上的三条硬规矩:
|
||||
1. **预检不产生任何副作用**:只走白名单里的只读命令(`git ls-remote/rev-parse/log/show/…`、
|
||||
HTTP GET)。`_git_read()` / `_api_read()` 会**拒绝**任何写操作 —— 这条有自检
|
||||
(`--self-test` 断言"写命令与 POST 在预检路径上被拒")。
|
||||
2. **正式 tag / Release 只在显式批准后执行**:`--execute --approve-version X.Y.Z --approve-sha <完整40位>`
|
||||
三个都给齐、且预检没有阻塞项,才会 `git tag -a` + `git push <tag>` + 建 Release;
|
||||
执行后**读回**(远端 tag SHA == 本地打的那个提交、Release 的 tag_name/version 一致)才算成功。
|
||||
3. **拿不到证据就说拿不到**:没有凭据、查不到 CI 状态时打印"未核对",不写成"已通过"。
|
||||
|
||||
用法:
|
||||
python3 scripts/release_preflight.py # 建议 patch(默认)
|
||||
python3 scripts/release_preflight.py --bump minor
|
||||
python3 scripts/release_preflight.py --version 0.2.0
|
||||
python3 scripts/release_preflight.py --self-test # 只读保证的自检
|
||||
# 批准后才执行(本轮不执行):
|
||||
python3 scripts/release_preflight.py --execute --approve-version 0.2.0 --approve-sha <完整SHA>
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
CHANGELOG = ROOT / "CHANGELOG.md"
|
||||
FULL_SHA = re.compile(r"^[0-9a-f]{40}$")
|
||||
SEMVER = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$")
|
||||
# 预检路径允许的只读 git 子命令(写命令在这里直接拒)
|
||||
GIT_READ_OK = {"ls-remote", "rev-parse", "log", "show", "cat-file", "for-each-ref",
|
||||
"status", "diff", "describe", "tag", "remote"}
|
||||
|
||||
|
||||
class WriteRefused(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _git_read(*args, cwd=None, check=False) -> tuple[int, str]:
|
||||
"""只读 git 调用。**任何写子命令在这里就被拒**(预检不许有副作用)。"""
|
||||
if args and args[0] not in GIT_READ_OK:
|
||||
raise WriteRefused(f"预检路径不允许 git {args[0]}(只读白名单:{sorted(GIT_READ_OK)})")
|
||||
if args and args[0] == "tag":
|
||||
# 只有"列出来"的写法算只读;`git tag v1.2.3` / `-a` / `-d` 都是写 —— 一律拒
|
||||
rest = list(args[1:])
|
||||
if not rest or rest[0] not in ("-l", "--list", "--contains", "--points-at"):
|
||||
raise WriteRefused(f"预检路径不允许打/改/删 tag:git {' '.join(args)}")
|
||||
p = subprocess.run(["git", *args], cwd=cwd or ROOT, capture_output=True, text=True)
|
||||
if check and p.returncode != 0:
|
||||
raise RuntimeError(f"git {' '.join(args)} 失败:{p.stderr[:200]}")
|
||||
return p.returncode, (p.stdout if p.returncode == 0 else p.stderr)
|
||||
|
||||
|
||||
def _cred(host: str) -> str:
|
||||
"""从 ~/.git-credentials 取凭据(**只返回,不打印**)。取不到就返回空串。"""
|
||||
f = Path.home() / ".git-credentials"
|
||||
if not f.is_file():
|
||||
return ""
|
||||
host_only = (host or "").split(":")[0]
|
||||
for line in f.read_text(encoding="utf-8").splitlines():
|
||||
# ⚠️ LAN 主机的凭据在文件里是 **URL 编码**的(`192.168.50.223%3a3000`)——
|
||||
# 先 unquote 再比;按字面匹配的症状是"没有凭据",会把人引向网络/权限方向。
|
||||
u = urllib.parse.urlparse(urllib.parse.unquote(line.strip()))
|
||||
if host_only and u.hostname == host_only:
|
||||
return urllib.parse.unquote(u.password or "")
|
||||
return ""
|
||||
|
||||
|
||||
def _api_read(base: str, path: str, token: str, method: str = "GET") -> tuple[int, dict]:
|
||||
"""只读 API。**非 GET 在这里就被拒**。"""
|
||||
if method != "GET":
|
||||
raise WriteRefused(f"预检路径不允许 {method} {path}(只读)")
|
||||
req = urllib.request.Request(f"{base}/api/v1{path}", method="GET",
|
||||
headers={"Authorization": "token " + token})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=25) as x:
|
||||
return x.status, json.loads(x.read().decode() or "{}")
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, {"_error": e.read().decode()[:200]}
|
||||
except (urllib.error.URLError, OSError) as e:
|
||||
return 0, {"_error": str(e)[:200]}
|
||||
|
||||
|
||||
# ── 解析 ────────────────────────────────────────────────────────────────
|
||||
def released_versions(text: str) -> list[str]:
|
||||
return [m.group(1) for m in re.finditer(r"^##\s*\[(\d+\.\d+\.\d+)\]", text, re.MULTILINE)]
|
||||
|
||||
|
||||
def block_of(text: str, heading: str) -> str:
|
||||
m = re.search(rf"^##\s*\[{re.escape(heading)}\][^\n]*$", text, re.MULTILINE)
|
||||
if not m:
|
||||
return ""
|
||||
nxt = re.search(r"^##\s*\[", text[m.end():], re.MULTILINE)
|
||||
end = m.end() + (nxt.start() if nxt else len(text) - m.end())
|
||||
return text[m.start():end].rstrip()
|
||||
|
||||
|
||||
def section(text: str, sec: str) -> str:
|
||||
if sec not in text:
|
||||
return ""
|
||||
tail = text.split(sec, 1)[1]
|
||||
return re.split(r"^###\s", tail, maxsplit=1, flags=re.MULTILINE)[0].strip()
|
||||
|
||||
|
||||
def bump(version: str, kind: str) -> str:
|
||||
a, b, c = (int(x) for x in version.split("."))
|
||||
if kind == "major":
|
||||
return f"{a + 1}.0.0"
|
||||
if kind == "minor":
|
||||
return f"{a}.{b + 1}.0"
|
||||
return f"{a}.{b}.{c + 1}"
|
||||
|
||||
|
||||
def repo_slug() -> str:
|
||||
rc, out = _git_read("remote", "get-url", "origin")
|
||||
if rc != 0:
|
||||
return ""
|
||||
m = re.search(r"[:/]([^/]+/[^/]+?)(?:\.git)?$", out.strip())
|
||||
return m.group(1) if m else ""
|
||||
|
||||
|
||||
def server_base() -> str:
|
||||
rc, out = _git_read("remote", "get-url", "origin")
|
||||
if rc != 0:
|
||||
return ""
|
||||
u = urllib.parse.urlparse(out.strip())
|
||||
if not u.scheme:
|
||||
return ""
|
||||
return f"{u.scheme}://{u.netloc}"
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
ap = argparse.ArgumentParser(add_help=True)
|
||||
ap.add_argument("--bump", default="patch", choices=["major", "minor", "patch"])
|
||||
ap.add_argument("--version", default="")
|
||||
ap.add_argument("--sha", default="")
|
||||
ap.add_argument("--execute", action="store_true")
|
||||
ap.add_argument("--approve-version", default="")
|
||||
ap.add_argument("--approve-sha", default="")
|
||||
ap.add_argument("--no-api", action="store_true", help="不查远端(没有凭据时的显式选择)")
|
||||
ap.add_argument("--self-test", action="store_true")
|
||||
args = ap.parse_args(argv)
|
||||
if args.self_test:
|
||||
return self_test()
|
||||
|
||||
text = CHANGELOG.read_text(encoding="utf-8") if CHANGELOG.is_file() else ""
|
||||
rel = released_versions(text)
|
||||
if not rel:
|
||||
print("❌ CHANGELOG.md 里没有已发布版本 ⇒ 没有版本基线,发不了版", file=sys.stderr)
|
||||
return 2
|
||||
current = rel[0]
|
||||
candidate = args.version or bump(current, args.bump)
|
||||
if not SEMVER.match(candidate):
|
||||
print(f"❌ 候选版本号不合法:{candidate!r}(要 主.次.修订 三段十进制、无前导零)", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
rc, head = _git_read("rev-parse", "HEAD")
|
||||
head = head.strip() if rc == 0 else ""
|
||||
rc, branch = _git_read("rev-parse", "--abbrev-ref", "HEAD")
|
||||
branch = branch.strip() if rc == 0 else ""
|
||||
want_sha = args.sha or head
|
||||
rc, dirty = _git_read("status", "--porcelain")
|
||||
dirty_lines = [l for l in dirty.splitlines() if l.strip() and not l.startswith("?? .ci-")]
|
||||
|
||||
slug, base = repo_slug(), server_base()
|
||||
print("── 发布预检(只读:不会打 tag、不会建 Release、不会推送)──")
|
||||
print(f" 仓库 : {slug or '(取不到 origin)'}")
|
||||
print(f" 分支/提交 : {branch} @ {head or '(取不到 HEAD)'}"
|
||||
f"{'' if FULL_SHA.match(head) else ' ⚠️ 不是完整 40 位'}")
|
||||
print(f" 工作区 : {'干净' if not dirty_lines else f'有 {len(dirty_lines)} 处未提交 —— 发版要在干净树上看'}")
|
||||
print(f" 当前已发布 : {current}")
|
||||
print(f" 待发布版本 : {candidate}({'显式 --version' if args.version else f'--bump {args.bump} 的建议值;'
|
||||
f'可选 major/minor/patch'})")
|
||||
|
||||
# CHANGELOG 内容
|
||||
unreleased = block_of(text, "Unreleased")
|
||||
print("\n CHANGELOG(Unreleased 原样打印):")
|
||||
print(" " + (unreleased.replace("\n", "\n ")[:1500] if unreleased.strip() else "(没有 Unreleased 段:"
|
||||
"正式发版前要把这次改动写进去)"))
|
||||
known = section(block_of(text, current), "### 已知问题") if current in rel else ""
|
||||
print(f"\n 当前版本的『已知问题』: {known.splitlines()[0] if known else '(没有写)'}")
|
||||
|
||||
# 检查器(结构与 tag 一致性)
|
||||
chk = subprocess.run([sys.executable or "python3", str(ROOT / "scripts" / "check_changelog.py"), "--tags"],
|
||||
cwd=ROOT, capture_output=True, text=True)
|
||||
checker_ok = chk.returncode == 0
|
||||
print(f"\n CHANGELOG/版本记录检查: {'✅ 通过' if checker_ok else '❌ 不通过 —— 先修它再发版'}")
|
||||
if not checker_ok:
|
||||
print(" " + (chk.stdout + chk.stderr).strip().replace("\n", "\n ")[-800:])
|
||||
|
||||
# 远端 tag / Release / CI 证据
|
||||
tag = f"v{candidate}"
|
||||
blockers: list[str] = []
|
||||
if dirty_lines:
|
||||
blockers.append(f"工作区有 {len(dirty_lines)} 处未提交")
|
||||
if not FULL_SHA.match(head):
|
||||
blockers.append("HEAD 不是完整 40 位 SHA")
|
||||
if want_sha != head:
|
||||
blockers.append(f"--sha {want_sha[:12]} != HEAD {head[:12]}(发版必须指向准确提交)")
|
||||
if not checker_ok:
|
||||
blockers.append("CHANGELOG/版本记录检查不通过")
|
||||
if not unreleased.strip():
|
||||
blockers.append("没有 Unreleased 段:发版前要把这次改动写进 CHANGELOG")
|
||||
if candidate in rel:
|
||||
blockers.append(f"版本 {candidate} 已经在 CHANGELOG 里出现过(版本号只增不减)")
|
||||
|
||||
token = "" if args.no_api else _cred(urllib.parse.urlparse(base).hostname or "")
|
||||
rc, tags_out = _git_read("ls-remote", "--tags", "origin")
|
||||
remote_tags = [l.split("refs/tags/")[-1] for l in tags_out.splitlines() if "refs/tags/" in l]
|
||||
if f"{tag}^{{}}" in remote_tags:
|
||||
remote_tags.remove(f"{tag}^{{}}")
|
||||
print(f"\n 远端 tag : {tag} {'已存在 ⛔ 不能覆盖已发布的 tag' if tag in remote_tags else '不存在(可发)'}"
|
||||
f"|已存在的正式 tag: {[t for t in remote_tags if re.match(r'^v\\d', t)] or '(无)'}")
|
||||
|
||||
run_line = "(未查询)"
|
||||
ctx_line = "(未查询)"
|
||||
if token and base and slug:
|
||||
st, body = _api_read(base, f"/repos/{slug}/releases/tags/{urllib.parse.quote(tag)}", token)
|
||||
rel_exists = st == 200
|
||||
print(f" Gitea Release: {tag} {'已存在 ⛔' if rel_exists else '不存在(可发)'}")
|
||||
if rel_exists:
|
||||
blockers.append(f"Gitea Release {tag} 已存在")
|
||||
st, stt = _api_read(base, f"/repos/{slug}/commits/{head}/status", token)
|
||||
if st == 200:
|
||||
ctxs = [(s.get("context"), s.get("status")) for s in stt.get("statuses") or []]
|
||||
ctx_line = "、".join(f"{c}={v}" for c, v in ctxs) or "(这个提交上一个状态都没有)"
|
||||
if not any(v == "success" for _, v in ctxs):
|
||||
blockers.append(f"提交 {head[:12]} 上没有成功的 status context(CI 证据不足)")
|
||||
else:
|
||||
ctx_line = f"(查不到:HTTP {st})"
|
||||
blockers.append("查不到该提交的 CI 状态")
|
||||
st, runs = _api_read(base, f"/repos/{slug}/actions/runs?limit=20", token)
|
||||
if st == 200:
|
||||
mine = [r for r in (runs.get("workflow_runs") or [])
|
||||
if (r.get("head_sha") or "") == head]
|
||||
if mine:
|
||||
r0 = mine[0]
|
||||
run_line = (f"run #{r0.get('run_number')} {r0.get('status')} "
|
||||
f"{r0.get('url') or ''}".strip())
|
||||
else:
|
||||
run_line = "(这个提交上没有运行记录)"
|
||||
else:
|
||||
print(f" Gitea Release: **未核对**({'显式 --no-api' if args.no_api else '没有取到凭据'})")
|
||||
blockers.append("没有核对远端 Release/CI 状态(缺凭据)")
|
||||
print(f" CI 状态 : {ctx_line}")
|
||||
print(f" 最近运行 : {run_line}")
|
||||
print(" 遗留风险/未验证: 见 CHANGELOG 的『已知问题』与本次 Unreleased 里写明的未验证项"
|
||||
"(预检不替你判断它们的可接受性)")
|
||||
|
||||
print("\n── 结论 ──")
|
||||
if blockers:
|
||||
for b in blockers:
|
||||
print(f" ⛔ {b}")
|
||||
print(f" ⇒ 现在**不能**发 {candidate}")
|
||||
else:
|
||||
print(f" ✅ 预检通过:可以发 {candidate}(tag {tag} → 提交 {head[:12]})")
|
||||
print(" 批准后执行(本轮不执行):")
|
||||
print(f" python3 scripts/release_preflight.py --execute --version {candidate} "
|
||||
f"--approve-version {candidate} --approve-sha {head}")
|
||||
|
||||
if not args.execute:
|
||||
return 1 if blockers else 0
|
||||
|
||||
# ── 显式批准后的执行路径(本轮不跑)────────────────────────────────
|
||||
if blockers:
|
||||
print("⛔ 预检有阻塞项,**不执行**打 tag/建 Release", file=sys.stderr)
|
||||
return 1
|
||||
if args.approve_version != candidate or args.approve_sha != head:
|
||||
print("⛔ 批准参数与预检结果不符(--approve-version/--approve-sha 必须逐字符等于预检结果)—— 不执行",
|
||||
file=sys.stderr)
|
||||
return 1
|
||||
print(f"\n── 执行(已获批准)──\n git tag -a {tag} -m 'release {candidate}' → push → 建 Release → 读回")
|
||||
p = subprocess.run(["git", "tag", "-a", tag, "-m", f"release {candidate}"], cwd=ROOT,
|
||||
capture_output=True, text=True)
|
||||
if p.returncode:
|
||||
print(f"❌ 打 tag 失败:{p.stderr[:200]}", file=sys.stderr)
|
||||
return 1
|
||||
p = subprocess.run(["git", "push", "origin", tag], cwd=ROOT, capture_output=True, text=True)
|
||||
if p.returncode:
|
||||
print(f"❌ 推送 tag 失败(**不建 Release**):{p.stderr[:200]}", file=sys.stderr)
|
||||
return 1
|
||||
rc, back = _git_read("ls-remote", "--tags", "origin", tag)
|
||||
back_sha = (back.split()[0] if back.strip() else "").strip()
|
||||
rc2, tagged = _git_read("rev-parse", f"{tag}^{{commit}}")
|
||||
ok_tag = bool(back_sha) and tagged.strip() == head
|
||||
print(f" 读回远端 {tag} = {back_sha[:12] or '(空)'}|本地 tag 指向 {tagged.strip()[:12]}"
|
||||
f" ⇒ {'✅ 一致' if ok_tag else '❌ 不一致'}")
|
||||
if not ok_tag:
|
||||
return 1
|
||||
body = json.dumps({"tag_name": tag, "name": tag, "body": block_of(text, current) or unreleased,
|
||||
"draft": False, "prerelease": False}).encode()
|
||||
req = urllib.request.Request(f"{base}/api/v1/repos/{slug}/releases", data=body, method="POST",
|
||||
headers={"Authorization": "token " + token,
|
||||
"Content-Type": "application/json"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as x:
|
||||
created = json.loads(x.read().decode() or "{}")
|
||||
except (urllib.error.URLError, OSError) as e:
|
||||
print(f"❌ 建 Release 失败(tag 已经推上去了,别重复推):{e}", file=sys.stderr)
|
||||
return 1
|
||||
st, got = _api_read(base, f"/repos/{slug}/releases/tags/{urllib.parse.quote(tag)}", token)
|
||||
same = st == 200 and (got.get("tag_name") == tag)
|
||||
print(f" Release 读回:HTTP {st} tag_name={got.get('tag_name')!r} id={got.get('id')}"
|
||||
f" ⇒ {'✅ 一致' if same else '❌ 不一致'}")
|
||||
print(f" (说明来自 CHANGELOG,不维护第二份正文;tag 不可移动、不可覆盖)")
|
||||
return 0 if same else 1
|
||||
|
||||
|
||||
def self_test() -> int:
|
||||
"""预检的**只读保证**要能被证明:写命令与 POST 必须在预检路径上被拒。"""
|
||||
problems: list[str] = []
|
||||
for fn, arg in ((_git_read, "push"), (_git_read, "commit"), (_git_read, "tag")):
|
||||
try:
|
||||
fn(arg, "x")
|
||||
problems.append(f"预检竟然允许 git {arg}")
|
||||
except WriteRefused:
|
||||
pass
|
||||
try:
|
||||
_git_read("tag", "-a", "v9.9.9", "-m", "x")
|
||||
problems.append("预检竟然允许打 tag(-a)")
|
||||
except WriteRefused:
|
||||
pass
|
||||
for method in ("POST", "PUT", "DELETE", "PATCH"):
|
||||
try:
|
||||
_api_read("http://127.0.0.1:1", "/x", "t", method)
|
||||
problems.append(f"预检竟然允许 {method}")
|
||||
except WriteRefused:
|
||||
pass
|
||||
# 版本号推进
|
||||
if bump("1.2.3", "patch") != "1.2.4" or bump("1.2.3", "minor") != "1.3.0" or bump("1.2.3", "major") != "2.0.0":
|
||||
problems.append("版本推进算错了")
|
||||
if problems:
|
||||
print("❌ 预检自检失败:", file=sys.stderr)
|
||||
for p in problems:
|
||||
print(f" · {p}", file=sys.stderr)
|
||||
return 1
|
||||
print("✅ 预检自检通过:写类 git 命令与 POST/PUT/DELETE/PATCH 在预检路径上全部被拒;版本推进正确")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
Reference in New Issue
Block a user