From c0059527266f71b057807b66eabee8c854af8765 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Fri, 16 Oct 2026 22:17:12 -0500 Subject: [PATCH] feat(release): publish the farming analyzer as a release asset Attach a deterministic atrinik-tools-farming-analyzer-.tar.gz and SHA256SUMS to each release, so consumers such as the autonomous playtester can pin the analyzer by digest. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/check.yml | 2 +- .github/workflows/release.yml | 1 + .releaserc.json | 3 +- farming_analyzer/README.md | 11 +++ scripts/release/package-farming-analyzer.sh | 35 ++++++++++ tests/test_farming_analyzer_release.py | 74 +++++++++++++++++++++ 6 files changed, 124 insertions(+), 2 deletions(-) create mode 100755 scripts/release/package-farming-analyzer.sh create mode 100644 tests/test_farming_analyzer_release.py diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index df34ec1..ef34161 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -24,7 +24,7 @@ jobs: - name: Validate diagnostic utilities run: | python3 -m unittest discover -s tests -v - shellcheck split_symbols.sh + shellcheck split_symbols.sh scripts/release/package-farming-analyzer.sh - name: Preflight Content catalog release env: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2cf6250..6848c47 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,4 +37,5 @@ jobs: npx --yes --package=semantic-release@25.0.9 --package=conventional-changelog-conventionalcommits@9.3.1 + --package=@semantic-release/exec@7.1.0 semantic-release diff --git a/.releaserc.json b/.releaserc.json index 3e0e902..d5bede1 100644 --- a/.releaserc.json +++ b/.releaserc.json @@ -4,6 +4,7 @@ "plugins": [ ["@semantic-release/commit-analyzer", {"preset": "conventionalcommits", "releaseRules": "./scripts/release/release-rules.cjs"}], ["@semantic-release/release-notes-generator", {"preset": "conventionalcommits"}], - ["@semantic-release/github", {"successComment": false, "failComment": false}] + ["@semantic-release/exec", {"publishCmd": "scripts/release/package-farming-analyzer.sh v${nextRelease.version} build/release"}], + ["@semantic-release/github", {"assets": [{"path": "build/release/*"}], "successComment": false, "failComment": false}] ] } diff --git a/farming_analyzer/README.md b/farming_analyzer/README.md index 68afe47..ee39d37 100644 --- a/farming_analyzer/README.md +++ b/farming_analyzer/README.md @@ -453,6 +453,17 @@ estimates, not guaranteed live measurements. A scan holds the supplied skill level constant; if a route grants more than one level, rerun the analyzer at the new level because the XP colors and cap will change during real play. +## Release asset + +Each tools release attaches `atrinik-tools-farming-analyzer-.tar.gz` +and its `SHA256SUMS`, so a consumer such as the autonomous playtester can pin +the analyzer by digest. The archive holds only `LICENSE` and `farming_analyzer/` +under one `atrinik-tools-farming-analyzer-/` directory. It is built by +`scripts/release/package-farming-analyzer.sh` from the release tag with +`git archive` and `gzip -n`, so the same tag always yields the same bytes. The +analyzer stays standard-library only. Consumers supply `--content-root` and +`--server-root` from their own pinned content and Classic server sources. + ## Validation Run the focused regression suite from the repository root: diff --git a/scripts/release/package-farming-analyzer.sh b/scripts/release/package-farming-analyzer.sh new file mode 100755 index 0000000..c9a503f --- /dev/null +++ b/scripts/release/package-farming-analyzer.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Package the MIT farming analyzer as a deterministic release asset so +# consumers can pin it by digest instead of a mutable source archive. +set -euo pipefail + +if [[ $# -ne 2 ]]; then + echo "usage: $0 TAG OUTPUT_DIRECTORY" >&2 + exit 2 +fi + +tag=$1 +output_directory=$2 +if [[ ! ${tag} =~ ^v([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then + echo "invalid release tag: ${tag}" >&2 + exit 1 +fi + +version=${BASH_REMATCH[1]} +source_commit=$(git rev-parse "${tag}^{commit}") +if ! git merge-base --is-ancestor "${source_commit}" refs/heads/main && + ! git merge-base --is-ancestor "${source_commit}" refs/remotes/origin/main; then + echo "release tag is not reachable from main: ${tag}" >&2 + exit 1 +fi + +package=atrinik-tools-farming-analyzer-${version} +mkdir -p "${output_directory}" +# git archive records the commit time for every member; gzip -n omits the +# name and timestamp, so the same tag always yields the same bytes. +git archive --format=tar --prefix="${package}/" "${source_commit}" LICENSE farming_analyzer | + gzip -9n >"${output_directory}/${package}.tar.gz" +( + cd "${output_directory}" + sha256sum "${package}.tar.gz" >SHA256SUMS +) diff --git a/tests/test_farming_analyzer_release.py b/tests/test_farming_analyzer_release.py new file mode 100644 index 0000000..6df120e --- /dev/null +++ b/tests/test_farming_analyzer_release.py @@ -0,0 +1,74 @@ +"""The farming analyzer release asset is deterministic and contains only its package.""" + +import hashlib +import shutil +import subprocess +import tarfile +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts" / "release" / "package-farming-analyzer.sh" + + +@unittest.skipUnless(shutil.which("git"), "git is required") +class FarmingAnalyzerReleaseAssetTests(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory() + self.repo = Path(self.directory.name) / "repo" + self.repo.mkdir() + self.git("init", "-q", "-b", "main") + (self.repo / "LICENSE").write_text("MIT\n") + (self.repo / "farming_analyzer").mkdir() + (self.repo / "farming_analyzer" / "__init__.py").write_text('"""Analyzer."""\n') + (self.repo / "map-checker-qt").mkdir() + (self.repo / "map-checker-qt" / "LICENSE").write_text("GPL\n") + self.git("add", ".") + self.git("commit", "-q", "-m", "release") + self.git("tag", "v1.2.3") + + def tearDown(self): + self.directory.cleanup() + + def git(self, *args): + return subprocess.run( + ["git", "-C", str(self.repo), "-c", "user.name=t", "-c", "user.email=t@example.invalid", + "-c", "commit.gpgsign=false", "-c", "tag.gpgsign=false", *args], + check=True, capture_output=True, text=True).stdout + + def package(self, tag, output): + return subprocess.run(["bash", str(SCRIPT), tag, str(output)], cwd=self.repo, + capture_output=True, text=True) + + def test_asset_is_deterministic_and_scoped_to_the_analyzer(self): + first, second = Path(self.directory.name) / "a", Path(self.directory.name) / "b" + for output in (first, second): + result = self.package("v1.2.3", output) + self.assertEqual(result.returncode, 0, result.stderr) + name = "atrinik-tools-farming-analyzer-1.2.3.tar.gz" + data = (first / name).read_bytes() + self.assertEqual(data, (second / name).read_bytes()) + self.assertEqual((first / "SHA256SUMS").read_text(), + f"{hashlib.sha256(data).hexdigest()} {name}\n") + with tarfile.open(first / name) as archive: + members = sorted(member.name for member in archive.getmembers() if member.isfile()) + self.assertEqual(members, [ + "atrinik-tools-farming-analyzer-1.2.3/LICENSE", + "atrinik-tools-farming-analyzer-1.2.3/farming_analyzer/__init__.py", + ]) + + def test_rejects_malformed_and_unreleased_tags(self): + output = Path(self.directory.name) / "out" + self.assertNotEqual(self.package("1.2.3", output).returncode, 0) + self.git("checkout", "-q", "-b", "side") + (self.repo / "LICENSE").write_text("changed\n") + self.git("commit", "-q", "-am", "side") + self.git("tag", "v9.9.9") + result = self.package("v9.9.9", output) + self.assertNotEqual(result.returncode, 0) + self.assertIn("not reachable from main", result.stderr) + + +if __name__ == "__main__": + unittest.main()