Files
RustRaptor/tools/upload_draft_release.py

240 lines
12 KiB
Python

#!/usr/bin/env python3
"""Export an exact frozen release to its existing empty draft; never publish it."""
import argparse
import hashlib
import json
import re
import shutil
import subprocess
import sys
import time
from pathlib import Path
from dataclasses import dataclass
from urllib.parse import quote
REPO = 'Quintine/RustRaptor'
TAG = 'v0.1.0-preview.20261020'
COMMIT = '7ecaa42b29ff0db5d2f7cdfe6da0a3221dfea555'
RELEASE_ID = 418286902
FLOOR = 1_800_000_000
@dataclass(frozen=True)
class ReleaseTarget:
commit: str = COMMIT
tag: str = TAG
release_id: int = RELEASE_ID
prerelease: bool = True
def __post_init__(self):
if not isinstance(self.commit, str) or not re.fullmatch('[0-9a-f]{40}', self.commit):
raise ValueError('release commit must be an exact lowercase 40-character SHA')
if not isinstance(self.tag, str) or not re.fullmatch(r'v[0-9A-Za-z._-]+', self.tag):
raise ValueError('release tag must be a safe exact version tag')
if type(self.release_id) is not int or self.release_id <= 0:
raise ValueError('release ID must be a positive integer')
if type(self.prerelease) is not bool:
raise ValueError('prerelease expectation must be a boolean')
PREVIEW_TARGET = ReleaseTarget()
def digest(path):
with Path(path).open('rb') as stream:
return hashlib.file_digest(stream, 'sha256').hexdigest()
def write_json(path, value):
with Path(path).open('x', encoding='utf-8') as stream:
json.dump(value, stream, indent=2)
stream.write('\n')
def checked_release(release, target=PREVIEW_TARGET):
if (release.get('id') != target.release_id or release.get('tag_name') != target.tag
or release.get('target_commitish') != target.commit or release.get('draft') is not True
or release.get('prerelease') is not target.prerelease):
raise ValueError('existing release ID/tag/target/draft state differs')
return release
def checked_tag(reference, annotated=None, target=PREVIEW_TARGET):
if reference.get('ref') != 'refs/tags/' + target.tag:
raise ValueError('wrong frozen tag reference')
obj = reference.get('object', {})
if obj.get('type') == 'tag':
if (not isinstance(annotated, dict) or annotated.get('sha') != obj.get('sha')
or annotated.get('tag') != target.tag):
raise ValueError('annotated tag identity differs')
obj = annotated.get('object', {})
if obj.get('type') != 'commit' or obj.get('sha') != target.commit:
raise ValueError('frozen tag no longer points to the tested commit')
def checked_remote(remote, name, size, sha256):
if (remote.get('name') != name or remote.get('size') != size
or remote.get('state') != 'uploaded' or remote.get('digest') != 'sha256:' + sha256):
raise ValueError('remote asset name/size/state/SHA-256 differs; retain local export')
return remote
def checked_receipt(plan, plan_sha, asset, receipt):
if (receipt.get('status') != 'ZIP_CRC_AND_COMPLETE_PAYLOAD_SHA256_PASS'
or receipt.get('asset') != asset['name'] or receipt.get('kind') != asset['kind']
or receipt.get('checkpoint') != plan['snapshot'] or receipt.get('plan_sha256') != plan_sha
or receipt.get('payload_files') != len(asset['files'])
or receipt.get('payload_sha256') != {r['name']: r['sha256'] for r in asset['files']}
or not re.fullmatch('[0-9a-f]{64}', receipt.get('sha256', ''))
or not 0 < receipt.get('bytes', 0) < 2 * 1024**3):
raise ValueError('local receipt differs from the one frozen runner plan')
class DraftExporter:
def __init__(self, package, state, temporary, target=PREVIEW_TARGET):
self.target = target
self.package = package.resolve(strict=True)
self.state = state.absolute()
self.temporary = temporary.absolute()
if any(directory.resolve() != directory for directory in (self.state, self.temporary)):
raise ValueError('state and temporary paths must be literal paths without symlink ancestors')
for directory in (self.state, self.temporary):
if directory.is_relative_to(self.package):
raise ValueError('release outputs must remain outside the frozen source tree')
directory.mkdir(parents=True, exist_ok=False)
self.receipts = self.state / 'receipts'
self.remote = self.state / 'remote-receipts'
self.receipts.mkdir()
self.remote.mkdir()
self.plan_path = self.state / 'release-plan.json'
self.exporter = self.package / 'tools/export_release.py'
self.verified = {}
def log(self, event, **details):
row = {'time_utc': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()), 'event': event, **details}
print(json.dumps(row), flush=True)
with (self.state / 'progress.jsonl').open('a') as stream:
stream.write(json.dumps(row) + '\n')
def api(self, endpoint):
return json.loads(subprocess.check_output(['gh', 'api', endpoint], cwd=self.package))
def release(self):
reference = self.api(f'repos/{REPO}/git/ref/tags/{self.target.tag}')
obj = reference.get('object', {})
annotated = None
if obj.get('type') == 'tag':
sha = obj.get('sha', '')
if not re.fullmatch('[0-9a-f]{40}', sha):
raise ValueError('unsafe annotated tag SHA')
annotated = self.api(f'repos/{REPO}/git/tags/{sha}')
checked_tag(reference, annotated, self.target)
return checked_release(self.api(f'repos/{REPO}/releases/{self.target.release_id}'), self.target)
def snapshot(self):
actual = {'commit': subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=self.package, text=True).strip(),
'runtime_manifest_sha256': digest(self.package / 'runtime-manifest.json'),
'windows_manifest_sha256': digest(self.package / 'runtime-windows-manifest.json')}
if actual['commit'] != self.target.commit or subprocess.check_output(
['git', 'status', '--porcelain', '--untracked-files=all'], cwd=self.package):
raise ValueError('frozen source commit or cleanliness differs')
if hasattr(self, 'plan') and actual != self.plan['snapshot']:
raise ValueError('frozen runtime manifests changed')
if shutil.disk_usage(self.package).free < FLOOR:
raise ValueError('runner workspace crossed the 1.8GB free-space floor')
def remote_members(self, release):
assets = release.get('assets', [])
names = [asset.get('name') for asset in assets]
if len(names) != len(set(names)) or set(names) != set(self.verified):
raise ValueError('unexpected, missing or duplicate remote assets; no clobber allowed')
for asset in assets:
receipt = self.verified[asset['name']]
checked_remote(asset, asset['name'], receipt['bytes'], receipt['sha256'])
return assets
def upload(self, path, name, expected_sha, expected_size):
self.snapshot()
self.remote_members(self.release())
if (path.is_symlink() or not path.is_file() or path.stat().st_size != expected_size
or digest(path) != expected_sha):
raise ValueError('local upload no longer matches authenticated receipt')
self.log('uploading', asset=name, bytes=expected_size, sha256=expected_sha)
result = subprocess.run(['gh', 'api', '--method', 'POST', f'https://uploads.github.com/repos/{REPO}/releases/{self.target.release_id}/assets?name=' + quote(name, safe=''),
'--input', str(path), '-H', 'Content-Type: application/octet-stream'],
cwd=self.package, capture_output=True, text=True)
(self.state / (name + '.upload.stdout')).write_text(result.stdout)
(self.state / (name + '.upload.stderr')).write_text(result.stderr)
if result.returncode:
raise RuntimeError('upload returned nonzero; retain local file and inspect remote draft before retry')
uploaded = checked_remote(json.loads(result.stdout), name, expected_size, expected_sha)
release = self.release()
matches = [asset for asset in release['assets'] if asset.get('name') == name]
if len(matches) != 1 or matches[0].get('id') != uploaded.get('id'):
raise ValueError('uploaded asset is not uniquely attached to the guarded draft')
checked_remote(matches[0], name, expected_size, expected_sha)
self.verified[name] = {'bytes': expected_size, 'sha256': expected_sha}
self.remote_members(release)
write_json(self.remote / (name + '.json'), matches[0])
self.log('remote_sha256_verified', asset=name, bytes=expected_size, sha256=expected_sha)
def run(self):
self.snapshot()
initial = self.release()
if initial.get('assets'):
raise ValueError('draft must be completely empty before this one-pass export')
write_json(self.state / 'initial-empty-draft.json', initial)
subprocess.run([sys.executable, '-B', str(self.exporter), 'plan', '--package', str(self.package),
'--expected-commit', self.target.commit, '--output', str(self.plan_path)], check=True, cwd=self.package)
self.plan = json.loads(self.plan_path.read_text())
plan_sha = digest(self.plan_path)
if (len(self.plan['assets']) != 9 or self.plan['snapshot']['commit'] != self.target.commit
or sorted(asset['kind'] for asset in self.plan['assets']) != ['linux'] + ['shared-data'] * 7 + ['windows']):
raise ValueError('frozen release no longer has exactly the expected nine ZIPs')
for asset in self.plan['assets']:
self.snapshot()
self.remote_members(self.release())
name = asset['name']
path = self.temporary / name
receipt_path = self.receipts / (name + '.json')
self.log('building', asset=name, uncompressed_bytes=asset['uncompressed_bytes'])
subprocess.run([sys.executable, '-B', str(self.exporter), 'archive', '--plan', str(self.plan_path),
'--asset', name, '--output', str(path), '--receipt', str(receipt_path)],
check=True, cwd=self.package)
receipt = json.loads(receipt_path.read_text())
checked_receipt(self.plan, plan_sha, asset, receipt)
self.upload(path, name, receipt['sha256'], receipt['bytes'])
path.unlink() # Only this just-authenticated disposable archive, after remote proof.
self.log('temporary_export_removed', asset=name)
sums = self.state / 'SHA256SUMS'
manifest = self.state / 'release-manifest.json'
subprocess.run([sys.executable, '-B', str(self.exporter), 'checksums', '--plan', str(self.plan_path),
'--receipts', str(self.receipts), '--output', str(sums),
'--manifest-output', str(manifest), '--tag', self.target.tag], check=True, cwd=self.package)
for path in (sums, manifest, self.package / 'release/Install-Linux.sh', self.package / 'release/Install-Windows.ps1'):
self.upload(path, path.name, digest(path), path.stat().st_size)
final = self.release()
self.remote_members(final)
if len(final['assets']) != 13:
raise ValueError('complete release must contain nine ZIPs plus four companion assets')
write_json(self.state / 'final-13-assets-draft-still-unpublished.json', final)
self.log('COMPLETE_THIRTEEN_REMOTE_ASSETS_VERIFIED_DRAFT_NOT_PUBLISHED', checkpoint=self.target.commit)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--package', type=Path, required=True)
parser.add_argument('--state', type=Path, required=True)
parser.add_argument('--temporary', type=Path, required=True)
parser.add_argument('--commit', default=COMMIT)
parser.add_argument('--tag', default=TAG)
parser.add_argument('--release-id', type=int, default=RELEASE_ID)
parser.add_argument('--prerelease', choices=('true', 'false'), default='true')
args = parser.parse_args()
target = ReleaseTarget(args.commit, args.tag, args.release_id, args.prerelease == 'true')
DraftExporter(args.package, args.state, args.temporary, target).run()
if __name__ == '__main__':
main()