#!/usr/bin/env python3 """Publish a verified build to Gitea and advance the packwiz manifests and indexed icon. 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 channel_file(relative): """The indexed pack icon is the only non-TOML file allowed on the channel.""" path = Path(relative) return path.suffix == '.toml' or path.as_posix() == 'icon.png' 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(channel_file(path.relative_to(staged)) for path in files), 'Distribution branch must contain only generated TOML metadata and the pack icon') if (staged / 'icon.png').exists(): pack.require((staged / 'icon.png').read_bytes() == (ROOT / 'packwiz/icon.png').read_bytes(), 'Distribution icon differs from the verified source icon') 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(channel_file(name) 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('*'): if not path.is_file(): continue 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()