From a139fdafa71b5ac34456ea2e4da6a6d77ff039c9 Mon Sep 17 00:00:00 2001 From: Andreas Raeder Date: Tue, 11 Aug 2026 17:03:44 +0200 Subject: [PATCH] feat(ci): preview the next release version on pull requests --- .github/workflows/main.yml | 63 ++++++++++++++++++++++ profiles/python/.github/workflows/main.yml | 63 ++++++++++++++++++++++ profiles/schema/.github/workflows/main.yml | 63 ++++++++++++++++++++++ tests/test_profiles.py | 28 ++++++++++ 4 files changed, 217 insertions(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 31272e3..daa0306 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -66,6 +66,69 @@ jobs: # (full copy + make init) and run the generated repository's own check # suite. A red profile blocks the merge, so no broken core ever reaches a # grafted repository through an update. + version-preview: + # Show contributors what a merge would release, based on the conventional + # commits on the branch. Pull requests only; informational. + if: github.event_name == 'pull_request' && github.event.pull_request.draft == false + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + steps: + - name: Check out + uses: actions/checkout@v4 + with: + fetch-depth: 0 # PSR needs full history + tags to compute the bump + ref: ${{ github.event.pull_request.head.sha }} + + - name: Set up the environment + uses: ./.github/actions/setup-python-env + + - name: Compute release preview + id: preview + run: | + # PSR only computes a release on the main branch; name the checked-out + # PR head 'main' so the preview reflects what a merge would release. + git checkout -B main >/dev/null 2>&1 || true + current=$(uv version --short 2>/dev/null || echo "unknown") + next=$(uv run semantic-release version --print 2>/dev/null || echo "") + if [ -n "$next" ] && [ "$next" != "$current" ]; then + echo "released=true" >> "$GITHUB_OUTPUT" + else + echo "released=false" >> "$GITHUB_OUTPUT" + fi + echo "current=$current" >> "$GITHUB_OUTPUT" + echo "next=$next" >> "$GITHUB_OUTPUT" + # Stamp the upcoming changelog section locally (no commit/tag/push/build) + # so the preview shows the notes this merge would add. + if [ -n "$next" ] && [ "$next" != "$current" ]; then + uv run semantic-release version --no-commit --no-tag --no-push --no-vcs-release --skip-build >/dev/null 2>&1 || true + fi + { + echo "changelog<> "$GITHUB_OUTPUT" + + - name: Post release preview comment + uses: marocchino/sticky-pull-request-comment@v2 + with: + header: version-preview + message: | + ## Release preview + + ${{ steps.preview.outputs.released == 'true' && format('Merging this PR would release **v{0}** (current: `v{1}`).', steps.preview.outputs.next, steps.preview.outputs.current) || format('No version bump from the current commits (stays at `v{0}`). Use conventional commit types (`feat`, `fix`, ...) to trigger a release.', steps.preview.outputs.current) }} + +
Changelog preview (truncated) + + ```markdown + ${{ steps.preview.outputs.changelog }} + ``` + +
+ + Preview via python-semantic-release and conventional commits. + test-profiles: runs-on: ubuntu-latest strategy: diff --git a/profiles/python/.github/workflows/main.yml b/profiles/python/.github/workflows/main.yml index 89d3fa1..5ba2415 100644 --- a/profiles/python/.github/workflows/main.yml +++ b/profiles/python/.github/workflows/main.yml @@ -70,6 +70,69 @@ jobs: - name: Check if documentation can be built run: make docs-test + version-preview: + # Show contributors what a merge would release, based on the conventional + # commits on the branch. Pull requests only; informational. + if: github.event_name == 'pull_request' && github.event.pull_request.draft == false + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + steps: + - name: Check out + uses: actions/checkout@v4 + with: + fetch-depth: 0 # PSR needs full history + tags to compute the bump + ref: ${{ github.event.pull_request.head.sha }} + + - name: Set up the environment + uses: ./.github/actions/setup-python-env + + - name: Compute release preview + id: preview + run: | + # PSR only computes a release on the main branch; name the checked-out + # PR head 'main' so the preview reflects what a merge would release. + git checkout -B main >/dev/null 2>&1 || true + current=$(uv version --short 2>/dev/null || echo "unknown") + next=$(uv run semantic-release version --print 2>/dev/null || echo "") + if [ -n "$next" ] && [ "$next" != "$current" ]; then + echo "released=true" >> "$GITHUB_OUTPUT" + else + echo "released=false" >> "$GITHUB_OUTPUT" + fi + echo "current=$current" >> "$GITHUB_OUTPUT" + echo "next=$next" >> "$GITHUB_OUTPUT" + # Stamp the upcoming changelog section locally (no commit/tag/push/build) + # so the preview shows the notes this merge would add. + if [ -n "$next" ] && [ "$next" != "$current" ]; then + uv run semantic-release version --no-commit --no-tag --no-push --no-vcs-release --skip-build >/dev/null 2>&1 || true + fi + { + echo "changelog<> "$GITHUB_OUTPUT" + + - name: Post release preview comment + uses: marocchino/sticky-pull-request-comment@v2 + with: + header: version-preview + message: | + ## Release preview + + ${{ steps.preview.outputs.released == 'true' && format('Merging this PR would release **v{0}** (current: `v{1}`).', steps.preview.outputs.next, steps.preview.outputs.current) || format('No version bump from the current commits (stays at `v{0}`). Use conventional commit types (`feat`, `fix`, ...) to trigger a release.', steps.preview.outputs.current) }} + +
Changelog preview (truncated) + + ```markdown + ${{ steps.preview.outputs.changelog }} + ``` + +
+ + Preview via python-semantic-release and conventional commits. + # >>> benchmarks # Continuous benchmarking with Bencher: pytest-benchmark produces the JSON, # `bencher run` uploads it and comments the comparison against the base diff --git a/profiles/schema/.github/workflows/main.yml b/profiles/schema/.github/workflows/main.yml index b0ffce9..14b6f5d 100644 --- a/profiles/schema/.github/workflows/main.yml +++ b/profiles/schema/.github/workflows/main.yml @@ -55,3 +55,66 @@ jobs: - name: Check if documentation can be built run: make docs-test + + version-preview: + # Show contributors what a merge would release, based on the conventional + # commits on the branch. Pull requests only; informational. + if: github.event_name == 'pull_request' && github.event.pull_request.draft == false + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + steps: + - name: Check out + uses: actions/checkout@v4 + with: + fetch-depth: 0 # PSR needs full history + tags to compute the bump + ref: ${{ github.event.pull_request.head.sha }} + + - name: Set up the environment + uses: ./.github/actions/setup-python-env + + - name: Compute release preview + id: preview + run: | + # PSR only computes a release on the main branch; name the checked-out + # PR head 'main' so the preview reflects what a merge would release. + git checkout -B main >/dev/null 2>&1 || true + current=$(uv version --short 2>/dev/null || echo "unknown") + next=$(uv run semantic-release version --print 2>/dev/null || echo "") + if [ -n "$next" ] && [ "$next" != "$current" ]; then + echo "released=true" >> "$GITHUB_OUTPUT" + else + echo "released=false" >> "$GITHUB_OUTPUT" + fi + echo "current=$current" >> "$GITHUB_OUTPUT" + echo "next=$next" >> "$GITHUB_OUTPUT" + # Stamp the upcoming changelog section locally (no commit/tag/push/build) + # so the preview shows the notes this merge would add. + if [ -n "$next" ] && [ "$next" != "$current" ]; then + uv run semantic-release version --no-commit --no-tag --no-push --no-vcs-release --skip-build >/dev/null 2>&1 || true + fi + { + echo "changelog<> "$GITHUB_OUTPUT" + + - name: Post release preview comment + uses: marocchino/sticky-pull-request-comment@v2 + with: + header: version-preview + message: | + ## Release preview + + ${{ steps.preview.outputs.released == 'true' && format('Merging this PR would release **v{0}** (current: `v{1}`).', steps.preview.outputs.next, steps.preview.outputs.current) || format('No version bump from the current commits (stays at `v{0}`). Use conventional commit types (`feat`, `fix`, ...) to trigger a release.', steps.preview.outputs.current) }} + +
Changelog preview (truncated) + + ```markdown + ${{ steps.preview.outputs.changelog }} + ``` + +
+ + Preview via python-semantic-release and conventional commits. diff --git a/tests/test_profiles.py b/tests/test_profiles.py index d407a41..c33ad06 100644 --- a/tests/test_profiles.py +++ b/tests/test_profiles.py @@ -231,6 +231,34 @@ def test_ci_target_names_the_current_branch(makefile: str) -> None: assert 'Ready to push"' in ci, f"{makefile}: ci has no fallback without a branch" +@pytest.mark.parametrize( + "workflow", + [".github/workflows/main.yml", *(f"profiles/{p}/.github/workflows/main.yml" for p in PROFILES)], +) +def test_every_main_workflow_previews_the_release(workflow: str) -> None: + # Conventional commit types decide the version, and until this job existed + # nobody saw the consequence of getting one wrong until after the merge. + # Three copies of the same job, so they drift unless something checks. + spec = yaml.safe_load((REPO / workflow).read_text(encoding="utf-8")) + job = spec["jobs"].get("version-preview") + assert job is not None, f"{workflow}: no version-preview job" + assert job["permissions"]["pull-requests"] == "write", "cannot comment without this" + assert "pull_request" in job["if"] and "draft" in job["if"], "must be pull requests only, and not drafts" + steps = {step.get("name", ""): step for step in job["steps"]} + checkout = next(step for step in job["steps"] if str(step.get("uses", "")).startswith("actions/checkout")) + # PSR walks history and tags to compute the bump; a shallow clone yields + # a wrong answer rather than an error. + assert checkout["with"]["fetch-depth"] == 0 + preview = steps["Compute release preview"]["run"] + # PSR only computes a release on the main branch, so the PR head has to be + # named main or the preview is always "no release". + assert "git checkout -B main" in preview + assert "semantic-release version --print" in preview + # The changelog stamp must not commit, tag, push or release anything. + for guard in ("--no-commit", "--no-tag", "--no-push", "--no-vcs-release"): + assert guard in preview, f"{workflow}: changelog preview missing {guard}" + + @pytest.mark.parametrize("profile", PROFILES) def test_instances_explain_conventional_commits(tmp_path: Path, profile: str) -> None: # The commit-msg hook rejects a non-conventional message, and until this