Files
sanctuary-beta/scripts/publish_pack.py
T
2026-09-15 09:29:11 +02:00

219 lines
12 KiB
Python

#!/usr/bin/env python3
"""Publish a verified build to Gitea and advance the metadata-only packwiz branch.
This command publishes externally. Run check/build/assemblePack, commit, and push
the source branch first. Git's configured credential helper supplies credentials
in memory; no token is accepted on the command line or written to disk.
"""
import argparse
import base64
import hashlib
import json
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
from urllib.error import HTTPError, URLError
from urllib.parse import quote, urlsplit, urlunsplit
from urllib.request import HTTPRedirectHandler, Request, build_opener, urlopen
import uuid
import zipfile
sys.dont_write_bytecode = True
import pack
ROOT = pack.ROOT
CHANNEL = 'packwiz'
def git(*args, cwd=ROOT, input_text=None):
result = subprocess.run(['git', *args], cwd=cwd, input=input_text,
text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
pack.require(result.returncode == 0, 'Git operation failed: ' + ' '.join(args[:2]))
return result.stdout.strip()
class NoRedirect(HTTPRedirectHandler):
# Never forward an Authorization header to a redirected URL.
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
class Gitea:
def __init__(self, origin):
parsed = urlsplit(pack.https_url(origin))
pack.require(not parsed.query, 'Git remote URL must not contain query parameters')
path = parsed.path.removesuffix('.git').strip('/')
pack.require(len(path.split('/')) == 2, 'Expected an owner/repository Git URL')
self.repo_url = f'https://{parsed.netloc}/{path}'
self.api_url = f'https://{parsed.netloc}/api/v1/repos/{path}'
result = subprocess.run(['git', 'credential', 'fill'], cwd=ROOT,
input='url=' + origin + '\n\n', text=True,
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
pack.require(result.returncode == 0, 'Git credential helper did not supply credentials')
credentials = dict(line.split('=', 1) for line in result.stdout.splitlines() if '=' in line)
pack.require(credentials.get('username') and credentials.get('password'),
'Git credential helper needs a username and password/token')
token = credentials['username'] + ':' + credentials['password']
self.authorization = 'Basic ' + base64.b64encode(token.encode()).decode()
self.opener = build_opener(NoRedirect)
def request(self, endpoint, method='GET', value=None, missing_ok=False,
content_type='application/json'):
data = value if isinstance(value, bytes) else (
json.dumps(value).encode() if value is not None else None)
request = Request(self.api_url + endpoint, data=data, method=method,
headers={'Authorization': self.authorization,
'Accept': 'application/json', 'Content-Type': content_type})
try:
with self.opener.open(request, timeout=60) as response:
body = response.read()
return json.loads(body) if body else None
except HTTPError as error:
if missing_ok and error.code == 404:
return None
# Do not include request/response headers or credential-helper output.
raise SystemExit(f'Gitea {method} {endpoint.split("?")[0]} failed: HTTP {error.code}')
except URLError:
raise SystemExit('Gitea connection failed; no distribution channel was advanced.')
def upload(self, release, path):
filename = path.name
pack.require(not any(c in filename for c in '\r\n"'), 'Invalid asset filename')
existing = [asset for asset in release['assets'] if asset['name'] == filename]
pack.require(len(existing) <= 1, 'Duplicate release asset: ' + filename)
if existing:
asset = existing[0]
else:
boundary = 'sanctuary-' + uuid.uuid4().hex
payload = (f'--{boundary}\r\nContent-Disposition: form-data; '
f'name="attachment"; filename="{filename}"\r\n'
'Content-Type: application/octet-stream\r\n\r\n').encode()
payload += path.read_bytes() + f'\r\n--{boundary}--\r\n'.encode()
asset = self.request(f'/releases/{release["id"]}/assets?name={quote(filename)}',
'POST', payload,
content_type='multipart/form-data; boundary=' + boundary)
release['assets'].append(asset)
# Some reverse-proxied Gitea installations advertise HTTP despite their
# public HTTPS origin. Upgrade only this same-origin URL, then verify it.
advertised = urlsplit(asset['browser_download_url'])
if advertised.scheme == 'http' and advertised.netloc == urlsplit(self.repo_url).netloc:
advertised = advertised._replace(scheme='https')
asset_url = pack.https_url(urlunsplit(advertised))
# Verify anonymous access: players must not need publishing credentials.
with urlopen(asset_url, timeout=60) as response:
digest = hashlib.sha256(response.read()).hexdigest()
pack.require(digest == pack.sha256(path),
'Immutable release asset conflicts with local build: ' + filename)
return asset_url
def remote_tag_commit(tag):
refs = git('ls-remote', 'origin', 'refs/tags/' + tag, 'refs/tags/' + tag + '^{}')
found = dict(reversed(line.split('\t', 1)) for line in refs.splitlines())
return found.get('refs/tags/' + tag + '^{}') or found.get('refs/tags/' + tag)
def publish_channel(origin, version, source_head):
staged = ROOT / 'build/packwiz-release'
pack.check_index(staged)
files = [path for path in staged.rglob('*') if path.is_file()]
pack.require(all(path.suffix == '.toml' for path in files),
'Distribution branch must contain only generated TOML metadata')
remote = git('ls-remote', '--heads', 'origin', 'refs/heads/' + CHANNEL)
with tempfile.TemporaryDirectory(prefix='sanctuary-pack-channel-') as temp:
directory = Path(temp)
git('init', '--quiet', cwd=directory)
git('remote', 'add', 'origin', origin, cwd=directory)
# A source checkout may use Git's inferred identity without user.* config.
# Attribute generated metadata to the verified source commit's author.
git('config', 'user.name', git('show', '-s', '--format=%an', source_head), cwd=directory)
git('config', 'user.email', git('show', '-s', '--format=%ae', source_head), cwd=directory)
if remote:
git('fetch', '--quiet', 'origin', CHANNEL, cwd=directory)
git('checkout', '--quiet', '-b', CHANNEL, 'FETCH_HEAD', cwd=directory)
tracked = git('ls-files', cwd=directory).splitlines()
pack.require(all(name.endswith('.toml') for name in tracked),
'Existing distribution branch contains unexpected files')
for name in tracked:
(directory / name).unlink()
else:
git('checkout', '--quiet', '--orphan', CHANNEL, cwd=directory)
for path in files:
target = directory / path.relative_to(staged)
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(path, target)
git('add', '--all', cwd=directory)
if git('status', '--porcelain', cwd=directory):
git('commit', '--quiet', '-m',
f'Publish Sanctuary {version} ({source_head[:12]})', cwd=directory)
# No force: reject a concurrent publication instead of overwriting it.
git('push', '--quiet', 'origin', 'HEAD:refs/heads/' + CHANNEL, cwd=directory)
else:
print('Distribution metadata already matches this release.')
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--notes-file', type=Path, help='UTF-8 release notes; otherwise a short build reference')
parser.add_argument('--asset', action='append', type=Path, default=[],
help='Additional public release asset, e.g. the generated .mrpack (repeatable)')
args = parser.parse_args()
pack.require(not git('status', '--porcelain'), 'Commit all source changes before publishing')
branch = git('symbolic-ref', '--quiet', '--short', 'HEAD')
pack.require(branch != CHANNEL, 'Publish from a source branch, not the distribution branch')
head = git('rev-parse', 'HEAD')
remote = git('ls-remote', '--heads', 'origin', 'refs/heads/' + branch)
pack.require(remote and remote.split()[0] == head, 'Push the current source branch before publishing')
values, _ = pack.check()
version = values['pack_version']
jar = ROOT / 'mods/sanctuary/build/libs' / ('sanctuary-' + values['mod_version'] + '.jar')
pack.require(jar.is_file(), 'Run ./gradlew check build assemblePack first')
with zipfile.ZipFile(jar) as archive:
mod = json.loads(archive.read('fabric.mod.json'))
pack.require(mod['id'] == 'sanctuary' and mod['version'] == values['mod_version'],
'Built JAR does not match the Sanctuary source version')
assets = [jar] + [path.resolve() for path in args.asset]
pack.require(all(path.is_file() for path in assets), 'A requested release asset is missing')
pack.require(len({path.name for path in assets}) == len(assets), 'Release asset names must be unique')
notes = args.notes_file.read_text(encoding='utf-8') if args.notes_file else (
f'Sanctuary {version}\n\nMinecraft {values["minecraft_version"]}; '
f'Fabric {values["loader_version"]}.\n\nSource: {head}.\n')
origin = git('remote', 'get-url', 'origin')
gitea = Gitea(origin)
tag = version
existing_commit = remote_tag_commit(tag)
pack.require(existing_commit is None or existing_commit == head,
'Release tag already belongs to another source commit; increment the version')
release = gitea.request('/releases/tags/' + quote(tag), missing_ok=True)
if release is None:
release = gitea.request('/releases', 'POST', {
'tag_name': tag, 'target_commitish': head, 'name': 'Sanctuary ' + version,
'body': notes, 'draft': False, 'prerelease': True,
})
pack.require(not release.get('draft'), 'The existing release is a draft; publish it explicitly first')
pack.require(remote_tag_commit(tag) == head, 'Release tag must resolve to the verified source commit')
jar_url = gitea.upload(release, jar)
for asset in assets[1:]:
gitea.upload(release, asset)
pack.assemble(jar_url)
publish_channel(origin, version, head)
stable_url = gitea.repo_url + '/raw/branch/' + CHANNEL + '/pack.toml'
with urlopen(stable_url, timeout=60) as response:
remote_manifest = response.read()
pack.require(remote_manifest == (ROOT / 'build/packwiz-release/pack.toml').read_bytes(),
'Published manifest differs from this build; inspect the distribution channel')
for path in (ROOT / 'build/packwiz-release').rglob('*.toml'):
relative = path.relative_to(ROOT / 'build/packwiz-release').as_posix()
with urlopen(stable_url.removesuffix('pack.toml') + quote(relative), timeout=60) as response:
published = response.read()
pack.require(published == path.read_bytes(), 'Published metadata differs: ' + relative)
print('Published Sanctuary ' + version)
print('Release: ' + gitea.repo_url + '/releases/tag/' + tag)
print('Packwiz: ' + stable_url)
if __name__ == '__main__':
main()