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:
姚章浩
2026-10-12 17:58:06 +08:00
parent 718cfad3de
commit fc13e9d170
5 changed files with 802 additions and 5 deletions
+35 -1
View File
@@ -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 项要读它("允许跳过哪些用例""前端用什么跑测试")。
# 给一份**合法但空**的骨架 —— 空声明不等于"随便跳",必需用例被跳过照样红。
+21
View File
@@ -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 不许因此变红。
````
+366
View File
@@ -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
View File
@@ -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
+351
View File
@@ -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:]))