release-workshop-board.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183
  1. """Seal and publish only the verified nine-file workshop board release."""
  2. import argparse
  3. from concurrent.futures import ThreadPoolExecutor
  4. import hashlib
  5. import importlib.util
  6. import io
  7. import json
  8. from pathlib import Path
  9. import subprocess
  10. import urllib.error
  11. import urllib.request
  12. import zipfile
  13. ROOT = Path(__file__).resolve().parents[1]
  14. NAME = 'ksf-workshop-board-20261110-r1'
  15. OUT = ROOT / 'docs' / NAME
  16. PACK = OUT / 'package'
  17. STAGING = ROOT / 'work' / (NAME + '-publication')
  18. BRANCH = 'codex/workshop-board-20261110-r1'
  19. REMOTE = 'https://git.fmode.cn/18307996893/kangshifu-board.git'
  20. PUBLIC = REMOTE[:-4]
  21. sha = lambda raw: hashlib.sha256(raw).hexdigest()
  22. encode = lambda obj: (json.dumps(obj, ensure_ascii=False, indent=2) + '\n').encode('utf-8')
  23. def need(ok, code):
  24. if not ok:
  25. raise ValueError(code)
  26. def run(args, cwd=ROOT):
  27. result = subprocess.run(args, cwd=cwd, capture_output=True, timeout=120)
  28. need(result.returncode == 0, 'COMMAND_FAILED:' + args[0])
  29. return result.stdout
  30. def git(*args, cwd=STAGING):
  31. return run(['git', *args], cwd).decode('utf-8').strip()
  32. def request(url):
  33. req = urllib.request.Request(url, headers={'User-Agent': 'ksf-board-release/1', 'Cache-Control': 'no-cache'})
  34. return urllib.request.urlopen(req, timeout=40)
  35. def seal():
  36. need(not (OUT / 'publish').exists(), 'RELEASE_ALREADY_SEALED')
  37. manifest = json.loads((PACK / 'deploy/industry-ui-files.json').read_bytes())
  38. need(len(manifest['files']) == 9 and sum(f['allowMissing'] for f in manifest['files']) == 2, 'BAD_SCOPE')
  39. for f in manifest['files']:
  40. need(sha((PACK / 'payload' / f['path']).read_bytes()) == f['sha256'], 'PAYLOAD_CHANGED')
  41. def check_baseline(row):
  42. try:
  43. with request('https://kangshifu.fmode.cn/' + row['path']) as response:
  44. raw = response.read()
  45. if row.get('allowMissing'):
  46. need('text/html' in response.headers.get('Content-Type', ''), 'NEW_MODULE_ALREADY_EXISTS')
  47. else:
  48. digest = sha(raw.replace(b'\r\n', b'\n'))
  49. expected = row.get('priorNormalizedSha256', [row['sha256']])
  50. need(digest in expected, 'PRODUCTION_BASELINE_CHANGED:' + row['path'])
  51. except urllib.error.HTTPError as error:
  52. need(error.code == 404 and row.get('allowMissing'), 'PRODUCTION_BASELINE_UNAVAILABLE')
  53. return {'path': row['path'], 'observedBaselineStillValid': True}
  54. with ThreadPoolExecutor(max_workers=6) as pool:
  55. freshness = list(pool.map(check_baseline, [{'path': 'index.html', 'sha256': manifest['homepageSha256']}, *manifest['files']]))
  56. tests = run(['node', '--test', 'test/publisher.test.js', 'test/workshop-outputs.test.js'], PACK)
  57. need(b'pass 13' in tests and b'fail 0' in tests, 'RELEASE_TESTS_FAILED')
  58. (PACK / 'evidence/release-tests.log').write_bytes(tests)
  59. run(['C:/Program Files/Git/bin/bash.exe', '-n', 'scripts/deploy-site.sh'], PACK)
  60. scripts = [*list((PACK / 'payload').rglob('*.js')), *list((PACK / 'scripts').glob('*.mjs'))]
  61. for p in scripts:
  62. run(['node', '--check', str(p)])
  63. browser = json.loads((PACK / 'evidence/release-browser.json').read_bytes())
  64. api = json.loads((PACK / 'evidence/public-api-acceptance.json').read_bytes())
  65. need(browser['checksPassed'] == 15 and not browser['errors'] and browser['businessWrites'] == 0, 'BROWSER_CHECK_REQUIRED')
  66. need(api['publicReadbackPassed'] and api['originalPublicRecordsUnchanged'] and api['sharedGroupStateUnchanged'], 'API_CHECK_REQUIRED')
  67. validation = {'ok': True, 'release': NAME, 'releaseTestsPassed': 13, 'browserCompatibilityChecksPassed': 15,
  68. 'browserErrors': 0, 'browserMethod': browser['method'], 'staticArchitectureCheckPassed': True,
  69. 'payloadAndPublisherSyntaxPassed': len(scripts), 'deploymentShellSyntaxPassed': True,
  70. 'backendMinimalPatchTestsPassed': 3, 'backendAlreadyDeployedAndReadBack': True,
  71. 'originalPublicRecordsAndGroupsUnchanged': True, 'prepublicationBaselineRechecked': freshness,
  72. 'businessWrites': 0, 'mainSkillIncluded': False, 'manualIncluded': False,
  73. 'productionStaticFilesDeployed': False, 'realEmployeeOrHostTrial': False,
  74. 'linuxSitePermissionsAcceptance': 'run on production by deploy-site.sh'}
  75. (PACK / 'validation.json').write_bytes(encode(validation))
  76. files = {p.relative_to(PACK).as_posix(): p.read_bytes() for p in PACK.rglob('*') if p.is_file()}
  77. need(not any(n.startswith(('skills/', 'work/', 'private/')) for n in files), 'UNEXPECTED_RELEASE_CONTENT')
  78. files['FILES.sha256'] = ''.join(sha(raw) + ' ' + name + '\n' for name, raw in sorted(files.items())).encode('utf-8')
  79. (PACK / 'FILES.sha256').write_bytes(files['FILES.sha256'])
  80. stream = io.BytesIO()
  81. with zipfile.ZipFile(stream, 'w', zipfile.ZIP_DEFLATED) as archive:
  82. for name, raw in sorted(files.items()):
  83. info = zipfile.ZipInfo(name, date_time=(2026, 11, 10, 0, 0, 0))
  84. info.external_attr = 0o100644 << 16
  85. archive.writestr(info, raw, compress_type=zipfile.ZIP_DEFLATED)
  86. raw_zip = stream.getvalue()
  87. with zipfile.ZipFile(io.BytesIO(raw_zip)) as archive:
  88. need(archive.testzip() is None, 'ARCHIVE_CORRUPT')
  89. for line in archive.read('FILES.sha256').decode().splitlines():
  90. digest, name = line.split(' ', 1)
  91. need(sha(archive.read(name)) == digest, 'ARCHIVE_HASH_MISMATCH')
  92. release = {'schema': 'ksf.workshop-board.release.v1', 'release': NAME,
  93. 'archive': {'path': NAME + '.zip', 'bytes': len(raw_zip), 'sha256': sha(raw_zip)},
  94. 'filesManifestEntries': len(files) - 1, 'frontendFiles': manifest['files'],
  95. 'homepageSha256': manifest['homepageSha256'], 'deploymentScript': 'scripts/deploy-site.sh',
  96. 'targetDirectory': '/opt/kangshifu-web', 'backendFieldAlreadyDeployed': 'reportSeriesKey',
  97. 'mainSkillIncluded': False, 'manualIncluded': False, 'businessWrites': 0,
  98. 'productionStaticDeploymentPending': True,
  99. 'validation': {'testsPassed': 13, 'browserChecksPassed': 15, 'browserErrors': 0}}
  100. publish = {'release.json': encode(release), NAME + '.zip': raw_zip, 'DEPLOY.md': files['DEPLOY.md'],
  101. 'validation.json': files['validation.json'], 'evidence/ui.patch': files['evidence/ui.patch'],
  102. 'evidence/public-api-acceptance.json': files['evidence/public-api-acceptance.json'],
  103. 'evidence/release-browser.json': files['evidence/release-browser.json'], '.gitattributes': b'* -text\n'}
  104. spec = importlib.util.spec_from_file_location('secret_checks', ROOT / 'scripts/publish-workshop-onboarding-r5.py')
  105. helper = importlib.util.module_from_spec(spec)
  106. spec.loader.exec_module(helper)
  107. scan = helper.secret_scan(publish)
  108. (OUT / 'public-secret-check.json').write_bytes(encode(scan))
  109. for name, raw in publish.items():
  110. p = OUT / 'publish' / name
  111. p.parent.mkdir(parents=True, exist_ok=True)
  112. p.write_bytes(raw)
  113. print(json.dumps({'ok': True, 'sealed': True, **release['archive'], 'filesManifestEntries': release['filesManifestEntries'], 'secretCheck': scan}))
  114. def prepare():
  115. need(not STAGING.exists(), 'NEW_STAGING_DIRECTORY_REQUIRED')
  116. files = {p.relative_to(OUT / 'publish').as_posix(): p.read_bytes() for p in (OUT / 'publish').rglob('*') if p.is_file()}
  117. need('release.json' in files and len(files) == 8, 'UNSEALED_RELEASE')
  118. STAGING.mkdir(parents=True)
  119. for name, raw in files.items():
  120. p = STAGING / name
  121. p.parent.mkdir(parents=True, exist_ok=True)
  122. p.write_bytes(raw)
  123. git('init', '--initial-branch=' + BRANCH)
  124. for name in ['user.name', 'user.email']:
  125. git('config', name, git('config', '--get', name, cwd=ROOT))
  126. git('config', 'core.autocrlf', 'false')
  127. git('remote', 'add', 'origin', REMOTE)
  128. git('add', '--', *sorted(files))
  129. git('commit', '-m', 'release: workshop group outputs, report navigation and version history')
  130. need(set(git('ls-files').splitlines()) == set(files), 'UNEXPECTED_PUBLICATION_FILES')
  131. for name, raw in files.items():
  132. need(run(['git', 'cat-file', 'blob', 'HEAD:' + name], STAGING) == raw, 'GIT_BLOB_CHANGED')
  133. plan = {'commit': git('rev-parse', 'HEAD'), 'branch': BRANCH,
  134. 'files': {n: sha(raw) for n, raw in files.items()}, 'mainBranchModified': False}
  135. (OUT / 'publication-plan.json').write_bytes(encode(plan))
  136. print(json.dumps({'ok': True, 'prepared': True, 'commit': plan['commit'], 'files': len(files)}))
  137. def publish():
  138. plan = json.loads((OUT / 'publication-plan.json').read_bytes())
  139. commit = plan['commit']
  140. need(git('rev-parse', 'HEAD') == commit and not git('status', '--porcelain'), 'STAGING_CHANGED')
  141. before = git('ls-remote', 'origin', 'refs/heads/' + BRANCH)
  142. need(not before or before.split()[0] == commit, 'REMOTE_BRANCH_CHANGED')
  143. git('push', 'origin', 'HEAD:refs/heads/' + BRANCH)
  144. need(git('ls-remote', 'origin', 'refs/heads/' + BRANCH).split()[0] == commit, 'PUSH_UNCONFIRMED')
  145. def verify(item):
  146. name, digest = item
  147. url = PUBLIC + '/raw/' + commit + '/' + name
  148. with request(url) as response:
  149. raw = response.read()
  150. need(response.status == 200 and sha(raw) == digest, 'PUBLIC_RESOURCE_HASH_MISMATCH')
  151. return {'path': name, 'url': url, 'bytes': len(raw), 'sha256': digest, 'http': response.status, 'matchesLocal': True}
  152. with ThreadPoolExecutor(max_workers=4) as pool:
  153. checked = list(pool.map(verify, plan['files'].items()))
  154. receipt = {'ok': True, 'release': NAME, 'commit': commit, 'branch': BRANCH,
  155. 'manifestUrl': PUBLIC + '/raw/' + commit + '/release.json',
  156. 'deploymentGuideUrl': PUBLIC + '/raw/' + commit + '/DEPLOY.md',
  157. 'publicResourcesPublishedAndReadBack': True, 'resources': checked,
  158. 'backendAlreadyDeployedAndVerified': True, 'productionStaticFilesDeployed': False,
  159. 'mainSkillAndManualDeployed': False, 'mainBranchModified': False, 'businessWrites': 0}
  160. (OUT / 'online-release-receipt.json').write_bytes(encode(receipt))
  161. print(json.dumps({k: v for k, v in receipt.items() if k != 'resources'}, ensure_ascii=False, indent=2))
  162. if __name__ == '__main__':
  163. parser = argparse.ArgumentParser(description=__doc__)
  164. parser.add_argument('action', choices=['seal', 'prepare', 'publish'])
  165. args = parser.parse_args()
  166. {'seal': seal, 'prepare': prepare, 'publish': publish}[args.action]()