#!/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()