Files
knowledge-governance/scripts/development_snapshot.py
T
姚章浩 013ce4a24e
build / build (push) Successful in 1m3s
fix(version): 补全开发快照记录与同步交付闭环
收录 main、注释开发快照和完整运行 SHA;开发说明绑定原提交,补记 0.1.1–0.1.25。
进行中同步按仓库合并,完成后重新执行;启动入队,部署脚本读回版本证据。
开发快照独立于正式标签与 Release,既有冲突标签不移动。

已运行:整仓 make verify,Go 673 通过、前端 274 通过,92 个已声明可选跳过,必需跳过 0。
隔离 PostgreSQL 重复同步队列用例及 -race 通过;未更新现有 8088、未修改生产问答。
2026-10-18 18:11:49 +08:00

83 lines
5.8 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):
heading=re.search(r'^## 开发快照 snapshot/v'+re.escape(version)+r' - \d{4}-\d{2}-\d{2}\s*$',text,re.M)
if not heading:return ''
tail=text[heading.end():];return re.split(r'^##\s',tail,maxsplit=1,flags=re.M)[0].strip()
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)
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']
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()