013ce4a24e
build / build (push) Successful in 1m3s
收录 main、注释开发快照和完整运行 SHA;开发说明绑定原提交,补记 0.1.1–0.1.25。 进行中同步按仓库合并,完成后重新执行;启动入队,部署脚本读回版本证据。 开发快照独立于正式标签与 Release,既有冲突标签不移动。 已运行:整仓 make verify,Go 673 通过、前端 274 通过,92 个已声明可选跳过,必需跳过 0。 隔离 PostgreSQL 重复同步队列用例及 -race 通过;未更新现有 8088、未修改生产问答。
83 lines
5.8 KiB
Python
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()
|