diff --git a/.github/maven-release/README.md b/.github/maven-release/README.md index 43820f8..588f173 100644 --- a/.github/maven-release/README.md +++ b/.github/maven-release/README.md @@ -13,7 +13,14 @@ If CurseForge has accepted the file but it isn't public yet, wait for approval. Start a new run with the recorded CurseForge file ID so it verifies that upload without submitting the jar again. The release workflow sets Java 8 through Minecraft 1.16.5, Java 16 for Minecraft 1.17.1, and Java 17 for Minecraft 1.18.2 -through 1.20.1, and Java 21 for Minecraft 1.20.6, 1.21.1 and 1.21.11. +through 1.20.1, Java 21 for Minecraft 1.20.6, 1.21.1 and 1.21.11, and Java 25 +for Minecraft 26.1.2. The pure target helper in verify.py uses an explicit +whitelist; unqualified targets, including 26.2, are rejected. + +The dispatcher runs its offline tests before staging an artifact. Release +records still require the actual merged source, successful version-branch CI +run, artifact ID and checksums. A prepared route is not an approved release +record and does not publish anything. The GitHub release targets the exact version-branch source recorded in `releases.json`, not the publication workflow's default branch. diff --git a/.github/maven-release/test_verify.py b/.github/maven-release/test_verify.py new file mode 100644 index 0000000..d2ae018 --- /dev/null +++ b/.github/maven-release/test_verify.py @@ -0,0 +1,90 @@ +import contextlib +import inspect +import io +from pathlib import Path +import re +import unittest +from unittest.mock import patch + +import verify + + +class ReleaseTargetTests(unittest.TestCase): + def test_released_minecraft_lines_keep_their_runtime(self): + for java, versions in { + 8: ("1.10.2", "1.12.2", "1.13.2", "1.14.4", "1.15.2", "1.16.5"), + 16: ("1.17.1",), + 17: ("1.18.2", "1.19.4", "1.20.1"), + 21: ("1.20.6", "1.21.1", "1.21.11"), + 25: ("26.1.2",), + }.items(): + for version in versions: + with self.subTest(version=version): + self.assertEqual((version, f"Java {java}"), verify.release_target(f"master-{version}")) + + def test_unqualified_or_malformed_targets_are_rejected(self): + for branch in ("master-26.2", "master-26.2.0", "master-26.1.1", "master-26.1.3", + "master-1.21.12", "master-26.1.2-extra", "feature/26.1.2", + "26.1.2", "master-main", "master-26.1.2\n"): + with self.subTest(branch=branch), self.assertRaises(ValueError): + verify.release_target(branch) + + def test_missing_java25_entry_is_rejected(self): + source = inspect.getsource(verify.release_target) + marker = '"26.1.2": 25' + self.assertIn(marker, source) + mutated = source.replace(marker + ",", "") + namespace = {"require": verify.require} + exec(compile(mutated, "", "exec"), namespace) + with self.assertRaisesRegex(ValueError, "Unsupported Minecraft version"): + namespace["release_target"]("master-26.1.2") + + def test_target_cli_has_no_publication_side_effects(self): + output = io.StringIO() + with patch.object(verify.sys, "argv", ["verify.py", "target", "2.2.16.121111", "unused"]), \ + patch.object(verify, "github") as github, patch.object(verify, "read_url") as read_url, \ + patch.object(verify, "stage") as stage, contextlib.redirect_stdout(output): + verify.main() + self.assertEqual("minecraft=1.21.11\njava=Java 21\n", output.getvalue()) + github.assert_not_called() + read_url.assert_not_called() + stage.assert_not_called() + + def test_target_cli_requires_an_approved_version(self): + with patch.object(verify.sys, "argv", ["verify.py", "target", "0.0.0.9999999", "unused"]), \ + self.assertRaisesRegex(ValueError, "Version is not approved"): + verify.main() + + def test_dispatcher_retains_live_publication_guards(self): + workflow = (Path(__file__).resolve().parent.parent / "workflows/deploy-release.yml").read_text() + for marker in ("default: check-only", "github.event.repository.default_branch", + "inputs.mode == 'publish' && inputs.existing_curseforge_file_id == ''", + "inputs.mode == 'publish' && steps.preflight.outputs.state == 'absent'", + "Do not retry an upload.", "https://maven.moddev.zone/releases", + 'verify-curseforge "$RELEASE_VERSION" "$RELEASE_WORK"', + 'require-absent "$RELEASE_VERSION" "$RELEASE_WORK"'): + self.assertIn(marker, workflow) + self.assertIn('verify.py target "$RELEASE_VERSION" "$release_work"', workflow) + self.assertNotIn('capture("^master-', workflow) + self.assertIn("python3 -m unittest discover", workflow) + target = re.search(r'--verify-tag --target "\$(target|source_commit|RELEASE_SOURCE_COMMIT)"', + workflow) + self.assertIsNotNone(target) + guards = ('[[ "$' + target.group(1) + '" =~ ^[' + chars + ']{40}$ ]]' + for chars in ("a-f0-9", "0-9a-f")) + self.assertTrue(any(guard in workflow for guard in guards)) + + def test_ci_and_tag_provenance_stay_sealed(self): + source = Path(verify.__file__).read_text() + for marker in ('ci["head_sha"] == release["commit"]', + 'ci["event"] == "push"', 'ci["head_branch"] == release["branch"]', + 'ref["sha"] == release["commit"]', + 'details["digest"] == "sha256:" + release["archiveSha256"]', + 'sums[jars[0]] == release["mainSha256"]', + 'bundle.namelist().count(member) == 1'): + self.assertIn(marker, source) + + +if __name__ == "__main__": + unittest.main() + diff --git a/.github/maven-release/verify.py b/.github/maven-release/verify.py index b7b5eeb..fda046d 100644 --- a/.github/maven-release/verify.py +++ b/.github/maven-release/verify.py @@ -27,6 +27,19 @@ def digest(data): return hashlib.sha256(data).hexdigest() +def release_target(branch): + require(branch.startswith("master-"), "Release requires a version master branch") + minecraft = branch.removeprefix("master-") + java_versions = { + "1.10.2": 8, "1.12.2": 8, "1.13.2": 8, "1.14.4": 8, + "1.15.2": 8, "1.16.5": 8, "1.17.1": 16, "1.18.2": 17, + "1.19.4": 17, "1.20.1": 17, "1.20.6": 21, "1.21.1": 21, + "1.21.11": 21, "26.1.2": 25, + } + require(minecraft in java_versions, f"Unsupported Minecraft version: {minecraft}") + return minecraft, f"Java {java_versions[minecraft]}" + + def read_url(url, allow_absent=False): if url.startswith(MAVEN + "/"): # The CDN can cache pre-upload 404s even with a no-cache request header. @@ -161,7 +174,10 @@ def main(): manifest = json.loads(MANIFEST.read_text(encoding="utf-8")) require(version in manifest["releases"], f"Version is not approved: {version}") release, work = manifest["releases"][version], Path(folder) - if mode in ("stage", "stage-candidate"): + if mode == "target": + minecraft, java = release_target(release["branch"]) + print(f"minecraft={minecraft}\njava={java}") + elif mode in ("stage", "stage-candidate"): stage(manifest, version, release, work, candidate=mode == "stage-candidate") elif mode == "verify-curseforge": verify_curseforge(manifest, version, release, int(os.environ["CURSEFORGE_FILE_ID"]), diff --git a/.github/workflows/deploy-release.yml b/.github/workflows/deploy-release.yml index dd4a373..257b46c 100644 --- a/.github/workflows/deploy-release.yml +++ b/.github/workflows/deploy-release.yml @@ -60,6 +60,7 @@ jobs: set -euo pipefail [[ "$RELEASE_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]] [[ -z "$EXISTING_CF_FILE" || "$EXISTING_CF_FILE" =~ ^[1-9][0-9]*$ ]] + python3 -m unittest discover -s .github/maven-release -p 'test_*.py' release_work="$RUNNER_TEMP/release" python3 .github/maven-release/verify.py stage-candidate "$RELEASE_VERSION" "$release_work" bash gradlew -p .github/maven-release generatePomFileForMavenJavaPublication \ @@ -69,19 +70,11 @@ jobs: echo "Maven already has this release. Supply its existing CurseForge file ID." >&2 exit 1 fi - mc=$(jq -er --arg v "$RELEASE_VERSION" '.releases[$v].branch | capture("^master-(?1\\.[0-9]+\\.[0-9]+)$").mc' .github/maven-release/releases.json) - case "$mc" in - 1.10.2|1.12.2|1.13.2|1.14.4|1.15.2|1.16.5) java="Java 8" ;; - 1.17.1) java="Java 16" ;; - 1.18.2|1.19.4|1.20.1) java="Java 17" ;; - 1.20.6|1.21.1|1.21.11) java="Java 21" ;; - *) echo "Unsupported Minecraft version for this publisher: $mc" >&2; exit 1 ;; - esac - echo "minecraft=$mc" >> "$GITHUB_OUTPUT" - echo "java=$java" >> "$GITHUB_OUTPUT" + target=$(python3 .github/maven-release/verify.py target "$RELEASE_VERSION" "$release_work") + printf '%s\n' "$target" >> "$GITHUB_OUTPUT" echo "state=$state" >> "$GITHUB_OUTPUT" echo "RELEASE_WORK=$release_work" >> "$GITHUB_ENV" - echo "Verified $RELEASE_VERSION for Minecraft $mc. Maven: $state." >> "$GITHUB_STEP_SUMMARY" + echo "Verified $RELEASE_VERSION. $target. Maven: $state." >> "$GITHUB_STEP_SUMMARY" - name: Check publication credentials if: inputs.mode == 'publish'