Files
姚章浩 74ba737b1e
build / build (push) Successful in 1m4s
fix(version): 按完整提交区分同号历史开发说明
历史分支中存在两条开发号 0.1.1;归档选择按 SHA 匹配,保留原编号与冲突标签。
增加同号前置说明遮蔽及错 SHA 不复用回归,标签计划使用相同精确选择方式。
已运行:定向 Go 测试、快照工具临时仓正反例及整仓 make verify;未部署。
2026-10-18 18:18:43 +08:00

86 lines
6.2 KiB
Python

#!/usr/bin/env python3
"""Explicit development delivery tags; never creates formal tags or releases."""
import argparse,json,re,subprocess,tempfile
from pathlib import Path
ROOT=Path(__file__).resolve().parent.parent
SHA=re.compile(r'^[a-f0-9]{40}$')
def git(root,*args):
p=subprocess.run(['git',*args],cwd=root,text=True,capture_output=True)
if p.returncode:raise RuntimeError('git operation failed: '+args[0])
return p.stdout.strip()
def notes(text,version,sha=None):
for heading in re.finditer(r'^## 开发快照 snapshot/v'+re.escape(version)+r' - \d{4}-\d{2}-\d{2}\s*$',text,re.M):
body=re.split(r'^##\s',text[heading.end():],maxsplit=1,flags=re.M)[0].strip()
if sha is None or ('目标提交:'+sha) in body.splitlines():return body
return ''
def plan(root,sha,backfill=False):
if not sha or not SHA.fullmatch(sha):raise ValueError('exact 40-character commit SHA required')
if git(root,'cat-file','-t',sha)!='commit':raise ValueError('snapshot target must be a commit')
text=git(root,'show',sha+':CHANGELOG.md');m=re.search(r'^##\s*\[(\d+\.\d+\.\d+)\]',text,re.M)
if not m:raise ValueError('formal baseline missing')
baseline=m.group(1);base='v'+baseline
if git(root,'cat-file','-t','refs/tags/'+base)!='tag':raise ValueError('annotated formal baseline missing')
git(root,'merge-base','--is-ancestor',base,sha)
n=int(git(root,'rev-list','--count',base+'..'+sha));version='.'.join(baseline.split('.')[:2]+[str(n)])
if n<=0:raise ValueError('formal baseline cannot be tagged as a development delivery')
body=notes(text,version)
if not body and backfill:
body=notes(git(root,'show','HEAD:CHANGELOG.md'),version,sha)
if '目标提交:'+sha not in body:raise ValueError('backfilled notes must bind the exact historic SHA')
if not body:raise ValueError('this development version has no independent CHANGELOG entry')
if not re.search(r'^### (Added|Changed|Fixed|Security)\s*$',body,re.M):raise ValueError('snapshot notes have no change category')
return {'commit_sha':sha,'display_version':version,'tag':'snapshot/v'+version,'message':'Development snapshot '+version+'\n\n'+body,'backfilled':bool(backfill)}
def publish(root,p):
# Only explicitly named development refs are written. Existing refs, including
# legacy misnumbered snapshots, are never moved or overwritten.
tag=p['tag'];sha=p['commit_sha'];ref='refs/tags/'+tag
remote=git(root,'ls-remote','origin');pairs=[line.split() for line in remote.splitlines() if line.strip()];refs={name:obj for obj,name in pairs}
if ref in refs:
if refs.get(ref+'^{}')!=sha:raise ValueError('remote snapshot already exists with different or non-annotated identity')
return {'tag':tag,'commit_sha':sha,'state':'already_published'}
reachable=False
for obj,name in pairs:
if name.startswith('refs/heads/') and subprocess.run(['git','merge-base','--is-ancestor',sha,obj],cwd=root,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL).returncode==0:reachable=True;break
if not reachable:raise ValueError('snapshot target has not been pushed to a remote branch')
exists=subprocess.run(['git','show-ref','--verify','--quiet',ref],cwd=root).returncode==0
if exists:
if git(root,'cat-file','-t',ref)!='tag' or git(root,'rev-parse',ref+'^{}')!=sha:raise ValueError('local snapshot conflicts with requested identity')
if git(root,'for-each-ref','--format=%(contents)',ref).strip()!=p['message'].strip():raise ValueError('existing snapshot annotation differs; will not overwrite')
else:
with tempfile.NamedTemporaryFile(mode='w',prefix='kg-snapshot-note-',encoding='utf-8') as f:
f.write(p['message']+'\n');f.flush();git(root,'tag','-a',tag,sha,'-F',f.name)
git(root,'push','origin',ref+':'+ref)
readback={line.split()[1]:line.split()[0] for line in git(root,'ls-remote','--tags','origin',ref,ref+'^{}').splitlines()}
if readback.get(ref+'^{}')!=sha or readback.get(ref)!=git(root,'rev-parse',ref):raise RuntimeError('remote snapshot read-back failed')
return {'tag':tag,'commit_sha':sha,'state':'published_and_read_back'}
def self_test():
with tempfile.TemporaryDirectory(prefix='kg-snapshot-test-') as d:
r=Path(d);git(r,'init','-b','main');git(r,'config','user.name','Fixture');git(r,'config','user.email','fixture@example.invalid')
base='# Changelog\n\n## [0.1.0] - 2026-10-13\n\n### Added\n\n- fixture baseline\n'
(r/'CHANGELOG.md').write_text(base);git(r,'add','CHANGELOG.md');git(r,'commit','-m','feat: fixture baseline');git(r,'tag','-a','v0.1.0','-m','fixture')
(r/'CHANGELOG.md').write_text('## 开发快照 snapshot/v0.1.1 - 2026-10-18\n\n### Fixed\n\n- specific fixture fix\n\n'+base);git(r,'add','CHANGELOG.md');git(r,'commit','-m','fix: fixture change')
sha=git(r,'rev-parse','HEAD');before=git(r,'tag');p=plan(r,sha)
assert p['tag']=='snapshot/v0.1.1' and 'specific fixture fix' in p['message']
duplicate='## 开发快照 snapshot/v0.1.1 - 2026-10-18\n\n目标提交:'+('a'*40)+'\n\n### Fixed\n\n- wrong branch\n\n## 开发快照 snapshot/v0.1.1 - 2026-10-18\n\n目标提交:'+sha+'\n\n### Fixed\n\n- exact branch'
assert 'exact branch' in notes(duplicate,'0.1.1',sha) and 'wrong branch' not in notes(duplicate,'0.1.1',sha)
assert before==git(r,'tag'),'planning wrote a tag'
try:plan(r,'a'*7)
except ValueError:pass
else:raise AssertionError('short SHA accepted')
bare=r/'remote.git';git(r,'init','--bare',str(bare));git(r,'remote','add','origin',str(bare));git(r,'push','origin','main')
out=publish(r,p);assert out['state']=='published_and_read_back'
assert git(r,'rev-parse',p['tag']+'^{}')==sha
assert publish(r,p)['state']=='already_published'
wrong=dict(p,commit_sha='a'*40)
try:publish(r,wrong)
except ValueError:pass
else:raise AssertionError('conflicting remote snapshot overwritten')
assert git(r,'tag','--list','v*').splitlines()==['v0.1.0'],'formal tags changed'
print('development snapshot fixture passed; real repository tags untouched')
def main():
a=argparse.ArgumentParser();a.add_argument('--sha');a.add_argument('--backfill',action='store_true');a.add_argument('--publish',action='store_true');a.add_argument('--self-test',action='store_true');o=a.parse_args()
if o.self_test:return self_test()
p=plan(ROOT,o.sha,o.backfill)
print(json.dumps(publish(ROOT,p) if o.publish else p,ensure_ascii=False))
if __name__=='__main__':main()