From 050275c88e5a65bf4712f365e734e9b5cbcefce8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 6 Nov 2026 04:44:42 -0800 Subject: [PATCH 1/4] =?UTF-8?q?=E2=9C=A8=20feat(workflows):=20dry-run=20re?= =?UTF-8?q?lease=20preparation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A release assembles under rules that a pull request's checks never apply: prek hooks install with packages from the last seven days held back, fragments link to their pull requests, and the changelog builds and formats. simplint#55 bumped the Ruff hook to a release younger than seven days, merged green, and failed the next release in its prepare step. prepare-towncrier-release and prepare-release-creator now take a dry-run input that runs the assemble job and skips push, so a caller can run its own prepare workflow inside the required PR gate. A dry run needs no App key and has its own concurrency group per ref, so it never queues behind a real release. --- .../workflows/prepare-release-creator.yaml | 12 +++-- .../workflows/prepare-towncrier-release.yaml | 12 +++-- README.md | 51 ++++++++++++++++++- changelog.d/+prepare-dry-run.feature.md | 2 + tests/prepare_dry_run/test_dry_run.py | 15 ++++++ 5 files changed, 85 insertions(+), 7 deletions(-) create mode 100644 changelog.d/+prepare-dry-run.feature.md create mode 100644 tests/prepare_dry_run/test_dry_run.py diff --git a/.github/workflows/prepare-release-creator.yaml b/.github/workflows/prepare-release-creator.yaml index 93ca36a..806b9a7 100644 --- a/.github/workflows/prepare-release-creator.yaml +++ b/.github/workflows/prepare-release-creator.yaml @@ -50,13 +50,18 @@ on: description: Newline-separated name=date cutoffs that override exclude-newer for single packages type: string default: "" + dry-run: + description: Assemble the release without pushing, so a pull request fails when merging it would break the release + type: boolean + default: false secrets: RELEASE_APP_PRIVATE_KEY: - description: Key held by the caller's release-auth environment - required: true + description: Key held by the caller's release-auth environment, unused in a dry run + required: false permissions: {} concurrency: - group: prepare-release-${{ github.repository }} + # A dry run must not queue behind, or hold up, a real release of the repository. + group: prepare-release-${{ github.repository }}${{ inputs.dry-run && format('-dry-run-{0}', github.ref) || '' }} cancel-in-progress: false jobs: assemble: @@ -236,6 +241,7 @@ jobs: push: name: ⬆️ Push release commit and tag needs: assemble + if: ${{ !inputs.dry-run }} runs-on: ubuntu-26.04 environment: name: release-auth diff --git a/.github/workflows/prepare-towncrier-release.yaml b/.github/workflows/prepare-towncrier-release.yaml index 6404c05..1fdfe4c 100644 --- a/.github/workflows/prepare-towncrier-release.yaml +++ b/.github/workflows/prepare-towncrier-release.yaml @@ -30,13 +30,18 @@ on: description: Newline-separated name=date cutoffs that override exclude-newer for single packages type: string default: "" + dry-run: + description: Assemble the release without pushing, so a pull request fails when merging it would break the release + type: boolean + default: false secrets: RELEASE_APP_PRIVATE_KEY: - description: Key held by the caller's release-auth environment - required: true + description: Key held by the caller's release-auth environment, unused in a dry run + required: false permissions: {} concurrency: - group: prepare-release-${{ github.repository }} + # A dry run must not queue behind, or hold up, a real release of the repository. + group: prepare-release-${{ github.repository }}${{ inputs.dry-run && format('-dry-run-{0}', github.ref) || '' }} cancel-in-progress: false jobs: assemble: @@ -99,6 +104,7 @@ jobs: push: name: ⬆️ Push release commit and tag needs: assemble + if: ${{ !inputs.dry-run }} runs-on: ubuntu-26.04 environment: name: release-auth diff --git a/README.md b/README.md index 9c029ec..707e430 100644 --- a/README.md +++ b/README.md @@ -651,6 +651,55 @@ The `push` job refuses `pull_request`, `pull_request_target`, `workflow_run`, `i callers. Its first step fails with an error that names the event, before the patch is downloaded and before the App token exists. Trigger the caller on `push`, `schedule`, `workflow_dispatch`, or `release`. +### Dry run on pull requests + +A release assembles under rules a pull request's own checks never apply: hooks install with packages from the last seven +days held back, fragments link to their pull requests, and the changelog builds and formats. A pull request that breaks +any of these merges green and fails the next release. `dry-run: true` runs `assemble` alone and skips `push`, so the +pull request fails instead. A dry run needs no App key and uses its own concurrency group per ref, so it never queues +behind a real release. + +Make the caller's prepare workflow callable and run it from the workflow that holds the required gate, so the dry run +uses the same inputs as the release: + +```yaml +# prepare-release.yaml +on: + workflow_dispatch: + inputs: + bump: + type: choice + options: [auto, major, minor, patch] + default: auto + workflow_call: +jobs: + prepare: + uses: >- + tox-dev/github-workflows/.github/workflows/prepare-towncrier-release.yaml@SHA + permissions: + contents: read + pull-requests: read + with: + bump: ${{ inputs.bump || 'auto' }} + dry-run: ${{ github.event_name != 'workflow_dispatch' }} + secrets: + RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} +``` + +```yaml +# check.yaml +jobs: + release: + uses: ./.github/workflows/prepare-release.yaml + permissions: + contents: read + pull-requests: read + gate: + needs: [release, test] +``` + +`prepare-release-creator.yaml` takes the same `dry-run` input. + ## Read Towncrier configuration `read-towncrier-config` reads `towncrier.toml` or `pyproject.toml` from the checkout's committed `HEAD`, so an untrusted @@ -816,7 +865,7 @@ Simplint's single-package release uses unprefixed tags and no package scope: - `cargo-set-version`: run `cargo set-version` with the number before assembly; default `false`. `cargo-set-version-args` holds the arguments before the number, such as `-p {package}`. Cargo and cargo-edit install only when this is set. -- `annotated`, `release-auth-deployment`, `exclude-newer`, and `exclude-newer-package` match +- `annotated`, `release-auth-deployment`, `exclude-newer`, `exclude-newer-package`, and `dry-run` match `prepare-towncrier-release`. The caller keeps the `release-auth` environment with `RELEASE_APP_PRIVATE_KEY` and the `RELEASE_APP_CLIENT_ID` variable. diff --git a/changelog.d/+prepare-dry-run.feature.md b/changelog.d/+prepare-dry-run.feature.md new file mode 100644 index 0000000..530ae14 --- /dev/null +++ b/changelog.d/+prepare-dry-run.feature.md @@ -0,0 +1,2 @@ +`prepare-towncrier-release` and `prepare-release-creator` take a `dry-run` input that assembles the release and skips +the push, so a pull request check fails when merging it would break the next release. diff --git a/tests/prepare_dry_run/test_dry_run.py b/tests/prepare_dry_run/test_dry_run.py new file mode 100644 index 0000000..b373d84 --- /dev/null +++ b/tests/prepare_dry_run/test_dry_run.py @@ -0,0 +1,15 @@ +import pytest +from conftest import WORKFLOWS, at, load_yaml + + +@pytest.mark.parametrize("workflow", ["prepare-release-creator.yaml", "prepare-towncrier-release.yaml"]) +def test_dry_run_assembles_without_pushing(workflow: str) -> None: + document = load_yaml(WORKFLOWS / workflow) + + assert at(document, "on", "workflow_call", "inputs", "dry-run", "default") == "false" + assert at(document, "on", "workflow_call", "secrets", "RELEASE_APP_PRIVATE_KEY", "required") == "false" + assert at(document, "jobs", "push", "if") == "${{ !inputs.dry-run }}" + assert "if" not in at(document, "jobs", "assemble") + assert at(document, "concurrency", "group") == ( + "prepare-release-${{ github.repository }}${{ inputs.dry-run && format('-dry-run-{0}', github.ref) || '' }}" + ) From 5f467571f8fbd54e3d28cb004ee920b48a2422ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 6 Nov 2026 05:15:20 -0800 Subject: [PATCH 2/4] =?UTF-8?q?=F0=9F=93=9D=20docs:=20pass=20dry-run=20exp?= =?UTF-8?q?licitly=20to=20the=20caller?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 707e430..c7e56ab 100644 --- a/README.md +++ b/README.md @@ -672,6 +672,10 @@ on: options: [auto, major, minor, patch] default: auto workflow_call: + inputs: + dry-run: + type: boolean + required: true jobs: prepare: uses: >- @@ -681,7 +685,7 @@ jobs: pull-requests: read with: bump: ${{ inputs.bump || 'auto' }} - dry-run: ${{ github.event_name != 'workflow_dispatch' }} + dry-run: ${{ inputs.dry-run == true }} secrets: RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} ``` @@ -694,11 +698,15 @@ jobs: permissions: contents: read pull-requests: read + with: + dry-run: true gate: needs: [release, test] ``` -`prepare-release-creator.yaml` takes the same `dry-run` input. +Pass `dry-run` explicitly rather than deriving it from `github.event_name`: a called workflow sees its caller's event, +so a manually dispatched check workflow would otherwise prepare a real release. `prepare-release-creator.yaml` takes the +same `dry-run` input. ## Read Towncrier configuration From 1dac4bdaf18920303e4d1a099620d9cf2b860114 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 6 Nov 2026 06:31:55 -0800 Subject: [PATCH 3/4] =?UTF-8?q?=F0=9F=91=B7=20ci:=20dry-run=20the=20releas?= =?UTF-8?q?e=20in=20the=20PR=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yaml | 10 +++++++++- .github/workflows/prepare-release.yaml | 9 ++++++++- README.md | 2 +- tests/required_checks/test_gate.py | 2 +- 4 files changed, 19 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f68fe55..54c9a9a 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -134,10 +134,18 @@ jobs: env: EXCLUDE_NEWER: 7 days run: uvx --exclude-newer "$EXCLUDE_NEWER" --with tox-uv tox run -e type + release: + name: 🏷️ Release dry run + uses: $/.github/workflows/prepare-release.yaml + permissions: + contents: read + pull-requests: read + with: + dry-run: true gate: name: 🚦 Required checks pass if: ${{ !cancelled() }} - needs: [check, shell, test, type] + needs: [check, shell, test, type, release] runs-on: ubuntu-26.04 permissions: {} steps: diff --git a/.github/workflows/prepare-release.yaml b/.github/workflows/prepare-release.yaml index a5d7ab9..ca20a50 100644 --- a/.github/workflows/prepare-release.yaml +++ b/.github/workflows/prepare-release.yaml @@ -8,6 +8,12 @@ on: type: choice options: [auto, major, minor, patch] default: auto + workflow_call: + inputs: + dry-run: + description: Assemble the release without pushing, for the pull request gate + type: boolean + required: true permissions: {} jobs: prepare: @@ -17,7 +23,8 @@ jobs: contents: read pull-requests: read with: - bump: ${{ inputs.bump }} + bump: ${{ inputs.bump || 'auto' }} + dry-run: ${{ inputs.dry-run == true }} # Temporary: release-creator 4.3.2 is younger than seven days. Delete after 2026-11-06. exclude-newer-package: release-creator=2026-10-30T15:13:19Z secrets: diff --git a/README.md b/README.md index c7e56ab..6940a4a 100644 --- a/README.md +++ b/README.md @@ -694,7 +694,7 @@ jobs: # check.yaml jobs: release: - uses: ./.github/workflows/prepare-release.yaml + uses: $/.github/workflows/prepare-release.yaml permissions: contents: read pull-requests: read diff --git a/tests/required_checks/test_gate.py b/tests/required_checks/test_gate.py index b5219bf..761f7d2 100644 --- a/tests/required_checks/test_gate.py +++ b/tests/required_checks/test_gate.py @@ -66,7 +66,7 @@ def test_gate_is_used_by_ci_and_needs_its_jobs() -> None: ci = load_yaml(WORKFLOWS / "ci.yaml") gate = steps(ci, "gate")[-1] - assert at(ci, "jobs", "gate", "needs") == ["check", "shell", "test", "type"] + assert at(ci, "jobs", "gate", "needs") == ["check", "shell", "test", "type", "release"] assert at(ci, "jobs", "gate", "if") == "${{ !cancelled() }}" assert gate["uses"] == "$/required-checks" assert gate["with"] == {"needs": "${{ toJSON(needs) }}"} From eea684b3f5231fa5c52cba8899ffa809706c2a4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 6 Nov 2026 07:32:40 -0800 Subject: [PATCH 4/4] =?UTF-8?q?=F0=9F=94=8E=20test:=20narrow=20the=20assem?= =?UTF-8?q?ble=20job=20mapping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/prepare_dry_run/test_dry_run.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/prepare_dry_run/test_dry_run.py b/tests/prepare_dry_run/test_dry_run.py index b373d84..4f76a14 100644 --- a/tests/prepare_dry_run/test_dry_run.py +++ b/tests/prepare_dry_run/test_dry_run.py @@ -1,5 +1,5 @@ import pytest -from conftest import WORKFLOWS, at, load_yaml +from conftest import WORKFLOWS, at, load_yaml, mapping @pytest.mark.parametrize("workflow", ["prepare-release-creator.yaml", "prepare-towncrier-release.yaml"]) @@ -9,7 +9,7 @@ def test_dry_run_assembles_without_pushing(workflow: str) -> None: assert at(document, "on", "workflow_call", "inputs", "dry-run", "default") == "false" assert at(document, "on", "workflow_call", "secrets", "RELEASE_APP_PRIVATE_KEY", "required") == "false" assert at(document, "jobs", "push", "if") == "${{ !inputs.dry-run }}" - assert "if" not in at(document, "jobs", "assemble") + assert "if" not in mapping(at(document, "jobs", "assemble")) assert at(document, "concurrency", "group") == ( "prepare-release-${{ github.repository }}${{ inputs.dry-run && format('-dry-run-{0}', github.ref) || '' }}" )