74ba737b1e
build / build (push) Successful in 1m4s
历史分支中存在两条开发号 0.1.1;归档选择按 SHA 匹配,保留原编号与冲突标签。 增加同号前置说明遮蔽及错 SHA 不复用回归,标签计划使用相同精确选择方式。 已运行:定向 Go 测试、快照工具临时仓正反例及整仓 make verify;未部署。
86 lines
6.2 KiB
Python
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()
|