package-mobile-filter-release.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191
  1. """Freeze r14's mobile layout and linked brand/shop filters on the public r13 baseline."""
  2. import argparse
  3. from concurrent.futures import ThreadPoolExecutor
  4. from datetime import datetime, timezone
  5. import difflib
  6. import hashlib
  7. import json
  8. from pathlib import Path
  9. import re
  10. from urllib.error import HTTPError
  11. from urllib.request import Request, urlopen
  12. import zipfile
  13. ROOT = Path(__file__).resolve().parents[1]
  14. RELEASE = 'ksf-board-mobile-filters-20261108-r14'
  15. OUT = ROOT / 'artifacts' / RELEASE
  16. PACK = OUT / 'package'
  17. WORK = ROOT / 'work' / RELEASE
  18. HOME = 'dfeb6a6d0b63e8e1190f9308bf6619a5ca523a5a2f91af5f7ca5ad5fc9642db3'
  19. def sha(raw):
  20. return hashlib.sha256(raw).hexdigest()
  21. def normal(raw):
  22. return raw.replace(b'\r\n', b'\n')
  23. def save(path, value):
  24. path.parent.mkdir(parents=True, exist_ok=True)
  25. path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
  26. def copy(source, relative):
  27. target = PACK / relative
  28. target.parent.mkdir(parents=True, exist_ok=True)
  29. target.write_bytes(source.read_bytes())
  30. def capture(name):
  31. try:
  32. with urlopen(Request('https://kangshifu.fmode.cn/' + name, headers={'Cache-Control': 'no-cache'}), timeout=35) as response:
  33. raw = response.read(3_000_001)
  34. content_type = response.headers.get('Content-Type', '')
  35. assert len(raw) < 3_000_001
  36. exists = name == 'index.html' or 'javascript' in content_type or 'text/css' in content_type
  37. if not exists:
  38. assert b'<html' in raw[:500].lower() or b'<!doctype' in raw[:500].lower(), name
  39. if exists:
  40. target = WORK / 'public-baseline' / name
  41. target.parent.mkdir(parents=True, exist_ok=True)
  42. target.write_bytes(raw)
  43. return dict(path=name, exists=exists, status=response.status, contentType=content_type,
  44. bytes=len(raw), sha256=sha(raw) if exists else None,
  45. normalizedSha256=sha(normal(raw)) if exists else None)
  46. except HTTPError as error:
  47. assert error.code == 404, (name, error.code)
  48. return dict(path=name, exists=False, status=404, sha256=None, normalizedSha256=None)
  49. def dependencies(selected):
  50. visited, queue = set(), list(selected)
  51. while queue:
  52. name = queue.pop()
  53. if name in visited:
  54. continue
  55. visited.add(name)
  56. for rel in re.findall(r'(?:from\s*|import\s*)[\'\"]([.][^\'\"]+)[\'\"]', (ROOT / name).read_text(encoding='utf-8')):
  57. queue.append((ROOT / name).parent.joinpath(rel).resolve().relative_to(ROOT).as_posix())
  58. return visited - set(selected)
  59. def build():
  60. assert not PACK.exists(), 'NEW_RELEASE_REQUIRED'
  61. mobile = ROOT / 'work/mobile-adaptation-20261108'
  62. linkage = ROOT / 'work/brand-shop-linkage-20261108'
  63. expected, originals = {}, {}
  64. for directory in [mobile, linkage]:
  65. for entry in json.loads((directory / 'source-manifest.json').read_bytes())['files']:
  66. name = entry['path']
  67. if not name.startswith('src/'):
  68. continue
  69. if name not in expected:
  70. originals[name] = (directory / 'baseline' / name).read_bytes() if entry['beforeSha256'] else None
  71. else:
  72. assert entry['beforeSha256'] == expected[name], 'BROKEN_BASELINE_CHAIN:' + name
  73. expected[name] = entry['afterSha256']
  74. assert len(expected) == 11
  75. for name, digest in expected.items():
  76. assert sha((ROOT / name).read_bytes()) == digest, 'LOCAL_SOURCE_CHANGED:' + name
  77. # Production owns every relative dependency outside the 11-file list.
  78. required = dependencies(expected)
  79. required.add('src/app.config.js')
  80. with ThreadPoolExecutor(max_workers=8) as pool:
  81. baseline = list(pool.map(capture, sorted(set(expected) | required | {'index.html'})))
  82. observed = {row['path']: row for row in baseline}
  83. assert observed['index.html']['sha256'] == HOME, 'HOMEPAGE_CHANGED'
  84. for name in required:
  85. assert observed[name]['exists'], 'MISSING_DEPENDENCY:' + name
  86. assert observed[name]['normalizedSha256'] == sha(normal((ROOT / name).read_bytes())), 'LOCAL_DEPENDENCY_DIFFERS:' + name
  87. files, patch = [], []
  88. for name in sorted(expected):
  89. old = observed[name]
  90. if originals[name] is None:
  91. assert not old['exists'], 'NEW_FILE_ALREADY_ONLINE:' + name
  92. else:
  93. assert old['normalizedSha256'] == sha(normal(originals[name])), 'PRODUCTION_BASELINE_CHANGED:' + name
  94. raw = normal((ROOT / name).read_bytes())
  95. dest = PACK / 'payload' / name
  96. dest.parent.mkdir(parents=True, exist_ok=True)
  97. dest.write_bytes(raw)
  98. files.append(dict(path=name, sha256=sha(raw), allowMissing=not old['exists'],
  99. priorNormalizedSha256=[old['normalizedSha256']] if old['exists'] else [],
  100. observedProductionSha256=old['sha256']))
  101. before = normal((WORK / 'public-baseline' / name).read_bytes()).decode() if old['exists'] else ''
  102. patch.extend(difflib.unified_diff(before.splitlines(True), raw.decode().splitlines(True), fromfile='public-r13/' + name, tofile='r14/' + name))
  103. prior = ROOT / 'artifacts/ksf-board-research-20261108-r13/package'
  104. publisher = (prior / 'scripts/publish-industry-ui.mjs').read_text(encoding='utf-8')
  105. line = next(line for line in publisher.splitlines() if line.startswith('export const FILES='))
  106. publisher = publisher.replace(line, 'export const FILES=Object.freeze(' + json.dumps(sorted(expected)) + ');')
  107. publisher = publisher.replace('ksf-board-research-20261108-r13', RELEASE)
  108. target = PACK / 'scripts/publish-industry-ui.mjs'
  109. target.parent.mkdir(parents=True, exist_ok=True)
  110. target.write_text(publisher, encoding='utf-8')
  111. for name in ['rollback-ui.mjs', 'verify-public.mjs', 'check-site-modes.mjs']:
  112. copy(ROOT / 'scripts/release-templates' / name, 'scripts/' + name)
  113. save(PACK / 'deploy/industry-ui-files.json', dict(version='ksf.industry-ui.publish.v1', release=RELEASE, homepageSha256=HOME, files=files))
  114. save(PACK / 'evidence/production-baseline.json', dict(observedAt=datetime.now(timezone.utc).isoformat(), files=baseline))
  115. (PACK / 'evidence/ui.patch').write_text(''.join(patch), encoding='utf-8')
  116. # These are isolated test dependencies. They are deliberately outside payload/.
  117. for name in sorted(required):
  118. copy(WORK / 'public-baseline' / name, 'test-support/site/' + name)
  119. tests = ['brand-shop-filters', 'library', 'library-selection', 'library-pages', 'library-images', 'industry-workshop', 'industry-research-directions']
  120. for name in tests:
  121. source = ROOT / ('test/' + name + '.test.js')
  122. text = source.read_text(encoding='utf-8').replace('../src/', '../test-support/site/src/').replace('../server/library-api.mjs', '../test-support/library-api.mjs')
  123. dest = PACK / ('test/' + source.name)
  124. dest.parent.mkdir(parents=True, exist_ok=True)
  125. dest.write_text(text, encoding='utf-8')
  126. for name in expected:
  127. copy(PACK / 'payload' / name, 'test-support/site/' + name)
  128. copy(ROOT / 'server/library-api.mjs', 'test-support/library-api.mjs')
  129. evidence_files = {
  130. mobile: ['acceptance.json', 'supplement.json', 'board-pages.json', 'reports-regression.json', 'source-manifest.json', 'static-check.txt', 'industry-results-320.png', 'product-detail-320.png', 'industry-menu-390.png'],
  131. linkage: ['acceptance.json', 'source-manifest.json', 'static-check.txt', 'industry-390.png', 'library-390.png'],
  132. }
  133. for directory, names in evidence_files.items():
  134. for name in names:
  135. copy(directory / name, 'evidence/local/' + directory.name + '/' + name)
  136. for name in ['移动端适配-本地验收.md', '品牌与店铺筛选联动-本地验收.md']:
  137. copy(ROOT / 'docs/board-deploy-20261108' / name, 'docs/local-history/' + name)
  138. copy(ROOT / 'scripts/package-mobile-filter-release.py', 'scripts/package-mobile-filter-release.py')
  139. save(PACK / 'package.json', dict(type='module', private=True))
  140. save(PACK / 'RELEASE.json', dict(release=RELEASE, createdAt=datetime.now(timezone.utc).isoformat(),
  141. scope='Mobile layout and bidirectional brand/shop filters; frontend only', staticFiles=sorted(expected),
  142. sourceFilesSha256=expected, requiredProductionFiles=sorted(required), expectedChanges=11,
  143. expectedNewFiles=2, backendFiles=[], databaseIncluded=False, credentialsIncluded=False,
  144. serviceRestartRequired=False, paidCalls=0, productionDeployed=False))
  145. print(json.dumps(dict(ok=True, package=str(PACK), staticFiles=len(expected), dependencyFiles=len(required), sealed=False)))
  146. def seal():
  147. acceptance = json.loads((PACK / 'evidence/acceptance.json').read_bytes())
  148. assert acceptance['ok'] and acceptance['publisher']['ok'] and acceptance['mobile']['ok'] and acceptance['linkage']['ok']
  149. for name, digest in acceptance['testedFilesSha256'].items():
  150. assert sha((PACK / name).read_bytes()) == digest, 'TESTED_FILE_CHANGED:' + name
  151. manifest = json.loads((PACK / 'deploy/industry-ui-files.json').read_bytes())
  152. assert sorted(p.relative_to(PACK / 'payload').as_posix() for p in (PACK / 'payload').rglob('*') if p.is_file()) == sorted(f['path'] for f in manifest['files'])
  153. assert not list(PACK.rglob('*.sqlite')) and not list(PACK.rglob('*.db')) and not list(PACK.rglob('*.env'))
  154. paths = sorted(p for p in PACK.rglob('*') if p.is_file() and p.name != 'FILES.sha256')
  155. (PACK / 'FILES.sha256').write_text(''.join(sha(p.read_bytes()) + ' ' + p.relative_to(PACK).as_posix() + '\n' for p in paths), encoding='utf-8')
  156. archive = OUT / (RELEASE + '.zip')
  157. with zipfile.ZipFile(archive, 'x', zipfile.ZIP_DEFLATED) as bundle:
  158. for path in sorted(PACK.rglob('*')):
  159. if path.is_file():
  160. bundle.write(path, path.relative_to(PACK).as_posix())
  161. with zipfile.ZipFile(archive) as bundle:
  162. assert bundle.testzip() is None
  163. for line in bundle.read('FILES.sha256').decode().splitlines():
  164. digest, name = line.split(' ', 1)
  165. assert sha(bundle.read(name)) == digest
  166. save(OUT / 'delivery.json', dict(ok=True, release=RELEASE, archive=str(archive), bytes=archive.stat().st_size,
  167. sha256=sha(archive.read_bytes()), manifestEntries=len(paths), productionDeployed=False, paidCalls=0))
  168. print((OUT / 'delivery.json').read_text(encoding='utf-8'))
  169. if __name__ == '__main__':
  170. parser = argparse.ArgumentParser(description=__doc__)
  171. parser.add_argument('--seal', action='store_true')
  172. (seal if parser.parse_args().seal else build)()