mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-10-25 21:40:05 +00:00
719 lines
39 KiB
Python
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()
|