prepare-workshop-board-release.py 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136
  1. """Prepare only the workshop board UI against the current public site, excluding Skill/manual/runtime."""
  2. from concurrent.futures import ThreadPoolExecutor
  3. import difflib
  4. import hashlib
  5. import json
  6. from pathlib import Path
  7. import posixpath
  8. import re
  9. from urllib.request import Request, urlopen
  10. from urllib.error import HTTPError
  11. ROOT = Path(__file__).resolve().parents[1]
  12. RELEASE = 'ksf-workshop-board-20261110-r1'
  13. OUT = ROOT / 'docs' / RELEASE
  14. PACK = OUT / 'package'
  15. WORK = ROOT / 'work' / RELEASE
  16. FILES = [
  17. 'src/core/kfs/build.js', 'src/core/library/reports.js', 'src/core/library/types.js',
  18. 'src/core/workshop-outputs.js', 'src/layout/kf-app.js', 'src/layout/kf-nav.js',
  19. 'src/pages/view-reports.js', 'src/pages/view-workshop-outputs.js', 'src/utils/router.js',
  20. ]
  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 write_json(path, value):
  26. path.parent.mkdir(parents=True, exist_ok=True)
  27. path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
  28. def capture(name):
  29. try:
  30. req = Request('https://kangshifu.fmode.cn/' + name, headers={'Cache-Control': 'no-cache'})
  31. with urlopen(req, timeout=40) as response:
  32. raw = response.read(3_000_001)
  33. assert len(raw) < 3_000_001
  34. content_type = response.headers.get('Content-Type', '')
  35. exists = name == 'index.html' or 'javascript' in content_type or 'text/css' in content_type
  36. if exists:
  37. target = WORK / 'baseline' / name
  38. target.parent.mkdir(parents=True, exist_ok=True)
  39. target.write_bytes(raw)
  40. return {'path': name, 'exists': exists, 'status': response.status, 'contentType': content_type,
  41. 'sha256': sha(raw) if exists else None,
  42. 'normalizedSha256': sha(normal(raw)) if exists else None}
  43. except HTTPError as e:
  44. assert e.code == 404, (name, e.code)
  45. return {'path': name, 'exists': False, 'status': 404, 'sha256': None, 'normalizedSha256': None}
  46. def dependencies(observed):
  47. visited, queue = set(), list(FILES)
  48. while queue:
  49. batch = sorted(set(queue) - visited)
  50. queue = []
  51. absent = [name for name in batch if name not in observed]
  52. with ThreadPoolExecutor(max_workers=8) as pool:
  53. for row in pool.map(capture, absent):
  54. observed[row['path']] = row
  55. for name in batch:
  56. visited.add(name)
  57. source = ROOT / name if name in FILES else WORK / 'baseline' / name
  58. assert source.exists(), 'MISSING_PRODUCTION_DEPENDENCY:' + name
  59. for relative in re.findall(r'(?:from\s*|import\s*)[\'\"]([.][^\'\"]+)[\'\"]', source.read_text('utf-8')):
  60. queue.append(posixpath.normpath(posixpath.join(posixpath.dirname(name), relative)))
  61. return visited - set(FILES)
  62. def main():
  63. assert not PACK.exists(), 'NEW_RELEASE_REQUIRED'
  64. with ThreadPoolExecutor(max_workers=8) as pool:
  65. rows = list(pool.map(capture, sorted(set(FILES) | {'index.html'})))
  66. observed = {r['path']: r for r in rows}
  67. dependencies_set = dependencies(observed)
  68. rows = [observed[name] for name in sorted(observed)]
  69. differences = [name for name in dependencies_set if not (ROOT / name).exists() or
  70. observed[name]['normalizedSha256'] != sha(normal((ROOT / name).read_bytes()))]
  71. write_json(WORK / 'baseline.json', {'files': rows, 'localDifferencesExcludedFromRelease': differences,
  72. 'dependencySource': 'existing-production-modules; not uncommitted unrelated changes'})
  73. patches, files = [], []
  74. for name in sorted(FILES):
  75. prior = observed[name]
  76. raw = normal((ROOT / name).read_bytes())
  77. old = normal((WORK / 'baseline' / name).read_bytes()) if prior['exists'] else b''
  78. dest = PACK / 'payload' / name
  79. dest.parent.mkdir(parents=True, exist_ok=True)
  80. dest.write_bytes(raw)
  81. files.append({'path': name, 'sha256': sha(raw), 'allowMissing': not prior['exists'],
  82. 'priorNormalizedSha256': [prior['normalizedSha256']] if prior['exists'] else [],
  83. 'observedProductionSha256': prior['sha256'], 'changed': old != raw})
  84. patches.extend(difflib.unified_diff(old.decode().splitlines(True), raw.decode().splitlines(True),
  85. fromfile='production/' + name, tofile=RELEASE + '/' + name))
  86. # Reuse the previously tested publisher, including 0644 writes and concurrent-change checks.
  87. publisher = (ROOT / 'artifacts/ksf-board-mobile-filters-20261108-r14/package/scripts/publish-industry-ui.mjs').read_text('utf-8')
  88. line = next(line for line in publisher.splitlines() if line.startswith('export const FILES='))
  89. publisher = publisher.replace(line, 'export const FILES=Object.freeze(' + json.dumps(sorted(FILES)) + ');')
  90. publisher = publisher.replace('ksf-board-mobile-filters-20261108-r14', RELEASE)
  91. target = PACK / 'scripts/publish-industry-ui.mjs'
  92. target.parent.mkdir(parents=True, exist_ok=True)
  93. target.write_text(publisher, encoding='utf-8', newline='\n')
  94. for name in ['rollback-ui.mjs', 'check-site-modes.mjs']:
  95. text = (ROOT / 'scripts/release-templates' / name).read_text('utf-8').replace('ksf-board-mobile-filters-20261108-r14', RELEASE)
  96. (PACK / 'scripts' / name).write_text(text, encoding='utf-8', newline='\n')
  97. write_json(PACK / 'deploy/industry-ui-files.json', {'version': 'ksf.industry-ui.publish.v1', 'release': RELEASE,
  98. 'homepageSha256': observed['index.html']['sha256'], 'files': files})
  99. write_json(PACK / 'evidence/production-baseline.json', {'files': rows})
  100. (PACK / 'evidence/ui.patch').write_text(''.join(patches), encoding='utf-8')
  101. for name in dependencies_set:
  102. dest = PACK / 'test-support/site' / name
  103. dest.parent.mkdir(parents=True, exist_ok=True)
  104. dest.write_bytes((WORK / 'baseline' / name).read_bytes())
  105. for name in FILES:
  106. dest = PACK / 'test-support/site' / name
  107. dest.parent.mkdir(parents=True, exist_ok=True)
  108. dest.write_bytes((PACK / 'payload' / name).read_bytes())
  109. test = (ROOT / 'test/workshop-outputs.test.js').read_text('utf-8').replace('../src/', '../test-support/site/src/')
  110. dest = PACK / 'test/workshop-outputs.test.js'
  111. dest.parent.mkdir(parents=True, exist_ok=True)
  112. dest.write_text(test, encoding='utf-8')
  113. write_json(PACK / 'package.json', {'type': 'module', 'private': True})
  114. write_json(OUT / 'preparation.json', {'release': RELEASE, 'staticFiles': len(files),
  115. 'dependencyFiles': len(dependencies_set), 'newFiles': sum(f['allowMissing'] for f in files),
  116. 'changedFiles': sum(f['changed'] for f in files), 'homepageSha256': observed['index.html']['sha256'],
  117. 'mainSkillIncluded': False, 'manualIncluded': False, 'productionDeployed': False})
  118. print(json.dumps({'ok': True, 'files': len(files), 'dependenciesVerified': len(dependencies_set),
  119. 'changedFiles': sum(f['changed'] for f in files), 'package': str(PACK)}, ensure_ascii=False))
  120. if __name__ == '__main__':
  121. main()