package-development-board.py 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165
  1. """Package the bounded, read-only development reference UI on the actual public baseline."""
  2. from concurrent.futures import ThreadPoolExecutor
  3. from datetime import datetime, timezone
  4. import difflib
  5. import hashlib
  6. import json
  7. from pathlib import Path
  8. import re
  9. from urllib.request import Request, urlopen
  10. from urllib.error import HTTPError
  11. import zipfile
  12. ROOT = Path(__file__).resolve().parents[1]
  13. RELEASE = 'ksf-development-references-20261108-r15'
  14. OUT = ROOT / 'artifacts' / RELEASE
  15. PACK = OUT / 'package'
  16. WORK = ROOT / 'work' / RELEASE
  17. HOME = 'dfeb6a6d0b63e8e1190f9308bf6619a5ca523a5a2f91af5f7ca5ad5fc9642db3'
  18. FILES = ['src/core/industry-navigation.js', 'src/core/development-references.js',
  19. 'src/services/development-service.js', 'src/pages/industry-development-view.js',
  20. 'src/pages/view-industry.js', 'src/data/development-references.json']
  21. def sha(raw):
  22. return hashlib.sha256(raw).hexdigest()
  23. def normal(raw):
  24. return raw.replace(b'\r\n', b'\n')
  25. def save(name, value):
  26. path = PACK / name
  27. path.parent.mkdir(parents=True, exist_ok=True)
  28. path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
  29. def copy(source, name):
  30. target = PACK / name
  31. target.parent.mkdir(parents=True, exist_ok=True)
  32. target.write_bytes(source.read_bytes())
  33. def capture(name):
  34. try:
  35. with urlopen(Request('https://kangshifu.fmode.cn/' + name, headers={'Cache-Control': 'no-cache'}), timeout=35) as response:
  36. raw = response.read(3000001)
  37. kind = response.headers.get('Content-Type', '')
  38. assert len(raw) < 3000001
  39. exists = name == 'index.html' or ('application/json' in kind if name.endswith('.json') else 'javascript' in kind)
  40. if not exists:
  41. assert b'<html' in raw[:500].lower() or b'<!doctype' in raw[:500].lower()
  42. if exists:
  43. target = WORK / 'baseline' / name
  44. target.parent.mkdir(parents=True, exist_ok=True)
  45. target.write_bytes(raw)
  46. return dict(path=name, exists=exists, status=response.status, contentType=kind,
  47. sha256=sha(raw) if exists else None, normalizedSha256=sha(normal(raw)) if exists else None)
  48. except HTTPError as error:
  49. assert error.code == 404, (name, error.code)
  50. return dict(path=name, exists=False, status=404, sha256=None, normalizedSha256=None)
  51. def build():
  52. assert not PACK.exists(), 'NEW_RELEASE_REQUIRED'
  53. acceptance = json.loads((ROOT / 'docs/development-board-20261108/browser-acceptance.json').read_bytes())
  54. assert acceptance['ok'] and len(acceptance['layouts']) == 88
  55. required, queue = set(), [name for name in FILES if name.endswith('.js')]
  56. while queue:
  57. name = queue.pop()
  58. if name in required:
  59. continue
  60. required.add(name)
  61. for rel in re.findall(r'(?:from\s*|import\s*)[\'\"]([.][^\'\"]+)[\'\"]', (ROOT / name).read_text(encoding='utf-8')):
  62. queue.append((ROOT / name).parent.joinpath(rel).resolve().relative_to(ROOT).as_posix())
  63. required -= set(FILES)
  64. required.add('src/app.config.js')
  65. with ThreadPoolExecutor(max_workers=8) as pool:
  66. observed = list(pool.map(capture, sorted(set(FILES) | required | {'index.html'})))
  67. by_name = {r['path']: r for r in observed}
  68. assert by_name['index.html']['sha256'] == HOME, 'HOMEPAGE_CHANGED'
  69. for name in required:
  70. assert by_name[name]['exists'], 'MISSING_DEPENDENCY:' + name
  71. assert by_name[name]['normalizedSha256'] == sha(normal((ROOT / name).read_bytes())), 'UNRELEASED_DEPENDENCY:' + name
  72. entries, patch = [], []
  73. for name in sorted(FILES):
  74. old = by_name[name]
  75. if name not in ['src/core/industry-navigation.js', 'src/pages/view-industry.js']:
  76. assert not old['exists'], 'NEW_FILE_ALREADY_PRESENT:' + name
  77. else:
  78. assert old['exists'], 'EXISTING_FILE_MISSING:' + name
  79. raw = normal((ROOT / name).read_bytes())
  80. target = PACK / 'payload' / name
  81. target.parent.mkdir(parents=True, exist_ok=True)
  82. target.write_bytes(raw)
  83. entries.append(dict(path=name, sha256=sha(raw), allowMissing=not old['exists'],
  84. priorNormalizedSha256=[old['normalizedSha256']] if old['exists'] else [], observedProductionSha256=old['sha256']))
  85. before = normal((WORK / 'baseline' / name).read_bytes()).decode() if old['exists'] else ''
  86. if name.endswith('.js'):
  87. patch.extend(difflib.unified_diff(before.splitlines(True), raw.decode().splitlines(True), fromfile='public/' + name, tofile='r15/' + name))
  88. publisher = (ROOT / 'artifacts/ksf-board-mobile-filters-20261108-r14/package/scripts/publish-industry-ui.mjs').read_text(encoding='utf-8')
  89. line = next(line for line in publisher.splitlines() if line.startswith('export const FILES='))
  90. publisher = publisher.replace(line, 'export const FILES=Object.freeze(' + json.dumps(sorted(FILES)) + ');')
  91. publisher = publisher.replace('ksf-board-mobile-filters-20261108-r14', RELEASE)
  92. (PACK / 'scripts').mkdir(parents=True, exist_ok=True)
  93. (PACK / 'scripts/publish-industry-ui.mjs').write_text(publisher, encoding='utf-8')
  94. for name in ['rollback-ui.mjs', 'check-site-modes.mjs']:
  95. script = (ROOT / 'scripts/release-templates' / name).read_text(encoding='utf-8').replace('ksf-board-mobile-filters-20261108-r14', RELEASE)
  96. (PACK / 'scripts' / name).write_text(script, encoding='utf-8')
  97. copy(ROOT / 'scripts/release-templates/verify-development-public.mjs', 'scripts/verify-public.mjs')
  98. copy(ROOT / 'docs/development-board-20261108/上线执行单.md', '上线执行单.md')
  99. for name in ['browser-acceptance.json', 'data-publication.json', 'directions-1440.png', 'directions-390.png',
  100. 'categories-1440.png', 'materials-1440.png', 'standards-1440.png']:
  101. copy(ROOT / 'docs/development-board-20261108' / name, 'evidence/' + name)
  102. for name in ['development-references.test.js']:
  103. text = (ROOT / 'test' / name).read_text(encoding='utf-8').replace('../src/', '../payload/src/').replace('../data/', '../payload/data/')
  104. (PACK / 'test').mkdir(exist_ok=True)
  105. (PACK / 'test' / name).write_text(text, encoding='utf-8')
  106. # Two service imports required by native tests; test-support never enters the web root.
  107. for name in ['src/utils/router.js']:
  108. copy(ROOT / name, 'payload/' + name) if name in FILES else copy(ROOT / name, 'test-support/' + name)
  109. text = (PACK / 'test/development-references.test.js').read_text(encoding='utf-8')
  110. text = text.replace("import {readDevelopmentReferences} from '../payload/src/services/development-service.js';", '')
  111. # Native suite can load the actual service through a complete isolated module tree.
  112. for name in ['src/core/development-references.js', 'src/services/development-service.js']:
  113. copy(ROOT / name, 'test-support/' + name)
  114. text = "import {readDevelopmentReferences} from '../test-support/src/services/development-service.js';\n" + text
  115. (PACK / 'test/development-references.test.js').write_text(text, encoding='utf-8')
  116. save('package.json', {'type':'module', 'private':True})
  117. save('deploy/industry-ui-files.json',dict(version='ksf.industry-ui.publish.v1',release=RELEASE,homepageSha256=HOME,files=entries))
  118. save('evidence/production-baseline.json',dict(observedAt=datetime.now(timezone.utc).isoformat(),files=observed))
  119. (PACK / 'evidence/ui.patch').write_text(''.join(patch), encoding='utf-8')
  120. save('RELEASE.json',dict(release=RELEASE,staticFiles=sorted(FILES),expectedChanges=6,expectedNewFiles=4,
  121. backendFiles=[],databaseIncluded=False,credentialsIncluded=False,serviceRestartRequired=False,
  122. snapshotPublication=True,productionDeployed=False,paidCalls=0))
  123. print(json.dumps(dict(ok=True,package=str(PACK),staticFiles=len(FILES),dependencies=len(required),sealed=False)))
  124. def seal():
  125. check = json.loads((PACK / 'evidence/package-acceptance.json').read_bytes())
  126. assert check['ok']
  127. for name, digest in check['testedFilesSha256'].items():
  128. assert sha((PACK / name).read_bytes()) == digest, 'TESTED_FILE_CHANGED:' + name
  129. assert sorted(p.relative_to(PACK / 'payload').as_posix() for p in (PACK / 'payload').rglob('*') if p.is_file()) == sorted(FILES)
  130. assert not list(PACK.rglob('*.env')) and not list(PACK.rglob('*.sqlite'))
  131. paths = sorted(p for p in PACK.rglob('*') if p.is_file() and p.name != 'FILES.sha256')
  132. (PACK / 'FILES.sha256').write_text(''.join(sha(p.read_bytes()) + ' ' + p.relative_to(PACK).as_posix() + '\n' for p in paths), encoding='utf-8')
  133. bundle_path = OUT / (RELEASE + '.zip')
  134. with zipfile.ZipFile(bundle_path, 'x', zipfile.ZIP_DEFLATED) as z:
  135. for p in sorted(PACK.rglob('*')):
  136. if p.is_file():
  137. z.write(p,p.relative_to(PACK).as_posix())
  138. with zipfile.ZipFile(bundle_path) as z:
  139. assert z.testzip() is None
  140. for line in z.read('FILES.sha256').decode().splitlines():
  141. digest,name=line.split(' ',1)
  142. assert sha(z.read(name))==digest
  143. (OUT / 'delivery.json').write_text(json.dumps(dict(ok=True,archive=str(bundle_path),bytes=bundle_path.stat().st_size,
  144. sha256=sha(bundle_path.read_bytes()),productionDeployed=False,paidCalls=0),indent=2)+'\n',encoding='utf-8')
  145. print((OUT / 'delivery.json').read_text(encoding='utf-8'))
  146. if __name__ == '__main__':
  147. import sys
  148. (seal if '--seal' in sys.argv else build)()