259 lines
13 KiB
Python
259 lines
13 KiB
Python
"""Root-executed, hash-bound draft/upload/publication. Import has no side effects."""
|
|
if not __debug__:
|
|
raise RuntimeError('Publication requires Python assertions; optimized execution is refused.')
|
|
|
|
import argparse
|
|
from datetime import datetime, timezone
|
|
import hashlib
|
|
import json
|
|
from pathlib import Path
|
|
import re
|
|
import tomllib
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
import uuid
|
|
|
|
HERE = Path(__file__).resolve().parent
|
|
ROOT = HERE.parents[1]
|
|
ORIGIN = 'http://217.177.34.108:3000'
|
|
API = ORIGIN + '/api/v1/repos/sorokin.oleg.edu/ARCADIA-Studio'
|
|
TAG = 'v0.2.0-preview.navigation.20260911'
|
|
TARGET = '802855f7e6f2f00a107a12196b458b267f586b8d'
|
|
BRANCH = 'codex/navigation-repair-prerelease-source-20260911'
|
|
SOURCE_BRANCH_HEAD = '0c9ee1505774a0f64c5f3a58b0da64a4673d5000'
|
|
TITLE = 'Source navigation — Windows development preview'
|
|
ZIP = 'ArcadiaStudio-0.2.0-preview.navigation.20260911-x86_64-pc-windows-gnullvm.zip'
|
|
PACKAGE = ROOT / '.local/nav-repair-retry-20260911/packages'
|
|
ZIP_SHA = 'd7228c542f162663933ea002739a78c722bcca41e437df63b7c868ea2af201f1'
|
|
CHECKSUM_SHA = '435b6fff3fbc1640b4db31c5d848dbcde101aa54cc86fe0eef9c8da6a501ccac'
|
|
|
|
def now():
|
|
return datetime.now(timezone.utc).isoformat()
|
|
|
|
def digest(raw):
|
|
return hashlib.sha256(raw).hexdigest()
|
|
|
|
def read(path):
|
|
return json.loads(path.read_text(encoding='utf-8'))
|
|
|
|
def exclusive(path, value):
|
|
with path.open('x', encoding='utf-8', newline='\n') as stream:
|
|
json.dump(value, stream, indent=2, ensure_ascii=False)
|
|
stream.write('\n')
|
|
|
|
class Attempt:
|
|
def __init__(self, action, name):
|
|
assert re.fullmatch(r'[a-z0-9][a-z0-9-]{0,47}', name), 'Use a fresh simple attempt name'
|
|
self.path = HERE / 'runs' / (action + '-' + name)
|
|
self.path.mkdir(parents=True, exist_ok=False)
|
|
self.value = {'started': now(), 'action': action, 'tag': TAG, 'target': TARGET, 'success': False}
|
|
|
|
def event(self, step, **fields):
|
|
event = {'atUtc': now(), 'step': step, **fields}
|
|
with (self.path / 'events.jsonl').open('a', encoding='utf-8', newline='\n') as stream:
|
|
stream.write(json.dumps(event, ensure_ascii=False) + '\n')
|
|
|
|
def finish(self):
|
|
self.value['finished'] = now()
|
|
exclusive(self.path / 'result.json', self.value)
|
|
print(json.dumps(self.value, ensure_ascii=False), flush=True)
|
|
|
|
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
raise RuntimeError('Unexpected redirect; authorization not forwarded')
|
|
|
|
class Client:
|
|
def __init__(self):
|
|
config = tomllib.loads((Path.home() / '.codex/config.toml').read_text(encoding='utf-8'))
|
|
self.token = config['mcp_servers']['gitea']['env']['GITEA_ACCESS_TOKEN']
|
|
self.opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect)
|
|
|
|
def redact(self, text):
|
|
return text.replace(self.token, '[redacted]')
|
|
|
|
def request(self, url, data=None, method=None, headers=None, binary=False, missing=False):
|
|
parsed = urllib.parse.urlsplit(url)
|
|
expected = urllib.parse.urlsplit(ORIGIN)
|
|
assert (parsed.scheme, parsed.netloc) == (expected.scheme, expected.netloc)
|
|
assert parsed.username is None and parsed.password is None
|
|
request = urllib.request.Request(url, data=data, method=method,
|
|
headers={'Authorization': 'token ' + self.token, **(headers or {})})
|
|
try:
|
|
with self.opener.open(request, timeout=120) as response:
|
|
body = response.read()
|
|
return body if binary else json.loads(body)
|
|
except urllib.error.HTTPError as error:
|
|
detail = self.redact(error.read(2000).decode('utf-8', 'replace'))
|
|
if missing and error.code == 404:
|
|
return None
|
|
raise RuntimeError(f'HTTP {error.code}: {detail}') from None
|
|
|
|
def json(self, url, value, method):
|
|
return self.request(url, json.dumps(value).encode('utf-8'), method,
|
|
{'Content-Type': 'application/json'})
|
|
|
|
def release(self):
|
|
# Listing authenticated drafts catches a reserved-tag collision even
|
|
# before a server has made its Git tag visible to the by-tag endpoint.
|
|
matches = []
|
|
for page in range(1, 21):
|
|
items = self.request(API + f'/releases?limit=50&page={page}')
|
|
assert isinstance(items, list)
|
|
matches.extend(item for item in items if item['tag_name'] == TAG)
|
|
if len(items) < 50:
|
|
break
|
|
else:
|
|
raise RuntimeError('Release collision scan exceeded its bound')
|
|
assert len(matches) <= 1, 'Multiple releases claim the reserved tag'
|
|
return matches[0] if matches else None
|
|
|
|
def tag(self):
|
|
return self.request(API + '/tags/' + urllib.parse.quote(TAG, safe=''), missing=True)
|
|
|
|
def inputs():
|
|
bindings = read(HERE / 'bindings.json')
|
|
for key in ('notesSha256', 'validationSha256'):
|
|
assert isinstance(bindings[key], str) and re.fullmatch(r'[0-9a-f]{64}', bindings[key]), 'Final notes and VALIDATION hashes must be reviewed and bound first'
|
|
notes = (HERE / 'RELEASE-NOTES.md').read_bytes()
|
|
validation = (HERE / 'VALIDATION.md').read_bytes()
|
|
assert digest(notes) == bindings['notesSha256']
|
|
assert digest(validation) == bindings['validationSha256']
|
|
for raw in (notes, validation):
|
|
text = raw.decode('utf-8')
|
|
assert all(marker not in text for marker in ('PUBLICATION-PENDING', 'PACKAGE-RUNTIME-ACCEPTANCE-PENDING'))
|
|
assets = {ZIP: (PACKAGE / ZIP).read_bytes(), ZIP + '.sha256': (PACKAGE / (ZIP + '.sha256')).read_bytes(), 'VALIDATION.md': validation}
|
|
assert len(assets[ZIP]) == 32971712 and digest(assets[ZIP]) == ZIP_SHA
|
|
assert digest(assets[ZIP + '.sha256']) == CHECKSUM_SHA
|
|
assert assets[ZIP + '.sha256'].decode('utf-8').strip() == ZIP_SHA + ' ' + ZIP
|
|
return bindings, notes.decode('utf-8'), assets
|
|
|
|
def source_and_tag(client, require_tag=True):
|
|
branch = client.request(API + '/branches/' + urllib.parse.quote(BRANCH, safe=''))
|
|
assert branch['commit']['id'] == SOURCE_BRANCH_HEAD, 'Published source branch changed'
|
|
target_commit = client.request(API + '/git/commits/' + TARGET)
|
|
assert target_commit['sha'] == TARGET, 'Exact source snapshot is unavailable'
|
|
tag = client.tag()
|
|
if tag is None:
|
|
assert not require_tag, 'Run the separately reviewed nonforce tag helper first'
|
|
else:
|
|
assert tag['name'] == TAG and tag['commit']['sha'] == TARGET, 'Tag collision'
|
|
return tag
|
|
|
|
def owned_release(value, bindings, notes):
|
|
expected_id = bindings.get('releaseId')
|
|
state = HERE / 'release-created.json'
|
|
if state.exists():
|
|
saved = read(state)
|
|
assert saved['tag'] == TAG and saved['target'] == TARGET
|
|
assert expected_id in (None, saved['releaseId'])
|
|
expected_id = saved['releaseId']
|
|
assert expected_id is not None and value['id'] == expected_id, 'Existing release is not bound to this run; inspect and review before setting releaseId'
|
|
assert value['tag_name'] == TAG and value['target_commitish'] == TARGET
|
|
assert value['prerelease'] is True and value['name'] == TITLE and value['body'] == notes
|
|
|
|
def verify_assets(client, release_id, assets, allow_missing=False):
|
|
remote = client.request(API + f'/releases/{release_id}/assets')
|
|
names = [item['name'] for item in remote]
|
|
assert len(names) == len(set(names)) and len({a['id'] for a in remote}) == len(remote)
|
|
assert set(names) <= set(assets)
|
|
if not allow_missing:
|
|
assert set(names) == set(assets), 'Exactly three assets required'
|
|
verified = []
|
|
for item in remote:
|
|
expected = assets[item['name']]
|
|
assert item['size'] == len(expected)
|
|
received = client.request(item['browser_download_url'], binary=True)
|
|
assert received == expected, 'Existing asset bytes differ; no overwrite or deletion permitted'
|
|
verified.append({'id': item['id'], 'name': item['name'], 'bytes': len(received), 'sha256': digest(received), 'url': item['browser_download_url']})
|
|
return verified
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument('action', choices=('inspect', 'create', 'upload', 'publish'))
|
|
parser.add_argument('--attempt', required=True)
|
|
args = parser.parse_args()
|
|
attempt = Attempt(args.action, args.attempt)
|
|
client = None
|
|
try:
|
|
if args.action == 'inspect':
|
|
client = Client()
|
|
release, tag = client.release(), client.tag()
|
|
attempt.value.update(success=True, release=release, remoteTag=tag,
|
|
scope='Read-only inspection; no acceptance or mutation')
|
|
return
|
|
bindings, notes, assets = inputs()
|
|
attempt.value['bindings'] = bindings
|
|
attempt.value['assetHashes'] = {name: digest(raw) for name, raw in assets.items()}
|
|
client = Client()
|
|
source_and_tag(client)
|
|
release = client.release()
|
|
if args.action == 'create':
|
|
if release is not None:
|
|
owned_release(release, bindings, notes)
|
|
assert release['draft'] is True
|
|
attempt.event('existing-owned-draft', releaseId=release['id'])
|
|
else:
|
|
assert not (HERE / 'release-created.json').exists() and bindings.get('releaseId') is None
|
|
attempt.event('create-request-started')
|
|
release = client.json(API + '/releases', {'tag_name': TAG, 'target_commitish': TARGET,
|
|
'name': TITLE, 'body': notes, 'draft': True, 'prerelease': True}, 'POST')
|
|
assert release['tag_name'] == TAG and release['target_commitish'] == TARGET and release['draft'] is True
|
|
# Persist ownership immediately; a failed readback can be retried safely.
|
|
exclusive(HERE / 'release-created.json', {'releaseId': release['id'], 'tag': TAG, 'target': TARGET, 'atUtc': now()})
|
|
attempt.event('created-draft', releaseId=release['id'])
|
|
fresh = client.release()
|
|
owned_release(fresh, bindings, notes)
|
|
assert fresh['draft'] is True
|
|
verify_assets(client, fresh['id'], assets, allow_missing=True)
|
|
else:
|
|
assert release is not None, 'Create the hash-bound draft first'
|
|
owned_release(release, bindings, notes)
|
|
endpoint = API + f'/releases/{release["id"]}'
|
|
if args.action == 'upload':
|
|
assert release['draft'] is True, 'Never upload into a public release'
|
|
existing = verify_assets(client, release['id'], assets, allow_missing=True)
|
|
for name, raw in assets.items():
|
|
if any(item['name'] == name for item in existing):
|
|
attempt.event('existing-asset-verified', name=name, sha256=digest(raw))
|
|
continue
|
|
boundary = 'ArcadiaNavigation' + uuid.uuid4().hex
|
|
prefix = (f'--{boundary}\r\nContent-Disposition: form-data; name="attachment"; filename="{name}"\r\nContent-Type: application/octet-stream\r\n\r\n').encode()
|
|
attempt.event('upload-request-started', name=name, sha256=digest(raw))
|
|
result = client.request(endpoint + '/assets?' + urllib.parse.urlencode({'name': name}),
|
|
prefix + raw + f'\r\n--{boundary}--\r\n'.encode(), 'POST',
|
|
{'Content-Type': 'multipart/form-data; boundary=' + boundary})
|
|
assert result['name'] == name and result['size'] == len(raw)
|
|
assert client.request(result['browser_download_url'], binary=True) == raw
|
|
attempt.event('uploaded-and-downloaded', name=name, assetId=result['id'], sha256=digest(raw))
|
|
attempt.value['assets'] = verify_assets(client, release['id'], assets)
|
|
else:
|
|
# Download all three afresh immediately before changing visibility.
|
|
before = verify_assets(client, release['id'], assets)
|
|
source_and_tag(client)
|
|
fresh = client.release()
|
|
owned_release(fresh, bindings, notes)
|
|
if fresh['draft']:
|
|
attempt.event('publish-request-started', releaseId=fresh['id'])
|
|
client.json(endpoint, {'draft': False, 'prerelease': True, 'body': notes}, 'PATCH')
|
|
final = client.release()
|
|
owned_release(final, bindings, notes)
|
|
assert final['draft'] is False
|
|
source_and_tag(client)
|
|
after = verify_assets(client, final['id'], assets)
|
|
assert sorted(before, key=lambda x: x['id']) == sorted(after, key=lambda x: x['id'])
|
|
attempt.value.update(assets=after, url=final['html_url'], publishedAt=final['published_at'])
|
|
fresh = client.release()
|
|
owned_release(fresh, bindings, notes)
|
|
attempt.value.update(success=True, releaseId=fresh['id'], draft=fresh['draft'], prerelease=True)
|
|
except BaseException as error:
|
|
message = str(error)
|
|
attempt.value['error'] = client.redact(message) if client else message
|
|
attempt.value['remoteOutcome'] = 'May be partial if a request started; inspect before retry. No automatic overwrite/delete.'
|
|
raise
|
|
finally:
|
|
attempt.finish()
|
|
|
|
if __name__ == '__main__':
|
|
main()
|