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-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-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/.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..6940a4a 100644 --- a/README.md +++ b/README.md @@ -651,6 +651,63 @@ 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: + inputs: + dry-run: + type: boolean + required: true +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: ${{ inputs.dry-run == true }} + 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 + with: + dry-run: true + gate: + needs: [release, test] +``` + +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 `read-towncrier-config` reads `towncrier.toml` or `pyproject.toml` from the checkout's committed `HEAD`, so an untrusted @@ -816,7 +873,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..4f76a14 --- /dev/null +++ b/tests/prepare_dry_run/test_dry_run.py @@ -0,0 +1,15 @@ +import pytest +from conftest import WORKFLOWS, at, load_yaml, mapping + + +@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 mapping(at(document, "jobs", "assemble")) + assert at(document, "concurrency", "group") == ( + "prepare-release-${{ github.repository }}${{ inputs.dry-run && format('-dry-run-{0}', github.ref) || '' }}" + ) 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) }}"}