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
1 change: 1 addition & 0 deletions .github/workflows/news.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ on:
types: [opened, reopened, synchronize, labeled, unlabeled]
permissions:
contents: read
pull-requests: read
jobs:
changelog:
name: 📝 News fragment
Expand Down
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ caller's `pylock.toml`.
configured directory against the configured categories, filename format, and nonempty content. A valid fragment runs
`towncrier check` against the PR base. A human PR without a fragment needs `skip news`; a bot PR without a fragment does
not. A bot's supplied fragment is checked and included in the normal release flow. The Action reads repository files and
Git history; it does not add labels or write to GitHub.
Git history and reads the PR's current labels; it does not add labels or write to GitHub.

Each check runs only when the pull request touches what it validates, compared with the merge base:

Expand All @@ -85,6 +85,7 @@ changelog:
runs-on: ubuntu-26.04
permissions:
contents: read
pull-requests: read
steps:
- uses: actions/checkout@CHECKOUT_SHA
with:
Expand All @@ -102,7 +103,9 @@ changelog:
```

The Action expects a pull request base ref and a Markdown or reStructuredText changelog. The caller should also run on
`labeled` and `unlabeled` events, so a label added after opening the PR updates the required check.
`labeled` and `unlabeled` events, so a label added after opening the PR updates the required check. The Action reads the
PR's labels from the API rather than from the event, so re-running an older run sees the labels the PR has now; the job
needs `pull-requests: read` for that.

## Refresh release tools

Expand Down
3 changes: 3 additions & 0 deletions changelog.d/+live-labels.change.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
`validate-news` reads the pull request's current labels from the API, so re-running an older Changelog run after adding
`skip news` passes instead of failing on the labels the original event carried. Callers grant `pull-requests: read` to
the job.
4 changes: 2 additions & 2 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -471,8 +471,8 @@ def _expression(
return environment.get("GH_TOKEN", environment.get("GH_ENTERPRISE_TOKEN", ""))
if expression == "github.event.pull_request.user.type":
return "Bot" if environment.get("BOT_AUTHOR") == "true" else "User"
if expression == "contains(github.event.pull_request.labels.*.name, 'skip news')":
return environment.get("SKIP_NEWS", "false")
if expression == "github.event.pull_request.number":
return environment.get("PULL_NUMBER", "7")
raise ValueError(f"unsupported GitHub step environment: {expression}") # pragma: no cover - new syntax only


Expand Down
68 changes: 53 additions & 15 deletions tests/validate_news/test_news.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,20 @@
import pytest
import yaml
from conftest import action, run
from pytest_httpserver import HTTPServer

_STEPS: Final = yaml.safe_load((Path(__file__).resolve().parents[2] / "validate-news/action.yml").read_text())["runs"][
"steps"
]


def _current_labels(github_api: tuple[HTTPServer, dict[str, str]], skip: str) -> dict[str, str]:
server, environment = github_api
labels = [{"name": "documentation"}, *([{"name": "skip news"}] if skip == "true" else [])]
server.expect_request("/api/v3/repos/tox-dev/fixture/pulls/7", method="GET").respond_with_json({"labels": labels})
return environment


def _script(action_scripts: Path, step_id: str) -> Path:
return action_scripts / f"validate-news-{next(i for i, step in enumerate(_STEPS) if step.get('id') == step_id)}.sh"

Expand All @@ -27,7 +35,13 @@ def _script(action_scripts: Path, step_id: str) -> Path:
],
)
def test_validate_news_without_fragment(
repository: Path, tmp_path: Path, action_scripts: Path, bot: str, skip: str, accepted: bool
repository: Path,
tmp_path: Path,
action_scripts: Path,
bot: str,
skip: str,
accepted: bool,
github_api: tuple[HTTPServer, dict[str, str]],
) -> None:
result = action(
repository,
Expand All @@ -37,7 +51,7 @@ def test_validate_news_without_fragment(
version="0.2.0",
BASE_REF="main",
BOT_AUTHOR=bot,
SKIP_NEWS=skip,
**_current_labels(github_api, skip),
)
assert (result.returncode == 0) == accepted
if not accepted:
Expand All @@ -52,7 +66,9 @@ def test_validate_news_without_fragment(
pytest.param("123.feature.2.md", id="numbered-multiple"),
],
)
def test_validate_news_checks_bot_fragment(repository: Path, tmp_path: Path, action_scripts: Path, name: str) -> None:
def test_validate_news_checks_bot_fragment(
repository: Path, tmp_path: Path, action_scripts: Path, name: str, github_api: tuple[HTTPServer, dict[str, str]]
) -> None:
(repository / "changelog.d" / name).write_text("Add another feature.\n")
run(repository, "git", "add", ".")
run(repository, "git", "commit", "-m", "Add news")
Expand All @@ -64,12 +80,14 @@ def test_validate_news_checks_bot_fragment(repository: Path, tmp_path: Path, act
version="0.2.0",
BASE_REF="main",
BOT_AUTHOR="true",
SKIP_NEWS="true",
**_current_labels(github_api, "true"),
)
assert result.returncode == 0, result.stderr


def test_validate_news_reads_rst_categories(repository: Path, tmp_path: Path, action_scripts: Path) -> None:
def test_validate_news_reads_rst_categories(
repository: Path, tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]]
) -> None:
project = repository / "pyproject.toml"
project.write_text(
project.read_text()
Expand All @@ -90,7 +108,7 @@ def test_validate_news_reads_rst_categories(repository: Path, tmp_path: Path, ac
version="0.2.0",
BASE_REF="main",
BOT_AUTHOR="false",
SKIP_NEWS="false",
**_current_labels(github_api, "false"),
)
assert result.returncode == 0, result.stderr

Expand All @@ -105,7 +123,13 @@ def test_validate_news_reads_rst_categories(repository: Path, tmp_path: Path, ac
],
)
def test_validate_news_rejects_bad_fragment(
repository: Path, tmp_path: Path, action_scripts: Path, name: str, content: str, error: str
repository: Path,
tmp_path: Path,
action_scripts: Path,
name: str,
content: str,
error: str,
github_api: tuple[HTTPServer, dict[str, str]],
) -> None:
(repository / "changelog.d" / name).write_text(content)
run(repository, "git", "add", ".")
Expand All @@ -118,13 +142,15 @@ def test_validate_news_rejects_bad_fragment(
version="0.2.0",
BASE_REF="main",
BOT_AUTHOR="true",
SKIP_NEWS="false",
**_current_labels(github_api, "false"),
)
assert result.returncode != 0
assert error in result.stderr


def test_validate_news_requires_pr_base(repository: Path, tmp_path: Path, action_scripts: Path) -> None:
def test_validate_news_requires_pr_base(
repository: Path, tmp_path: Path, action_scripts: Path, github_api: tuple[HTTPServer, dict[str, str]]
) -> None:
result = action(
repository,
tmp_path,
Expand All @@ -133,7 +159,7 @@ def test_validate_news_requires_pr_base(repository: Path, tmp_path: Path, action
version="0.2.0",
BASE_REF="",
BOT_AUTHOR="true",
SKIP_NEWS="false",
**_current_labels(github_api, "false"),
)
assert result.returncode != 0
assert "origin/" in result.stderr
Expand Down Expand Up @@ -264,6 +290,7 @@ def test_validate_news_checks_changed_release_inputs_against_the_lock(
monkeypatch: pytest.MonkeyPatch,
change: str,
accepted: bool,
github_api: tuple[HTTPServer, dict[str, str]],
) -> None:
monkeypatch.delenv("SSL_CERT_FILE")
project = repository / "pyproject.toml"
Expand All @@ -287,7 +314,7 @@ def test_validate_news_checks_changed_release_inputs_against_the_lock(
"validate-news",
version="0.2.0",
BASE_REF="main",
SKIP_NEWS="true",
**_current_labels(github_api, "true"),
)

assert (result.returncode == 0) is accepted, result.stderr
Expand Down Expand Up @@ -371,7 +398,12 @@ def test_validate_news_detects_news_input_change(
],
)
def test_validate_news_checks_towncrier_config_only_when_touched(
repository: Path, tmp_path: Path, action_scripts: Path, touched: str, accepted: bool
repository: Path,
tmp_path: Path,
action_scripts: Path,
touched: str,
accepted: bool,
github_api: tuple[HTTPServer, dict[str, str]],
) -> None:
project = repository / "pyproject.toml"
project.write_text(project.read_text().replace('directory = "changelog.d"\n', 'package = "fixture"\n'))
Expand All @@ -392,7 +424,7 @@ def test_validate_news_checks_towncrier_config_only_when_touched(
"validate-news",
version="0.2.0",
BASE_REF="main",
SKIP_NEWS="true",
**_current_labels(github_api, "true"),
)

assert (result.returncode == 0) is accepted, result.stderr
Expand All @@ -409,7 +441,13 @@ def test_validate_news_checks_towncrier_config_only_when_touched(
],
)
def test_validate_news_explains_choices(
repository: Path, tmp_path: Path, action_scripts: Path, bot: str, skip: str, explanation: str
repository: Path,
tmp_path: Path,
action_scripts: Path,
bot: str,
skip: str,
explanation: str,
github_api: tuple[HTTPServer, dict[str, str]],
) -> None:
result = action(
repository,
Expand All @@ -419,7 +457,7 @@ def test_validate_news_explains_choices(
version="0.2.0",
BASE_REF="main",
BOT_AUTHOR=bot,
SKIP_NEWS=skip,
**_current_labels(github_api, skip),
)

assert explanation in result.stdout
15 changes: 14 additions & 1 deletion validate-news/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -89,10 +89,23 @@ runs:
source "$GITHUB_ACTION_PATH/../tools/plural.sh"
release-creator validate-news --base "$BASE_REF" >"$RUNNER_TEMP/news-fragment-count"
echo "Changed fragments: $(plural "$(<"$RUNNER_TEMP/news-fragment-count")" fragment) valid against origin/$BASE_REF"
- id: labels
name: 🏷️ Read the current labels
shell: bash
env:
GH_TOKEN: ${{ github.token }}
REPOSITORY: ${{ github.repository }}
PULL: ${{ github.event.pull_request.number }}
run: |
set -euo pipefail
# A re-run replays its original event, whose label list can predate a skip news label added since.
skip=$(gh api "repos/$REPOSITORY/pulls/$PULL" --jq 'any(.labels[]; .name == "skip news")')
echo "Current labels of #$PULL: skip news $([[ "$skip" == true ]] && echo present || echo absent)"
echo "skip=$skip" >>"$GITHUB_OUTPUT"
- name: 🔍 Require news for human changes
shell: bash
env:
SKIP_NEWS: ${{ contains(github.event.pull_request.labels.*.name, 'skip news') }}
SKIP_NEWS: ${{ steps.labels.outputs.skip }}
BOT_AUTHOR: ${{ github.event.pull_request.user.type == 'Bot' }}
run: |
# shellcheck disable=SC1091 # GitHub supplies this Action path at runtime.
Expand Down