From f4b6e028a51c57e1d463995da748954bdbbd470a Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Mon, 21 Sep 2026 21:05:48 -0500 Subject: [PATCH] release-health re-folds and retries instead of losing a report One release reports several distributions -- audiocomponents publishes two from one tag, audioif three -- and each dispatches its own release-health event. A repository_dispatch freezes github.sha at creation time, so the second run checks out a tip that predates the first run's commit and its push is rejected. The concurrency group serializes the jobs but cannot fix the checkout. That is how pydevices-audioeffects' row sat stale at 0.1.1 while pydevices-audioinstruments right above it read 0.2.0. The sibling fix in reusable-synchronize-mip-package rebases and retries, but a rebase is wrong here: RELEASE_HEALTH.md is regenerated whole and data.json is one sorted object, so replaying our diff onto the new tip conflicts or drops the other distribution's row. Instead the fold moves into a file and the step loops: on a rejection, discard the commit, take the branch tip, and fold the payload in again. The fold is idempotent, so the result carries both rows. The commit message also takes the payload through env rather than interpolating it into the shell. tests/test_release_health.py lifts the step out of the YAML and runs it against real repositories, staging the race with two clones of one tip. Closes #34 --- .github/workflows/release-health.yml | 41 +++++-- tests/test_release_health.py | 165 +++++++++++++++++++++++++++ 2 files changed, 199 insertions(+), 7 deletions(-) create mode 100644 tests/test_release_health.py diff --git a/.github/workflows/release-health.yml b/.github/workflows/release-health.yml index bbb2c55..f15bf86 100644 --- a/.github/workflows/release-health.yml +++ b/.github/workflows/release-health.yml @@ -19,11 +19,23 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - - name: Fold in the report and regenerate the page + - name: Fold in the report, regenerate the page, and commit env: PAYLOAD: ${{ toJSON(github.event.client_payload) }} + DISTRIBUTION: ${{ github.event.client_payload.distribution }} + RELEASE_VERSION: ${{ github.event.client_payload.version }} run: | - python3 - <<'PY' + # The fold goes in a file because it has to be re-runnable: one + # release reports several distributions, each dispatching its own + # event, and a repository_dispatch freezes github.sha at creation + # time -- so a second run checks out a tip that predates the first + # run's commit and its push is rejected (.github#34). A plain rebase + # is not enough here: RELEASE_HEALTH.md is regenerated whole and + # data.json is one sorted object, so replaying our diff onto the new + # tip conflicts or drops the other distribution's row. Instead, + # discard our commit, take the branch tip, and fold this payload in + # again -- the fold is idempotent, so the result carries both rows. + cat > "$RUNNER_TEMP/fold.py" <<'PY' import json, os, datetime p = json.loads(os.environ["PAYLOAD"]) path = "release-health/data.json" @@ -50,10 +62,25 @@ jobs: "| Distribution | Version | TestPyPI | Assets | MIP | PyPI | Updated |\n" "|---|---|---|---|---|---|---|\n" + "\n".join(rows) + "\n") PY - - name: Commit - run: | + git config user.name "pydevices-release-automation[bot]" git config user.email "pydevices-release-automation[bot]@users.noreply.github.com" - git add RELEASE_HEALTH.md release-health/data.json - git diff --cached --quiet || git commit -m "release-health: ${{ github.event.client_payload.distribution }} ${{ github.event.client_payload.version }}" - git push + BRANCH=$(git rev-parse --abbrev-ref HEAD) + + for attempt in 1 2 3; do + python3 "$RUNNER_TEMP/fold.py" + git add RELEASE_HEALTH.md release-health/data.json + if git diff --cached --quiet; then + echo "Nothing to record for $DISTRIBUTION $RELEASE_VERSION." + exit 0 + fi + git commit -q -m "release-health: $DISTRIBUTION $RELEASE_VERSION" + if git push origin "HEAD:$BRANCH"; then + exit 0 + fi + echo "push rejected (attempt $attempt); re-folding onto the branch tip" + git fetch --quiet origin "$BRANCH" + git reset --quiet --hard FETCH_HEAD + done + echo "::error::release-health could not push after 3 attempts" + exit 1 diff --git a/tests/test_release_health.py b/tests/test_release_health.py new file mode 100644 index 0000000..977f10a --- /dev/null +++ b/tests/test_release_health.py @@ -0,0 +1,165 @@ +"""Tests for .github/workflows/release-health.yml. + +These lift the step's `run:` body out of the YAML and run it against real git +repositories, so what is exercised is the workflow's own text. The case that +matters is .github#34: one release reports several distributions, each +dispatching its own event from a frozen SHA, and the second run's push is +rejected. +""" + +from __future__ import annotations + +import json +import subprocess +import tempfile +import textwrap +import unittest +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +HEALTH_WORKFLOW = REPO / ".github/workflows/release-health.yml" + + +def update_step_shell() -> str: + """The `run:` body of the fold-and-commit step.""" + text = HEALTH_WORKFLOW.read_text(encoding="utf-8") + start = text.index("- name: Fold in the report, regenerate the page, and commit") + block = text[start:] + begin = block.index("run: |\n") + len("run: |\n") + return textwrap.dedent(block[begin:]) + + +def git(cwd: Path, *args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["git", "-C", str(cwd), *args], + check=True, + capture_output=True, + text=True, + ) + + +def payload(distribution: str, version: str) -> str: + return json.dumps( + { + "distribution": distribution, + "version": version, + "run_url": f"https://example.invalid/{distribution}", + "testpypi": "success", + "assets": "success", + "mip": "success", + "pypi": "skipped", + } + ) + + +def run_step(clone: Path, distribution: str, version: str) -> subprocess.CompletedProcess[str]: + """Run the workflow step in `clone`, as one dispatched report would.""" + script = clone / ".step.sh" + script.write_text(update_step_shell(), encoding="utf-8") + runner_temp = clone.parent / f"runner-temp-{distribution}" + runner_temp.mkdir(exist_ok=True) + result = subprocess.run( + ["bash", "-e", ".step.sh"], + cwd=clone, + env={ + "PATH": "/usr/bin:/bin", + "HOME": str(clone.parent), + "RUNNER_TEMP": str(runner_temp), + "PAYLOAD": payload(distribution, version), + "DISTRIBUTION": distribution, + "RELEASE_VERSION": version, + }, + capture_output=True, + text=True, + check=False, + ) + script.unlink() + return result + + +class ConcurrentReportTests(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + root = Path(self._tmp.name) + self.origin = root / "origin.git" + subprocess.run( + ["git", "init", "-q", "--bare", "-b", "main", str(self.origin)], + check=True, + capture_output=True, + ) + + seed = root / "seed" + git(root, "clone", "-q", str(self.origin), str(seed)) + git(seed, "config", "user.email", "test@example.invalid") + git(seed, "config", "user.name", "Test") + (seed / "release-health").mkdir() + (seed / "release-health/data.json").write_text("{}\n", encoding="utf-8") + (seed / "RELEASE_HEALTH.md").write_text("# Release health\n", encoding="utf-8") + git(seed, "add", "-A") + git(seed, "commit", "-q", "-m", "seed") + git(seed, "push", "-q", "origin", "main") + + # Two checkouts of the same tip: what two reports from one release get, + # because a repository_dispatch freezes github.sha at creation time. + self.first = root / "first" + self.second = root / "second" + for clone in (self.first, self.second): + git(root, "clone", "-q", str(self.origin), str(clone)) + git(clone, "config", "user.email", "test@example.invalid") + git(clone, "config", "user.name", "Test") + + def tearDown(self): + self._tmp.cleanup() + + def published(self) -> dict: + with tempfile.TemporaryDirectory() as tmp: + out = Path(tmp) / "check" + subprocess.run( + ["git", "clone", "-q", str(self.origin), str(out)], + check=True, + capture_output=True, + ) + return { + "data": json.loads((out / "release-health/data.json").read_text()), + "page": (out / "RELEASE_HEALTH.md").read_text(encoding="utf-8"), + } + + def test_two_reports_from_one_release_both_land(self): + """.github#34: the second report must not lose its row to a rejection.""" + first = run_step(self.first, "pydevices-audioinstruments", "0.2.0") + self.assertEqual(first.returncode, 0, first.stdout + first.stderr) + + second = run_step(self.second, "pydevices-audioeffects", "0.2.0") + self.assertEqual(second.returncode, 0, second.stdout + second.stderr) + self.assertIn("push rejected (attempt 1)", second.stdout + second.stderr) + + state = self.published() + self.assertEqual( + sorted(state["data"]), + ["pydevices-audioeffects", "pydevices-audioinstruments"], + ) + self.assertEqual(state["data"]["pydevices-audioeffects"]["version"], "0.2.0") + self.assertEqual( + state["data"]["pydevices-audioinstruments"]["version"], "0.2.0" + ) + # Both rows are on the page, not just the one that pushed first. + self.assertIn("| [pydevices-audioeffects]", state["page"]) + self.assertIn("| [pydevices-audioinstruments]", state["page"]) + + def test_an_uncontended_report_pushes_first_time(self): + result = run_step(self.first, "pydevices-audiodsp", "0.5.1") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertNotIn("push rejected", result.stdout + result.stderr) + self.assertEqual(list(self.published()["data"]), ["pydevices-audiodsp"]) + + def test_a_repeated_report_is_recorded_not_skipped(self): + """A re-reported distribution changes its `updated` stamp, so it commits.""" + self.assertEqual(run_step(self.first, "pydevices-cmods", "0.1.0").returncode, 0) + git(self.first, "pull", "-q", "--ff-only") + second = run_step(self.first, "pydevices-cmods", "0.1.1") + self.assertEqual(second.returncode, 0, second.stdout + second.stderr) + self.assertEqual(self.published()["data"]["pydevices-cmods"]["version"], "0.1.1") + + +if __name__ == "__main__": + unittest.main()