package-workshop-docs-update.py 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131
  1. """Build the nine-file public update from the accepted r2 archive and tested candidate."""
  2. from pathlib import Path
  3. import argparse
  4. import hashlib
  5. import json
  6. import zipfile
  7. ROOT = Path(__file__).resolve().parents[1]
  8. OUT = ROOT / 'docs/workshop-skill-release-20261109-r3'
  9. NAME = 'ksf-workshop-skill-update-20261109-r3'
  10. OLD = ROOT / 'docs/workshop-skill-release-20261108-r2/ksf-workshop-unified-20261108-r2.zip'
  11. sha = lambda b: hashlib.sha256(b).hexdigest()
  12. encode = lambda obj: (json.dumps(obj, ensure_ascii=False, indent=2) + '\n').encode('utf-8')
  13. def stage():
  14. assert sha(OLD.read_bytes()) == '65660a366589d1b7a6c93b333e9ab1017a487e4b3f5c7399f225c38aedb9e844'
  15. with zipfile.ZipFile(OLD) as z:
  16. old = json.loads(z.read('release.json'))
  17. baseline = old['candidate']
  18. # These exact bytes are only for isolated rehearsal, never included in the update.
  19. for n, h in baseline.items():
  20. body = z.read('skill/' + n)
  21. assert sha(body) == h
  22. p = OUT / 'rehearsal-baseline' / n
  23. p.parent.mkdir(parents=True, exist_ok=True)
  24. p.write_bytes(body)
  25. expected = json.loads(z.read('expected-agent-read.json'))
  26. assert len(baseline) == 38
  27. skill = ROOT / 'skills/ksf-workshop-runtime'
  28. candidate = {}
  29. for line in (skill / 'FILES.sha256').read_text(encoding='utf-8').splitlines():
  30. h, n = line.split(' ', 1)
  31. assert '..' not in Path(n).parts and not Path(n).is_absolute() and n not in candidate
  32. assert sha((skill / n).read_bytes()) == h
  33. candidate[n] = h
  34. assert len(candidate) == 38
  35. candidate['FILES.sha256'] = sha((skill / 'FILES.sha256').read_bytes())
  36. assert candidate['SKILL.md'] == 'd8f3c5bd3ba8cd24a40aad37a19f9e2b3404201726070abc9fef9ade552e06e0'
  37. delta = {n: h for n, h in candidate.items() if baseline.get(n) != h}
  38. assert len(delta) == 9 and len(set(candidate) - set(baseline)) == 1
  39. assert not set(baseline) - set(candidate)
  40. assert all(n in ('SKILL.md', 'FILES.sha256') or n.startswith('references/workshop-') and n.endswith('.md') for n in delta)
  41. assert all(candidate[n] == h for n, h in baseline.items() if n.startswith('scripts/') or n == 'runtime-lock.json')
  42. expected['skillFingerprint'] = candidate['SKILL.md']
  43. expected['generationPolicy'] = 'Pin current contract generation; compare every read and board to it.'
  44. release = {'schema': 'ksf.workshop-docs-update.v1', 'releaseId': NAME,
  45. 'baselineRelease': old['releaseId'], 'baselineArchiveSha256': sha(OLD.read_bytes()),
  46. 'baseline': baseline, 'candidate': candidate,
  47. 'researchEntryNames': [n for n in old['legacyManifestPolicy']['entryNames'] if n != 'SKILL.md'],
  48. 'researchRuntimePins': old['legacyManifestPolicy']['verifiedRuntimeEntries'],
  49. 'plannedChanges': [{'path': n, 'action': 'update' if n in baseline else 'create',
  50. 'beforeSha256': baseline.get(n), 'afterSha256': h} for n, h in delta.items()],
  51. 'runtimeReplacementAllowed': False, 'businessWritesDuringUpgrade': False,
  52. 'serviceRestartAllowed': False, 'credentialsIncluded': False}
  53. acceptance_path = ROOT / 'docs/workshop-skill-retest-20261109-r2/retest-acceptance.json'
  54. acceptance = json.loads(acceptance_path.read_bytes())
  55. integrity = json.loads((acceptance_path.parent / 'skill-integrity.json').read_bytes())
  56. assert integrity['ok'] and integrity['skillSha256'] == candidate['SKILL.md']
  57. assert integrity['manifestSha256'] == candidate['FILES.sha256']
  58. assert acceptance['interfaceAcceptance']['failed'] == 0 and acceptance['regression']['failed'] == 0
  59. compact = {'schema': 'ksf.workshop-local-acceptance-summary.v1',
  60. 'skillSha256': candidate['SKILL.md'], 'manifestSha256': candidate['FILES.sha256'],
  61. 'sourceReceiptSha256': sha(acceptance_path.read_bytes()),
  62. 'scope': 'isolated constructed case; no production calls or writes',
  63. 'courseModules': 13, 'reports': 21, 'opportunityEntities': 5,
  64. 'interfaceChecksPassed': acceptance['interfaceAcceptance']['passed'],
  65. 'regressionPassed': acceptance['regression']['passed'],
  66. 'browserReportViewportChecks': acceptance['browser']['totalReportViewportChecks'],
  67. 'viewportWidths': acceptance['browser']['viewportWidths'],
  68. 'manifestEntries': integrity['manifestEntries'], 'relativeLinks': integrity['relativeLinks'],
  69. 'independentContextGenerationAndReview': True, 'actualDifferentModelReview': False,
  70. 'productionDatabaseWrites': 0, 'paidCalls': 0, 'originalLiveCaseResumed': False,
  71. 'notPerformed': acceptance['notPerformed']}
  72. files = {'payload/' + n: (skill / n).read_bytes() for n in delta}
  73. files.update({'release.json': encode(release), 'expected-agent-read.json': encode(expected),
  74. 'update-skill.mjs': (ROOT / 'scripts/update-workshop-docs.mjs').read_bytes(),
  75. 'run-update.py': (ROOT / 'scripts/run-workshop-docs-update.py').read_bytes(),
  76. 'probe-agent-library-read.py': (ROOT / 'scripts/probe-agent-library-read.py').read_bytes(),
  77. 'README.md': (OUT / 'README.md').read_bytes(), 'CHANGES.md': (OUT / 'CHANGES.md').read_bytes(),
  78. 'local-acceptance.json': encode(compact)})
  79. validation = OUT / 'installer-validation.json'
  80. if validation.exists():
  81. v = json.loads(validation.read_bytes())
  82. assert v['ok'] and v['updaterSha256'] == sha(files['update-skill.mjs'])
  83. assert v['runnerSha256'] == sha(files['run-update.py'])
  84. files['installer-validation.json'] = validation.read_bytes()
  85. for n in files:
  86. assert not any(t in n.lower() for t in ('credential-binding', 'private.pem', 'envelope', 'channel-config'))
  87. files['FILES.sha256'] = ''.join(sha(raw) + ' ' + n + '\n' for n, raw in sorted(files.items())).encode()
  88. for n, raw in files.items():
  89. p = OUT / 'package' / n
  90. p.parent.mkdir(parents=True, exist_ok=True)
  91. p.write_bytes(raw)
  92. return files, release
  93. def main():
  94. parser = argparse.ArgumentParser()
  95. parser.add_argument('--pack', action='store_true')
  96. args = parser.parse_args()
  97. files, release = stage()
  98. if not args.pack:
  99. print(json.dumps({'ok': True, 'staged': len(files), 'changes': 9, 'packageDir': str(OUT / 'package')}))
  100. return
  101. assert 'installer-validation.json' in files, 'Run installer tests before packaging'
  102. dest = OUT / (NAME + '.zip')
  103. assert not dest.exists(), 'Do not overwrite a released ZIP; choose a new revision'
  104. with zipfile.ZipFile(dest, 'x', zipfile.ZIP_DEFLATED) as z:
  105. for n, raw in sorted(files.items()):
  106. info = zipfile.ZipInfo(n, (2026, 11, 9, 0, 0, 0))
  107. info.external_attr = 0o100600 << 16
  108. z.writestr(info, raw, compress_type=zipfile.ZIP_DEFLATED)
  109. with zipfile.ZipFile(dest) as z:
  110. assert z.testzip() is None and set(z.namelist()) == set(files)
  111. for n, raw in files.items():
  112. assert z.read(n) == raw
  113. receipt = {'ok': True, 'releaseId': NAME, 'filename': dest.name,
  114. 'bytes': dest.stat().st_size, 'sha256': sha(dest.read_bytes()),
  115. 'packageEntries': len(files), 'outerManifestEntries': len(files) - 1,
  116. 'payloadFiles': 9, 'targetPublicFiles': 39, 'targetManifestEntries': 38,
  117. 'updates': 8, 'creates': 1, 'runtimeFilesChanged': 0,
  118. 'candidateSkillSha256': release['candidate']['SKILL.md'],
  119. 'candidateManifestSha256': release['candidate']['FILES.sha256'],
  120. 'credentialsIncluded': False, 'remoteInstalled': False, 'paidCalls': 0}
  121. (OUT / 'package-receipt.json').write_bytes(encode(receipt))
  122. print(json.dumps(receipt, ensure_ascii=False, indent=2))
  123. if __name__ == '__main__':
  124. main()