@@ -213,6 +213,61 @@ def _ver_tuple(v: str) -> tuple[int, int, int]:
return a , b , c
BREAK_ITEM = re . compile ( r " ^ \ s*(?:[-*+]| \ d+[.)])? \ s* \ * \ *破坏性 \ * \ * " )
STABILITY_HEADING = re . compile ( r " (?m)^##+ \ s*.*稳定契约承诺 " )
# 「稳定契约承诺」必须逐项覆盖的对外契约面(缺项 / 留占位 ⇒ 不许发 1.0.0)
STABILITY_ITEMS = ( " 接口 " , " 数据模型 " , " 配置 " , " CLI 输出与退出码 " )
STABILITY_PLACEHOLDERS = ( " 待填 " , " TODO " , " TBD " , " xxx " , " XXX " )
STABILITY_DOCS = ( " docs/RELEASE_RUNBOOK.md " , " docs/STABILITY_CONTRACT.md " )
def released_in_commit ( sha : str , * , root : Path = ROOT ) - > list [ str ] :
""" 目标提交里 CHANGELOG 的已发布版本清单(顶部在前)。 """
rc , text = _git_read ( " show " , f " { sha } :CHANGELOG.md " , cwd = root )
return released_versions ( text ) if rc == 0 else [ ]
def breaking_in_commit ( sha : str , version : str , * , root : Path = ROOT ) - > int :
""" **候选版本那一段**里的破坏性变化条数(发版事实看提交里的记录,不看工作区)。 """
rc , text = _git_read ( " show " , f " { sha } :CHANGELOG.md " , cwd = root )
if rc != 0 :
return 0
return sum ( 1 for line in block_of ( text , version ) . splitlines ( ) if BREAK_ITEM . match ( line ) )
def bump_level ( prev : str | None , cand : str ) - > str | None :
""" 抬了哪一档:major / minor / patch;比不出来(首发 / 号没涨)⇒ None。 """
if not ( prev and SEMVER . match ( prev ) and SEMVER . match ( cand ) ) :
return None
a , b = _ver_tuple ( prev ) , _ver_tuple ( cand )
if b [ 0 ] > a [ 0 ] :
return " major "
if b [ 0 ] == a [ 0 ] and b [ 1 ] > a [ 1 ] :
return " minor "
if b [ 0 ] == a [ 0 ] and b [ 1 ] == a [ 1 ] and b [ 2 ] > a [ 2 ] :
return " patch "
return None
def stability_contract_problems ( * , root : Path = ROOT ) - > list [ str ] :
""" 《稳定契约承诺》是否写实(发 1.0.0 的前置条件);返回缺失项(空 = 合格)。 """
doc = next ( ( root / d for d in STABILITY_DOCS
if ( root / d ) . is_file ( )
and STABILITY_HEADING . search ( ( root / d ) . read_text ( encoding = " utf-8 " ) ) ) , None )
if doc is None :
return [ f " 找不到《稳定契约承诺》段(在 { ' 或 ' . join ( STABILITY_DOCS ) } 里以 `## …稳定契约承诺` 开头) " ]
text = doc . read_text ( encoding = " utf-8 " )
sec = text [ STABILITY_HEADING . search ( text ) . start ( ) : ]
problems : list [ str ] = [ ]
for item in STABILITY_ITEMS :
if item not in sec :
problems . append ( f " 没覆盖「 { item } 」 " )
holes = [ w for w in STABILITY_PLACEHOLDERS if w in sec ]
if holes :
problems . append ( f " 还留着占位没填( { ' 、 ' . join ( holes ) } ) " )
return problems
def readiness ( f : dict ) - > tuple [ bool , list [ str ] , list [ str ] ] :
""" 把**事实**翻成(是否就绪, 阻塞项, 准备建议)——纯函数,可用假事实直接自检。
@@ -245,6 +300,30 @@ def readiness(f: dict) -> tuple[bool, list[str], list[str]]:
advice . append ( " 下一次正式发布建议用 <主>.<次+1>.0(保证正式号严格大于此前所有开发号) " )
except ValueError :
pass
prev , level = f . get ( " prev_version " ) , f . get ( " bump_level " )
breaking_n = int ( f . get ( " breaking_in_candidate " ) or 0 )
if prev and level and breaking_n :
prev_major = _ver_tuple ( str ( prev ) ) [ 0 ]
if prev_major == 0 and level == " patch " :
blocks = ( f " 有 { breaking_n } 条破坏性变化,但 { prev } → { cand } 只抬了**修订**: "
f " 0.x 阶段破坏性变化必须抬**次版本**(例如 "
f " 0. { _ver_tuple ( str ( prev ) ) [ 1 ] + 1 } .0)—— 否则使用者按 ' 只是修 bug ' 升级会踩坑 " )
blockers . append ( blocks )
advice . append ( f " 把候选改成 0. { _ver_tuple ( str ( prev ) ) [ 1 ] + 1 } .0(抬次版本), "
f " 并在发布文档里写清升级/迁移与回退 " )
if prev_major > = 1 and level != " major " :
blockers . append ( f " 有 { breaking_n } 条破坏性变化,但 { prev } → { cand } 没有抬**主版本**: "
f " ≥1.0.0 之后破坏性变化必须抬**主版本**(例如 { prev_major + 1 } .0.0) " )
advice . append ( f " 把候选改成 { prev_major + 1 } .0.0(抬主版本) " )
if level is None and prev and SEMVER . match ( str ( prev ) ) and SEMVER . match ( str ( cand ) ) \
and _ver_tuple ( str ( cand ) ) < = _ver_tuple ( str ( prev ) ) :
blockers . append ( f " 候选 { cand } 不大于上一个正式版本 { prev } (版本号只增不减) " )
if f . get ( " first_1_0_0 " ) and not f . get ( " stability_ok " ) :
problems = f . get ( " stability_problems " ) or [ " (未取到具体缺失项) " ]
blockers . append ( " 候选进入 1.0.0 = 对外承诺契约稳定:必须先把《稳定契约承诺》写实、 "
" 并在候选发布记录里链接它 —— 缺: " + " ; " . join ( str ( x ) for x in problems ) )
advice . append ( " 在 docs 的《稳定契约承诺》里逐项写实 接口 / 数据模型 / 配置 / CLI 输出与退出码, "
" 并在本次 CHANGELOG 的发布记录里链接该文档;不满足就先发 0.x " )
if f . get ( " candidate_tagged " ) :
blockers . append ( f " 远端已经有 tag v { cand } ⇒ 不能覆盖已发布的 tag(版本号只增不减) " )
if f . get ( " dirty " ) :
@@ -371,6 +450,36 @@ def main(argv: list[str]) -> int:
tag = f " v { candidate } "
required , policy_src = required_contexts ( )
dev_number , dev_commits = current_dev_number ( )
rels = released_in_commit ( want_sha )
prev_version = next ( ( v for v in rels if v != candidate ) , None )
level = bump_level ( prev_version , candidate )
breaking_n = breaking_in_commit ( want_sha , candidate )
first_1_0_0 = ( SEMVER . match ( candidate ) is not None and _ver_tuple ( candidate ) [ 0 ] == 1
and ( prev_version is None or _ver_tuple ( prev_version ) [ 0 ] == 0 ) )
stab_problems : list [ str ] = [ ]
if first_1_0_0 :
stab_problems = stability_contract_problems ( )
rc , ctext = _git_read ( " show " , f " { want_sha } :CHANGELOG.md " )
blk = block_of ( ctext , candidate ) if rc == 0 else " "
if not ( any ( d in blk for d in STABILITY_DOCS ) or " 稳定契约承诺 " in blk ) :
stab_problems . append ( " 候选发布记录里没有链接《稳定契约承诺》 "
f " (应引 { ' 或 ' . join ( STABILITY_DOCS ) } ) " )
stab_ok = not stab_problems
level_cn = { " major " : " 主版本 " , " minor " : " 次版本 " , " patch " : " 修订 " } . get ( level or " " , " 看不出来(首发 / 号没涨) " )
prev_major = _ver_tuple ( prev_version ) [ 0 ] if ( prev_version and SEMVER . match ( prev_version ) ) else None
if not breaking_n :
tail = " 候选记录里没有破坏性变化 ⇒ 抬哪一档不限(但候选号必须 > 当前开发号) "
elif prev_major is None :
tail = f " 候选记录里有 { breaking_n } 条破坏性变化(首发,无上一版本可比) "
else :
need = ( " 0.x 阶段:有破坏性必须抬次版本(或主版本) " if prev_major == 0
else " ≥1.0.0:有破坏性必须抬主版本 " )
ok = ( level in ( " minor " , " major " ) ) if prev_major == 0 else ( level == " major " )
tail = f " 候选记录里有 { breaking_n } 条破坏性变化 ⇒ { need } { ' ✅ 抬档够 ' if ok else ' ⛔ 抬档不够 ' } "
print ( f " 版本走向 : { prev_version or ' (首发) ' } → { candidate } (抬 { level_cn } )| { tail } " )
if first_1_0_0 :
print ( f " 1.0.0 前置 : 《稳定契约承诺》 "
f " { ' ✅ 已写实 ' if stab_ok else ' ⛔ 未写实: ' + ' ; ' . join ( stab_problems ) } " )
print ( f " 当前开发号 : { dev_number or ' (无正式基线 / 取不到 tag ⇒ dev+g<短SHA>) ' } "
f " { f ' (自 v { current } 起 { dev_commits } 个提交) ' if ( dev_number and dev_commits is not None ) else ' ' } " )
top_in_commit = top_version_in_commit ( want_sha )
@@ -437,6 +546,8 @@ def main(argv: list[str]) -> int:
" candidate " : candidate , " tag " : tag , " first_release " : first_release ,
" explicit_version " : bool ( args . version ) , " in_target_commit " : in_target ,
" top_in_commit " : top_in_commit , " dev_number " : dev_number ,
" prev_version " : prev_version , " bump_level " : level , " breaking_in_candidate " : breaking_n ,
" first_1_0_0 " : first_1_0_0 , " stability_ok " : stab_ok , " stability_problems " : stab_problems ,
" dirty " : len ( dirty_lines ) , " head_full " : bool ( FULL_SHA . match ( head ) ) ,
" sha_is_head " : want_sha == head , " checker_ok " : checker_ok ,
" unreleased_present " : bool ( unreleased . strip ( ) ) ,
@@ -495,6 +606,9 @@ def self_test() -> int:
" remote_tags_checked " : True , " required_contexts " : [ " build / build (push) " ] ,
" contexts " : { " build / build (push) " : " success " } , " contexts_error " : " " ,
" release_exists " : False , " release_query_error " : " " , " no_api " : False ,
# 版本走向/破坏性/1.0.0 前置(默认:无上一版本、无破坏性 ⇒ 新判据不参与)
" prev_version " : None , " bump_level " : None , " breaking_in_candidate " : 0 ,
" first_1_0_0 " : False , " stability_ok " : True , " stability_problems " : [ ] ,
}
def rd ( name : str , ready_want : bool , needle : str = " " , * * over ) - > None :
@@ -526,6 +640,31 @@ def self_test() -> int:
True , " " , candidate = " 0.2.0 " , dev_number = " 0.1.12 " , in_target_commit = True ,
contexts = { " build / build (push) " : " success " } )
rd ( " 候选版本没进目标提交 ⇒ 不就绪 " , False , " 还没写进目标提交 " , in_target_commit = False )
# ── 抬档判据:破坏性变化 ⇒ 0.x 抬次、≥1.0.0 抬主 ──
rd ( " 破坏性 + 0.x 只抬修订(0.1.0→0.1.3)⇒ 不就绪 " ,
False , " 必须抬**次版本** " , candidate = " 0.1.3 " , prev_version = " 0.1.0 " , bump_level = " patch " ,
breaking_in_candidate = 1 )
rd ( " 破坏性 + 0.x 抬次(0.1.0→0.2.0)⇒ 该条不拦 " , True ,
candidate = " 0.2.0 " , prev_version = " 0.1.0 " , bump_level = " minor " , breaking_in_candidate = 2 )
rd ( " 破坏性 + ≥1.0.0 只抬次(1.2.3→1.3.0)⇒ 不就绪 " ,
False , " 必须抬**主版本** " , candidate = " 1.3.0 " , prev_version = " 1.2.3 " , bump_level = " minor " ,
breaking_in_candidate = 1 )
rd ( " 破坏性 + ≥1.0.0 抬主(1.2.3→2.0.0)⇒ 该条不拦 " , True ,
candidate = " 2.0.0 " , prev_version = " 1.2.3 " , bump_level = " major " , breaking_in_candidate = 1 )
rd ( " 无破坏性 + 只抬修订(0.1.0→0.1.3)⇒ 抬档不拦 " , True ,
candidate = " 0.1.3 " , prev_version = " 0.1.0 " , bump_level = " patch " , breaking_in_candidate = 0 )
rd ( " 候选号没涨(0.2.0→0.2.0)⇒ 不就绪 " , False , " 只增不减 " ,
candidate = " 0.2.0 " , prev_version = " 0.2.0 " , bump_level = None , breaking_in_candidate = 0 )
# ── 1.0.0 首见:必须有写实的《稳定契约承诺》 ──
rd ( " 首发 1.0.0 但《稳定契约承诺》没写实 ⇒ 不就绪 " , False , " 稳定契约承诺 " ,
candidate = " 1.0.0 " , prev_version = " 0.9.3 " , bump_level = " major " , first_1_0_0 = True ,
stability_ok = False , stability_problems = [ " 没覆盖「配置」 " , " 还留着占位没填(待填) " ] )
rd ( " 首发 1.0.0 且《稳定契约承诺》写实 ⇒ 该条不拦 " , True ,
candidate = " 1.0.0 " , prev_version = " 0.9.3 " , bump_level = " major " , first_1_0_0 = True ,
stability_ok = True , stability_problems = [ ] )
rd ( " 已经是 1.x 再抬到 2.0.0 ⇒ 不要求重写承诺段 " , True ,
candidate = " 2.0.0 " , prev_version = " 1.4.0 " , bump_level = " major " , breaking_in_candidate = 1 ,
first_1_0_0 = False )
rd ( " 候选版本只在历史里(顶部是别的版本)⇒ 不就绪,且信息里给出真实顶部版本 " ,
False , " 顶部**已发布版本是 `## [0.3.0]` " ,
in_target_commit = False , top_in_commit = " 0.3.0 " , candidate = " 0.2.0 " )
@@ -639,7 +778,7 @@ def self_test() -> int:
print ( f " · { p } " , file = sys . stderr )
return 1
print ( " ✅ 预检自检通过:写类 git 命令与 POST/PUT/DELETE/PATCH 在预检路径上全部被拒;版本推进正确; "
" 就绪判定 21 个分支(context 缺项/pending/failure/查询异常、无策略、Release 非 404、 "
" 就绪判定 3 2 个分支(context 缺项/pending/failure/查询异常、无策略、Release 非 404、 "
" tag 占用、未核对远端、首发未显式指定、记录未进提交…逐条阻断;证据齐才就绪); "
" 『顶部版本才算数』与『写进提交才算数』用临时仓库验证; "
" `--execute` 明确拒绝且零写操作、零 API 调用 " )