package-workshop-intake-update.py 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153
  1. """Build r6 from verified local instructions and reconstruct the sealed r5 baseline."""
  2. from pathlib import Path
  3. import argparse
  4. import hashlib
  5. import json
  6. import re
  7. import zipfile
  8. ROOT = Path(__file__).resolve().parents[1]
  9. OUT = ROOT / 'docs/workshop-skill-release-20261109-r6'
  10. NAME = 'ksf-workshop-skill-update-20261109-r6'
  11. ARCHIVES = [
  12. ('r2', 'docs/workshop-skill-release-20261108-r2/ksf-workshop-unified-20261108-r2.zip', '65660a366589d1b7a6c93b333e9ab1017a487e4b3f5c7399f225c38aedb9e844'),
  13. ('r3', 'docs/workshop-skill-release-20261109-r3/ksf-workshop-skill-update-20261109-r3.zip', '83c44c8a2f16c682faa5f91f41f513c7d638839cedf43516f96a6cb9bec14828'),
  14. ('r4', 'docs/workshop-skill-release-20261109-r4/ksf-workshop-skill-update-20261109-r4.zip', 'c9b60ce30fb5d3bed2b2a73ac7bc33f6c6b639dc100afd7cef4fe82a7e988308'),
  15. ('r5', 'docs/workshop-skill-release-20261109-r5/ksf-workshop-skill-update-20261109-r5.zip', 'e72bdd9c08817ae6e017808d3f75ad00e4a15dc044605c70678018fab95b25d4'),
  16. ]
  17. DELTA = ['SKILL.md', 'references/workshop-category-scan.md', 'references/workshop-course-flow.md',
  18. 'references/workshop-feedback.md', 'references/workshop-intake.md',
  19. 'references/workshop-research-loop.md', 'FILES.sha256']
  20. sha = lambda b: hashlib.sha256(b).hexdigest()
  21. encode = lambda v: (json.dumps(v, ensure_ascii=False, indent=2) + '\n').encode('utf-8')
  22. def stage():
  23. bodies = {}
  24. for version, relative, digest in ARCHIVES:
  25. archive = ROOT / relative
  26. assert sha(archive.read_bytes()) == digest, version
  27. with zipfile.ZipFile(archive) as z:
  28. assert z.testzip() is None
  29. meta = json.loads(z.read('release.json'))
  30. prefix = 'skill/' if version == 'r2' else 'payload/'
  31. bodies = {n: z.read(prefix + n) if prefix + n in z.namelist() else bodies[n]
  32. for n in meta['candidate']}
  33. for n, h in meta['candidate'].items():
  34. assert sha(bodies[n]) == h, (version, n)
  35. baseline = meta['candidate']
  36. assert len(baseline) == 41 and baseline['SKILL.md'] == '16b428f4f4fa0819a764505269116f3f91e503342d9813d3855bc5c2cf80feb6'
  37. for n, raw in bodies.items():
  38. p = OUT / 'rehearsal-baseline-r5' / n
  39. p.parent.mkdir(parents=True, exist_ok=True)
  40. p.write_bytes(raw)
  41. skill = ROOT / 'skills/ksf-workshop-runtime'
  42. candidate = {}
  43. for line in (skill / 'FILES.sha256').read_text(encoding='utf-8').splitlines():
  44. digest, n = line.split(' ', 1)
  45. assert re.fullmatch(r'[a-f0-9]{64}', digest)
  46. assert re.fullmatch(r'[A-Za-z0-9_./-]+', n) and '..' not in Path(n).parts
  47. assert not Path(n).is_absolute() and n not in candidate
  48. assert sha((skill / n).read_bytes()) == digest, n
  49. candidate[n] = digest
  50. candidate['FILES.sha256'] = sha((skill / 'FILES.sha256').read_bytes())
  51. assert len(candidate) == 42
  52. assert candidate['SKILL.md'] == '3cb64f381937411b0a2b853772bb19d37f44528a2902a89ef475b707472d3473'
  53. assert candidate['FILES.sha256'] == 'd1ba798caa776f1691436db885365927e4d77378327578a6c1f12d3fc8564373'
  54. assert not set(baseline) - set(candidate)
  55. assert {n for n in candidate if baseline.get(n) != candidate[n]} == set(DELTA)
  56. assert set(candidate) - set(baseline) == {'references/workshop-intake.md'}
  57. runtime = [n for n in baseline if n == 'runtime-lock.json' or n.startswith('scripts/')]
  58. assert len(runtime) == 7 and all(candidate[n] == baseline[n] for n in runtime)
  59. links = 0
  60. for n in candidate:
  61. if not n.endswith('.md'):
  62. continue
  63. p = skill / n
  64. for target in re.findall(r'\]\(([^)]+)\)', p.read_text(encoding='utf-8')):
  65. if '://' in target or target.startswith('#'):
  66. continue
  67. resolved = (p.parent / target.split('#', 1)[0]).resolve()
  68. assert resolved.is_relative_to(skill.resolve()) and resolved.is_file(), (n, target)
  69. links += 1
  70. plan = [{'path': n, 'action': 'update' if n in baseline else 'create',
  71. 'beforeSha256': baseline.get(n), 'afterSha256': candidate[n]} for n in DELTA]
  72. release = {'schema': 'ksf.workshop-research-update.v1', 'releaseId': NAME,
  73. 'baselines': {'r5': baseline}, 'candidate': candidate,
  74. 'baselineArchiveSha256': {'r5': ARCHIVES[-1][2]},
  75. 'researchEntryNames': meta['researchEntryNames'],
  76. 'researchRuntimePins': meta['researchRuntimePins'],
  77. 'plans': {'r5': plan}, 'runtimeReplacementAllowed': False,
  78. 'businessWritesDuringUpgrade': False, 'serviceRestartAllowed': False,
  79. 'credentialsIncluded': False}
  80. integrity = {'ok': True, 'skillSha256': candidate['SKILL.md'], 'manifestSha256': candidate['FILES.sha256'],
  81. 'manifestEntries': 41, 'publicFiles': 42, 'relativeLinks': links,
  82. 'references': {'all': 33, 'md': 31, 'json': 2},
  83. 'runtimeFilesUnchanged': 7, 'courseModulesPreserved': 13}
  84. review = json.loads((ROOT / 'docs/workshop-intake-improvement-20261109/integrity-review.json').read_bytes())
  85. assert review['currentLocalSkillSha256'] == candidate['SKILL.md'] and review['runtimeFilesModified'] == 0
  86. local = {'schema': 'ksf.intake-local-review.v1', 'ok': True,
  87. 'skillSha256': candidate['SKILL.md'], 'manifestSha256': candidate['FILES.sha256'],
  88. 'reviewSourceSha256': sha(encode(review)),
  89. 'behaviorReview': review['behaviorReview'],
  90. 'skillStructureValidation': review['skillStructureValidation'],
  91. 'scope': 'manual local synthetic scenarios, not live-instance acceptance',
  92. 'remoteInstalled': False, 'productionDatabaseWrites': 0, 'paidCalls': 0}
  93. files = {'payload/' + n: (skill / n).read_bytes() for n in DELTA}
  94. files.update({'release.json': encode(release),
  95. 'update-skill.mjs': (ROOT / 'scripts/update-workshop-intake.mjs').read_bytes(),
  96. 'run-update.py': (ROOT / 'scripts/run-workshop-intake-update.py').read_bytes(),
  97. 'README.md': (OUT / 'README.md').read_bytes(),
  98. 'CHANGES.md': (OUT / 'CHANGES.md').read_bytes(),
  99. 'skill-integrity.json': encode(integrity), 'local-acceptance.json': encode(local)})
  100. validation = OUT / 'installer-validation.json'
  101. if validation.exists():
  102. value = json.loads(validation.read_bytes())
  103. assert value['ok'] and value['updaterSha256'] == sha(files['update-skill.mjs'])
  104. assert value['runnerSha256'] == sha(files['run-update.py'])
  105. files[validation.name] = validation.read_bytes()
  106. files['FILES.sha256'] = ''.join(sha(raw) + ' ' + n + '\n' for n, raw in sorted(files.items())).encode()
  107. for n, raw in files.items():
  108. assert not any(t in n.lower() for t in ['credential-binding', 'private.pem', 'envelope', 'channel-config'])
  109. p = OUT / 'package' / n
  110. p.parent.mkdir(parents=True, exist_ok=True)
  111. p.write_bytes(raw)
  112. (OUT / 'skill-integrity.json').write_bytes(encode(integrity))
  113. (OUT / 'local-acceptance.json').write_bytes(encode(local))
  114. return files, release
  115. def main():
  116. parser = argparse.ArgumentParser()
  117. parser.add_argument('--pack', action='store_true')
  118. args = parser.parse_args()
  119. files, release = stage()
  120. if not args.pack:
  121. print(json.dumps({'ok': True, 'stagedFiles': len(files), 'baseline': 'r5', 'changes': 7}))
  122. return
  123. assert 'installer-validation.json' in files, 'Installer validation required before packaging'
  124. target = OUT / (NAME + '.zip')
  125. assert not target.exists(), 'Never overwrite a released ZIP'
  126. with zipfile.ZipFile(target, 'x', zipfile.ZIP_DEFLATED) as z:
  127. for n, raw in sorted(files.items()):
  128. info = zipfile.ZipInfo(n, (2026, 11, 9, 0, 0, 0))
  129. info.external_attr = 0o100600 << 16
  130. z.writestr(info, raw, compress_type=zipfile.ZIP_DEFLATED)
  131. with zipfile.ZipFile(target) as z:
  132. assert z.testzip() is None and set(z.namelist()) == set(files)
  133. for n, raw in files.items():
  134. assert z.read(n) == raw
  135. receipt = {'ok': True, 'releaseId': NAME, 'filename': target.name, 'bytes': target.stat().st_size,
  136. 'sha256': sha(target.read_bytes()), 'packageEntries': len(files),
  137. 'outerManifestEntries': len(files) - 1, 'payloadFiles': 7, 'targetPublicFiles': 42,
  138. 'plans': {'r5': {'updates': 6, 'creates': 1}},
  139. 'candidateSkillSha256': release['candidate']['SKILL.md'],
  140. 'candidateManifestSha256': release['candidate']['FILES.sha256'],
  141. 'runtimeFilesChanged': 0, 'credentialsIncluded': False,
  142. 'remoteInstalled': False, 'productionDatabaseWrites': 0, 'paidCalls': 0}
  143. (OUT / 'package-receipt.json').write_bytes(encode(receipt))
  144. print(json.dumps(receipt, ensure_ascii=False, indent=2))
  145. if __name__ == '__main__':
  146. main()