Files
Iris/.github/test_release.py
T
2026-10-23 10:41:36 -04:00

719 lines
39 KiB
Python

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()