Files
aego/tools/release/stable-channel.py
T
sanspie 5eb1832907
ci-platforms / darwin amd64 smoke (push) Skipped
ci / build (amd64, linux, ubuntu-24.04) (push) Failing after 15m28s
ci / release (push) Skipped
ci-platforms / windows amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
ci / checks (push) Successful in 7m47s
Complete Stage 12 for v0.12.0
2026-11-05 11:46:53 +03:00

376 lines
19 KiB
Python

#!/usr/bin/env python3
"""Verify release assets and stage both stable-channel files before cutover.
The production API must use HTTPS. A Unix-socket-only test transport is
available only in the private synthetic Gitea fixture. This script never
prints credentials and refuses to remove unexpected releases or tags.
"""
import argparse
import base64
import hashlib
import json
import os
from pathlib import Path
import re
import secrets
import subprocess
import sys
import tempfile
from urllib.parse import quote, urlsplit
FILES = ('release.json','release.json.sig')
class ChannelError(Exception):
pass
def digest(data):
return hashlib.sha256(data).hexdigest()
def verify_signature(data, signature, key, scratch):
try:
public = base64.b64decode(key.strip(),validate=True)
raw = base64.b64decode(signature.strip(),validate=True)
except ValueError as e:
raise ChannelError('invalid Ed25519 key or signature encoding') from e
if len(public) != 32 or len(raw) != 64:
raise ChannelError('invalid Ed25519 key or signature size')
(scratch/'key.der').write_bytes(bytes.fromhex('302a300506032b6570032100')+public)
(scratch/'message').write_bytes(data)
(scratch/'signature').write_bytes(raw)
try:
result = subprocess.run(['openssl','pkeyutl','-verify','-pubin','-keyform','DER',
'-inkey',str(scratch/'key.der'),'-rawin','-in',str(scratch/'message'),
'-sigfile',str(scratch/'signature')],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
except OSError as e:
raise ChannelError('openssl is required for Ed25519 signature verification') from e
if result.returncode:
raise ChannelError('release signature does not match the update key')
return digest(public)
def verify_candidate(dist,version,channel,base,public_key,scratch,require_windows):
u=urlsplit(base)
if u.scheme != 'https' or not u.netloc or u.username or u.password or u.query or u.fragment:
raise ChannelError('release download base must be an HTTPS URL')
data=(dist/'release.json').read_bytes()
signature=(dist/'release.json.sig').read_bytes()
key_hash=verify_signature(data,signature,public_key,scratch)
try: manifest=json.loads(data)
except ValueError as e: raise ChannelError('invalid candidate manifest') from e
if manifest.get('version') != version or manifest.get('channel') != channel:
raise ChannelError('candidate manifest version or channel mismatch')
assets=manifest.get('assets')
if not isinstance(assets,list) or not assets:
raise ChannelError('candidate manifest has no release assets')
identities=set()
for asset in assets:
url=asset.get('url','')
name=url.rsplit('/',1)[-1]
if not re.fullmatch(r'[A-Za-z0-9_.-]+',name) or url != base.rstrip('/')+'/'+name:
raise ChannelError('manifest asset URL is outside the release')
path=dist/name
if path.is_symlink() or not path.is_file():
raise ChannelError('manifest asset is not a regular local file: '+name)
before=path.stat()
with path.open('rb') as f: sha=hashlib.file_digest(f,'sha256').hexdigest()
after=path.stat()
if (before.st_ino,before.st_size,before.st_mtime_ns) != (after.st_ino,after.st_size,after.st_mtime_ns):
raise ChannelError('manifest asset changed while verifying: '+name)
if sha != asset.get('sha256') or after.st_size != asset.get('size'):
raise ChannelError('manifest asset hash or size mismatch: '+name)
identity=(asset.get('os'),asset.get('arch'),asset.get('kind'))
if identity in identities:
raise ChannelError('duplicate asset type in manifest')
identities.add(identity)
if ('linux','amd64','tarball') not in identities or (require_windows and ('windows','amd64','installer') not in identities):
raise ChannelError('manifest lacks a required release installer or archive')
return key_hash
class API:
def __init__(self,base,token,resolve,socket_path,scratch):
self.base = base.rstrip('/')
parsed = urlsplit(self.base)
match = re.fullmatch(r'/api/v1/repos/([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+)',parsed.path)
if not match or parsed.query or parsed.fragment or not parsed.netloc:
raise ChannelError('unexpected Gitea API URL')
if socket_path:
if (os.environ.get('AEGO_PRIVATE_GITEA') != '1' or parsed.scheme != 'http' or
parsed.netloc != 'fixture.invalid' or match[1] != 'lab' or
str(socket_path) != '/run/gitea-lab/gitea.sock'):
raise ChannelError('Unix socket transport is restricted to the private fixture')
elif parsed.scheme != 'https':
raise ChannelError('production Gitea API must use HTTPS')
if not token or any(c in token for c in '\r\n\0'):
raise ChannelError('missing or invalid API token')
self.origin = parsed.scheme+'://'+parsed.netloc
self.repo = '/'+match[1]+'/'+match[2]
self.socket = socket_path
self.resolve = resolve
self.scratch = scratch
# Passing the token on stdin keeps it out of process arguments.
self.config = 'header = "Authorization: token '+token.replace('\\','\\\\').replace('"','\\"')+'"\n'
self.seq = 0
def request(self,method,url,body=None,upload=None,accept=(200,)):
if not url.startswith(self.origin+'/'):
raise ChannelError('request escapes configured Gitea origin')
self.seq += 1
output = self.scratch/('response-'+str(self.seq))
cmd = ['curl','--config','-','-sS','-m','180','--max-filesize','1048576',
'--max-redirs','4','-L','-o',str(output),'-w','%{http_code}','-X',method]
if self.socket:
cmd += ['--unix-socket',str(self.socket),'--noproxy','*','--proto','=http','--proto-redir','=http']
else:
cmd += ['--proto','=https','--proto-redir','=https']
if self.resolve:
cmd += ['--resolve',self.resolve]
if body is not None:
cmd += ['-H','Content-Type: application/json','--data-binary',json.dumps(body,separators=(',',':'))]
if upload is not None:
file,name = upload
cmd += ['-F',f'attachment=@{file};filename={name}']
cmd.append(url)
try:
p = subprocess.run(cmd,input=self.config,text=True,capture_output=True,timeout=200)
except (OSError,subprocess.TimeoutExpired) as e:
raise ChannelError(f'{method} {url}: transport failure') from e
code = int(p.stdout) if re.fullmatch(r'\d{3}',p.stdout) else 0
if p.returncode or code not in accept:
raise ChannelError(f'{method} {url}: HTTP {code or "unavailable"}')
return code, output.read_bytes() if output.exists() else b''
def json(self,method,path,body=None,missing=False):
status,raw = self.request(method,self.base+path,body=body,accept=(200,201,204,404) if missing else (200,201,204))
if status == 404:
return None
if not raw:
return None
try: return json.loads(raw)
except ValueError as e: raise ChannelError('invalid JSON in Gitea response') from e
def get_release(self,tag):
return self.json('GET','/releases/tags/'+quote(tag,safe=''),missing=True)
def get_ref(self,tag):
value = self.json('GET','/git/refs/tags/'+quote(tag,safe=''),missing=True)
if value is None or value == []:
return None
if not isinstance(value,list) or len(value) != 1 or value[0].get('ref') != 'refs/tags/'+tag:
raise ChannelError('unexpected or ambiguous Git tag response')
obj = value[0].get('object',{})
if obj.get('type') != 'commit' or not re.fullmatch(r'[a-f0-9]{40}',obj.get('sha','')):
raise ChannelError('stable channel requires a lightweight commit tag')
return obj['sha']
def assets(self,release_id):
value = self.json('GET',f'/releases/{release_id}/assets')
if not isinstance(value,list):
raise ChannelError('invalid release attachment inventory')
return value
def download(self,tag,name):
url = self.origin+self.repo+'/releases/download/'+quote(tag,safe='')+'/'+quote(name,safe='')
return self.request('GET',url)[1]
def upload(self,release_id,name,path):
url = self.base+f'/releases/{release_id}/assets?name='+quote(name,safe='')
return json.loads(self.request('POST',url,upload=(path,name),accept=(201,))[1])
def snapshot(api,tag,release,expected=None,exact=True):
assets=api.assets(release['id'])
names=[x.get('name') for x in assets]
if len(names) != len(set(names)) or any(name not in names for name in FILES):
raise ChannelError(tag+': missing or duplicate channel files')
if exact and set(names) != set(FILES):
raise ChannelError(tag+': unexpected assets would be lost; refusing to replace release')
data={name:api.download(tag,name) for name in FILES}
for name in FILES:
item=next(x for x in assets if x['name']==name)
if item.get('size') != len(data[name]):
raise ChannelError(tag+': asset size mismatch: '+name)
if expected is not None and data[name] != expected[name]:
raise ChannelError(tag+': asset content mismatch: '+name)
return data
def publish_channel(api,tag,commit,new_files,public_key,scratch,record,save):
version = tag[1:]
new_key = verify_signature(new_files['release.json'],new_files['release.json.sig'],public_key,scratch)
try: manifest=json.loads(new_files['release.json'])
except ValueError as e: raise ChannelError('invalid candidate manifest') from e
if manifest.get('version') != version or manifest.get('channel') != 'stable':
raise ChannelError('candidate manifest version or channel mismatch')
release=api.get_release(tag)
if not release or release.get('draft') or release.get('prerelease') or api.get_ref(tag) != commit:
raise ChannelError('published version release/tag does not match the expected commit')
snapshot(api,tag,release,new_files,exact=False)
old=api.get_release('stable')
old_ref=api.get_ref('stable')
old_files=None
if old is None:
if old_ref is not None:
raise ChannelError('unrelated stable tag exists without a channel release')
else:
if old.get('draft') or not old.get('prerelease') or old_ref is None:
raise ChannelError('unexpected current stable channel state')
old_files=snapshot(api,'stable',old)
old_key=verify_signature(old_files['release.json'],old_files['release.json.sig'],public_key,scratch)
if old_key != new_key:
raise ChannelError('old and new channel signing keys differ')
try: old_manifest=json.loads(old_files['release.json'])
except ValueError as e: raise ChannelError('invalid prior channel manifest') from e
if old_manifest.get('channel') != 'stable' or not old_manifest.get('version'):
raise ChannelError('unexpected prior channel manifest')
record.update(old_release_id=old['id'],old_commit=old_ref,old_version=old_manifest['version'])
record.update(version=tag,commit=commit,key_sha256=new_key,
old_hashes={n:digest(v) for n,v in (old_files or {}).items()},
new_hashes={n:digest(v) for n,v in new_files.items()})
save('prechecked')
def stage(label,target,files,title,body):
name='stable-'+label+'-'+commit[:12]+'-'+secrets.token_hex(6)
value=api.json('POST','/releases',{'tag_name':name,'target_commitish':target,
'name':title,'body':body,'prerelease':True,'draft':True})
if not value or not value.get('id'):
raise ChannelError('could not create '+label+' draft')
record[label]={'id':value['id'],'tag':name,'target':target}
save(label+'-created')
for filename in FILES:
f=scratch/(label+'-'+filename)
f.write_bytes(files[filename])
api.upload(value['id'],filename,f)
snapshot(api,name,value,files)
save(label+'-verified')
return value
backup=stage('backup',old_ref,old_files,old.get('name') or 'Stable channel',
old.get('body') or 'Previous stable channel.') if old else None
candidate=stage('candidate',commit,new_files,'Stable channel',
'Where installed copies of Aego look for updates; it points at '+tag+'.')
# A concurrent release must not be overwritten after staging.
current=api.get_release('stable')
if old:
if not current or current.get('id') != old['id'] or api.get_ref('stable') != old_ref:
raise ChannelError('stable channel changed during staging')
snapshot(api,'stable',current,old_files)
elif current is not None or api.get_ref('stable') is not None:
raise ChannelError('stable channel appeared during staging')
save('ready-to-switch')
def live_matches(expected_id,expected_ref,files):
current=api.get_release('stable')
if not current or current.get('id') != expected_id or current.get('draft') or not current.get('prerelease'):
return False
if api.get_ref('stable') != expected_ref:
return False
snapshot(api,'stable',current,files)
return True
try:
if old:
save('deleting-old-release')
api.json('DELETE',f'/releases/{old["id"]}')
save('deleting-old-tag')
api.json('DELETE','/tags/stable')
save('publishing-candidate')
api.json('PATCH',f'/releases/{candidate["id"]}',
{'tag_name':'stable','target_commitish':commit,'draft':False,'prerelease':True})
if not live_matches(candidate['id'],commit,new_files):
raise ChannelError('published stable channel did not verify')
save('published')
return
except (ChannelError,OSError) as original:
record['failure']=str(original)
save('cutover-failed')
try:
# A response can be lost after the server committed the operation.
if live_matches(candidate['id'],commit,new_files):
save('published-after-response-loss')
return
if old and live_matches(old['id'],old_ref,old_files):
save('original-intact')
raise ChannelError('switch failed; original stable channel is intact') from original
current=api.get_release('stable')
if current is not None:
raise ChannelError('stable release changed unexpectedly; refusing destructive rollback')
ref=api.get_ref('stable')
if ref is not None:
if ref not in {old_ref,commit}:
raise ChannelError('stable tag changed unexpectedly; refusing destructive rollback')
api.json('DELETE','/tags/stable')
if not backup:
raise ChannelError('no previous stable channel was available to restore')
save('restoring-backup')
api.json('PATCH',f'/releases/{backup["id"]}',
{'tag_name':'stable','target_commitish':old_ref,'draft':False,'prerelease':True})
if not live_matches(backup['id'],old_ref,old_files):
raise ChannelError('restored stable channel did not verify')
save('restored')
raise ChannelError('switch failed; previous stable channel was restored') from original
except (ChannelError,OSError) as recovery:
if record.get('phase') not in ('restored','original-intact'):
record['recovery_error']=str(recovery)
save('manual-recovery-required')
raise
def main():
p=argparse.ArgumentParser()
p.add_argument('--verify-only',action='store_true')
p.add_argument('--tag',required=True)
p.add_argument('--commit')
p.add_argument('--dist',type=Path,required=True)
p.add_argument('--receipt-dir',type=Path)
p.add_argument('--version')
p.add_argument('--channel',choices=('stable','nightly'))
p.add_argument('--base-url')
p.add_argument('--require-windows',action='store_true')
a=p.parse_args()
if not re.fullmatch(r'(?:nightly|v\d+\.\d+\.\d+)',a.tag):
raise ChannelError('invalid release tag')
key=os.environ.get('AEGO_UPDATE_KEY','')
dist=a.dist.resolve(strict=True)
if a.verify_only:
if not a.version or not a.channel or not a.base_url:
raise ChannelError('version, channel and base URL are required for verification')
if (a.tag == 'nightly' and a.channel != 'nightly') or (a.tag != 'nightly' and
(a.channel != 'stable' or a.version != a.tag[1:])):
raise ChannelError('release tag, version and channel do not agree')
with tempfile.TemporaryDirectory(prefix='aego-release-verify-',dir=dist) as tmp:
verified=verify_candidate(dist,a.version,a.channel,a.base_url,key,Path(tmp),a.require_windows)
print('release manifest, signature and asset checksums verified; public key SHA-256',verified)
return 0
if (a.tag == 'nightly' or not a.commit or not re.fullmatch(r'[a-f0-9]{40}',a.commit)
or a.receipt_dir is None):
raise ChannelError('stable promotion needs a full commit and receipt directory')
token=os.environ.get('GITEA_TOKEN','')
api_url=os.environ.get('GITEA_API','')
socket_path=os.environ.get('AEGO_STAGE_FIXTURE_SOCKET')
new_files={name:(dist/name).read_bytes() for name in FILES}
receipt_dir=a.receipt_dir.resolve()
receipt_dir.mkdir(parents=True,exist_ok=True)
path=receipt_dir/('stable-'+a.commit[:12]+'-'+secrets.token_hex(6)+'.json')
record={'phase':'starting','receipt':str(path),'published':False}
def save(phase):
record['phase']=phase
record['published']=phase in ('published','published-after-response-loss')
temp=path.with_suffix('.tmp')
with temp.open('w') as f:
json.dump(record,f,indent=2); f.write('\n'); f.flush(); os.fsync(f.fileno())
os.replace(temp,path)
save('starting')
try:
with tempfile.TemporaryDirectory(prefix='aego-stable-channel-',dir=dist) as tmp:
scratch=Path(tmp)
api=API(api_url,token,os.environ.get('GITEA_RESOLVE',''),socket_path,scratch)
publish_channel(api,a.tag,a.commit,new_files,key,scratch,record,save)
except (ChannelError,OSError,ValueError) as e:
record['error']=str(e)
save(record['phase'])
print('stable channel:',str(e),'; receipt:',path,file=sys.stderr)
return 1
print('stable channel verified at',a.tag,'; receipt:',path)
return 0
if __name__ == '__main__':
try: sys.exit(main())
except (ChannelError,OSError,ValueError) as e:
print('stable channel:',str(e),file=sys.stderr); sys.exit(1)