Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 2 additions & 1 deletion .releaserc.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"}],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Build the asset before creating the release tag

If this packaging command fails on a mainline release—for example because git archive, gzip, or sha256sum errors—semantic-release 25.0.9 has already created and pushed the tag before invoking publish plugins, as shown by the pinned implementation's tag and publish ordering. On rerun that tag is selected as the last release and there are no remaining commits to publish, per the release-selection path, leaving the version without a GitHub release or assets unless a maintainer manually removes the tag. Generate the archive with prepareCmd from the pending release commit so packaging failures occur before tag creation.

AGENTS.md reference: AGENTS.md:L32-L33

Useful? React with 👍 / 👎.

["@semantic-release/github", {"assets": [{"path": "build/release/*"}], "successComment": false, "failComment": false}]
]
}
11 changes: 11 additions & 0 deletions farming_analyzer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<version>.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-<version>/` 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:
Expand Down
35 changes: 35 additions & 0 deletions scripts/release/package-farming-analyzer.sh
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +20 to +21

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Permit packaging releases from maintenance branches

When the release workflow runs on a supported N.N.x branch, its new tag normally points to a maintenance-only commit that is not an ancestor of either local or remote main, so both checks fail and the publishCmd aborts before @semantic-release/github can upload the assets. Validate reachability against the active release branch instead of hard-coding main, otherwise maintenance squash merges cannot be released.

AGENTS.md reference: AGENTS.md:L32-L33

Useful? React with 👍 / 👎.

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"
Comment on lines +30 to +31

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Fix the archive umask before hashing

When a release runs with a non-default repository, global, or system tar.umask, git archive applies that setting to member permission bits, producing different bytes and a different digest for the same tag; for example, tar.umask=0002 and 0022 yield different archives here. This contradicts the documented reproducibility guarantee, so invoke git archive with an explicit tar.umask before hashing it; git-config(1) documents that this variable restricts the permission bits of tar archive entries.

Useful? React with 👍 / 👎.

(
cd "${output_directory}"
sha256sum "${package}.tar.gz" >SHA256SUMS
)
74 changes: 74 additions & 0 deletions tests/test_farming_analyzer_release.py
Original file line number Diff line number Diff line change
@@ -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()
Loading