| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191 |
- """Freeze r14's mobile layout and linked brand/shop filters on the public r13 baseline."""
- import argparse
- from concurrent.futures import ThreadPoolExecutor
- from datetime import datetime, timezone
- import difflib
- import hashlib
- import json
- from pathlib import Path
- import re
- from urllib.error import HTTPError
- from urllib.request import Request, urlopen
- import zipfile
- ROOT = Path(__file__).resolve().parents[1]
- RELEASE = 'ksf-board-mobile-filters-20261108-r14'
- OUT = ROOT / 'artifacts' / RELEASE
- PACK = OUT / 'package'
- WORK = ROOT / 'work' / RELEASE
- HOME = 'dfeb6a6d0b63e8e1190f9308bf6619a5ca523a5a2f91af5f7ca5ad5fc9642db3'
- def sha(raw):
- return hashlib.sha256(raw).hexdigest()
- def normal(raw):
- return raw.replace(b'\r\n', b'\n')
- def save(path, value):
- path.parent.mkdir(parents=True, exist_ok=True)
- path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
- def copy(source, relative):
- target = PACK / relative
- target.parent.mkdir(parents=True, exist_ok=True)
- target.write_bytes(source.read_bytes())
- def capture(name):
- try:
- with urlopen(Request('https://kangshifu.fmode.cn/' + name, headers={'Cache-Control': 'no-cache'}), timeout=35) as response:
- raw = response.read(3_000_001)
- content_type = response.headers.get('Content-Type', '')
- assert len(raw) < 3_000_001
- exists = name == 'index.html' or 'javascript' in content_type or 'text/css' in content_type
- if not exists:
- assert b'<html' in raw[:500].lower() or b'<!doctype' in raw[:500].lower(), name
- if exists:
- target = WORK / 'public-baseline' / name
- target.parent.mkdir(parents=True, exist_ok=True)
- target.write_bytes(raw)
- return dict(path=name, exists=exists, status=response.status, contentType=content_type,
- bytes=len(raw), sha256=sha(raw) if exists else None,
- normalizedSha256=sha(normal(raw)) if exists else None)
- except HTTPError as error:
- assert error.code == 404, (name, error.code)
- return dict(path=name, exists=False, status=404, sha256=None, normalizedSha256=None)
- def dependencies(selected):
- visited, queue = set(), list(selected)
- while queue:
- name = queue.pop()
- if name in visited:
- continue
- visited.add(name)
- for rel in re.findall(r'(?:from\s*|import\s*)[\'\"]([.][^\'\"]+)[\'\"]', (ROOT / name).read_text(encoding='utf-8')):
- queue.append((ROOT / name).parent.joinpath(rel).resolve().relative_to(ROOT).as_posix())
- return visited - set(selected)
- def build():
- assert not PACK.exists(), 'NEW_RELEASE_REQUIRED'
- mobile = ROOT / 'work/mobile-adaptation-20261108'
- linkage = ROOT / 'work/brand-shop-linkage-20261108'
- expected, originals = {}, {}
- for directory in [mobile, linkage]:
- for entry in json.loads((directory / 'source-manifest.json').read_bytes())['files']:
- name = entry['path']
- if not name.startswith('src/'):
- continue
- if name not in expected:
- originals[name] = (directory / 'baseline' / name).read_bytes() if entry['beforeSha256'] else None
- else:
- assert entry['beforeSha256'] == expected[name], 'BROKEN_BASELINE_CHAIN:' + name
- expected[name] = entry['afterSha256']
- assert len(expected) == 11
- for name, digest in expected.items():
- assert sha((ROOT / name).read_bytes()) == digest, 'LOCAL_SOURCE_CHANGED:' + name
- # Production owns every relative dependency outside the 11-file list.
- required = dependencies(expected)
- required.add('src/app.config.js')
- with ThreadPoolExecutor(max_workers=8) as pool:
- baseline = list(pool.map(capture, sorted(set(expected) | required | {'index.html'})))
- observed = {row['path']: row for row in baseline}
- assert observed['index.html']['sha256'] == HOME, 'HOMEPAGE_CHANGED'
- for name in required:
- assert observed[name]['exists'], 'MISSING_DEPENDENCY:' + name
- assert observed[name]['normalizedSha256'] == sha(normal((ROOT / name).read_bytes())), 'LOCAL_DEPENDENCY_DIFFERS:' + name
- files, patch = [], []
- for name in sorted(expected):
- old = observed[name]
- if originals[name] is None:
- assert not old['exists'], 'NEW_FILE_ALREADY_ONLINE:' + name
- else:
- assert old['normalizedSha256'] == sha(normal(originals[name])), 'PRODUCTION_BASELINE_CHANGED:' + name
- raw = normal((ROOT / name).read_bytes())
- dest = PACK / 'payload' / name
- dest.parent.mkdir(parents=True, exist_ok=True)
- dest.write_bytes(raw)
- files.append(dict(path=name, sha256=sha(raw), allowMissing=not old['exists'],
- priorNormalizedSha256=[old['normalizedSha256']] if old['exists'] else [],
- observedProductionSha256=old['sha256']))
- before = normal((WORK / 'public-baseline' / name).read_bytes()).decode() if old['exists'] else ''
- patch.extend(difflib.unified_diff(before.splitlines(True), raw.decode().splitlines(True), fromfile='public-r13/' + name, tofile='r14/' + name))
- prior = ROOT / 'artifacts/ksf-board-research-20261108-r13/package'
- publisher = (prior / 'scripts/publish-industry-ui.mjs').read_text(encoding='utf-8')
- line = next(line for line in publisher.splitlines() if line.startswith('export const FILES='))
- publisher = publisher.replace(line, 'export const FILES=Object.freeze(' + json.dumps(sorted(expected)) + ');')
- publisher = publisher.replace('ksf-board-research-20261108-r13', RELEASE)
- target = PACK / 'scripts/publish-industry-ui.mjs'
- target.parent.mkdir(parents=True, exist_ok=True)
- target.write_text(publisher, encoding='utf-8')
- for name in ['rollback-ui.mjs', 'verify-public.mjs', 'check-site-modes.mjs']:
- copy(ROOT / 'scripts/release-templates' / name, 'scripts/' + name)
- save(PACK / 'deploy/industry-ui-files.json', dict(version='ksf.industry-ui.publish.v1', release=RELEASE, homepageSha256=HOME, files=files))
- save(PACK / 'evidence/production-baseline.json', dict(observedAt=datetime.now(timezone.utc).isoformat(), files=baseline))
- (PACK / 'evidence/ui.patch').write_text(''.join(patch), encoding='utf-8')
- # These are isolated test dependencies. They are deliberately outside payload/.
- for name in sorted(required):
- copy(WORK / 'public-baseline' / name, 'test-support/site/' + name)
- tests = ['brand-shop-filters', 'library', 'library-selection', 'library-pages', 'library-images', 'industry-workshop', 'industry-research-directions']
- for name in tests:
- source = ROOT / ('test/' + name + '.test.js')
- text = source.read_text(encoding='utf-8').replace('../src/', '../test-support/site/src/').replace('../server/library-api.mjs', '../test-support/library-api.mjs')
- dest = PACK / ('test/' + source.name)
- dest.parent.mkdir(parents=True, exist_ok=True)
- dest.write_text(text, encoding='utf-8')
- for name in expected:
- copy(PACK / 'payload' / name, 'test-support/site/' + name)
- copy(ROOT / 'server/library-api.mjs', 'test-support/library-api.mjs')
- evidence_files = {
- 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'],
- linkage: ['acceptance.json', 'source-manifest.json', 'static-check.txt', 'industry-390.png', 'library-390.png'],
- }
- for directory, names in evidence_files.items():
- for name in names:
- copy(directory / name, 'evidence/local/' + directory.name + '/' + name)
- for name in ['移动端适配-本地验收.md', '品牌与店铺筛选联动-本地验收.md']:
- copy(ROOT / 'docs/board-deploy-20261108' / name, 'docs/local-history/' + name)
- copy(ROOT / 'scripts/package-mobile-filter-release.py', 'scripts/package-mobile-filter-release.py')
- save(PACK / 'package.json', dict(type='module', private=True))
- save(PACK / 'RELEASE.json', dict(release=RELEASE, createdAt=datetime.now(timezone.utc).isoformat(),
- scope='Mobile layout and bidirectional brand/shop filters; frontend only', staticFiles=sorted(expected),
- sourceFilesSha256=expected, requiredProductionFiles=sorted(required), expectedChanges=11,
- expectedNewFiles=2, backendFiles=[], databaseIncluded=False, credentialsIncluded=False,
- serviceRestartRequired=False, paidCalls=0, productionDeployed=False))
- print(json.dumps(dict(ok=True, package=str(PACK), staticFiles=len(expected), dependencyFiles=len(required), sealed=False)))
- def seal():
- acceptance = json.loads((PACK / 'evidence/acceptance.json').read_bytes())
- assert acceptance['ok'] and acceptance['publisher']['ok'] and acceptance['mobile']['ok'] and acceptance['linkage']['ok']
- for name, digest in acceptance['testedFilesSha256'].items():
- assert sha((PACK / name).read_bytes()) == digest, 'TESTED_FILE_CHANGED:' + name
- manifest = json.loads((PACK / 'deploy/industry-ui-files.json').read_bytes())
- 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'])
- assert not list(PACK.rglob('*.sqlite')) and not list(PACK.rglob('*.db')) and not list(PACK.rglob('*.env'))
- paths = sorted(p for p in PACK.rglob('*') if p.is_file() and p.name != 'FILES.sha256')
- (PACK / 'FILES.sha256').write_text(''.join(sha(p.read_bytes()) + ' ' + p.relative_to(PACK).as_posix() + '\n' for p in paths), encoding='utf-8')
- archive = OUT / (RELEASE + '.zip')
- with zipfile.ZipFile(archive, 'x', zipfile.ZIP_DEFLATED) as bundle:
- for path in sorted(PACK.rglob('*')):
- if path.is_file():
- bundle.write(path, path.relative_to(PACK).as_posix())
- with zipfile.ZipFile(archive) as bundle:
- assert bundle.testzip() is None
- for line in bundle.read('FILES.sha256').decode().splitlines():
- digest, name = line.split(' ', 1)
- assert sha(bundle.read(name)) == digest
- save(OUT / 'delivery.json', dict(ok=True, release=RELEASE, archive=str(archive), bytes=archive.stat().st_size,
- sha256=sha(archive.read_bytes()), manifestEntries=len(paths), productionDeployed=False, paidCalls=0))
- print((OUT / 'delivery.json').read_text(encoding='utf-8'))
- if __name__ == '__main__':
- parser = argparse.ArgumentParser(description=__doc__)
- parser.add_argument('--seal', action='store_true')
- (seal if parser.parse_args().seal else build)()
|