From 2d211cf106919877e93a73152b1aeb7958ac926b Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Fri, 23 Oct 2026 10:41:36 -0400 Subject: [PATCH] B+ --- .github/release.json | 8 + .github/release.py | 327 ++++++++++++++++ .github/test_release.py | 718 ++++++++++++++++++++++++++++++++++ .github/workflows/release.yml | 157 ++++++++ build.gradle | 15 + 5 files changed, 1225 insertions(+) create mode 100644 .github/release.json create mode 100644 .github/release.py create mode 100644 .github/test_release.py create mode 100644 .github/workflows/release.yml diff --git a/.github/release.json b/.github/release.json new file mode 100644 index 000000000..24132e3ad --- /dev/null +++ b/.github/release.json @@ -0,0 +1,8 @@ +{ + "name": "Iris", + "platform": "modded", + "versionProperty": "irisVersion", + "betaTag": "modded-beta", + "targets": "gradle/mod-targets.json", + "volmlibRef": "master" +} diff --git a/.github/release.py b/.github/release.py new file mode 100644 index 000000000..cff3fed19 --- /dev/null +++ b/.github/release.py @@ -0,0 +1,327 @@ +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) diff --git a/.github/test_release.py b/.github/test_release.py new file mode 100644 index 000000000..a1a6a0bf8 --- /dev/null +++ b/.github/test_release.py @@ -0,0 +1,718 @@ +import copy +import hashlib +import importlib.util +import io +import json +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch +import urllib.parse +import zipfile + + +SPEC = importlib.util.spec_from_file_location('release', Path(__file__).with_name('release.py')) +release = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(release) + +SHA = 'a' * 40 +OTHER_SHA = 'b' * 40 + + +def digest(data: bytes) -> str: + return 'sha256:' + hashlib.sha256(data).hexdigest() + + +class FakeGitHub: + def __init__(self) -> None: + self.tags: dict[str, str] = {} + self.releases: dict[int, dict] = {} + self.uploads: dict[int, dict[str, dict]] = {} + self.writes: list[tuple[str, str]] = [] + self.latest: dict | None = None + self.fail_upload: str | None = None + self.fail_delete: int | None = None + self.invalid_receipt: bool = False + self.next_asset: int = 1 + + def seed(self, publication: dict, assets: dict[str, bytes], draft: bool = False, + immutable: bool = False) -> dict: + identity: dict = {key: publication[key] for key in ('sha', 'version', 'run', 'attempt')} + record: dict = { + 'id': len(self.releases) + 1, + 'tag_name': publication['tag'], + 'body': '', + 'draft': draft, + 'prerelease': publication['channel'] == 'beta', + 'immutable': immutable, + } + record['upload_url'] = f"https://uploads.github.com/repos/example/product/releases/{record['id']}/assets{{?name,label}}" + self.releases[record['id']] = record + self.uploads[record['id']] = {} + for name, data in assets.items(): + self.add_asset(record['id'], name, data) + self.tags[publication['tag']] = publication['sha'] + return record + + def add_asset(self, identifier: int, name: str, data: bytes) -> dict: + receipt: dict = {'id': self.next_asset, 'name': name, 'digest': digest(data)} + self.next_asset += 1 + self.uploads[identifier][name] = receipt + return copy.deepcopy(receipt) + + def tag_commit(self, tag: str) -> str | None: + return self.tags.get(tag) + + def assets(self, identifier: int) -> list[dict]: + return copy.deepcopy(list(self.uploads[identifier].values())) + + def request(self, method: str, path: str, payload: dict | bytes | None = None, + missing: bool = False) -> dict | list | None: + if method != 'GET': + self.writes.append((method, path)) + if method == 'GET' and path.startswith('releases/tags/'): + tag: str = path.removeprefix('releases/tags/') + return copy.deepcopy(next((record for record in self.releases.values() + if record['tag_name'] == tag), None)) + if method == 'GET' and path == 'releases/latest': + return copy.deepcopy(self.latest) + if method == 'POST' and path == 'releases': + identifier: int = len(self.releases) + 1 + record: dict = copy.deepcopy(payload) + record.update({'id': identifier, 'immutable': False, + 'upload_url': f'https://uploads.github.com/repos/example/product/releases/{identifier}/assets{{?name,label}}'}) + self.releases[identifier] = record + self.uploads[identifier] = {} + return copy.deepcopy(record) + if method == 'POST' and path.startswith('https://uploads.github.com/'): + address: urllib.parse.SplitResult = urllib.parse.urlsplit(path) + name: str = urllib.parse.parse_qs(address.query)['name'][0] + if name == self.fail_upload: + raise RuntimeError('Upload unavailable') + identifier: int = int(address.path.split('/')[-2]) + receipt: dict = self.add_asset(identifier, name, payload) + if self.invalid_receipt: + receipt['digest'] = digest(b'corrupted') + return receipt + if method == 'POST' and path == 'git/refs': + tag: str = payload['ref'].removeprefix('refs/tags/') + if tag in self.tags: + raise RuntimeError('Tag already exists') + self.tags[tag] = payload['sha'] + return {'object': {'type': 'commit', 'sha': payload['sha']}} + if method == 'PATCH' and path.startswith('git/refs/tags/'): + self.tags[path.removeprefix('git/refs/tags/')] = payload['sha'] + return None + if method == 'DELETE' and path.startswith('releases/assets/'): + identifier: int = int(path.rsplit('/', 1)[1]) + if identifier == self.fail_delete: + raise RuntimeError('Delete unavailable') + for uploaded in self.uploads.values(): + for name, entry in list(uploaded.items()): + if entry['id'] == identifier: + del uploaded[name] + return None + raise AssertionError(f'Unknown asset: {identifier}') + if method == 'PATCH' and path.startswith('releases/'): + identifier: int = int(path.rsplit('/', 1)[1]) + self.releases[identifier].update(copy.deepcopy(payload)) + if payload.get('make_latest') == 'true': + self.latest = copy.deepcopy(self.releases[identifier]) + return copy.deepcopy(self.releases[identifier]) + raise AssertionError(f'Unexpected API request: {method} {path}') + + +class ReleaseTestCase(unittest.TestCase): + def setUp(self) -> None: + temporary: tempfile.TemporaryDirectory = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root: Path = Path(temporary.name) + self.config: dict = {'name': 'Example', 'platform': 'bukkit', 'versionProperty': 'exampleVersion', + 'betaTag': 'bukkit-beta', 'volmlibRef': 'c' * 40} + (self.root / 'gradle.properties').write_text('exampleVersion=1.2.3\n') + self.event: dict = {'repository': {'default_branch': 'main'}, 'ref': 'refs/heads/main', + 'after': SHA, 'head_commit': {'id': SHA, 'message': 'V+ Publish'}} + self.environment: dict = {'GITHUB_EVENT_NAME': 'push', 'GITHUB_SHA': SHA, + 'GITHUB_RUN_NUMBER': '17', 'GITHUB_RUN_ATTEMPT': '2'} + network = patch.object(release.urllib.request, 'urlopen', side_effect=AssertionError('Network is forbidden')) + network.start() + self.addCleanup(network.stop) + + def publication(self, channel: str = 'stable', **changes) -> dict: + result: dict = { + 'channel': channel, 'version': '1.2.3-beta.17.2' if channel == 'beta' else '1.2.3', + 'tag': 'bukkit-beta' if channel == 'beta' else 'v1.2.3', 'sha': SHA, 'run': 17, 'attempt': 2, + 'matrix': {'include': [{'id': 'bukkit', 'loader': 'bukkit', 'minecraft': ''}]}, + } + result.update(changes) + return result + + def matrix(self, versions: tuple[str, ...] = ('1.20.1', '1.21.1')) -> list[dict]: + return [{'id': f'{loader}-{version}', 'loader': loader, 'minecraft': version} + for version in versions for loader in ('fabric', 'forge', 'neoforge')] + + def artifact_tree(self, publication: dict, extra: tuple[str, ...] = ()) -> Path: + directory: Path = self.root / 'artifacts' + directory.mkdir(exist_ok=True) + descriptors: dict[str, str] = {'bukkit': 'plugin.yml', 'fabric': 'fabric.mod.json', + 'forge': 'META-INF/mods.toml', 'neoforge': 'META-INF/neoforge.mods.toml'} + for target in publication['matrix']['include']: + folder: Path = directory / f"release-{target['id']}" + folder.mkdir() + loader: str = target['loader'] + if loader == 'bukkit': + filename: str = f"Example-{publication['version']}-bukkit.jar" + metadata: str = f"name: Example\nversion: {publication['version']}\nmain: example.Plugin\n" + else: + display: str = {'fabric': 'Fabric', 'forge': 'Forge', 'neoforge': 'NeoForge'}[loader] + filename = f"Example v{publication['version']} [{display}] {target['minecraft']}+1.0.0.jar" + if loader == 'fabric': + metadata = json.dumps({'schemaVersion': 1, 'id': 'example', 'version': publication['version'], + 'depends': {'minecraft': '~' + target['minecraft']}}) + else: + metadata = (f"modLoader = 'javafml'\nloaderVersion = '[1,)'\nlicense = 'MIT'\n" + f"[[mods]]\nmodId = 'example'\nversion = '{publication['version']}'\n" + f"[[dependencies.example]]\nmodId = 'minecraft'\n" + f"versionRange = '[{target['minecraft']}]'\n") + path: Path = folder / filename + with zipfile.ZipFile(path, 'w') as archive: + archive.writestr(descriptors[loader], metadata) + for name in extra: + archive.writestr(name, '') + return directory + + +class PlanTests(ReleaseTestCase): + def test_markers_are_case_sensitive(self) -> None: + for message, channel in [('B+ Beta', 'beta'), ('V+ Release', 'stable'), + ('b+ beta', 'none'), ('v+ release', 'none'), ('Fix a bug', 'none')]: + with self.subTest(message=message): + self.event['head_commit']['message'] = message + result: dict = release.plan(self.root, self.config, self.event, self.environment) + self.assertEqual(channel, result['channel']) + if channel == 'beta': + self.assertEqual('1.2.3-beta.17.2', result['version']) + self.assertEqual('bukkit-beta', result['tag']) + elif channel == 'stable': + self.assertEqual('1.2.3', result['version']) + self.assertEqual('v1.2.3', result['tag']) + + def test_both_markers_fail(self) -> None: + self.event['head_commit']['message'] = 'B+ first\nV+ second' + with self.assertRaises(ValueError): + release.plan(self.root, self.config, self.event, self.environment) + + def test_only_pushed_default_branch_head_can_release(self) -> None: + cases: list[tuple[dict, dict]] = [ + ({'ref': 'refs/heads/feature'}, {}), ({'ref': 'refs/tags/V+1.2.3'}, {}), + ({'deleted': True}, {}), ({'repository': {}}, {}), + ({'head_commit': None}, {}), ({}, {'GITHUB_EVENT_NAME': 'pull_request'}), + ] + for event_changes, environment_changes in cases: + with self.subTest(event=event_changes, environment=environment_changes): + self.assertEqual({'channel': 'none'}, release.plan( + self.root, self.config, {**self.event, **event_changes}, + {**self.environment, **environment_changes})) + + def test_head_identity_must_match_event_and_environment(self) -> None: + for field in ('after', 'commit', 'environment'): + with self.subTest(field=field): + event: dict = copy.deepcopy(self.event) + environment: dict = self.environment.copy() + if field == 'commit': + event['head_commit']['id'] = OTHER_SHA + elif field == 'environment': + environment['GITHUB_SHA'] = 'not-a-sha' + else: + event['after'] = OTHER_SHA + with self.assertRaises(ValueError): + release.plan(self.root, self.config, event, environment) + + def test_old_commits_do_not_trigger_release(self) -> None: + self.event['commits'] = [{'id': OTHER_SHA, 'message': 'V+ Release'}] + self.event['head_commit']['message'] = 'Fix a bug' + self.assertEqual({'channel': 'none'}, release.plan(self.root, self.config, self.event, self.environment)) + + def test_invalid_or_duplicate_versions_fail(self) -> None: + for value in ('', 'exampleVersion=01.2.3\n', 'exampleVersion=1.2\n', + 'exampleVersion=1.2.3-beta.1\n', 'exampleVersion=1.2.3\nexampleVersion=2.3.4\n'): + with self.subTest(value=value): + (self.root / 'gradle.properties').write_text(value) + with self.assertRaises(ValueError): + release.plan(self.root, self.config, self.event, self.environment) + + def test_invalid_run_identity_fails(self) -> None: + for key in ('GITHUB_RUN_NUMBER', 'GITHUB_RUN_ATTEMPT'): + with self.subTest(key=key), self.assertRaises(ValueError): + release.plan(self.root, self.config, self.event, {**self.environment, key: '0'}) + + def test_minecraft_file_matrix_contains_all_loaders(self) -> None: + (self.root / 'targets.json').write_text(json.dumps({'1.20.1': {}, '1.21.1': {}})) + config: dict = {**self.config, 'platform': 'modded', 'targets': 'targets.json'} + self.assertEqual(self.matrix(), release.targets(self.root, config)) + + def test_minecraft_directory_matrix_matches_files(self) -> None: + folder: Path = self.root / 'targets' + folder.mkdir() + (folder / '1.20.1.json').write_text(json.dumps({'minecraft': '1.20.1', + 'loaders': ['fabric', 'forge', 'neoforge']})) + config: dict = {**self.config, 'platform': 'modded', 'targets': 'targets'} + self.assertEqual(self.matrix(('1.20.1',)), release.targets(self.root, config)) + for minecraft, loaders in [('1.20.2', ['fabric', 'forge', 'neoforge']), ('1.20.1', ['fabric'])]: + with self.subTest(minecraft=minecraft, loaders=loaders): + (folder / '1.20.1.json').write_text(json.dumps({'minecraft': minecraft, 'loaders': loaders})) + with self.assertRaises(ValueError): + release.targets(self.root, config) + + def test_empty_or_invalid_minecraft_matrix_fails(self) -> None: + for content in ({}, {'invalid': {}}): + with self.subTest(content=content): + (self.root / 'targets.json').write_text(json.dumps(content)) + with self.assertRaises(ValueError): + release.targets(self.root, {**self.config, 'platform': 'modded', 'targets': 'targets.json'}) + + +class ArtifactTests(ReleaseTestCase): + def test_complete_modded_matrix_and_checksums(self) -> None: + publication: dict = self.publication(matrix={'include': self.matrix()}) + assets: dict[str, bytes] = release.collect_artifacts(self.artifact_tree(publication), publication) + self.assertEqual(7, len(assets)) + checksums: bytes = assets.pop('SHA256SUMS-1.2.3.txt') + self.assertEqual(''.join(f'{hashlib.sha256(data).hexdigest()} {name}\n' + for name, data in sorted(assets.items())).encode(), checksums) + + def test_missing_or_extra_matrix_target_fails(self) -> None: + publication: dict = self.publication() + directory: Path = self.artifact_tree(publication) + (directory / 'release-extra').mkdir() + with self.assertRaises(ValueError): + release.collect_artifacts(directory, publication) + (directory / 'release-extra').rmdir() + (directory / 'release-bukkit').rename(directory / 'release-wrong') + with self.assertRaises(ValueError): + release.collect_artifacts(directory, publication) + + def test_intermediate_artifact_beside_runtime_fails(self) -> None: + publication: dict = self.publication() + directory: Path = self.artifact_tree(publication) + (directory / 'release-bukkit' / 'sources.jar').write_bytes(b'sources') + with self.assertRaises(ValueError): + release.collect_artifacts(directory, publication) + + def test_symbolic_link_artifacts_fail(self) -> None: + publication: dict = self.publication() + directory: Path = self.artifact_tree(publication) + path: Path = next((directory / 'release-bukkit').iterdir()) + target: Path = self.root / path.name + path.rename(target) + path.symlink_to(target) + with self.assertRaises(ValueError): + release.collect_artifacts(directory, publication) + + def test_bukkit_is_forbidden_in_modded_release(self) -> None: + publication: dict = self.publication(matrix={'include': self.matrix(('1.20.1',))}) + with self.assertRaises(ValueError): + release.collect_artifacts(self.artifact_tree(publication, ('plugin.yml',)), publication) + + def test_velocity_is_forbidden_in_bukkit_release(self) -> None: + publication: dict = self.publication() + with self.assertRaises(ValueError): + release.collect_artifacts(self.artifact_tree(publication, ('velocity-plugin.json',)), publication) + + def test_modded_descriptor_is_forbidden_in_bukkit_release(self) -> None: + publication: dict = self.publication() + with self.assertRaises(ValueError): + release.collect_artifacts(self.artifact_tree(publication, ('fabric.mod.json',)), publication) + + def test_required_platform_descriptor_is_checked(self) -> None: + publication: dict = self.publication() + directory: Path = self.artifact_tree(publication) + path: Path = next((directory / 'release-bukkit').iterdir()) + with zipfile.ZipFile(path, 'w') as archive: + archive.writestr('fabric.mod.json', '{}') + with self.assertRaises(ValueError): + release.collect_artifacts(directory, publication) + + def test_wrong_version_and_embedded_version_suffix_fail(self) -> None: + publication: dict = self.publication() + directory: Path = self.artifact_tree(publication) + path: Path = next((directory / 'release-bukkit').iterdir()) + for version in ('9.8.7', '11.2.3'): + with self.subTest(version=version): + changed: Path = path.with_name(f'Example-{version}-bukkit.jar') + path.rename(changed) + with self.assertRaises(ValueError): + release.collect_artifacts(directory, publication) + changed.rename(path) + + def test_descriptor_version_must_match_release(self) -> None: + publication: dict = self.publication() + directory: Path = self.artifact_tree(publication) + path: Path = next((directory / 'release-bukkit').iterdir()) + with zipfile.ZipFile(path, 'w') as archive: + archive.writestr('plugin.yml', 'name: Example\nversion: 1.2.4\nmain: example.Plugin\n') + with self.assertRaises(ValueError): + release.collect_artifacts(directory, publication) + + def test_modded_descriptor_versions_must_match_release(self) -> None: + publication: dict = self.publication(matrix={'include': self.matrix(('1.20.1',))}) + directory: Path = self.artifact_tree(publication) + for folder in directory.iterdir(): + with self.subTest(target=folder.name): + path: Path = next(folder.iterdir()) + original: bytes = path.read_bytes() + with zipfile.ZipFile(path) as archive: + descriptor: str = archive.namelist()[0] + contents: str = archive.read(descriptor).decode().replace('1.2.3', '1.2.4') + with zipfile.ZipFile(path, 'w') as archive: + archive.writestr(descriptor, contents) + with self.assertRaises(ValueError): + release.collect_artifacts(directory, publication) + path.write_bytes(original) + + def test_modded_filename_must_match_minecraft_target(self) -> None: + publication: dict = self.publication(matrix={'include': self.matrix(('1.20.1',))}) + directory: Path = self.artifact_tree(publication) + path: Path = next((directory / 'release-fabric-1.20.1').iterdir()) + path.rename(path.with_name(path.name.replace('1.20.1', '1.21.1'))) + with self.assertRaises(ValueError): + release.collect_artifacts(directory, publication) + + def test_modded_metadata_must_match_minecraft_target(self) -> None: + publication: dict = self.publication(matrix={'include': self.matrix(('1.20.1',))}) + directory: Path = self.artifact_tree(publication) + for folder in directory.iterdir(): + with self.subTest(target=folder.name): + path: Path = next(folder.iterdir()) + original: bytes = path.read_bytes() + with zipfile.ZipFile(path) as archive: + descriptor: str = archive.namelist()[0] + contents: str = archive.read(descriptor).decode().replace('1.20.1', '1.21.1') + with zipfile.ZipFile(path, 'w') as archive: + archive.writestr(descriptor, contents) + with self.assertRaises(ValueError): + release.collect_artifacts(directory, publication) + path.write_bytes(original) + + +class PublicationTests(ReleaseTestCase): + def setUp(self) -> None: + super().setUp() + self.github: FakeGitHub = FakeGitHub() + self.assets: dict[str, bytes] = {'Example-1.2.3-bukkit.jar': b'jar', 'SHA256SUMS-1.2.3.txt': b'checksums'} + + def test_new_stable_release_is_published_at_head(self) -> None: + release.publish(self.github, self.config, self.publication(), self.assets) + record: dict = self.github.releases[1] + self.assertEqual(SHA, self.github.tags['v1.2.3']) + self.assertFalse(record['draft']) + self.assertFalse(record['prerelease']) + self.assertEqual('true', record['make_latest']) + self.assertEqual(set(self.assets), set(self.github.uploads[1])) + + def test_stable_existing_tag_collision_has_no_writes(self) -> None: + self.github.tags['v1.2.3'] = OTHER_SHA + with self.assertRaises(ValueError): + release.publish(self.github, self.config, self.publication(), self.assets) + self.assertEqual([], self.github.writes) + self.assertEqual(OTHER_SHA, self.github.tags['v1.2.3']) + + def test_stable_same_content_rerun_has_no_writes(self) -> None: + self.github.seed(self.publication(), self.assets, immutable=True) + result: str = release.publish(self.github, self.config, self.publication(), self.assets) + self.assertIn('already published', result) + self.assertEqual([], self.github.writes) + + def test_published_stable_artifacts_cannot_be_replaced(self) -> None: + self.github.seed(self.publication(), self.assets, immutable=True) + with self.assertRaises(ValueError): + release.publish(self.github, self.config, self.publication(), + {**self.assets, 'Example-1.2.3-bukkit.jar': b'different'}) + self.assertEqual([], self.github.writes) + + def test_stable_release_identity_collision_has_no_writes(self) -> None: + self.github.seed(self.publication(sha=OTHER_SHA), self.assets) + self.github.tags['v1.2.3'] = SHA + with self.assertRaises(ValueError): + release.publish(self.github, self.config, self.publication(), self.assets) + self.assertEqual([], self.github.writes) + + def test_stable_release_cannot_reuse_published_prerelease(self) -> None: + record: dict = self.github.seed(self.publication(), self.assets) + record['prerelease'] = True + with self.assertRaises(ValueError): + release.publish(self.github, self.config, self.publication(), self.assets) + self.assertEqual([], self.github.writes) + + def test_partially_uploaded_stable_draft_resumes(self) -> None: + record: dict = self.github.seed(self.publication(), {'Example-1.2.3-bukkit.jar': b'jar'}, draft=True) + original_id: int = self.github.uploads[record['id']]['Example-1.2.3-bukkit.jar']['id'] + release.publish(self.github, self.config, self.publication(), self.assets) + self.assertFalse(self.github.releases[record['id']]['draft']) + self.assertEqual(original_id, self.github.uploads[record['id']]['Example-1.2.3-bukkit.jar']['id']) + self.assertEqual(set(self.assets), set(self.github.uploads[record['id']])) + self.assertEqual(1, len(self.github.releases)) + + def test_conflicting_draft_asset_fails_without_replacement(self) -> None: + self.github.seed(self.publication(), {'Example-1.2.3-bukkit.jar': b'other'}, draft=True) + with self.assertRaises(ValueError): + release.publish(self.github, self.config, self.publication(), self.assets) + self.assertEqual([], self.github.writes) + self.assertTrue(self.github.releases[1]['draft']) + + def test_newer_beta_run_or_attempt_is_preserved(self) -> None: + for run, attempt in ((18, 1), (17, 3)): + with self.subTest(run=run, attempt=attempt): + github: FakeGitHub = FakeGitHub() + github.seed(self.publication('beta', run=run, attempt=attempt, sha=OTHER_SHA), self.assets) + result: str = release.publish(github, self.config, self.publication('beta'), self.assets) + self.assertIn('Skipped', result) + self.assertEqual([], github.writes) + self.assertEqual(OTHER_SHA, github.tags['bukkit-beta']) + + def test_successful_beta_replaces_assets_and_remains_prerelease(self) -> None: + previous: dict = self.publication('beta', run=16, attempt=1, sha=OTHER_SHA, version='1.2.3-beta.16.1') + record: dict = self.github.seed(previous, {'old.jar': b'old'}) + release.publish(self.github, self.config, self.publication('beta'), self.assets) + current: dict = self.github.releases[record['id']] + self.assertEqual(SHA, self.github.tags['bukkit-beta']) + self.assertTrue(current['prerelease']) + self.assertFalse(current['draft']) + self.assertEqual('false', current['make_latest']) + self.assertEqual(set(self.assets), set(self.github.uploads[record['id']])) + + def test_failed_beta_upload_preserves_tag_and_assets_in_recoverable_draft(self) -> None: + previous: dict = self.publication('beta', run=16, attempt=1, sha=OTHER_SHA, version='1.2.3-beta.16.1') + record: dict = self.github.seed(previous, {'old.jar': b'old'}) + self.github.fail_upload = 'SHA256SUMS-1.2.3.txt' + with self.assertRaises(RuntimeError): + release.publish(self.github, self.config, self.publication('beta'), self.assets) + self.assertEqual(OTHER_SHA, self.github.tags['bukkit-beta']) + self.assertTrue(self.github.releases[record['id']]['draft']) + self.assertEqual(17, release.release_metadata(self.github.releases[record['id']])['run']) + self.assertIn('old.jar', self.github.uploads[record['id']]) + self.assertFalse(any(method == 'DELETE' or path.startswith('git/refs') + for method, path in self.github.writes)) + + def test_failed_beta_cleanup_resumes_without_reuploading_verified_assets(self) -> None: + previous: dict = self.publication('beta', run=16, attempt=1, sha=OTHER_SHA, version='1.2.3-beta.16.1') + record: dict = self.github.seed(previous, {'old.jar': b'old'}) + self.github.fail_delete = self.github.uploads[record['id']]['old.jar']['id'] + with self.assertRaises(RuntimeError): + release.publish(self.github, self.config, self.publication('beta'), self.assets) + self.assertTrue(self.github.releases[record['id']]['draft']) + self.assertEqual(OTHER_SHA, self.github.tags['bukkit-beta']) + uploaded_ids: dict[str, int] = {name: self.github.uploads[record['id']][name]['id'] for name in self.assets} + self.github.fail_delete = None + release.publish(self.github, self.config, self.publication('beta'), self.assets) + self.assertFalse(self.github.releases[record['id']]['draft']) + self.assertEqual(SHA, self.github.tags['bukkit-beta']) + self.assertEqual(uploaded_ids, {name: entry['id'] for name, entry in self.github.uploads[record['id']].items()}) + + def test_incomplete_newer_beta_draft_blocks_older_run(self) -> None: + self.github.seed(self.publication('beta', run=18, sha=OTHER_SHA), {}, draft=True) + result: str = release.publish(self.github, self.config, self.publication('beta'), self.assets) + self.assertIn('Skipped', result) + self.assertEqual([], self.github.writes) + self.assertTrue(self.github.releases[1]['draft']) + + def test_starter_upload_in_draft_is_replaced_before_publication(self) -> None: + self.github.seed(self.publication(), {'Example-1.2.3-bukkit.jar': b''}, draft=True) + starter: dict = self.github.uploads[1]['Example-1.2.3-bukkit.jar'] + identifier: int = starter['id'] + starter.update({'state': 'starter', 'digest': None}) + release.publish(self.github, self.config, self.publication(), self.assets) + self.assertFalse(self.github.releases[1]['draft']) + self.assertNotEqual(identifier, self.github.uploads[1]['Example-1.2.3-bukkit.jar']['id']) + self.assertEqual(digest(b'jar'), self.github.uploads[1]['Example-1.2.3-bukkit.jar']['digest']) + + def test_invalid_upload_digest_does_not_create_tag_or_publish(self) -> None: + self.github.invalid_receipt = True + with self.assertRaises(ValueError): + release.publish(self.github, self.config, self.publication(), self.assets) + self.assertEqual({}, self.github.tags) + self.assertTrue(self.github.releases[1]['draft']) + + def test_beta_will_not_replace_stable_or_immutable_release(self) -> None: + for immutable, prerelease in ((True, True), (False, False)): + with self.subTest(immutable=immutable, prerelease=prerelease): + github: FakeGitHub = FakeGitHub() + record: dict = github.seed(self.publication('beta', run=16, sha=OTHER_SHA), self.assets, + immutable=immutable) + record['prerelease'] = prerelease + with self.assertRaises(ValueError): + release.publish(github, self.config, self.publication('beta'), self.assets) + self.assertEqual([], github.writes) + + def test_unowned_existing_release_is_preserved(self) -> None: + record: dict = self.github.seed(self.publication(), self.assets) + record['body'] = 'Published manually' + with self.assertRaises(ValueError): + release.publish(self.github, self.config, self.publication(), self.assets) + self.assertEqual([], self.github.writes) + + def test_beta_tag_without_release_is_preserved(self) -> None: + self.github.tags['bukkit-beta'] = OTHER_SHA + with self.assertRaises(ValueError): + release.publish(self.github, self.config, self.publication('beta'), self.assets) + self.assertEqual([], self.github.writes) + + def test_modded_stable_release_never_changes_latest(self) -> None: + self.github.latest = {'tag_name': 'v1.0.0'} + release.publish(self.github, {**self.config, 'platform': 'modded'}, self.publication(), self.assets) + self.assertFalse(self.github.releases[1]['prerelease']) + self.assertEqual('false', self.github.releases[1]['make_latest']) + self.assertEqual('v1.0.0', self.github.latest['tag_name']) + + def test_bukkit_backport_does_not_replace_newer_latest(self) -> None: + for latest in ('v1.3.0', 'v2.0.0', 'v1.2.3'): + with self.subTest(latest=latest): + github: FakeGitHub = FakeGitHub() + github.latest = {'tag_name': latest} + release.publish(github, self.config, self.publication(), self.assets) + self.assertEqual('false', github.releases[1]['make_latest']) + self.assertEqual(latest, github.latest['tag_name']) + + def test_newer_bukkit_release_becomes_latest(self) -> None: + self.github.latest = {'tag_name': 'v1.2.2'} + release.publish(self.github, self.config, self.publication(), self.assets) + self.assertEqual('true', self.github.releases[1]['make_latest']) + self.assertEqual('v1.2.3', self.github.latest['tag_name']) + + +class GitHubReadTests(ReleaseTestCase): + def test_annotated_tag_resolves_to_commit(self) -> None: + github: release.GitHub = release.GitHub('example/product', 'test-token') + responses: dict[str, dict] = { + 'git/ref/tags/v1.2.3': {'object': {'type': 'tag', 'sha': OTHER_SHA}}, + f'git/tags/{OTHER_SHA}': {'object': {'type': 'commit', 'sha': SHA}}, + } + with patch.object(github, 'request', side_effect=lambda method, path, **options: responses[path]): + self.assertEqual(SHA, github.tag_commit('v1.2.3')) + + def test_noncommit_tag_cannot_be_published(self) -> None: + github: release.GitHub = release.GitHub('example/product', 'test-token') + with patch.object(github, 'request', return_value={'object': {'type': 'tree', 'sha': SHA}}): + with self.assertRaises(ValueError): + github.tag_commit('v1.2.3') + + def test_assets_include_all_pages(self) -> None: + github: release.GitHub = release.GitHub('example/product', 'test-token') + first: list[dict] = [{'id': number, 'name': f'{number}.jar'} for number in range(100)] + last: list[dict] = [{'id': 100, 'name': 'last.jar'}] + with patch.object(github, 'request', side_effect=[first, last]): + self.assertEqual(first + last, github.assets(1)) + + def test_invalid_repository_is_rejected(self) -> None: + for repository in ('example', '../example/product', 'example/product/extra'): + with self.subTest(repository=repository), self.assertRaises(ValueError): + release.GitHub(repository, 'test-token') + + +class DependencyResolutionTests(ReleaseTestCase): + def test_branch_resolves_to_full_commit_sha(self) -> None: + with patch.object(release.GitHub, 'request', return_value={'sha': SHA}) as request: + self.assertEqual(SHA, release.resolve_dependency('example/product', 'master', 'test-token')) + request.assert_called_once_with('GET', 'commits/master') + + def test_full_commit_reference_is_verified(self) -> None: + with patch.object(release.GitHub, 'request', return_value={'sha': SHA}) as request: + self.assertEqual(SHA, release.resolve_dependency('example/product', SHA, 'test-token')) + request.assert_called_once_with('GET', f'commits/{SHA}') + + def test_incomplete_or_invalid_resolved_sha_fails(self) -> None: + for sha in ('', 'a' * 39, 'a' * 41, 'A' * 40, 'g' * 40, 'master'): + with self.subTest(sha=sha), patch.object(release.GitHub, 'request', return_value={'sha': sha}): + with self.assertRaises(ValueError): + release.resolve_dependency('example/product', 'master', 'test-token') + + def test_reference_is_encoded_as_single_url_path_component(self) -> None: + with patch.object(release.GitHub, 'request', return_value={'sha': SHA}) as request: + self.assertEqual(SHA, release.resolve_dependency('example/product', 'feature/next+release #?', 'test-token')) + request.assert_called_once_with('GET', 'commits/feature%2Fnext%2Brelease%20%23%3F') + + +class MainTests(ReleaseTestCase): + def setUp(self) -> None: + super().setUp() + self.config.update({'volmlibRef': 'master', 'wormholesRef': 'release/api'}) + (self.root / '.github').mkdir() + (self.root / '.github/release.json').write_text(json.dumps(self.config)) + self.event['head_commit']['message'] = 'B+ Publish beta' + (self.root / 'event.json').write_text(json.dumps(self.event)) + self.environment.update({ + 'GITHUB_EVENT_PATH': str(self.root / 'event.json'), + 'GITHUB_OUTPUT': str(self.root / 'output.txt'), + 'GITHUB_REPOSITORY': 'example/product', + 'GH_TOKEN': 'test-token', + }) + script = patch.object(release, '__file__', str(self.root / '.github/release.py')) + script.start() + self.addCleanup(script.stop) + output = patch('sys.stdout', new=io.StringIO()) + output.start() + self.addCleanup(output.stop) + + def test_plan_freezes_each_dependency_branch_once(self) -> None: + with patch.dict(release.os.environ, self.environment, clear=True), \ + patch.object(release.sys, 'argv', ['release.py', 'plan']), \ + patch.object(release.GitHub, 'request', side_effect=[{'sha': SHA}, {'sha': OTHER_SHA}]) as request: + release.main() + outputs: dict[str, str] = dict(line.split('=', 1) for line in (self.root / 'output.txt').read_text().splitlines()) + self.assertEqual(SHA, outputs['volmlib_ref']) + self.assertEqual(OTHER_SHA, outputs['wormholes_ref']) + self.assertEqual('1.2.3-beta.17.2', outputs['version']) + self.assertEqual('2', outputs['attempt']) + self.assertEqual(2, request.call_count) + self.assertEqual(['commits/master', 'commits/release%2Fapi'], [call.args[1] for call in request.call_args_list]) + + def test_unmarked_push_never_resolves_dependencies(self) -> None: + self.event['head_commit']['message'] = 'Fix a bug' + (self.root / 'event.json').write_text(json.dumps(self.event)) + with patch.dict(release.os.environ, self.environment, clear=True), \ + patch.object(release.sys, 'argv', ['release.py', 'plan']), \ + patch.object(release, 'resolve_dependency', side_effect=AssertionError('Unexpected dependency lookup')): + release.main() + self.assertEqual('channel=none\n', (self.root / 'output.txt').read_text()) + + def test_publish_rerun_uses_original_planned_attempt_and_dependency_commits(self) -> None: + publication: dict = self.publication('beta') + artifacts: Path = self.artifact_tree(publication) + github: FakeGitHub = FakeGitHub() + self.environment.update({'GITHUB_RUN_ATTEMPT': '3', 'RELEASE_ATTEMPT': '2', + 'RELEASE_VOLMLIB_REF': SHA, 'RELEASE_WORMHOLES_REF': OTHER_SHA}) + with patch.dict(release.os.environ, self.environment, clear=True), \ + patch.object(release.sys, 'argv', ['release.py', 'publish', '--artifacts', str(artifacts)]), \ + patch.object(release, 'GitHub', return_value=github), \ + patch.object(release, 'resolve_dependency', side_effect=AssertionError('Dependencies must stay frozen')): + release.main() + metadata: dict = release.release_metadata(github.releases[1]) + self.assertEqual('1.2.3-beta.17.2', metadata['version']) + self.assertEqual(2, metadata['attempt']) + self.assertEqual(SHA, metadata['volmlib_ref']) + self.assertEqual(OTHER_SHA, metadata['wormholes_ref']) + self.assertFalse(github.releases[1]['draft']) + self.assertEqual({'Example-1.2.3-beta.17.2-bukkit.jar', 'SHA256SUMS-1.2.3-beta.17.2.txt'}, + set(github.uploads[1])) + writes: list[tuple[str, str]] = github.writes.copy() + release.os.environ['GITHUB_RUN_ATTEMPT'] = '4' + release.main() + self.assertEqual(writes, github.writes) + + def test_publish_rejects_unresolved_dependency_reference_before_writing(self) -> None: + artifacts: Path = self.artifact_tree(self.publication('beta')) + github: FakeGitHub = FakeGitHub() + self.environment.update({'RELEASE_ATTEMPT': '2', 'RELEASE_VOLMLIB_REF': SHA, + 'RELEASE_WORMHOLES_REF': OTHER_SHA}) + for key in ('RELEASE_VOLMLIB_REF', 'RELEASE_WORMHOLES_REF'): + with self.subTest(key=key), \ + patch.dict(release.os.environ, {**self.environment, key: 'master'}, clear=True), \ + patch.object(release.sys, 'argv', ['release.py', 'publish', '--artifacts', str(artifacts)]), \ + patch.object(release, 'GitHub', return_value=github): + with self.assertRaises(ValueError): + release.main() + self.assertEqual([], github.writes) + + +if __name__ == '__main__': + unittest.main() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..7dea04d35 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,157 @@ +name: Release + +on: + push: + branches: ['**'] + pull_request: + paths: + - '.github/**' + - '**/*.gradle' + - '**/gradle.properties' + - 'gradle/**/*.json' + +permissions: + contents: read + +env: + PYTHONDONTWRITEBYTECODE: '1' + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - name: Test release handling + run: python3 -m unittest discover -s .github -p 'test_release.py' -v + + plan: + needs: validate + if: github.event_name == 'push' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) + runs-on: ubuntu-latest + outputs: + channel: ${{ steps.release.outputs.channel }} + version: ${{ steps.release.outputs.version }} + attempt: ${{ steps.release.outputs.attempt }} + version_property: ${{ steps.release.outputs.version_property }} + matrix: ${{ steps.release.outputs.matrix }} + volmlib_ref: ${{ steps.release.outputs.volmlib_ref }} + wormholes_ref: ${{ steps.release.outputs.wormholes_ref }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - name: Select release channel and targets + id: release + env: + GH_TOKEN: ${{ github.token }} + run: python3 .github/release.py plan + + build: + needs: plan + if: needs.plan.outputs.channel != 'none' + runs-on: ubuntu-latest + timeout-minutes: 120 + strategy: + fail-fast: false + max-parallel: 3 + matrix: ${{ fromJSON(needs.plan.outputs.matrix) }} + env: + ORG_GRADLE_PROJECT_localVolmLibDirectory: ${{ github.workspace }}/VolmLib + RELEASE_VERSION: ${{ needs.plan.outputs.version }} + VERSION_PROPERTY: ${{ needs.plan.outputs.version_property }} + RELEASE_LOADER: ${{ matrix.loader }} + MINECRAFT_VERSION: ${{ matrix.minecraft }} + steps: + - name: Check out release sources + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + path: project + submodules: recursive + persist-credentials: false + - name: Check out VolmLib + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + repository: VolmitSoftware/VolmLib + ref: ${{ needs.plan.outputs.volmlib_ref }} + path: VolmLib + persist-credentials: false + - name: Check out Wormholes API sources + if: needs.plan.outputs.wormholes_ref != '' + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + repository: VolmitSoftware/Wormholes + ref: ${{ needs.plan.outputs.wormholes_ref }} + path: WormholesPlugin + submodules: recursive + persist-credentials: false + - name: Set up Java 21 toolchain + uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad + with: + distribution: temurin + java-version: '21' + - name: Set up Java 25 + uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad + with: + distribution: temurin + java-version: '25' + - uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 + - name: Build Wormholes API + if: needs.plan.outputs.wormholes_ref != '' + working-directory: WormholesPlugin + run: ./gradlew apiJar --no-daemon --max-workers=2 '-Dorg.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8' + - name: Locate Wormholes API + if: needs.plan.outputs.wormholes_ref != '' + shell: bash + run: | + api_jars=(WormholesPlugin/build/libs/*-api.jar) + test "${#api_jars[@]}" -eq 1 + test -f "${api_jars[0]}" + echo "ORG_GRADLE_PROJECT_wormholesApiJar=$PWD/${api_jars[0]}" >> "$GITHUB_ENV" + - name: Build and verify release jar + working-directory: project + shell: bash + run: | + arguments=(prepareRelease --no-daemon --max-workers=2 + '-Dorg.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8' + "-P$VERSION_PROPERTY=$RELEASE_VERSION") + if [[ "$RELEASE_LOADER" != bukkit ]]; then + arguments+=("-PreleaseLoader=$RELEASE_LOADER" "-PminecraftVersion=$MINECRAFT_VERSION" + -PmoddedBuildParallelism=1 -PmoddedTestForks=1) + fi + ./gradlew "${arguments[@]}" + - name: Upload verified jar + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 + with: + name: release-${{ matrix.id }} + path: project/build/release/*.jar + if-no-files-found: error + retention-days: 7 + compression-level: 0 + + publish: + needs: [plan, build] + runs-on: ubuntu-latest + permissions: + contents: write + concurrency: + group: release-publication + cancel-in-progress: false + queue: max + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - name: Download complete release matrix + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 + with: + pattern: release-* + path: build/release-artifacts + - name: Publish verified release + env: + GH_TOKEN: ${{ github.token }} + RELEASE_ATTEMPT: ${{ needs.plan.outputs.attempt }} + RELEASE_VOLMLIB_REF: ${{ needs.plan.outputs.volmlib_ref }} + RELEASE_WORMHOLES_REF: ${{ needs.plan.outputs.wormholes_ref }} + run: python3 .github/release.py publish --artifacts build/release-artifacts diff --git a/build.gradle b/build.gradle index a0aa5c3b7..96718e4c1 100644 --- a/build.gradle +++ b/build.gradle @@ -439,6 +439,21 @@ if (selectedModdedBuilds.isEmpty()) { throw new GradleException("Minecraft ${minecraftVersion} is not a mod target in gradle/mod-targets.json") } +tasks.register('prepareRelease', Sync) { + group = 'distribution' + description = 'Builds, verifies and stages one selected mod loader jar for publication.' + Provider releaseBuild = providers.provider { + String releaseLoader = providers.gradleProperty('releaseLoader').orNull + if (!moddedLoaderLabels.containsKey(releaseLoader)) { + throw new GradleException('prepareRelease requires -PreleaseLoader=fabric, forge or neoforge') + } + return selectedModdedBuilds[releaseLoader].get() + } + dependsOn(releaseBuild) + from(releaseBuild.flatMap { IrisModdedBuild task -> task.artifactFile }) + into(layout.buildDirectory.dir('release')) +} + tasks.register('buildFabric') { group = 'iris' description = "Builds and verifies the Fabric ${minecraftVersion} jar."