From b84d61331f016c3df51c48f5a729ceb92e2a6d8f Mon Sep 17 00:00:00 2001 From: felipe <35511790+felicabrera@users.noreply.github.com> Date: Fri, 14 Aug 2026 17:10:53 -0300 Subject: [PATCH] ci: publish dev builds as nightly prereleases (#2959) (#2963) --- .github/workflows/pyinstaller.yml | 49 ++++++++++++++-- tests/test_workflows.py | 96 +++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+), 6 deletions(-) create mode 100644 tests/test_workflows.py diff --git a/.github/workflows/pyinstaller.yml b/.github/workflows/pyinstaller.yml index 60c539f..0d037b6 100644 --- a/.github/workflows/pyinstaller.yml +++ b/.github/workflows/pyinstaller.yml @@ -4,6 +4,24 @@ on: push: branches: [main, dev] +# Two builds racing on the same branch could leave the nightly tag pointing at +# one commit while the .exe attached to the release was built from the other. +concurrency: + group: pyinstaller-${{ github.ref }} + cancel-in-progress: true + +# Needed by "Move the nightly tag to the built commit" below, which force-updates +# a ref under refs/tags/. +permissions: + contents: write + +env: + # Deliberately prefixed. A tag named after the branch it was built from shadows + # that branch, and a bare `main` then resolves to the tag in every clone: + # `git rev-parse main` warns "refname 'main' is ambiguous" and answers with the + # tag, not the branch head. + NIGHTLY_TAG: nightly-${{ github.ref_name }} + jobs: build: runs-on: ubuntu-latest @@ -41,6 +59,20 @@ jobs: with: name: maigret_standalone_win32 + # release-action creates a tag only when it does not exist yet, and GitHub + # ignores target_commitish once the tag is there ("Unused if the Git tag + # already exists"). So the tag has to be moved here, otherwise it stays + # pinned to the first build forever while the attached .exe keeps being + # replaced on every push. + - name: Move the nightly tag to the built commit + if: success() + run: | + set -euo pipefail + git tag --force "$NIGHTLY_TAG" "$GITHUB_SHA" + # Tag pushes do not match `on.push.branches`, and pushes authenticated + # with GITHUB_TOKEN never start a workflow run, so this cannot recurse. + git push --force origin "refs/tags/$NIGHTLY_TAG" + - name: Create New Release and Upload PyInstaller Binary to Release if: success() uses: ncipollo/release-action@v1.14.0 @@ -48,18 +80,23 @@ jobs: with: allowUpdates: true draft: false - prerelease: false + # A development build must never outrank a stable release. `prerelease` + # keeps it out of /releases/latest, and `makeLatest: false` stops it + # from stealing the marker back on the next push to main. + prerelease: true artifactErrorsFailBuild: true - makeLatest: true + makeLatest: false replacesArtifacts: true artifacts: maigret_standalone.exe name: Development Windows Release [${{ github.ref_name }}] - tag: ${{ github.ref_name }} + tag: ${{ env.NIGHTLY_TAG }} + commit: ${{ github.sha }} body: | - This is a development release built from the **${{ github.ref_name }}** branch. + This is a development release built from the **${{ github.ref_name }}** branch, at commit ${{ github.sha }}. + The `${{ env.NIGHTLY_TAG }}` tag is moved to that commit on every build, so it always matches the binary attached below. - Take into account that `dev` releases may be unstable. - Please, use [the development release](https://github.com/soxoj/maigret/releases/tag/main) build from the **main** branch. + It is **not** a stable release — for that, see [the latest release](https://github.com/soxoj/maigret/releases/latest). + ${{ github.ref_name == 'dev' && 'The `dev` branch is more experimental than `main`. Unless you need a specific unreleased fix, prefer [the `main` development build](https://github.com/soxoj/maigret/releases/tag/nightly-main).' || '' }} ## How to run diff --git a/tests/test_workflows.py b/tests/test_workflows.py new file mode 100644 index 0000000..43d8405 --- /dev/null +++ b/tests/test_workflows.py @@ -0,0 +1,96 @@ +"""Guards for the release-publishing GitHub Actions workflow. + +Regression tests for #2959. The PyInstaller development build used to publish +itself with ``makeLatest: true`` under a tag named after the branch it was built +from, which caused three separate problems: + +1. the Windows dev build held the repository's "Latest release" marker ahead of + every stable release, and stole it back on every push to ``main``; +2. the tag never moved, so it kept naming the first build while the attached + ``.exe`` was replaced on every push; +3. the tags ``main`` / ``dev`` shadowed the branches of the same name, making a + bare ``main`` refname ambiguous in every clone. + +These tests read the workflow as data, so they fail if any of the three +conditions is reintroduced. +""" + +import os + +import pytest + +yaml = pytest.importorskip("yaml") + +WORKFLOW_PATH = os.path.join( + os.path.dirname(os.path.dirname(os.path.realpath(__file__))), + ".github", + "workflows", + "pyinstaller.yml", +) + +RELEASE_ACTION = "ncipollo/release-action" + + +@pytest.fixture(scope="module") +def workflow(): + with open(WORKFLOW_PATH, encoding="utf-8") as f: + return yaml.safe_load(f) + + +@pytest.fixture(scope="module") +def release_step(workflow): + steps = workflow["jobs"]["build"]["steps"] + matching = [s for s in steps if RELEASE_ACTION in s.get("uses", "")] + assert len(matching) == 1, f"expected exactly one {RELEASE_ACTION} step" + return matching[0] + + +def _push_branches(workflow): + # PyAML resolves the bare `on` key to the boolean True (YAML 1.1 treats it as + # a truthy literal), so accept either spelling. + triggers = workflow.get("on", workflow.get(True)) + return triggers["push"]["branches"] + + +def _resolve(expression, workflow, branch): + """Expand the workflow-level env and `github.ref_name` in an expression.""" + for name, value in (workflow.get("env") or {}).items(): + expression = expression.replace("${{ env.%s }}" % name, str(value)) + return expression.replace("${{ github.ref_name }}", branch) + + +def test_dev_build_is_a_prerelease(release_step): + # Keeps the build out of /releases/latest and out of the `release: released` + # event that publishes to PyPI. + assert str(release_step["with"]["prerelease"]).lower() == "true" + + +def test_dev_build_never_claims_the_latest_marker(release_step): + assert str(release_step["with"]["makeLatest"]).lower() == "false" + + +def test_release_tag_does_not_shadow_a_branch(workflow, release_step): + tag = release_step["with"]["tag"] + for branch in _push_branches(workflow): + resolved = _resolve(tag, workflow, branch) + assert resolved != branch, ( + f"tag {resolved!r} shadows the {branch!r} branch: a bare " + f"{branch!r} refname would resolve to the tag in every clone" + ) + assert resolved, f"tag resolved to an empty string for branch {branch!r}" + + +def test_release_tag_is_moved_to_the_built_commit(workflow, release_step): + # release-action only creates a tag when it is missing, and GitHub ignores + # target_commitish for an existing tag, so an explicit push is what keeps the + # tag in step with the attached binary. + tag = release_step["with"]["tag"] + scripts = [s["run"] for s in workflow["jobs"]["build"]["steps"] if "run" in s] + moves_tag = any( + "refs/tags/" in script and "--force" in script for script in scripts + ) + assert moves_tag, ( + f"no step force-pushes {tag!r}; without it the tag stays pinned to the " + "first build while the release keeps getting new binaries" + ) + assert workflow.get("permissions", {}).get("contents") == "write"