publish-workshop-manual.py 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104
  1. """Publish the sealed static manual and deployment kit; this does not deploy the production website."""
  2. import argparse
  3. from concurrent.futures import ThreadPoolExecutor
  4. import hashlib
  5. import importlib.util
  6. import json
  7. from pathlib import Path
  8. import subprocess
  9. import urllib.request
  10. ROOT = Path(__file__).resolve().parents[1]
  11. OUT = ROOT / 'docs/workshop-manual-20261110-r1'
  12. STAGING = ROOT / 'work/workshop-manual-20261110-r1-publication'
  13. BRANCH = 'codex/workshop-manual-20261110-r1'
  14. REMOTE = 'https://git.fmode.cn/18307996893/kangshifu-board.git'
  15. PUBLIC = REMOTE[:-4]
  16. sha = lambda b: hashlib.sha256(b).hexdigest()
  17. encode = lambda v: (json.dumps(v, ensure_ascii=False, indent=2) + '\n').encode('utf-8')
  18. def git(*args, cwd=STAGING):
  19. result = subprocess.run(['git', *args], cwd=cwd, capture_output=True, timeout=90)
  20. if result.returncode:
  21. raise RuntimeError('GIT_OPERATION_FAILED')
  22. return result.stdout.decode('utf-8').strip()
  23. def prepare():
  24. assert not STAGING.exists(), 'NEW_PUBLICATION_DIRECTORY_REQUIRED'
  25. meta = json.loads((OUT / 'publish/manual-release.json').read_bytes())
  26. archive = meta['archive']['path']
  27. files = {p.relative_to(OUT / 'package').as_posix(): p.read_bytes()
  28. for p in (OUT / 'package').rglob('*') if p.is_file()}
  29. for name in [archive, 'manual-release.json']:
  30. files[name] = (OUT / 'publish' / name).read_bytes()
  31. assert sha(files[archive]) == meta['archive']['sha256']
  32. files['.gitattributes'] = b'* -text\n'
  33. spec = importlib.util.spec_from_file_location('public_release_checks', ROOT / 'scripts/publish-workshop-onboarding-r5.py')
  34. check = importlib.util.module_from_spec(spec)
  35. spec.loader.exec_module(check)
  36. scan = check.secret_scan(files)
  37. (OUT / 'public-secret-check.json').write_bytes(encode(scan))
  38. STAGING.mkdir(parents=True)
  39. for name, b in files.items():
  40. p = STAGING / name
  41. p.parent.mkdir(parents=True, exist_ok=True)
  42. p.write_bytes(b)
  43. git('init', '--initial-branch=' + BRANCH)
  44. git('config', 'user.name', git('config', '--get', 'user.name', cwd=ROOT))
  45. git('config', 'user.email', git('config', '--get', 'user.email', cwd=ROOT))
  46. git('config', 'core.autocrlf', 'false')
  47. git('remote', 'add', 'origin', REMOTE)
  48. git('add', '--', *sorted(files))
  49. git('commit', '-m', 'release: compact three-half-day manual paired with workshop Skill r10')
  50. assert set(git('ls-files').splitlines()) == set(files), 'UNEXPECTED_PUBLISHED_FILES'
  51. for name, b in files.items():
  52. oid = git('rev-parse', 'HEAD:' + name)
  53. result = subprocess.run(['git', 'cat-file', 'blob', oid], cwd=STAGING, capture_output=True, check=True)
  54. assert result.stdout == b, 'GIT_BLOB_CHANGED'
  55. commit = git('rev-parse', 'HEAD')
  56. plan = {'ok': True, 'commit': commit, 'branch': BRANCH, 'files': {n: sha(b) for n, b in files.items()},
  57. 'published': False, 'mainBranchModified': False, 'secretCheck': scan}
  58. (OUT / 'git-plan.json').write_bytes(encode(plan))
  59. print(json.dumps({'ok': True, 'prepared': True, 'commit': commit, 'files': len(files)}))
  60. def publish():
  61. plan = json.loads((OUT / 'git-plan.json').read_bytes())
  62. commit = plan['commit']
  63. assert git('rev-parse', 'HEAD') == commit and not git('status', '--porcelain'), 'PUBLICATION_STAGING_CHANGED'
  64. before = git('ls-remote', 'origin', 'refs/heads/' + BRANCH)
  65. assert not before or before.split()[0] == commit, 'REMOTE_BRANCH_ALREADY_DIFFERS'
  66. git('push', 'origin', 'HEAD:refs/heads/' + BRANCH)
  67. assert git('ls-remote', 'origin', 'refs/heads/' + BRANCH).split()[0] == commit
  68. def verify(item):
  69. name, expected = item
  70. url = PUBLIC + '/raw/' + commit + '/' + name
  71. request = urllib.request.Request(url, headers={'User-Agent': 'ksf-release-verifier/1'})
  72. with urllib.request.urlopen(request, timeout=40) as response:
  73. raw = response.read()
  74. assert response.status == 200 and sha(raw) == expected, 'ONLINE_FILE_HASH_MISMATCH'
  75. return {'path': name, 'url': url, 'status': response.status, 'bytes': len(raw),
  76. 'sha256': sha(raw), 'matchesLocal': True}
  77. with ThreadPoolExecutor(max_workers=4) as pool:
  78. resources = list(pool.map(verify, plan['files'].items()))
  79. receipt = {'ok': True, 'releaseId': 'ksf-workshop-manual-20261110-r1', 'publicResourcesPublished': True,
  80. 'repository': PUBLIC, 'branch': BRANCH, 'commit': commit,
  81. 'deploymentGuide': PUBLIC + '/raw/' + commit + '/DEPLOY.md',
  82. 'distributionUrl': PUBLIC + '/raw/' + commit + '/manual-release.json',
  83. 'resources': resources, 'allPublicFilesReadBack': True,
  84. 'productionWebsiteDeployed': False, 'productionAgentSkillUpdated': False,
  85. 'employeeWorkshopTrialCompleted': False, 'businessDataModified': False,
  86. 'mainBranchModified': False}
  87. (OUT / 'online-release-receipt.json').write_bytes(encode(receipt))
  88. print(json.dumps({k: v for k, v in receipt.items() if k != 'resources'}, ensure_ascii=False, indent=2))
  89. if __name__ == '__main__':
  90. parser = argparse.ArgumentParser(description=__doc__)
  91. parser.add_argument('command', choices=['prepare', 'publish'])
  92. args = parser.parse_args()
  93. prepare() if args.command == 'prepare' else publish()