package-product-overview.py 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115
  1. """Freeze the committed product overview patch, without private databases or credentials."""
  2. import argparse
  3. import hashlib
  4. import importlib.util
  5. import io
  6. import json
  7. from pathlib import Path
  8. import shutil
  9. import subprocess
  10. import tempfile
  11. import zipfile
  12. ROOT = Path(__file__).resolve().parents[1]
  13. BASE = 'f4bdaa70a1f9ceac7c44f79a32d7454143875fd5'
  14. NAME = 'ksf-product-overview-20261106-r1'
  15. FRONT = ['src/core/library.js', 'src/pages/library-product-detail.js']
  16. BACK = ['server/voc-library/' + n for n in ('board_library.py', 'product_insights.py', 'update_library.py', 'agent_batch.py')]
  17. DEPS = ['server/voc-library/' + n for n in ('voc_db.py', 'retrieval.py', 'schema.sql', 'taxonomy.json')]
  18. sha = lambda b: hashlib.sha256(b).hexdigest()
  19. norm = lambda b: sha(b.replace(b'\r\n', b'\n'))
  20. def main():
  21. p = argparse.ArgumentParser(description=__doc__)
  22. p.add_argument('--commit', default='HEAD'); p.add_argument('--homepage-file', required=True)
  23. args = p.parse_args()
  24. commit = subprocess.check_output(['git', 'rev-parse', args.commit + '^{commit}'], cwd=ROOT).decode().strip()
  25. subprocess.run(['git', 'merge-base', '--is-ancestor', BASE, commit], cwd=ROOT, check=True)
  26. frozen = zipfile.ZipFile(io.BytesIO(subprocess.check_output(['git', 'archive', '--format=zip', commit], cwd=ROOT)))
  27. old = zipfile.ZipFile(io.BytesIO(subprocess.check_output(['git', 'archive', '--format=zip', BASE], cwd=ROOT)))
  28. out = ROOT / 'artifacts/商品评价概况与入库更新-20261106'
  29. package = ROOT / 'work' / NAME / 'package'
  30. if package.exists() or (out / (NAME + '.zip')).exists(): raise ValueError('RELEASE_ALREADY_EXISTS')
  31. package.mkdir(parents=True)
  32. def write(name, body):
  33. dest = package / name; dest.parent.mkdir(parents=True, exist_ok=True); dest.write_bytes(body)
  34. def js(name, value): write(name, (json.dumps(value, ensure_ascii=False, indent=2) + '\n').encode())
  35. for name in FRONT + BACK: write('payload/' + name, frozen.read(name))
  36. for name in frozen.namelist():
  37. if name.startswith('skills/') and not name.endswith('/'): write(name, frozen.read(name))
  38. write('install-query-skills.py', frozen.read('scripts/install-query-skills.py'))
  39. baselines = {name: [{p.split('/', 2)[2]: sha(old.read(p)) for p in old.namelist() if p.startswith('skills/' + name + '/') and not p.endswith('/')}]
  40. for name in ('ksf-workshop-runtime', 'ksf-voc-library')}
  41. js('baselines.json', baselines)
  42. def entry(name):
  43. prior = old.read(name) if name in old.namelist() else None
  44. return dict(path=name, sha256=sha(frozen.read(name)), priorNormalizedSha256=[norm(prior)] if prior else [], allowMissing=prior is None)
  45. manifest = dict(version='ksf.product-overview.publish.v1', release=NAME,
  46. homepageSha256=json.loads(old.read('deploy/board-files-20261106.json'))['homepageSha256'], files=[entry(n) for n in FRONT])
  47. js('deploy/board-files.json', manifest); js('deploy/backend-files.json', dict(files=[entry(n) for n in BACK]))
  48. js('deploy/dependencies.json', dict(files=[dict(path=n, normalizedSha256=norm(frozen.read(n))) for n in DEPS]))
  49. publisher = frozen.read('scripts/publish-opportunity-ui.mjs').decode()
  50. start = publisher.index('export const FILES=Object.freeze(['); end = publisher.index(']);', start) + 3
  51. publisher = publisher[:start] + 'export const FILES=Object.freeze(' + json.dumps(FRONT) + ');' + publisher[end:]
  52. publisher = publisher.replace('ksf.opportunity-ui.publish.v1', manifest['version']).replace('ksf.opportunity-ui.20261103.v1', NAME)
  53. publisher = publisher.replace('filesChecked:6', 'filesChecked:FILES.length').replace('ksf-opportunity-ui', 'ksf-product-overview')
  54. 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')")
  55. write('scripts/publish-board.mjs', publisher.encode())
  56. write('发给运维001.md', frozen.read('docs/product-overview-20261106/发给运维001.md'))
  57. skills = {}
  58. for name in baselines:
  59. folder = package / 'skills' / name
  60. lines = (folder / 'FILES.sha256').read_text(encoding='utf-8').splitlines()
  61. for line in lines:
  62. h, rel = line.split(' ', 1); assert sha((folder / rel).read_bytes()) == h
  63. skills[name] = dict(entries=len(lines), manifestSha256=sha((folder / 'FILES.sha256').read_bytes()))
  64. # Exercise the actual delivered installer against both frozen baseline skills.
  65. spec = importlib.util.spec_from_file_location('overview_skill_installer', package / 'install-query-skills.py')
  66. installer = importlib.util.module_from_spec(spec); spec.loader.exec_module(installer)
  67. with tempfile.TemporaryDirectory(prefix='installer-check-', dir=package.parent) as td:
  68. data = Path(td)
  69. for name in baselines:
  70. for rel in baselines[name][0]:
  71. target = data / 'skills' / name / rel; target.parent.mkdir(parents=True, exist_ok=True)
  72. target.write_bytes(old.read('skills/' + name + '/' + rel))
  73. plan = installer.install(package, data)
  74. assert plan['filesChecked'] == 25 and len(plan['changed']) == 6
  75. assert all(n.startswith('ksf-voc-library/') for n in plan['changed'])
  76. assert installer.install(package, data, True)['filesystemVerified']
  77. assert not installer.install(package, data, True)['applied']
  78. # Check the two-file publisher plan/apply/no-op against the actual baseline.
  79. with tempfile.TemporaryDirectory(prefix='static-check-', dir=package.parent) as td:
  80. target = Path(td)
  81. for name in ['index.html', *FRONT]:
  82. dest = target / name; dest.parent.mkdir(parents=True, exist_ok=True)
  83. dest.write_bytes(old.read(name))
  84. # The maintained homepage is external to Git's local preview page.
  85. index = Path(args.homepage_file).read_bytes()
  86. assert sha(index) == manifest['homepageSha256'], 'HOMEPAGE_MISMATCH'
  87. (target / 'index.html').write_bytes(index)
  88. cli = ['node', str(package / 'scripts/publish-board.mjs'), '--target-dir', str(target)]
  89. result = json.loads(subprocess.check_output(cli, cwd=ROOT))
  90. assert result['changes'] == FRONT
  91. assert json.loads(subprocess.check_output([*cli, '--apply'], cwd=ROOT))['filesystemVerified']
  92. assert not json.loads(subprocess.check_output([*cli, '--apply'], cwd=ROOT))['changes']
  93. js('RELEASE.json', dict(release=NAME, sourceCommit=commit, baselineCommit=BASE, skills=skills,
  94. staticFiles=2, backendFiles=4, databaseIncluded=False, databaseMigrationRequired=False,
  95. credentialsIncluded=False, productionInstalled=False, productionBrowserVerified=False,
  96. installerCheck=True, publisherCheck=True))
  97. files = sorted(q for q in package.rglob('*') if q.is_file())
  98. assert not any(q.suffix in ('.sqlite', '.db', '.key', '.pyc') or q.name.startswith('.env') for q in files)
  99. write('FILES.sha256', ''.join(sha(q.read_bytes()) + ' ' + q.relative_to(package).as_posix() + '\n' for q in files).encode())
  100. out.mkdir(parents=True, exist_ok=True); archive = out / (NAME + '.zip')
  101. with zipfile.ZipFile(archive, 'x', zipfile.ZIP_DEFLATED, compresslevel=9) as z:
  102. for q in [*files, package / 'FILES.sha256']: z.write(q, q.relative_to(package).as_posix())
  103. with zipfile.ZipFile(archive) as z:
  104. assert z.testzip() is None
  105. for line in z.read('FILES.sha256').decode().splitlines():
  106. h, name = line.split(' ', 1); assert sha(z.read(name)) == h
  107. receipt = dict(ok=True, filename=archive.name, bytes=archive.stat().st_size, sha256=sha(archive.read_bytes()),
  108. entries=len(files) + 1, filesVerified=len(files), sourceCommit=commit, productionInstalled=False)
  109. (out / 'delivery-receipt.json').write_text(json.dumps(receipt, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
  110. shutil.copyfile(package / '发给运维001.md', out / '发给运维001.md')
  111. print(json.dumps(receipt, ensure_ascii=False, indent=2))
  112. if __name__ == '__main__': main()