import argparse import hashlib import json import os from pathlib import Path import re import sys import tomllib import urllib.error import urllib.parse import urllib.request import zipfile def read_config(root: Path) -> dict: return json.loads((root / '.github/release.json').read_text()) def targets(root: Path, config: dict) -> list[dict[str, str]]: if config['platform'] == 'bukkit': return [{'id': 'bukkit', 'loader': 'bukkit', 'minecraft': ''}] manifest: Path = root / config['targets'] if manifest.is_file(): versions: dict = json.loads(manifest.read_text()) entries: list[tuple[str, list[str]]] = [(version, ['fabric', 'forge', 'neoforge']) for version in versions] else: entries = [] for path in sorted(manifest.glob('*.json')): target: dict = json.loads(path.read_text()) if path.stem != target['minecraft']: raise ValueError(f'Minecraft manifest does not match {path.name}') entries.append((target['minecraft'], list(target['loaders']))) matrix: list[dict[str, str]] = [] for version, loaders in entries: if not re.fullmatch(r'[0-9]+(?:\.[0-9]+)+', version): raise ValueError(f'Invalid Minecraft version: {version}') if set(loaders) != {'fabric', 'forge', 'neoforge'}: raise ValueError(f'Incomplete loader matrix for {version}') for loader in sorted(loaders): matrix.append({'id': f'{loader}-{version}', 'loader': loader, 'minecraft': version}) if not matrix: raise ValueError('Release matrix is empty') return matrix def plan(root: Path, config: dict, event: dict, environment: dict) -> dict: branch: str = event.get('repository', {}).get('default_branch', '') if (environment.get('GITHUB_EVENT_NAME') != 'push' or event.get('deleted') or not branch or event.get('ref') != f'refs/heads/{branch}'): return {'channel': 'none'} commit: dict = event.get('head_commit') or {} message: str = commit.get('message', '') beta: bool = 'B+' in message stable: bool = 'V+' in message if beta and stable: raise ValueError('Use exactly one release marker: B+ or V+') if not beta and not stable: return {'channel': 'none'} sha: str = environment['GITHUB_SHA'] if event.get('after') != sha or commit.get('id') != sha or not re.fullmatch(r'[a-f0-9]{40}', sha): raise ValueError('Release commit does not match the pushed HEAD') key: str = config['versionProperty'] properties: str = (root / 'gradle.properties').read_text() values: list[str] = re.findall(rf'^{re.escape(key)}\s*=\s*(\S+)\s*$', properties, re.MULTILINE) if len(values) != 1 or not re.fullmatch(r'(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)', values[0]): raise ValueError(f'{key} must declare one stable major.minor.patch version') run: int = int(environment['GITHUB_RUN_NUMBER']) attempt: int = int(environment['GITHUB_RUN_ATTEMPT']) if run < 1 or attempt < 1: raise ValueError('Invalid workflow run identity') version: str = f'{values[0]}-beta.{run}.{attempt}' if beta else values[0] return { 'channel': 'beta' if beta else 'stable', 'version': version, 'version_property': key, 'tag': config['betaTag'] if beta else f'v{version}', 'sha': sha, 'run': run, 'attempt': attempt, 'matrix': {'include': targets(root, config)}, 'volmlib_ref': config['volmlibRef'], 'wormholes_ref': config.get('wormholesRef', ''), } def collect_artifacts(directory: Path, release: dict) -> dict[str, bytes]: expected: dict[str, dict] = {f"release-{entry['id']}": entry for entry in release['matrix']['include']} if {path.name for path in directory.iterdir()} != set(expected): raise ValueError('Downloaded artifacts do not match the complete release matrix') assets: dict[str, bytes] = {} descriptors: dict[str, str] = {'bukkit': 'plugin.yml', 'fabric': 'fabric.mod.json', 'forge': 'META-INF/mods.toml', 'neoforge': 'META-INF/neoforge.mods.toml'} for name, target in expected.items(): folder: Path = directory / name paths: list[Path] = list(folder.iterdir()) if folder.is_dir() and not folder.is_symlink() else [] if len(paths) != 1 or paths[0].suffix != '.jar' or paths[0].is_symlink() or not paths[0].is_file(): raise ValueError(f'{name} must contain exactly one runtime jar') path: Path = paths[0] loader: str = target['loader'] suffix: str = re.escape(release['version']) + r'-bukkit\.jar' if loader != 'bukkit': display: str = {'fabric': 'Fabric', 'forge': 'Forge', 'neoforge': 'NeoForge'}[loader] suffix = (r'v' + re.escape(release['version']) + r' \[' + display + r'\] ' + re.escape(target['minecraft']) + r'\+[^/\s]+\.jar') if not re.fullmatch(r'[A-Za-z][A-Za-z0-9]*' + ('-' if loader == 'bukkit' else ' ') + suffix, path.name): raise ValueError(f'Artifact does not match the release target: {path.name}') with zipfile.ZipFile(path) as archive: entries: set[str] = set(archive.namelist()) descriptor: str = descriptors[target['loader']] if descriptor not in entries: raise ValueError(f'{path.name} is missing {descriptor}') excluded: set[str] = set(descriptors.values()) - {descriptor} if 'velocity-plugin.json' in entries or entries.intersection(excluded): raise ValueError(f'{path.name} contains an excluded platform') contents: str = archive.read(descriptor).decode() if loader == 'bukkit': match: re.Match | None = re.search(r'^version:\s*[\'\"]?([^\s\'\"]+)', contents, re.MULTILINE) version: str | None = match[1] if match else None elif loader == 'fabric': metadata: dict = json.loads(contents) version = metadata.get('version') if metadata.get('depends', {}).get('minecraft') not in (target['minecraft'], '~' + target['minecraft']): raise ValueError(f'{path.name} declares a different Minecraft target') else: metadata = tomllib.loads(contents) mods: list[dict] = metadata.get('mods', []) version = mods[0].get('version') if len(mods) == 1 else None dependencies: list[dict] = metadata.get('dependencies', {}).get(mods[0].get('modId'), []) if len(mods) == 1 else [] minecraft: list[str] = [entry.get('versionRange') for entry in dependencies if entry.get('modId') == 'minecraft'] if minecraft != ['[' + target['minecraft'] + ']']: raise ValueError(f'{path.name} declares a different Minecraft target') if version != release['version']: raise ValueError(f'{path.name} declares a different product version') asset_name: str = re.sub(r'[^A-Za-z0-9._-]+', '-', path.name) if asset_name in assets: raise ValueError(f'Duplicate release asset: {asset_name}') assets[asset_name] = path.read_bytes() checksums: str = ''.join(f'{hashlib.sha256(data).hexdigest()} {name}\n' for name, data in sorted(assets.items())) assets[f"SHA256SUMS-{release['version']}.txt"] = checksums.encode() return assets class GitHub: def __init__(self, repository: str, token: str): if not re.fullmatch(r'[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+', repository): raise ValueError('Invalid GitHub repository') self.repository = repository self.token = token def request(self, method: str, path: str, payload: dict | bytes | None = None, missing: bool = False) -> dict | list | None: url: str = path if path.startswith('https://uploads.github.com/') else f'https://api.github.com/repos/{self.repository}/{path}' binary: bool = isinstance(payload, bytes) data: bytes | None = payload if binary else json.dumps(payload).encode() if payload is not None else None request: urllib.request.Request = urllib.request.Request(url, data=data, method=method, headers={ 'Authorization': f'Bearer {self.token}', 'Accept': 'application/vnd.github+json', 'X-GitHub-Api-Version': '2026-03-10', 'Content-Type': 'application/octet-stream' if binary else 'application/json', 'User-Agent': 'Volmit-release', }) try: with urllib.request.urlopen(request, timeout=120) as response: body: bytes = response.read() return json.loads(body) if body else None except urllib.error.HTTPError as error: if missing and error.code == 404: return None raise RuntimeError(f'GitHub {method} {urllib.parse.urlsplit(url).path}: HTTP {error.code}') from error def tag_commit(self, tag: str) -> str | None: reference: dict | None = self.request('GET', f'git/ref/tags/{tag}', missing=True) if reference is None: return None target: dict = reference['object'] for _ in range(8): if target['type'] == 'commit': return target['sha'] if target['type'] != 'tag': break target = self.request('GET', f"git/tags/{target['sha']}")['object'] raise ValueError(f'{tag} does not resolve to a commit') def assets(self, release_id: int) -> list[dict]: result: list[dict] = [] page: int = 1 while True: batch: list[dict] = self.request('GET', f'releases/{release_id}/assets?per_page=100&page={page}') result.extend(batch) if len(batch) < 100: return result page += 1 def resolve_dependency(repository: str, reference: str, token: str) -> str: commit: dict = GitHub(repository, token).request('GET', f'commits/{urllib.parse.quote(reference, safe="")}') sha: str = commit['sha'] if not re.fullmatch(r'[a-f0-9]{40}', sha): raise ValueError(f'{repository} did not resolve to a commit') return sha def release_metadata(existing: dict) -> dict: match: re.Match | None = re.search(r'', existing.get('body') or '') if match is None: raise ValueError('Existing release has no publication identity; refusing to overwrite it') return json.loads(match[1]) def verify_assets(existing: list[dict], assets: dict[str, bytes]) -> bool: return {entry['name']: entry.get('digest') for entry in existing} == { name: 'sha256:' + hashlib.sha256(data).hexdigest() for name, data in assets.items()} def publish(github: GitHub, config: dict, release: dict, assets: dict[str, bytes]) -> str: tag: str = release['tag'] beta: bool = release['channel'] == 'beta' existing: dict | None = github.request('GET', f'releases/tags/{tag}', missing=True) commit: str | None = github.tag_commit(tag) if not beta and commit is not None and commit != release['sha']: raise ValueError(f'{tag} already points to another commit') if existing is not None: metadata: dict = release_metadata(existing) if bool(existing['prerelease']) != beta: raise ValueError('Existing release belongs to a different release channel') if beta and (metadata['run'], metadata['attempt']) > (release['run'], release['attempt']): return 'Skipped older beta run' if not beta and metadata['sha'] != release['sha']: raise ValueError('Stable release belongs to another commit') if not existing['draft'] and commit == release['sha'] and metadata['version'] == release['version']: if verify_assets(github.assets(existing['id']), assets): return 'Release already published with identical artifacts' if not beta: raise ValueError('Published release artifacts differ; refusing to overwrite them') if existing.get('immutable'): raise ValueError('Rolling beta requires mutable releases in repository settings') if not beta and not existing['draft']: raise ValueError('Stable release is already published') elif beta and commit is not None: raise ValueError('Beta tag exists without a release; refusing to move it') identity: dict = {key: release[key] for key in ('version', 'sha', 'run', 'attempt', 'volmlib_ref', 'wormholes_ref') if key in release} platform: str = 'Modded' if config['platform'] == 'modded' else 'CraftBukkit' body: str = (f"{platform} jars for {config['name']} {release['version']}.\n\n" f"Commit: `{release['sha']}`\n\n" f"") attributes: dict = { 'tag_name': tag, 'target_commitish': release['sha'], 'name': f"{config['name']} {platform} {'Beta' if beta else release['version']}", 'body': body, 'prerelease': beta, 'make_latest': 'false', } if existing is None: existing = github.request('POST', 'releases', {**attributes, 'draft': True}) elif beta: existing = github.request('PATCH', f"releases/{existing['id']}", {**attributes, 'draft': True}) uploaded: list[dict] = github.assets(existing['id']) by_name: dict[str, dict] = {entry['name']: entry for entry in uploaded} for name, data in assets.items(): digest: str = 'sha256:' + hashlib.sha256(data).hexdigest() if name in by_name: if by_name[name].get('state') == 'starter': github.request('DELETE', f"releases/assets/{by_name[name]['id']}") elif by_name[name].get('digest') != digest: raise ValueError(f'Existing asset differs: {name}') else: continue upload_url: str = existing['upload_url'].split('{')[0] + '?' + urllib.parse.urlencode({'name': name}) receipt: dict = github.request('POST', upload_url, data) if receipt.get('digest') != digest or receipt['name'] != name: raise ValueError(f'Upload verification failed: {name}') for entry in uploaded: if entry['name'] not in assets: github.request('DELETE', f"releases/assets/{entry['id']}") commit = github.tag_commit(tag) if commit is None: github.request('POST', 'git/refs', {'ref': f'refs/tags/{tag}', 'sha': release['sha']}) elif commit != release['sha']: if not beta: raise ValueError('Stable tag changed during publication') github.request('PATCH', f'git/refs/tags/{tag}', {'sha': release['sha'], 'force': True}) if not beta and config['platform'] == 'bukkit': latest: dict | None = github.request('GET', 'releases/latest', missing=True) match: re.Match | None = re.match(r'^v?(\d+)\.(\d+)\.(\d+)(?:$|-)', latest['tag_name']) if latest else None if latest is None or (match and tuple(map(int, release['version'].split('.'))) > tuple(map(int, match.groups()))): attributes['make_latest'] = 'true' github.request('PATCH', f"releases/{existing['id']}", {**attributes, 'draft': False}) return f"Published {tag} at {release['sha']}" def main() -> None: parser: argparse.ArgumentParser = argparse.ArgumentParser() parser.add_argument('command', choices=['plan', 'publish']) parser.add_argument('--artifacts', type=Path) arguments: argparse.Namespace = parser.parse_args() root: Path = Path(__file__).resolve().parent.parent config: dict = read_config(root) event: dict = json.loads(Path(os.environ['GITHUB_EVENT_PATH']).read_text()) environment: dict = dict(os.environ) if arguments.command == 'publish': environment['GITHUB_RUN_ATTEMPT'] = environment['RELEASE_ATTEMPT'] release: dict = plan(root, config, event, environment) if arguments.command == 'plan': if release['channel'] != 'none': release['volmlib_ref'] = resolve_dependency('VolmitSoftware/VolmLib', config['volmlibRef'], environment['GH_TOKEN']) if release['wormholes_ref']: release['wormholes_ref'] = resolve_dependency('VolmitSoftware/Wormholes', config['wormholesRef'], environment['GH_TOKEN']) with Path(os.environ['GITHUB_OUTPUT']).open('a') as output: for key, value in release.items(): output.write(f'{key}={json.dumps(value, separators=(",", ":")) if isinstance(value, dict) else value}\n') print(f"Release channel: {release['channel']}") elif release['channel'] != 'none': if arguments.artifacts is None: raise ValueError('Publication requires the complete artifact directory') for key in ('volmlib_ref', 'wormholes_ref'): if release[key]: resolved: str = environment[f'RELEASE_{key.upper()}'] if not re.fullmatch(r'[a-f0-9]{40}', resolved): raise ValueError(f'{key} is not a resolved dependency commit') release[key] = resolved github: GitHub = GitHub(os.environ['GITHUB_REPOSITORY'], os.environ['GH_TOKEN']) print(publish(github, config, release, collect_artifacts(arguments.artifacts, release))) if __name__ == '__main__': try: main() except (ValueError, RuntimeError) as error: print(f'::error::{error}', file=sys.stderr) sys.exit(1)