package-full-schema-intake.py 9.8 KB

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