package-complete-release.py 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114
  1. """Freeze the complete committed release; database transport is delivered separately."""
  2. import argparse
  3. import hashlib
  4. import io
  5. import json
  6. from pathlib import Path
  7. import re
  8. import subprocess
  9. import zipfile
  10. ROOT = Path(__file__).resolve().parents[1]
  11. RELEASE = 'ksf-complete-deploy-20261106-r1'
  12. sha = lambda b: hashlib.sha256(b).hexdigest()
  13. def main():
  14. parser = argparse.ArgumentParser(description=__doc__)
  15. parser.add_argument('--commit', default='HEAD')
  16. args = parser.parse_args()
  17. commit = subprocess.check_output(['git', 'rev-parse', args.commit + '^{commit}'], cwd=ROOT).decode().strip()
  18. out = ROOT / 'artifacts/全部更新部署-20261106'
  19. work = ROOT / 'work' / RELEASE
  20. package = work / 'package'
  21. archive = out / (RELEASE + '.zip')
  22. if archive.exists() or package.exists():
  23. raise ValueError('RELEASE_ALREADY_EXISTS')
  24. frozen = zipfile.ZipFile(io.BytesIO(subprocess.check_output(['git', 'archive', '--format=zip', commit], cwd=ROOT)))
  25. baseline = json.loads(frozen.read('deploy/board-files-20261106.json'))
  26. plan = json.loads(frozen.read('deploy/complete-release-plan.json'))
  27. for prior in plan['previousPushedCommits']:
  28. subprocess.run(['git', 'merge-base', '--is-ancestor', prior['commit'], commit], cwd=ROOT, check=True)
  29. static = {f['path'] for f in baseline['files']}
  30. package.mkdir(parents=True)
  31. for name in frozen.namelist():
  32. if name.endswith('/'):
  33. continue
  34. if name in static or name == 'package.json' or name.startswith(('server/', 'skills/', 'scripts/', 'integrations/', 'test/')):
  35. target = package / 'payload' / name
  36. target.parent.mkdir(parents=True, exist_ok=True)
  37. target.write_bytes(frozen.read(name))
  38. for f in baseline['files']:
  39. assert sha((package / 'payload' / f['path']).read_bytes()) == f['sha256'], 'STATIC_CONTENT_CHANGED_SINCE_BASELINE'
  40. baseline.update(commit=commit, release=RELEASE)
  41. (package / 'deploy').mkdir()
  42. (package / 'deploy/board-files.json').write_text(json.dumps(baseline, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
  43. (package / 'deploy/complete-release-plan.json').write_bytes(frozen.read('deploy/complete-release-plan.json'))
  44. (package / 'deploy/complete-release-validation.json').write_bytes(frozen.read('deploy/complete-release-validation.json'))
  45. publisher = frozen.read('scripts/publish-opportunity-ui.mjs').decode('utf-8')
  46. start = publisher.index('export const FILES=Object.freeze([')
  47. end = publisher.index(']);', start) + 3
  48. publisher = publisher[:start] + 'export const FILES=Object.freeze(' + json.dumps([f['path'] for f in baseline['files']]) + ');' + publisher[end:]
  49. publisher = publisher.replace('ksf.opportunity-ui.publish.v1', 'ksf.board.publish.v1').replace('ksf.opportunity-ui.20261103.v1', RELEASE)
  50. publisher = publisher.replace('filesChecked:6', 'filesChecked:FILES.length').replace('ksf-opportunity-ui', 'ksf-board')
  51. publisher = publisher.replace("sourceDir:root,manifestPath:path.join(root,'deploy/opportunity-ui-files-20261103.json')", "sourceDir:path.join(root,'payload'),manifestPath:path.join(root,'deploy/board-files.json')")
  52. (package / 'scripts').mkdir()
  53. (package / 'scripts/publish-board.mjs').write_text(publisher, encoding='utf-8', newline='\n')
  54. node = 'node'
  55. subprocess.run([node, '--check', str(package / 'scripts/publish-board.mjs')], check=True, capture_output=True)
  56. subprocess.run([node, 'server/scripts/build-member-groups.mjs'], cwd=package / 'payload', check=True, capture_output=True)
  57. docs = {
  58. '完整发布执行单.md': 'docs/board-deploy-20261106/完整发布执行单.md',
  59. '看板部署细则.md': 'docs/board-deploy-20261106/发给运维001-部署已推送看板.md',
  60. '数据库分包投递.md': 'docs/voc-database-delivery-20261106/发给运维001-数据库分包投递.md',
  61. '王刚001-数据库就绪后验收.md': 'docs/voc-database-delivery-20261106/给王刚001-数据库就绪后继续验收.md',
  62. }
  63. for name, source in docs.items():
  64. body = frozen.read(source).decode('utf-8')
  65. if name == '完整发布执行单.md':
  66. body = f'> 本包固定源码提交:{commit}\n\n' + body
  67. (package / name).write_text(body, encoding='utf-8', newline='\n')
  68. functions = json.loads((package / 'payload/server/dist/member-groups/manifest.json').read_text())
  69. skills = {}
  70. for folder in (package / 'payload/skills').iterdir():
  71. manifest = folder / 'FILES.sha256'
  72. for line in manifest.read_text(encoding='utf-8').splitlines():
  73. h, rel = line.split(' ', 1)
  74. assert sha((folder / rel).read_bytes()) == h, 'SKILL_CHECKSUM_MISMATCH'
  75. skills[folder.name] = {'manifestSha256': sha(manifest.read_bytes()), 'filesVerified': len(manifest.read_text().splitlines())}
  76. imported = 0
  77. for relative in static:
  78. p = package / 'payload' / relative
  79. if p.suffix != '.js':
  80. continue
  81. for m in re.finditer(r'''(?:from|import)\s*\(?\s*['"](\.[^'"]+)['"]''', p.read_text(encoding='utf-8')):
  82. target = (p.parent / m[1]).resolve()
  83. assert target.is_relative_to((package / 'payload/src').resolve()) and target.is_file(), 'MODULE_DEPENDENCY_MISSING'
  84. imported += 1
  85. expected_files = [p for p in package.rglob('*') if p.is_file()]
  86. assert not any(p.suffix.lower() in {'.sqlite', '.db', '.zip', '.pem', '.key', '.pyc'} or p.name.startswith('.env') or p.name == 'credential-binding.json' for p in expected_files)
  87. assert not (package / 'payload/index.html').exists()
  88. release = {'release': RELEASE, 'sourceCommit': commit, 'previousPushedCommits': plan['previousPushedCommits'],
  89. 'staticFiles': len(static), 'relativeImportsVerified': imported, 'functions': functions,
  90. 'skills': skills, 'databaseIncluded': False, 'credentialsIncluded': False,
  91. 'databaseTransportRequired': ['ksf-voc-database-20261106-r1-part01.zip', 'ksf-voc-database-20261106-r1-part02.zip'],
  92. 'productionInstalled': False, 'browserVerified': False, 'productionWrites': 0}
  93. (package / 'RELEASE.json').write_text(json.dumps(release, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
  94. all_files = sorted(p for p in package.rglob('*') if p.is_file())
  95. (package / 'FILES.sha256').write_text(''.join(sha(p.read_bytes()) + ' ' + p.relative_to(package).as_posix() + '\n' for p in all_files), encoding='utf-8', newline='\n')
  96. out.mkdir(parents=True, exist_ok=True)
  97. with zipfile.ZipFile(archive, 'x', zipfile.ZIP_DEFLATED, compresslevel=9) as z:
  98. for p in [*all_files, package / 'FILES.sha256']:
  99. z.write(p, p.relative_to(package).as_posix())
  100. with zipfile.ZipFile(archive) as z:
  101. assert z.testzip() is None
  102. for line in z.read('FILES.sha256').decode('utf-8').splitlines():
  103. h, name = line.split(' ', 1)
  104. assert sha(z.read(name)) == h
  105. receipt = {'ok': True, 'filename': archive.name, 'bytes': archive.stat().st_size,
  106. 'sha256': sha(archive.read_bytes()), 'entries': len(all_files) + 1, 'filesVerified': len(all_files), **release}
  107. (out / 'delivery-receipt.json').write_text(json.dumps(receipt, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
  108. (out / '完整发布执行单.md').write_bytes((package / '完整发布执行单.md').read_bytes())
  109. print(json.dumps(receipt, ensure_ascii=False, indent=2))
  110. if __name__ == '__main__':
  111. main()