diff --git a/.github/workflows/push.yaml b/.github/workflows/push.yaml index dee106473..bac9fe30f 100644 --- a/.github/workflows/push.yaml +++ b/.github/workflows/push.yaml @@ -57,9 +57,9 @@ jobs: fetch: false Test: runs-on: ubuntu-latest - # Slowest observed 2026-10-06..08: 4 minutes. Docs waits on this job - # (Publish does not). Fail a hang instead of holding a shared runner for - # the 6-hour default. + # Slowest observed 2026-10-06..08: 4 minutes. Publish waits on this job, + # and Docs waits on both. Fail a hang instead of holding a shared runner + # for the 6-hour default. timeout-minutes: 45 if: | (github.repository == 'PolicyEngine/policyengine-core') @@ -112,6 +112,8 @@ jobs: folder: docs/_build/html Publish: runs-on: ubuntu-latest + # Upload a release to PyPI only after its tests pass. + needs: Test if: | (github.repository == 'PolicyEngine/policyengine-core') && (github.event.head_commit.message == 'Update PolicyEngine Core') diff --git a/changelog.d/fix-publish-needs-test.fixed.md b/changelog.d/fix-publish-needs-test.fixed.md new file mode 100644 index 000000000..e37fd7989 --- /dev/null +++ b/changelog.d/fix-publish-needs-test.fixed.md @@ -0,0 +1 @@ +The release workflow now publishes to PyPI only after the Test job passes, instead of running Publish beside Test. diff --git a/tests/core/test_release_tagging.py b/tests/core/test_release_tagging.py index 12762cbfd..c364128ef 100644 --- a/tests/core/test_release_tagging.py +++ b/tests/core/test_release_tagging.py @@ -1,3 +1,4 @@ +import re import shutil import subprocess import sys @@ -309,3 +310,32 @@ def test_publish_workflow_check_accepts_equivalent_settings(mutate): mutate(jobs) assert_publish_tags_only_after_pypi_succeeds(jobs["Publish"]) + + +def test_publish_workflow_publishes_only_after_tests_pass(): + # Without ``needs: Test``, Publish ran beside Test, so a release could + # reach PyPI while its tests were still running or after they failed. + jobs = yaml.safe_load(PUSH_WORKFLOW.read_text())["jobs"] + publishing = [ + name + for name, job in jobs.items() + if any( + "gh-action-pypi-publish" in step.get("uses", "") + for step in job.get("steps", []) + ) + ] + + assert publishing == ["Publish"] + needs = jobs["Publish"].get("needs", []) + assert "Test" in ([needs] if isinstance(needs, str) else needs) + # A job that needs Test is skipped whenever Test is, so both must run on + # the same commits for releases to happen at all. + assert jobs["Publish"]["if"] == jobs["Test"]["if"] + # A status function such as always() would let Publish run after a + # failed Test; without one, Publish runs only when Test succeeds. + # (Expression function names are matched case-insensitively.) + assert not re.search( + r"\b(always|failure|cancelled|success)\s*\(", + jobs["Publish"]["if"], + re.IGNORECASE, + )