diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f0bdedd7..22d09b35 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,12 +26,18 @@ name: release # Nothing is uploaded before them: the build job refuses every dispatch # until both environments exist and are limited so. Then, for each release: # 1. The release commit passes `tests` ON ITSELF, in a push or dispatch -# run of tests.yml. On main, the push run that landed it does that -# when that run finished green; one that failed does not, nor one -# that a later landing cancelled (each landing cancelled the one -# before until #593). Otherwise, and always on a `release/v` -# branch, which no push run tests: BEFORE TAGGING, dispatch tests.yml -# on a branch that points at the release commit, and wait for it: +# run of tests.yml. The push run that landed it on main, or that its +# push to a `release/v` branch started (once the branch holds +# #850's tests.yml, which runs on both), does that when that run +# finished green; one that failed does not, nor one that a later +# landing cancelled (each landing cancelled the one before until +# #593). A commit that came to a release branch by fast-forward +# already has main's push run. Once the branch holds #850's tests.yml, +# the fast-forward also starts the branch's own push run on that +# commit, and that newer run decides. Tag once it has finished green. +# Otherwise, for a commit that no finished green run tested: BEFORE +# TAGGING, dispatch tests.yml on a branch that points at the release +# commit, and wait for it: # git push origin :refs/heads/ # gh workflow run tests.yml --ref -R opensoft/openRepoShape # A `release/v` branch serves while it is still at that commit. @@ -55,6 +61,27 @@ name: release # A version is uploaded once. A bad upload is fixed by the next patch, never # by moving a tag (decision 4). # +# RELEASE BRANCHES AND BACK-PORTS (#850), on Brett Heap's ruling at +# 2026-10-17T19:52:26Z, "back-port to release/v1.1 as 1.1.1, open the issue": +# - main stays the trunk, and every change lands there first; a release +# is its annotated tag, never a branch; +# - `release/v` is cut at the tag `v.0`, and each patch +# `v.` is tagged on it; +# - a fix for a patch lands on main first, through its own review, CI and +# gate, and is then back-ported to `release/v`: by a fast-forward +# to main while main holds only the patch's work, and otherwise by a +# pull request to `release/v` of `git cherry-pick -x `, +# the squash commit main landed. The fast-forward stands unless a +# `release/*` ruleset requires a pull request (that ruleset waits on +# Brett Heap's word, #850); then every back-port is the cherry-pick; +# - a push runs the tests.yml its own commit holds, so a release branch +# runs it only once it holds #850's change: `release/v1.1` from its +# first fast-forward past it, or a cherry-pick of it; `release/v1.0` +# never has, and still needs a dispatch (step 1); +# - the patch is tagged on `release/v` once step 1 holds for that +# commit (its tests.yml runs have all finished and the newest is green), +# then released by steps 2 to 5. +# # DISPATCH ONLY. Nothing here runs on a push, a tag or a pull request: an # upload is an act Brett Heap approves. The version is the tag's (decision # 4): `v1.1.0` gives `1.1.0`, which the build writes into the stamp, diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e97dc97f..9c090853 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -4,38 +4,61 @@ name: tests # clones them over the filesystem. It never calls `gh`, never touches the # network, and must never create a real repository. # -# The FULL suite runs on LINUX ONLY, on every trigger: a push to `main`, a pull -# request and a manual run all use the one ubuntu job below. Brett Heap decided -# on 2026-10-15, for the whole estate, to drop the macOS and Windows test jobs. +# The FULL suite runs on LINUX ONLY, on every trigger: a push to `main` or to a +# release branch (`release/v.`), a pull request and a manual run +# all use the one ubuntu job below. Brett Heap decided on 2026-10-15, for the +# whole estate, to drop the macOS and Windows test jobs. # One exception, on Brett Heap's word at 2026-10-15T18:20:35Z, # "Option 1: keep a small Git Bash job for #596": the `tests-git-bash` job # below runs the launcher's and the installer's suites, and nothing else, on # Windows under Git Bash. # +# A RELEASE BRANCH'S PUSHES RUN IT TOO (#850). A patch is tagged on its +# `release/v.` branch, and `release.yml` releases a commit only +# once a push or dispatch run of this workflow passed `tests` on it, so a +# commit a release branch takes gets that run from its own push, with no +# dispatch by hand. `release/v*` takes each of them, and any other name of one +# segment that starts `release/v`: in a branch filter `*` matches no `/`. +# A push runs the tests.yml its own commit holds, so a release branch runs it +# only once it holds this change: `release/v1.1` from its first fast-forward +# past it, or a cherry-pick of it; `release/v1.0` never has, and still needs a +# dispatch. +# # The `package` job at the end runs no pytest. It builds the openreposhape # wheel and installs it with pipx, so what it checks needs PyPI (the build # backend, git-filter-repo; #563). The other jobs take only test tools there. on: push: - branches: [main] + branches: [main, 'release/v*'] pull_request: workflow_dispatch: # A RUN IS CANCELLED ONLY BY A NEWER RUN IN ITS OWN GROUP: a newer push to the -# same pull request, or a second dispatch on the same commit. A pull request's -# group is its number. A `push` run on main has no number, and the old fallback -# `github.ref` put every one of them in the one group `tests-refs/heads/main`, -# so each landing cancelled the run on the main before it: on 2026-10-15 every -# push run from #561 (7d9bec9) to #580 (dd3f23a) ended `cancelled` and nine PRs -# landed with no Linux run over their combination (#592). `github.sha` gives a -# push run a group of its own commit, so no later landing cancels it and each -# landing keeps its own Linux result. `github.event_name` keeps a push run and -# a dispatch apart, so a dispatch on main, which runs on whatever commit main -# is at, never cancels that commit's push run; a second dispatch on the same -# commit still replaces the first. +# same pull request, or a second dispatch of the same ref at the same commit. +# A pull request's group is its ref and its number. A `push` run on main has +# no number, and the old fallback `github.ref` put every one of them in the +# one group `tests-refs/heads/main`, so each landing cancelled the run on the +# main before it: on 2026-10-15 every push run from #561 (7d9bec9) to #580 +# (dd3f23a) ended `cancelled` and nine PRs landed with no Linux run over their +# combination (#592). `github.sha` gives a push run a group of its own commit, +# so no later landing cancels it and each landing keeps its own Linux result. +# `github.event_name` keeps a push run and a dispatch apart, so a dispatch on +# main, which runs on whatever commit main is at, never cancels that commit's +# push run; a second dispatch of the same ref at the same commit still +# replaces the first. +# +# `github.ref` KEEPS TWO BRANCHES APART AT ONE COMMIT (#850). A group spans +# the repository, not a branch, and a push run's `github.sha` is the commit +# the push left its branch at. Without the ref, a fast-forward of +# `release/v1.1` to main's commit shares the group of main's push run on that +# commit and cancels it while it is still running, as #592's landings did. +# The ref sits beside the commit, never in its place as #592's fallback did. +# A pull request's ref, `refs/pull//merge`, is its own, so a pull +# request still has one group, and a newer push to it still cancels the older +# run. concurrency: - group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.sha }} + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}-${{ github.event.pull_request.number || github.sha }} cancel-in-progress: true jobs: @@ -158,9 +181,9 @@ jobs: # Git's real `curl` ahead of the suites' fake. conftest's comment there # has the rest. # - # ON EVERY TRIGGER, a push to `main` included: a squash lands a tree no - # pull-request run tested (#122), and these two suites are the only run - # of the one-line install on Windows. + # ON EVERY TRIGGER, a push to `main` or to a release branch included: a + # squash lands a tree no pull-request run tested (#122), and these two + # suites are the only run of the one-line install on Windows. tests-git-bash: runs-on: windows-latest timeout-minutes: 15 diff --git a/tests/test_release_workflow.py b/tests/test_release_workflow.py index 845a4d81..99fb2909 100644 --- a/tests/test_release_workflow.py +++ b/tests/test_release_workflow.py @@ -591,6 +591,14 @@ def test_the_header_says_how_to_release(workflow): assert "# ACCEPTED LIMITS, carried over from openDox-code's release.yml" \ in text assert "lane\n# sessions act as brettheap" in text + # #850's section, and the two sentences RV852 asked it to carry: a push + # runs the tests.yml its own commit holds, and the branch's newer push run + # decides after a fast-forward. + assert "# RELEASE BRANCHES AND BACK-PORTS (#850)" in text + assert "# - a push runs the tests.yml its own commit holds, so a release" \ + in text + assert "and that newer run decides. Tag once it has finished green." \ + in text # --- RV599, SHOULD 2: the commit is on main or its release branch ------------------- diff --git a/tests/test_workflow_concurrency.py b/tests/test_workflow_concurrency.py index 78f2c4c8..edb28ed8 100644 --- a/tests/test_workflow_concurrency.py +++ b/tests/test_workflow_concurrency.py @@ -1,5 +1,6 @@ # SPDX-License-Identifier: Apache-2.0 -"""`.github/workflows/tests.yml` never lets one landing cancel another's run. +"""`.github/workflows/tests.yml` runs on each push to main and to a release +branch, and never lets one landing cancel another's run. THE FAILURE THIS GUARDS IS SILENT. A `push` run on main has no pull request number, so a group that fell back to `github.ref` landed on `refs/heads/main` @@ -11,15 +12,29 @@ main's push run, about 30 minutes a landing. The fix groups a pull-request run by its number (a new push to the pull -request still cancels its older run) and every other run by its event and its -own commit. The event is part of the group because a `workflow_dispatch` run -starts on whatever commit main is at, which is also the commit of that commit's -push run: grouped by commit alone, the dispatch would cancel the push run it -sits beside (RV593 SHOULD-1). - -These tests read the workflow as TEXT, like the rest of this suite, and never -touch the network or `gh`. `actionlint` in the workflow itself checks the -expression's syntax; what it cannot check is which fallback was chosen. +request still cancels its older run) and every other run by its event, its +ref and its own commit. The event is part of the group because a +`workflow_dispatch` run starts on whatever commit main is at, which is also the +commit of that commit's push run: grouped by commit alone, the dispatch would +cancel the push run it sits beside (RV593 SHOULD-1). + +#850 ADDS THE RELEASE BRANCHES. A push to `release/v.` runs the +suite as a push to main does, so each commit a release branch takes has the +push run `release.yml` requires of a tagged commit, with no dispatch by hand. +That holds once the branch holds this change, not before: a push runs the +tests.yml its own commit holds, so `release/v1.1` runs the suite from its first +fast-forward past this change, or a cherry-pick of it, and `release/v1.0`, +whose tests.yml still says `branches: [main]`, never has and needs a dispatch. +The ref joined the group with it: a group spans the repository, and a +fast-forward of a release branch to main's commit pushes the very commit main's +push run is testing, so grouped by event and commit alone the release branch's +run would cancel main's. + +These tests read the concurrency block as TEXT, like the rest of this suite, +and never touch the network or `gh`. `actionlint` in the workflow itself checks +the expression's syntax; what it cannot check is which fallback was chosen. +The trigger is read with `repo_shape.load_yaml`, the fail-closed reader +`tests/test_release_workflow.py` reads both workflows with. """ from __future__ import annotations @@ -29,20 +44,26 @@ import pytest from conftest import REPO +from repo_shape import load_yaml WORKFLOW = REPO / ".github" / "workflows" / "tests.yml" -#: A pull request is grouped by its number. Anything else is grouped by its -#: event and the commit it runs on, so a push run and a dispatch on the same -#: commit stay in two groups and neither cancels the other. -GROUP = ("${{ github.workflow }}-${{ github.event_name }}-" +#: A pull request is grouped by its ref and its number. Anything else is +#: grouped by its event, its ref and the commit it runs on, so a push run and +#: a dispatch on the same commit stay in two groups, and so do the push runs +#: of main and of a release branch fast-forwarded to main's commit; neither +#: cancels the other. +GROUP = ("${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}-" "${{ github.event.pull_request.number || github.sha }}") #: Spellings the workflow once had, or could be given again, each with a #: phrase its refusal must carry. `github.ref` is what #592 fixed: every push #: run on main in one group. The `github.sha` one without the event is this #: file's own first fix, which stops a landing cancelling the last landing's -#: run but lets a dispatch cancel its commit's push run (RV593 SHOULD-1). +#: run but lets a dispatch cancel its commit's push run (RV593 SHOULD-1). The +#: one without the ref is the one #592 closed with, which lets a release +#: branch's push cancel main's run on the same commit (#850). The ref in place +#: of the commit would put every push run on main in one group again. EARLIER_GROUPS = { "ref": ("${{ github.workflow }}-" "${{ github.event.pull_request.number || github.ref }}", @@ -51,8 +72,21 @@ "${{ github.workflow }}-" "${{ github.event.pull_request.number || github.sha }}", "github.event_name"), + "sha-without-the-ref": ( + "${{ github.workflow }}-${{ github.event_name }}-" + "${{ github.event.pull_request.number || github.sha }}", + "fast-forwarded"), + "ref-without-the-commit": ( + "${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}", + "github.sha"), } +#: The branches whose pushes run the suite (#850): `main`, the trunk every +#: change lands on first, and each release branch, `release/v.`, +#: where a patch is tagged. `release.yml` releases a commit only once a push or +#: dispatch run of this workflow passed `tests` on it. +PUSH_BRANCHES = ["main", "release/v*"] + def workflow_lines() -> list[str]: """The workflow's lines, comments dropped, whatever the checkout's EOL.""" @@ -83,24 +117,35 @@ def group_problem(group: str | None) -> str | None: """Why `group` is not the expression this workflow must carry, or None.""" if group == GROUP: return None - if group is not None and "github.ref" in group: + if group is not None and re.search(r"\|\|\s*github\.ref\b", group): return ("it falls back to `github.ref`, which is `refs/heads/main` " "for every push run on main, so the next landing cancels the " "last one's run (#592)") + if group is not None and "github.sha" not in group: + return ("it does not name `github.sha`, so every push run on one " + "branch shares a group and the next landing cancels the last " + "one's run (#592)") if group is not None and "github.event_name" not in group: return ("it does not name `github.event_name`, so a dispatch on a " "commit shares a group with that commit's push run and " "cancels it (RV593 SHOULD-1)") + if group is not None and "github.ref" not in group: + return ("it does not name `github.ref`, so the push run of a release " + "branch fast-forwarded to main's commit shares a group with " + "main's push run on that commit and cancels it (#850)") return f"it is not {GROUP!r}" -def test_a_run_is_grouped_by_its_event_and_its_commit(): - """The group's fallback is `github.sha` and it names the event (#592). +def test_a_run_is_grouped_by_its_event_its_ref_and_its_commit(): + """The group's fallback is `github.sha` (#592), and it names the event + and the ref beside it (#850). - `github.ref` is `refs/heads/main` for every push run on main, so it puts - every landing in one group and the next landing cancels the last one. The - commit alone is not enough either: a dispatch runs on the commit main is - at, the same commit as its push run, and would cancel it. + `github.ref` is `refs/heads/main` for every push run on main, so in place + of the commit it puts every landing in one group and the next landing + cancels the last one. The commit alone is not enough either: a dispatch + runs on the commit main is at, the same commit as its push run, and would + cancel it; and a release branch fast-forwarded to main's commit pushes + that same commit, and its run would cancel main's. """ group = top_level_concurrency().get("group") problem = group_problem(group) @@ -129,3 +174,63 @@ def test_no_job_carries_a_concurrency_of_its_own(): assert nested == [], ( "a job in tests.yml sets `concurrency:` itself, which the group " f"checked above does not govern: {nested}") + + +def branch_filter_matches(pattern: str, branch: str) -> bool: + """GitHub's branch filter for the one wildcard this workflow uses: `*` + matches any characters but `/`. A pattern with any other special + character is refused here rather than guessed at.""" + assert not set(pattern) & set("?+[]!"), pattern + assert "**" not in pattern, pattern + return re.fullmatch("[^/]*".join(map(re.escape, pattern.split("*"))), + branch) is not None + + +def test_a_push_to_main_or_to_a_release_branch_runs_the_suite(): + """#850: the push trigger names `main` and `release/v*`, so a commit a + release branch takes, once the branch holds this change, gets the push + run `release.yml`'s gate requires of the tagged commit; before, each had + to be dispatched by hand. A pull request and a manual run still run it, + and `pull_request` takes no branch filter, so the pull request of a + cherry-pick back-port to `release/v` gets its run too.""" + on = load_yaml(WORKFLOW)["on"] + assert on["push"] == {"branches": PUSH_BRANCHES}, on["push"] + assert set(on) == {"push", "pull_request", "workflow_dispatch"}, set(on) + assert on["pull_request"] is None, on["pull_request"] + + +@pytest.mark.parametrize("branch, runs", [ + ("main", True), + ("release/v1.1", True), + ("release/v1.0", True), + ("l3-850-release-branches", False), + ("docs/release-notes-v1.0.0", False), +], ids=["main", "release-v1.1", "release-v1.0", "a-pull-requests-branch", + "a-branch-named-for-a-release"]) +def test_which_branchs_push_runs_the_suite(branch, runs): + """The push patterns take main, `release/v1.1`, where v1.1.1 is tagged, + and `release/v1.0`, where v1.0.1 was; a pull request's own branch runs + in its pull request instead, whatever its name says. + + These rows test only the pattern. A push runs the tests.yml its own + commit holds, so a branch whose tests.yml lacks this change runs nothing + on a push, whatever its name: `release/v1.0`'s still says + `branches: [main]`, and `release/v1.1`, cut at v1.1.0, runs the suite only + from its first fast-forward past this change, or a cherry-pick of it.""" + patterns = load_yaml(WORKFLOW)["on"]["push"]["branches"] + assert any(branch_filter_matches(pattern, branch) + for pattern in patterns) is runs, (branch, patterns) + + +def test_the_header_says_why_a_release_branch_runs_it_and_when(): + """#850's two comment blocks stay in tests.yml's header, with the rule that + bounds the trigger: a push runs the tests.yml its own commit holds, so a + release branch runs the suite only once it holds this change. A header + that loses them leaves the trigger and the group as two edits with no + reason to be kept. Each phrase sits on one line, whatever the EOL.""" + text = WORKFLOW.read_bytes().decode("utf-8") + for phrase in ( + "# A RELEASE BRANCH'S PUSHES RUN IT TOO (#850)", + "# A push runs the tests.yml its own commit holds, so a release", + "# `github.ref` KEEPS TWO BRANCHES APART AT ONE COMMIT (#850)"): + assert phrase in text, phrase