This commit is contained in:
Brian Neumann-Fopiano committed 2026-10-23 10:41:36 -04:00
1 parent 3240d57a3b
commit 2d211cf106
5 files changed
+1225

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
{
"name": "Iris",
"platform": "modded",
"versionProperty": "irisVersion",
"betaTag": "modded-beta",
"targets": "gradle/mod-targets.json",
"volmlibRef": "master"
}
+327
View File
@@ -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'<!-- release: (.+) -->', 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"<!-- release: {json.dumps(identity, sort_keys=True)} -->")
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)
+718
View File
@@ -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': '<!-- release: ' + json.dumps(identity) + ' -->',
'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()
+157
View File
@@ -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