package-industry-request-guard.py 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123
  1. """Build r6 from the immutable r5 release plus explicit request-guard changes only."""
  2. import argparse
  3. import difflib
  4. from datetime import datetime, timezone
  5. import hashlib
  6. import json
  7. from pathlib import Path
  8. import shutil
  9. import sqlite3
  10. import zipfile
  11. ROOT = Path(__file__).resolve().parents[1]
  12. BASE = ROOT / 'artifacts/ksf-industry-monitor-20261107-r5/package'
  13. WORK = ROOT / 'work/industry-monitoring-20261107-r1'
  14. RELEASE = 'ksf-industry-monitor-20261107-r6'
  15. INTEGRATION = """ from monitoring_dispatch import projection as dispatch_projection
  16. pipeline['requestGuard'] = dispatch_projection(self.task_store, self.data['generation'])
  17. """
  18. def sha(raw):
  19. return hashlib.sha256(raw).hexdigest()
  20. def main():
  21. p = argparse.ArgumentParser(description=__doc__)
  22. p.add_argument('--out', required=True, type=Path)
  23. p.add_argument('--seal', action='store_true', help='Seal an accepted candidate after receipts are written')
  24. args = p.parse_args()
  25. out = args.out.resolve()
  26. if args.seal:
  27. release = json.loads((out / 'RELEASE.json').read_text('utf-8'))
  28. receipt = json.loads((out / 'evidence/implementation-receipt-v6.json').read_text('utf-8'))
  29. if release['release'] != RELEASE or not receipt.get('browserPassed') or receipt.get('tests', {}).get('python') != 61:
  30. raise ValueError('CANDIDATE_ACCEPTANCE_REQUIRED')
  31. for name, digest in receipt['testedFilesSha256'].items():
  32. if sha((out / name).read_bytes()) != digest:
  33. raise ValueError('TESTED_SOURCE_CHANGED')
  34. archive = out.parent / (RELEASE + '.zip')
  35. if archive.exists():
  36. raise ValueError('SEALED_ARCHIVE_ALREADY_EXISTS')
  37. files = sorted(path for path in out.rglob('*') if path.is_file() and path != out / 'FILES.sha256')
  38. (out / 'FILES.sha256').write_text(''.join(sha(path.read_bytes()) + ' ' + path.relative_to(out).as_posix() + '\n' for path in files), 'utf-8')
  39. with zipfile.ZipFile(archive, 'x', zipfile.ZIP_DEFLATED) as z:
  40. for path in sorted(out.rglob('*')):
  41. if path.is_file():
  42. z.write(path, path.relative_to(out).as_posix())
  43. print(json.dumps({'ok': True, 'archive': str(archive), 'bytes': archive.stat().st_size,
  44. 'sha256': sha(archive.read_bytes()), 'productionDeployed': False, 'paidCalls': 0}, ensure_ascii=False))
  45. return
  46. if out.exists():
  47. raise ValueError('RELEASE_DIRECTORY_MUST_BE_NEW')
  48. # Do not copy unrelated work-in-progress innovation views or shared module changes.
  49. for line in (BASE / 'FILES.sha256').read_text('utf-8').splitlines():
  50. digest, name = line.split(' ', 1)
  51. if sha((BASE / name).read_bytes()) != digest:
  52. raise ValueError('R5_FROZEN_BASELINE_CHANGED')
  53. shutil.copytree(BASE, out)
  54. before = (BASE / 'server/voc-library/industry_monitor.py').read_text('utf-8')
  55. anchor = " self.task_query.get('pipelinePage', 1), self.task_query.get('pipelineRevision'))\n"
  56. if before.count(anchor) != 1:
  57. raise ValueError('R5_INTEGRATION_ANCHOR_CHANGED')
  58. updated = before.replace(anchor, anchor + INTEGRATION)
  59. (out / 'server/voc-library/industry_monitor.py').write_text(updated, 'utf-8')
  60. names = ['server/voc-library/monitoring_dispatch.py', 'server/voc-library/test_monitoring_dispatch.py',
  61. 'scripts/manage-industry-dispatch.py']
  62. for name in names:
  63. shutil.copyfile(ROOT / name, out / name)
  64. shutil.copyfile(ROOT / 'src/pages/industry-pipeline-view.js', out / 'payload/src/pages/industry-pipeline-view.js')
  65. # Verify the only UI change to the frozen module is the guard section.
  66. old_ui = (BASE / 'payload/src/pages/industry-pipeline-view.js').read_text('utf-8')
  67. new_ui = (out / 'payload/src/pages/industry-pipeline-view.js').read_text('utf-8')
  68. lines = [line for line in new_ui.splitlines(True) if 'class="request-guard"' not in line]
  69. if ''.join(lines) != old_ui:
  70. raise ValueError('UNRELATED_PIPELINE_UI_CHANGES')
  71. with (out / 'evidence/ui.patch').open('a', encoding='utf-8') as stream:
  72. stream.write(''.join(difflib.unified_diff(old_ui.splitlines(True), new_ui.splitlines(True),
  73. fromfile='r5/src/pages/industry-pipeline-view.js',
  74. tofile='r6/src/pages/industry-pipeline-view.js')))
  75. shutil.copyfile(ROOT / 'docs/industry-monitoring-20261107/request-guard.md', out / 'docs/request-guard.md')
  76. for name in ('dispatch-closeout-import.json', 'dispatch-paid-replay-verification.json'):
  77. shutil.copyfile(WORK / name, out / 'evidence' / name)
  78. # Auxiliary working copy only. The active research snapshot is never packaged or altered.
  79. dest = out / 'data/monitoring-registry/monitoring.sqlite'
  80. with sqlite3.connect((WORK / 'monitoring-registry/monitoring.sqlite').resolve().as_uri() + '?mode=ro', uri=True) as source:
  81. with sqlite3.connect(dest) as c:
  82. source.backup(c)
  83. c.execute("UPDATE pipeline_workers SET state='ARCHIVED_COPY'")
  84. c.execute("UPDATE dispatch_audits SET state='ARCHIVED_COPY'")
  85. c.commit()
  86. for file in (WORK / 'monitoring-registry/assets').iterdir():
  87. target = out / 'data/monitoring-registry/assets' / file.name
  88. if not target.exists():
  89. shutil.copyfile(file, target)
  90. # Refresh release identity and the publisher's source-byte manifest; preserve all public baselines.
  91. manifest_path = out / 'deploy/industry-ui-files.json'
  92. manifest = json.loads(manifest_path.read_text('utf-8'))
  93. manifest['release'] = RELEASE
  94. for entry in manifest['files']:
  95. entry['sha256'] = sha((out / 'payload' / entry['path']).read_bytes())
  96. manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + '\n', 'utf-8')
  97. publisher = out / 'scripts/publish-industry-ui.mjs'
  98. publisher.write_text(publisher.read_text('utf-8').replace('ksf-industry-monitor-20261107-r5', RELEASE), 'utf-8')
  99. for directory in ('docs', 'deploy'):
  100. for path in (out / directory).iterdir():
  101. if path.is_file() and path.suffix in ('.md', '.example'):
  102. path.write_text(path.read_text('utf-8-sig').replace('ksf-industry-monitor-20261107-r5', RELEASE), 'utf-8')
  103. with (out / 'docs/README.md').open('a', encoding='utf-8') as stream:
  104. stream.write('\n\n## r6:请求前核对与认领\n\n新增已结算76条请求保护、批次查重、并发核对认领和停费状态投影;操作见 [request-guard.md](request-guard.md)。上游执行端尚未接入,收费保持停止。本包仅在已冻结r5上追加本轮变更。\n')
  105. release_path = out / 'RELEASE.json'
  106. release = json.loads(release_path.read_text('utf-8'))
  107. release.update(release=RELEASE, createdAt=datetime.now(timezone.utc).isoformat(),
  108. builtFromFrozenRelease='ksf-industry-monitor-20261107-r5',
  109. requestGuardImplemented=True, externalCollectorsIntegrated=False,
  110. newPaidCalls=0, settledCloseoutIdentifiedRows=76, overallComplete=False)
  111. release['backendFiles'].append('server/voc-library/monitoring_dispatch.py')
  112. release_path.write_text(json.dumps(release, ensure_ascii=False, indent=2) + '\n', 'utf-8')
  113. # Browser acceptance and final test receipts are added before sealing, never after ZIP creation.
  114. print(json.dumps({'ok': True, 'candidate': str(out), 'sealed': False, 'paidCalls': 0}, ensure_ascii=False))
  115. if __name__ == '__main__':
  116. main()