import argparse import hashlib import importlib.util import io import json import os from pathlib import Path import shutil import subprocess import tarfile import tempfile import unittest from unittest.mock import patch import yaml ROOT = Path(__file__).resolve().parents[2] spec = importlib.util.spec_from_file_location('release_matrix', Path(__file__).with_name('release_matrix.py')) release = importlib.util.module_from_spec(spec) spec.loader.exec_module(release) class ReleaseMatrixTest(unittest.TestCase): def setUp(self): self.previous = Path.cwd() self.temp = tempfile.TemporaryDirectory() self.addCleanup(self.temp.cleanup) os.chdir(self.temp.name) self.addCleanup(os.chdir, self.previous) for name in ('.goreleaser.yaml', '.goreleaser.simple.yaml'): shutil.copyfile(ROOT / name, name) Path('backend/cmd/server').mkdir(parents=True) release.VERSION_FILE.write_text('9.8.7\n') def fixture_artifacts(self, simple=False): directory = Path('release-input') directory.mkdir() for target in release.targets(simple): name = release.archive_name('9.8.7', target) archive = directory / name if target['goos'] == 'linux': with tarfile.open(archive, 'w:gz') as out: info = tarfile.TarInfo('sub2api') info.size = 7 info.mode = 0o755 out.addfile(info, io.BytesIO(b'fixture')) else: archive.write_bytes(b'fixture archive') metadata = {'version': '9.8.7', 'sha': 'a' * 40, 'target': target, 'archive': name, 'sha256': release.sha256(archive)} (directory / f"manifest-{target['goos']}-{target['goarch']}.json").write_text(json.dumps(metadata)) return argparse.Namespace(input='release-input', version='9.8.7', sha='a' * 40, simple=simple, output='contexts') def test_full_and_simple_matrix_match_existing_targets(self): full = release.targets() self.assertEqual(len(full), 5) self.assertNotIn({'goos': 'windows', 'goarch': 'arm64'}, full) self.assertEqual(release.targets(True), [{'goos': 'linux', 'goarch': 'amd64'}]) def test_leaf_keeps_packaging_and_selects_only_one_target(self): original = release.config() release.generate_config(argparse.Namespace(mode='build', simple=False, goos='darwin', goarch='arm64', output='leaf.yaml')) leaf = yaml.safe_load(Path('leaf.yaml').read_text()) self.assertEqual(leaf['builds'][0]['goos'], ['darwin']) self.assertEqual(leaf['builds'][0]['goarch'], ['arm64']) self.assertEqual(leaf['builds'][0]['ignore'], []) self.assertEqual(leaf['archives'], original['archives']) self.assertEqual(leaf['release'], original['release']) self.assertFalse(leaf['dockers']) self.assertIn('{{ .Env.RELEASE_DATE }}', '\n'.join(leaf['builds'][0]['ldflags'])) def test_publication_config_has_no_compilation_or_docker_work(self): for simple in (False, True): with self.subTest(simple=simple): original = release.config(simple) release.generate_config(argparse.Namespace(mode='publish', simple=simple, output='publisher.yaml')) data = yaml.safe_load(Path('publisher.yaml').read_text()) self.assertTrue(data['builds'][0]['skip']) self.assertFalse(data['archives']) self.assertFalse(data['dockers']) self.assertEqual(data['release']['header'], original['release']['header']) self.assertEqual(data['release']['footer'], original['release']['footer']) if simple: self.assertTrue(data['checksum']['disable']) self.assertTrue(data['release']['skip_upload']) else: self.assertEqual(data['checksum']['extra_files'], data['release']['extra_files']) def test_collect_and_verify_hash_and_source_binding(self): args = self.fixture_artifacts() release.verify(args) file = next(Path(args.input).glob('*.tar.gz')) file.write_bytes(b'corrupted') with self.assertRaisesRegex(ValueError, 'checksum mismatch'): release.verify(args) def test_missing_extra_and_wrong_commit_artifacts_are_rejected(self): args = self.fixture_artifacts(True) args.sha = 'b' * 40 with self.assertRaises(ValueError): release.verify(args) args.sha = 'a' * 40 Path('release-input/unexpected').write_text('not an asset') with self.assertRaises(ValueError): release.verify(args) Path('release-input/unexpected').unlink() next(Path('release-input').glob('*.tar.gz')).unlink() with self.assertRaises(FileNotFoundError): release.verify(args) def test_linux_context_preserves_binary_executable_mode(self): args = self.fixture_artifacts() Path('Dockerfile.goreleaser').write_text('FROM scratch\nCOPY sub2api /sub2api\n') Path('deploy').mkdir() Path('deploy/docker-entrypoint.sh').write_text('#!/bin/sh\nexec /app/sub2api\n') Path('backend/resources').mkdir() Path('backend/resources/data').write_text('fixture') release.contexts(args) for arch in ('amd64', 'arm64'): binary = Path('contexts') / arch / 'sub2api' self.assertEqual(binary.read_bytes(), b'fixture') self.assertEqual(binary.stat().st_mode & 0o777, 0o755) def test_plan_requires_a_tag_for_publication(self): args = argparse.Namespace(ref='main', dry_run=False, simple=False) with patch.object(subprocess, 'check_output', return_value='a' * 40 + '\n'): with self.assertRaisesRegex(ValueError, 'version tag'): release.plan(args) args.ref = 'v9.8.7' with patch.object(subprocess, 'check_output', side_effect=['a' * 40 + '\n', 'b' * 40 + '\n']): with self.assertRaisesRegex(ValueError, 'does not match'): release.plan(args) def test_dry_run_plan_resolves_matrix_without_a_new_tag(self): with patch.dict(os.environ, {'GITHUB_OUTPUT': 'outputs', 'GITHUB_REPOSITORY_OWNER': 'ExampleOwner'}), patch.object(subprocess, 'check_output', return_value='a' * 40 + '\n'): release.plan(argparse.Namespace(ref='feature/matrix', dry_run=True, simple=False)) output = dict(line.split('=', 1) for line in Path('outputs').read_text().splitlines()) self.assertEqual(output['dry_run'], 'true') self.assertEqual(output['owner_lower'], 'exampleowner') self.assertEqual(len(json.loads(output['matrix'])['include']), 5) def test_docker_commands_do_not_publish_during_dry_run(self): fake_bin = Path('bin') fake_bin.mkdir() docker = fake_bin / 'docker' docker.write_text('#!/bin/sh\nprintf "%s\\n" "$*" >> "$DOCKER_LOG"\n') docker.chmod(0o755) env = {**os.environ, 'PATH': str(fake_bin.resolve()) + os.pathsep + os.environ['PATH'], 'DOCKER_LOG': str(Path('docker.log').resolve()), 'RUNNER_TEMP': self.temp.name, 'RELEASE_VERSION': '9.8.7', 'RELEASE_SHA': 'a' * 40, 'GITHUB_REPOSITORY': 'ExampleOwner/sub2api', 'DRY_RUN': 'true', 'SIMPLE_RELEASE': 'false', 'DOCKERHUB_USERNAME': 'skip'} subprocess.run(['bash', str(ROOT / '.github/release-tools/release-images.sh')], env=env, check=True) log = Path('docker.log').read_text() self.assertEqual(log.count('buildx build'), 2) self.assertIn('linux/arm64', log) self.assertNotIn('--push', log) self.assertNotIn('imagetools', log) self.assertNotIn('skip/sub2api', log) self.assertIn('ghcr.io/exampleowner/sub2api', log) def test_published_full_and_simple_image_tags(self): fake_bin = Path('bin') fake_bin.mkdir() docker = fake_bin / 'docker' docker.write_text('#!/bin/sh\nprintf "%s\\n" "$*" >> "$DOCKER_LOG"\n') docker.chmod(0o755) for simple in (False, True): with self.subTest(simple=simple): log_path = Path(f'docker-{simple}.log').resolve() env = {**os.environ, 'PATH': str(fake_bin.resolve()) + os.pathsep + os.environ['PATH'], 'DOCKER_LOG': str(log_path), 'RUNNER_TEMP': self.temp.name, 'RELEASE_VERSION': '9.8.7', 'RELEASE_SHA': 'a' * 40, 'GITHUB_REPOSITORY': 'ExampleOwner/sub2api', 'DRY_RUN': 'false', 'SIMPLE_RELEASE': str(simple).lower(), 'DOCKERHUB_USERNAME': 'fixturehub'} subprocess.run(['bash', str(ROOT / '.github/release-tools/release-images.sh')], env=env, check=True) log = log_path.read_text() self.assertIn('--push', log) self.assertEqual(log.count('buildx build'), 1 if simple else 2) if simple: self.assertNotIn('fixturehub', log) self.assertNotIn('imagetools', log) self.assertIn('ghcr.io/exampleowner/sub2api:latest', log) else: self.assertEqual(log.count('imagetools create'), 2) self.assertIn('fixturehub/sub2api:9.8', log) self.assertIn('ghcr.io/exampleowner/sub2api:9', log) if __name__ == '__main__': unittest.main()