Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
12 changes: 9 additions & 3 deletions .github/workflows/prepare-release-creator.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
9 changes: 8 additions & 1 deletion .github/workflows/prepare-release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down
12 changes: 9 additions & 3 deletions .github/workflows/prepare-towncrier-release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
59 changes: 58 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions changelog.d/+prepare-dry-run.feature.md
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 15 additions & 0 deletions tests/prepare_dry_run/test_dry_run.py
Original file line number Diff line number Diff line change
@@ -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) || '' }}"
)
2 changes: 1 addition & 1 deletion tests/required_checks/test_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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) }}"}
Expand Down