From 423ee88b11c9d106ac942d346b3d0a965b627f85 Mon Sep 17 00:00:00 2001 From: Jannis Leidel Date: Fri, 5 Jun 2026 13:25:48 +0200 Subject: [PATCH 1/5] Add news release actions --- check-news/README.md | 44 +++ check-news/action.yml | 47 +++ check-news/check_news.py | 227 +++++++++++++ check-news/test_check_news.py | 199 ++++++++++++ news_common/__init__.py | 187 +++++++++++ prepare-release/README.md | 44 +++ prepare-release/action.yml | 88 ++++++ prepare-release/prepare_release.py | 402 ++++++++++++++++++++++++ prepare-release/test_prepare_release.py | 176 +++++++++++ pyproject.toml | 6 + 10 files changed, 1420 insertions(+) create mode 100644 check-news/README.md create mode 100644 check-news/action.yml create mode 100644 check-news/check_news.py create mode 100644 check-news/test_check_news.py create mode 100644 news_common/__init__.py create mode 100644 prepare-release/README.md create mode 100644 prepare-release/action.yml create mode 100644 prepare-release/prepare_release.py create mode 100644 prepare-release/test_prepare_release.py diff --git a/check-news/README.md b/check-news/README.md new file mode 100644 index 00000000..e1803b17 --- /dev/null +++ b/check-news/README.md @@ -0,0 +1,44 @@ +# Check News + +Validate that pull requests include a conda news fragment. + +```yaml +name: News fragment + +on: + pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled, ready_for_review] + +permissions: + contents: read + pull-requests: read + +jobs: + news: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + persist-credentials: false + - uses: conda/actions/check-news@main + with: + skip-label: no-news + require-pr-number: true + fragment-format: sectioned + news-directory: news +``` + +The action accepts current conda sectioned snippets under `news/`, including +extensionless files and `.md` files. It ignores `news/TEMPLATE`, +`news/TEMPLATE.md`, and hidden files. + +Supported headings are: + +- `Enhancements` +- `Bug fixes` +- `Deprecations` +- `Docs` +- `Other` + +Pull requests without a news fragment can use the `no-news` label. diff --git a/check-news/action.yml b/check-news/action.yml new file mode 100644 index 00000000..8a5cd954 --- /dev/null +++ b/check-news/action.yml @@ -0,0 +1,47 @@ +name: Check News +description: Validate that pull requests include a conda news fragment. +branding: + icon: file-text + color: green + +inputs: + skip-label: + description: Label that marks a pull request as not needing a news fragment. + default: no-news + require-pr-number: + description: Require the PR number in the news fragment filename or contents. + default: 'true' + fragment-format: + description: News fragment format to validate. + default: sectioned + news-directory: + description: Directory containing news fragments. + default: news + exempt-authors: + description: Comma-separated GitHub logins that do not need a news fragment. + default: pre-commit-ci[bot],dependabot[bot],conda-bot,github-actions[bot] +outputs: + summary: + description: Summary of the news check result. + value: ${{ steps.check.outputs.summary }} + +runs: + using: composite + steps: + - name: Check News + id: check + shell: bash + run: > + python "$GITHUB_ACTION_PATH/check_news.py" + --skip-label "$INPUT_SKIP_LABEL" + --require-pr-number "$INPUT_REQUIRE_PR_NUMBER" + --fragment-format "$INPUT_FRAGMENT_FORMAT" + --news-directory "$INPUT_NEWS_DIRECTORY" + --exempt-authors "$INPUT_EXEMPT_AUTHORS" + env: + INPUT_SKIP_LABEL: ${{ inputs.skip-label }} + INPUT_REQUIRE_PR_NUMBER: ${{ inputs.require-pr-number }} + INPUT_FRAGMENT_FORMAT: ${{ inputs.fragment-format }} + INPUT_NEWS_DIRECTORY: ${{ inputs.news-directory }} + INPUT_EXEMPT_AUTHORS: ${{ inputs.exempt-authors }} + PYTHONPATH: ${{ github.action_path }}/.. diff --git a/check-news/check_news.py b/check-news/check_news.py new file mode 100644 index 00000000..5e65356a --- /dev/null +++ b/check-news/check_news.py @@ -0,0 +1,227 @@ +from __future__ import annotations + +import json +import os +import subprocess +import sys +from argparse import ArgumentParser, Namespace +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from news_common import ( + fragment_mentions_pr, + is_news_fragment, + parse_sectioned_news, +) + + +@dataclass(frozen=True) +class ChangedFile: + status: str + path: Path + + +class ActionError(Exception): + pass + + +def parse_bool(value: str | bool) -> bool: + if isinstance(value, bool): + return value + match value.strip().casefold(): + case "1" | "true" | "yes" | "on": + return True + case "0" | "false" | "no" | "off": + return False + case _: + raise ActionError(f"Invalid boolean value: {value!r}") + + +def parse_args(argv: list[str] | None = None) -> Namespace: + parser = ArgumentParser(description="Validate conda news fragments.") + parser.add_argument("--news-directory", default="news") + parser.add_argument("--skip-label", default="no-news") + parser.add_argument("--require-pr-number", default="true") + parser.add_argument( + "--fragment-format", + default="sectioned", + choices=["sectioned", "auto"], + ) + parser.add_argument( + "--exempt-authors", + default="pre-commit-ci[bot],dependabot[bot],conda-bot,github-actions[bot]", + help="Comma-separated GitHub logins that do not need a news fragment.", + ) + parser.add_argument( + "--changed-file", + action="append", + default=[], + help="Changed file path for tests; when omitted, git diff is used.", + ) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + try: + args = parse_args(argv) + check_news(args) + except ActionError as err: + print(f"::error::{err}", file=sys.stderr) + return 1 + return 0 + + +def check_news(args: Namespace) -> None: + payload = load_event_payload() + labels = pull_request_labels(payload) + author = pull_request_author(payload) + pr_number = pull_request_number(payload) + + if args.skip_label and args.skip_label in labels: + write_summary(f"News check skipped because `{args.skip_label}` is present.") + return + + exempt_authors = { + author.strip() + for author in args.exempt_authors.split(",") + if author.strip() + } + if author and author in exempt_authors: + write_summary(f"News check skipped for exempt author `{author}`.") + return + + changed_files = ( + [ChangedFile(status="A", path=Path(path)) for path in args.changed_file] + if args.changed_file + else get_changed_files(payload) + ) + news_directory = Path(args.news_directory) + news_files = [ + changed.path + for changed in changed_files + if not changed.status.startswith("D") + and is_news_fragment(changed.path, news_directory) + ] + + if not news_files: + raise ActionError( + "This PR needs a news fragment or the " + f"`{args.skip_label}` label. Add a file under `{news_directory}/`." + ) + + require_pr_number = parse_bool(args.require_pr_number) + errors: list[str] = [] + checked = 0 + for path in news_files: + if not path.exists(): + errors.append(f"{path}: changed news fragment is missing from the checkout") + continue + + text = path.read_text(encoding="utf-8") + fragment = parse_sectioned_news(path, text) + errors.extend(fragment.errors) + checked += 1 + + if require_pr_number: + if pr_number is None: + errors.append( + "Could not determine the pull request number for validation." + ) + elif not fragment_mentions_pr(path, text, pr_number): + errors.append( + f"{path}: expected the filename or contents to mention " + f"PR #{pr_number}" + ) + + if errors: + raise ActionError("\n".join(errors)) + + write_summary(f"Validated {checked} news fragment(s).") + + +def load_event_payload() -> dict[str, Any]: + path = os.environ.get("GITHUB_EVENT_PATH") + if not path: + return {} + event_path = Path(path) + if not event_path.is_file(): + return {} + return json.loads(event_path.read_text(encoding="utf-8")) + + +def pull_request_labels(payload: dict[str, Any]) -> set[str]: + pull_request = payload.get("pull_request") or {} + issue = payload.get("issue") or {} + return { + label.get("name", "") + for label in pull_request.get("labels", issue.get("labels", [])) + if isinstance(label, dict) + } + + +def pull_request_author(payload: dict[str, Any]) -> str | None: + pull_request = payload.get("pull_request") or {} + user = pull_request.get("user") or {} + return user.get("login") + + +def pull_request_number(payload: dict[str, Any]) -> int | None: + pull_request = payload.get("pull_request") or {} + number = pull_request.get("number") or payload.get("number") + return int(number) if number is not None else None + + +def get_changed_files(payload: dict[str, Any]) -> list[ChangedFile]: + pull_request = payload.get("pull_request") or {} + base = pull_request.get("base") or {} + head = pull_request.get("head") or {} + attempts = [ + (base.get("sha"), head.get("sha")), + (f"origin/{base.get('ref')}", "HEAD") if base.get("ref") else (None, None), + ("HEAD^", "HEAD"), + ] + + for before, after in attempts: + if not before or not after: + continue + try: + return diff_name_status(before, after) + except subprocess.CalledProcessError: + continue + + raise ActionError( + "Could not determine changed files. Use actions/checkout with fetch-depth: 0." + ) + + +def diff_name_status(before: str, after: str) -> list[ChangedFile]: + result = subprocess.run( + ["git", "diff", "--name-status", before, after], + check=True, + text=True, + capture_output=True, + ) + changed: list[ChangedFile] = [] + for line in result.stdout.splitlines(): + parts = line.split("\t") + if len(parts) < 2: + continue + status = parts[0] + path = parts[-1] + changed.append(ChangedFile(status=status, path=Path(path))) + return changed + + +def write_summary(text: str) -> None: + print(text) + if output := os.environ.get("GITHUB_OUTPUT"): + with Path(output).open("a", encoding="utf-8") as handle: + handle.write(f"summary={text}\n") + if summary := os.environ.get("GITHUB_STEP_SUMMARY"): + with Path(summary).open("a", encoding="utf-8") as handle: + handle.write(f"{text}\n") + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/check-news/test_check_news.py b/check-news/test_check_news.py new file mode 100644 index 00000000..26c63081 --- /dev/null +++ b/check-news/test_check_news.py @@ -0,0 +1,199 @@ +from __future__ import annotations + +import json +from argparse import Namespace +from typing import TYPE_CHECKING + +import pytest + +from check_news import ActionError, check_news +from news_common import ( + fragment_mentions_pr, + is_news_fragment, + parse_sectioned_news, +) + +if TYPE_CHECKING: + from pathlib import Path + +TEMPLATE = """\ +### Enhancements + +* + +### Bug fixes + +* + +### Deprecations + +* + +### Docs + +* + +### Other + +* +""" + + +def write_event( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + *, + number: int = 123, + labels: list[str] | None = None, + author: str = "contributor", +) -> None: + event = { + "number": number, + "pull_request": { + "number": number, + "labels": [{"name": label} for label in labels or []], + "user": {"login": author}, + }, + } + path = tmp_path / "event.json" + path.write_text(json.dumps(event), encoding="utf-8") + monkeypatch.setenv("GITHUB_EVENT_PATH", str(path)) + + +def args(**kwargs: object) -> Namespace: + defaults = { + "news_directory": "news", + "skip_label": "no-news", + "require_pr_number": "true", + "fragment_format": "sectioned", + "exempt_authors": ( + "pre-commit-ci[bot],dependabot[bot],conda-bot,github-actions[bot]" + ), + "changed_file": [], + } + defaults.update(kwargs) + return Namespace(**defaults) + + +def test_is_news_fragment() -> None: + assert is_news_fragment("news/123-fix") + assert is_news_fragment("news/123-fix.md") + assert not is_news_fragment("news/TEMPLATE") + assert not is_news_fragment("news/TEMPLATE.md") + assert not is_news_fragment("news/.DS_Store") + assert not is_news_fragment("news/nested/123-fix") + assert not is_news_fragment("docs/123-fix") + + +def test_parse_valid_single_section() -> None: + fragment = parse_sectioned_news( + "news/123-fix", + "### Bug fixes\n\n* Fix the thing. (#123)\n", + ) + + assert not fragment.errors + assert fragment.item_count == 1 + assert fragment.sections["Bug fixes"] == ["* Fix the thing. (#123)"] + + +def test_parse_valid_multi_section_with_wrapped_bullet() -> None: + fragment = parse_sectioned_news( + "news/123-feature", + "### Enhancements\n\n* Add a feature\n with details. (#123)\n\n" + "### Docs\n\n* Document it. (#123)\n", + ) + + assert not fragment.errors + assert fragment.item_count == 2 + assert fragment.sections["Enhancements"] == [ + "* Add a feature\n with details. (#123)" + ] + assert fragment.sections["Docs"] == ["* Document it. (#123)"] + + +def test_parse_placeholder_only_fails() -> None: + fragment = parse_sectioned_news("news/123-empty", TEMPLATE) + + assert fragment.errors == ("news/123-empty: no real news items found",) + + +def test_parse_unknown_heading_fails() -> None: + fragment = parse_sectioned_news( + "news/123-heading", + "### Fixes\n\n* Fix the thing. (#123)\n", + ) + + assert "unknown news heading 'Fixes'" in fragment.errors[0] + + +def test_parse_empty_file_fails() -> None: + fragment = parse_sectioned_news("news/123-empty", "") + + assert fragment.errors == ("news/123-empty: no news headings found",) + + +def test_fragment_mentions_pr() -> None: + assert fragment_mentions_pr("news/123-fix", "", 123) + assert fragment_mentions_pr("news/fix", "* Fix it. (#123)", 123) + assert not fragment_mentions_pr("news/1234-fix", "* Fix it. (#1234)", 123) + + +def test_check_news_passes_with_valid_fragment( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.chdir(tmp_path) + write_event(tmp_path, monkeypatch, number=123) + news = tmp_path / "news" + news.mkdir() + fragment = news / "123-fix" + fragment.write_text("### Bug fixes\n\n* Fix the thing. (#123)\n", encoding="utf-8") + + check_news(args(changed_file=[str(fragment.relative_to(tmp_path))])) + + +def test_check_news_skip_label_passes( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.chdir(tmp_path) + write_event(tmp_path, monkeypatch, labels=["no-news"]) + + check_news(args(changed_file=[])) + + +def test_check_news_exempt_author_passes( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.chdir(tmp_path) + write_event(tmp_path, monkeypatch, author="github-actions[bot]") + + check_news(args(changed_file=[])) + + +def test_check_news_requires_fragment( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.chdir(tmp_path) + write_event(tmp_path, monkeypatch) + + with pytest.raises(ActionError, match="needs a news fragment"): + check_news(args(changed_file=["conda/example.py"])) + + +def test_check_news_requires_matching_pr_number( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.chdir(tmp_path) + write_event(tmp_path, monkeypatch, number=123) + news = tmp_path / "news" + news.mkdir() + fragment = news / "456-fix" + fragment.write_text("### Bug fixes\n\n* Fix the thing. (#456)\n", encoding="utf-8") + + message = "expected the filename or contents to mention PR #123" + with pytest.raises(ActionError, match=message): + check_news(args(changed_file=[str(fragment.relative_to(tmp_path))])) diff --git a/news_common/__init__.py b/news_common/__init__.py new file mode 100644 index 00000000..29f71ded --- /dev/null +++ b/news_common/__init__.py @@ -0,0 +1,187 @@ +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from pathlib import Path + +SECTION_TYPES: dict[str, str] = { + "Enhancements": "enhancement", + "Bug fixes": "bugfix", + "Deprecations": "deprecation", + "Docs": "doc", + "Other": "other", +} +SECTION_ORDER: tuple[str, ...] = tuple(SECTION_TYPES) + +HEADING_RE = re.compile(r"^(?P#{2,6})\s+(?P.+?)\s*$") +BULLET_RE = re.compile(r"^\s*[*-]\s+(?P<body>.*\S)\s*$") +PR_RE = re.compile(r"(?<!\d)#?(?P<number>\d+)(?!\d)") + + +@dataclass(frozen=True) +class NewsFragment: + path: Path + sections: dict[str, list[str]] = field( + default_factory=lambda: {section: [] for section in SECTION_ORDER} + ) + errors: tuple[str, ...] = () + + @property + def item_count(self) -> int: + return sum(len(items) for items in self.sections.values()) + + +def normalize_heading(value: str) -> str: + return " ".join(value.strip().strip("#").strip().casefold().split()) + + +SECTION_ALIASES = { + normalize_heading(section): section + for section in SECTION_ORDER +} + + +def is_news_fragment(path: str | Path, news_directory: str | Path = "news") -> bool: + path = Path(path) + news_directory = Path(news_directory) + + try: + relative = path.relative_to(news_directory) + except ValueError: + return False + + if len(relative.parts) != 1: + return False + + if relative.name.startswith("."): + return False + + if relative.name in {"TEMPLATE", "TEMPLATE.md"}: + return False + + return relative.suffix in {"", ".md"} + + +def iter_news_fragments(news_directory: str | Path = "news") -> list[Path]: + news_directory = Path(news_directory) + if not news_directory.is_dir(): + return [] + return sorted( + path + for path in news_directory.iterdir() + if path.is_file() and is_news_fragment(path, news_directory) + ) + + +def parse_sectioned_news(path: str | Path, text: str) -> NewsFragment: + path = Path(path) + errors: list[str] = [] + raw_sections: dict[str, list[str]] = {section: [] for section in SECTION_ORDER} + seen_sections: set[str] = set() + current_section: str | None = None + saw_heading = False + inside_unknown = False + + for lineno, line in enumerate(text.splitlines(), start=1): + if match := HEADING_RE.match(line): + saw_heading = True + title = normalize_heading(match.group("title")) + current_section = SECTION_ALIASES.get(title) + inside_unknown = current_section is None + + if current_section is None: + errors.append( + f"{path}:{lineno}: unknown news heading " + f"{match.group('title')!r}" + ) + elif current_section in seen_sections: + errors.append( + f"{path}:{lineno}: duplicate news heading " + f"{current_section!r}" + ) + else: + seen_sections.add(current_section) + continue + + if current_section is None: + if line.strip() and not inside_unknown: + errors.append( + f"{path}:{lineno}: content appears before a known news heading" + ) + continue + + raw_sections[current_section].append(line.rstrip()) + + if not saw_heading: + errors.append(f"{path}: no news headings found") + + sections: dict[str, list[str]] = {section: [] for section in SECTION_ORDER} + for section, lines in raw_sections.items(): + items, item_errors = _extract_items(path, section, lines) + sections[section] = items + errors.extend(item_errors) + + if not errors and not any(sections.values()): + errors.append(f"{path}: no real news items found") + + return NewsFragment(path=path, sections=sections, errors=tuple(errors)) + + +def _extract_items( + path: Path, + section: str, + lines: list[str], +) -> tuple[list[str], list[str]]: + items: list[str] = [] + errors: list[str] = [] + block: list[str] = [] + block_start = 0 + + def flush() -> None: + nonlocal block + while block and not block[-1].strip(): + block.pop() + if not block: + return + + match = BULLET_RE.match(block[0]) + if match and not _is_placeholder(match.group("body")): + items.append("\n".join(block)) + block = [] + + for offset, line in enumerate(lines, start=1): + if BULLET_RE.match(line): + flush() + block = [line] + block_start = offset + continue + + if not line.strip(): + if block: + block.append("") + continue + + if block and (line.startswith(" ") or line.startswith("\t")): + block.append(line) + continue + + errors.append( + f"{path}: non-bullet content in {section!r} near section line " + f"{block_start or offset}: {line.strip()!r}" + ) + + flush() + return items, errors + + +def _is_placeholder(value: str) -> bool: + return normalize_heading(value).strip("<>") == "news item" + + +def fragment_mentions_pr(path: str | Path, text: str, pr_number: int | str) -> bool: + pr_number = str(pr_number) + for value in (Path(path).name, text): + for match in PR_RE.finditer(value): + if match.group("number") == pr_number: + return True + return False diff --git a/prepare-release/README.md b/prepare-release/README.md new file mode 100644 index 00000000..4eddfa13 --- /dev/null +++ b/prepare-release/README.md @@ -0,0 +1,44 @@ +# Prepare Release + +Generate release notes from conda news fragments and open or update a release +PR. The action is intended to run from a trusted `workflow_run` event after the +test workflow succeeds on a protected release branch. + +```yaml +name: Prepare release notes + +on: + workflow_run: + workflows: [Tests] + types: [completed] + branches: + - '[0-9]*.[0-9]*.x' + +permissions: + contents: write + pull-requests: write + +jobs: + prepare: + if: >- + github.event.workflow_run.conclusion == 'success' + && github.event.workflow_run.event == 'push' + && github.event.workflow_run.head_repository.full_name == github.repository + runs-on: ubuntu-latest + steps: + - uses: conda/actions/prepare-release@main + with: + news-directory: news + changelog-path: CHANGELOG.md + issue-reference: conda/infrastructure#556 +``` + +The action checks the same security conditions internally before checkout: + +- event is `workflow_run` +- triggering workflow concluded successfully +- triggering workflow came from a `push` +- triggering repository is the current repository +- triggering branch matches the configured release branch pattern + +The generated PR body uses `Refs conda/infrastructure#556`. diff --git a/prepare-release/action.yml b/prepare-release/action.yml new file mode 100644 index 00000000..dd793d8f --- /dev/null +++ b/prepare-release/action.yml @@ -0,0 +1,88 @@ +name: Prepare Release +description: Generate conda release notes from news fragments and open a release PR. +branding: + icon: git-pull-request + color: green + +inputs: + news-directory: + description: Directory containing news fragments. + default: news + changelog-path: + description: Changelog file to update. + default: CHANGELOG.md + release-branch-pattern: + description: Comma- or newline-separated release branch glob patterns. + default: '[0-9]*.[0-9]*.x' + issue-reference: + description: Issue reference to include in the generated PR body. + default: conda/infrastructure#556 + branch-prefix: + description: Prefix for the generated release-notes branch. + default: release-notes- + git-author-name: + description: Git author name for the generated commit. + default: Conda Bot + git-author-email: + description: Git author email for the generated commit. + default: 18747875+conda-bot@users.noreply.github.com + token: + description: GitHub token with contents: write and pull-requests: write. + default: ${{ github.token }} +outputs: + version: + description: Release version inferred from the release branch. + value: ${{ steps.prepare.outputs.version }} + branch: + description: Generated release-notes branch. + value: ${{ steps.prepare.outputs.branch }} + pull-request-url: + description: Generated or updated release PR URL. + value: ${{ steps.prepare.outputs.pull-request-url }} + +runs: + using: composite + steps: + - name: Verify Release Context + id: context + shell: bash + run: > + python "$GITHUB_ACTION_PATH/prepare_release.py" + verify-context + --release-branch-pattern "$INPUT_RELEASE_BRANCH_PATTERN" + env: + INPUT_RELEASE_BRANCH_PATTERN: ${{ inputs.release-branch-pattern }} + PYTHONPATH: ${{ github.action_path }}/.. + + - name: Checkout Release Branch + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ steps.context.outputs.head-sha }} + fetch-depth: 0 + token: ${{ inputs.token }} + + - name: Prepare Release Notes + id: prepare + shell: bash + run: > + python "$GITHUB_ACTION_PATH/prepare_release.py" + prepare + --news-directory "$INPUT_NEWS_DIRECTORY" + --changelog-path "$INPUT_CHANGELOG_PATH" + --release-branch-pattern "$INPUT_RELEASE_BRANCH_PATTERN" + --issue-reference "$INPUT_ISSUE_REFERENCE" + --branch-prefix "$INPUT_BRANCH_PREFIX" + --git-author-name "$INPUT_GIT_AUTHOR_NAME" + --git-author-email "$INPUT_GIT_AUTHOR_EMAIL" + --repository "$GITHUB_REPOSITORY" + --token "$INPUT_TOKEN" + env: + INPUT_NEWS_DIRECTORY: ${{ inputs.news-directory }} + INPUT_CHANGELOG_PATH: ${{ inputs.changelog-path }} + INPUT_RELEASE_BRANCH_PATTERN: ${{ inputs.release-branch-pattern }} + INPUT_ISSUE_REFERENCE: ${{ inputs.issue-reference }} + INPUT_BRANCH_PREFIX: ${{ inputs.branch-prefix }} + INPUT_GIT_AUTHOR_NAME: ${{ inputs.git-author-name }} + INPUT_GIT_AUTHOR_EMAIL: ${{ inputs.git-author-email }} + INPUT_TOKEN: ${{ inputs.token }} + PYTHONPATH: ${{ github.action_path }}/.. diff --git a/prepare-release/prepare_release.py b/prepare-release/prepare_release.py new file mode 100644 index 00000000..5fda1065 --- /dev/null +++ b/prepare-release/prepare_release.py @@ -0,0 +1,402 @@ +from __future__ import annotations + +import fnmatch +import json +import os +import re +import subprocess +import sys +from argparse import ArgumentParser, Namespace +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +from news_common import ( + SECTION_ORDER, + iter_news_fragments, + parse_sectioned_news, +) + +VERSION_BRANCH_RE = re.compile(r"^(?P<major_minor>\d+\.\d+)\.x$") +TAG_RE = re.compile(r"^v?(?P<version>\d+\.\d+\.(?P<micro>\d+))$") +CURRENT_DEVELOPMENTS = "[//]: # (current developments)" + + +class ActionError(Exception): + pass + + +def parse_args(argv: list[str] | None = None) -> Namespace: + parser = ArgumentParser(description="Prepare conda release notes.") + subparsers = parser.add_subparsers(dest="command", required=True) + + verify = subparsers.add_parser("verify-context") + add_context_args(verify) + + prepare = subparsers.add_parser("prepare") + add_context_args(prepare) + prepare.add_argument("--news-directory", default="news") + prepare.add_argument("--changelog-path", default="CHANGELOG.md") + prepare.add_argument("--issue-reference", default="conda/infrastructure#556") + prepare.add_argument("--branch-prefix", default="release-notes-") + prepare.add_argument("--git-author-name", default="Conda Bot") + prepare.add_argument( + "--git-author-email", + default="18747875+conda-bot@users.noreply.github.com", + ) + prepare.add_argument( + "--repository", + default=os.environ.get("GITHUB_REPOSITORY", ""), + ) + prepare.add_argument("--token", default=os.environ.get("GITHUB_TOKEN", "")) + + return parser.parse_args(argv) + + +def add_context_args(parser: ArgumentParser) -> None: + parser.add_argument("--release-branch-pattern", default="[0-9]*.[0-9]*.x") + + +def main(argv: list[str] | None = None) -> int: + try: + args = parse_args(argv) + if args.command == "verify-context": + context = verify_context(args.release_branch_pattern) + write_output("head-branch", context["head_branch"]) + write_output("head-sha", context["head_sha"]) + print(f"Verified release context for {context['head_branch']}.") + elif args.command == "prepare": + prepare_release(args) + except ActionError as err: + print(f"::error::{err}", file=sys.stderr) + return 1 + return 0 + + +def prepare_release(args: Namespace) -> None: + context = verify_context(args.release_branch_pattern) + base_branch = context["head_branch"] + version = infer_next_version(base_branch) + release_date = datetime.now(UTC).date().isoformat() + release_branch = f"{args.branch_prefix}{version}" + + run(["git", "checkout", "-B", release_branch]) + run(["git", "config", "user.name", args.git_author_name]) + run(["git", "config", "user.email", args.git_author_email]) + + fragment_paths = iter_news_fragments(args.news_directory) + fragments = collect_fragments(fragment_paths) + if not fragments: + raise ActionError(f"No news fragments found under {args.news_directory!r}.") + + entry = render_changelog_entry(version, release_date, fragments) + update_changelog(Path(args.changelog_path), entry, version) + + for path in fragment_paths: + path.unlink() + + changed_paths = get_changed_paths() + ensure_allowed_paths( + changed_paths, + changelog_path=Path(args.changelog_path), + news_paths=fragment_paths, + ) + if not changed_paths: + print("No release note changes to commit.") + return + + run(["git", "add", args.changelog_path, *map(str, fragment_paths)]) + run(["git", "commit", "-m", f"Prepare release notes for {version}"]) + run(["git", "push", "--force-with-lease", "origin", release_branch]) + + url = create_or_update_pr( + repository=args.repository, + branch=release_branch, + base_branch=base_branch, + version=version, + issue_reference=args.issue_reference, + token=args.token, + ) + + write_output("version", version) + write_output("branch", release_branch) + write_output("pull-request-url", url) + print(f"Prepared release notes for {version}: {url}") + + +def verify_context(release_branch_pattern: str) -> dict[str, str]: + event_name = os.environ.get("GITHUB_EVENT_NAME") + payload = load_event_payload() + repository = os.environ.get("GITHUB_REPOSITORY") + + if event_name != "workflow_run": + raise ActionError("prepare-release must run from the workflow_run event.") + + workflow_run = payload.get("workflow_run") or {} + if workflow_run.get("conclusion") != "success": + raise ActionError("The triggering workflow_run did not conclude successfully.") + if workflow_run.get("event") != "push": + raise ActionError("The triggering workflow_run must come from a push event.") + + head_repository = workflow_run.get("head_repository") or {} + if head_repository.get("full_name") != repository: + raise ActionError("The triggering workflow_run must come from this repository.") + + head_branch = workflow_run.get("head_branch") + head_sha = workflow_run.get("head_sha") + if not head_branch or not head_sha: + raise ActionError( + "The triggering workflow_run did not include a head branch and SHA." + ) + + patterns = split_patterns(release_branch_pattern) + if not any(fnmatch.fnmatchcase(head_branch, pattern) for pattern in patterns): + raise ActionError( + f"The triggering branch {head_branch!r} does not match " + f"{', '.join(patterns)!r}." + ) + + return {"head_branch": head_branch, "head_sha": head_sha} + + +def load_event_payload() -> dict[str, Any]: + event_path = os.environ.get("GITHUB_EVENT_PATH") + if not event_path: + return {} + path = Path(event_path) + if not path.is_file(): + return {} + return json.loads(path.read_text(encoding="utf-8")) + + +def split_patterns(value: str) -> list[str]: + patterns = [ + pattern.strip() + for chunk in value.splitlines() + for pattern in chunk.split(",") + if pattern.strip() + ] + return patterns or ["[0-9]*.[0-9]*.x"] + + +def infer_next_version(branch: str) -> str: + match = VERSION_BRANCH_RE.match(branch) + if not match: + raise ActionError(f"Cannot infer release version from branch {branch!r}.") + + major_minor = match.group("major_minor") + tags = run(["git", "tag", "--list", f"{major_minor}.*"], capture=True).splitlines() + tags.extend( + run(["git", "tag", "--list", f"v{major_minor}.*"], capture=True).splitlines() + ) + + micros = [] + for tag in tags: + tag_match = TAG_RE.match(tag) + if tag_match and tag_match.group("version").startswith(f"{major_minor}."): + micros.append(int(tag_match.group("micro"))) + + next_micro = max(micros) + 1 if micros else 0 + return f"{major_minor}.{next_micro}" + + +def collect_fragments(paths: list[Path]) -> dict[str, list[str]]: + fragments: dict[str, list[str]] = {section: [] for section in SECTION_ORDER} + errors: list[str] = [] + + for path in paths: + fragment = parse_sectioned_news(path, path.read_text(encoding="utf-8")) + errors.extend(fragment.errors) + for section, items in fragment.sections.items(): + fragments[section].extend(items) + + if errors: + raise ActionError("\n".join(errors)) + + return {section: items for section, items in fragments.items() if items} + + +def render_changelog_entry( + version: str, + release_date: str, + fragments: dict[str, list[str]], +) -> str: + lines = [f"## {version} ({release_date})", ""] + + for section in SECTION_ORDER: + items = fragments.get(section) + if not items: + continue + + lines.extend([f"### {section}", ""]) + for item in items: + lines.extend(item.splitlines()) + lines.append("") + + return "\n".join(lines).rstrip() + "\n\n\n" + + +def update_changelog(path: Path, entry: str, version: str) -> None: + if not path.is_file(): + raise ActionError(f"Changelog file does not exist: {path}") + + text = path.read_text(encoding="utf-8") + if re.search(rf"^##\s+{re.escape(version)}\s+\(", text, flags=re.MULTILINE): + raise ActionError(f"Changelog already contains an entry for {version}.") + + if CURRENT_DEVELOPMENTS in text: + marker_end = text.index(CURRENT_DEVELOPMENTS) + len(CURRENT_DEVELOPMENTS) + prefix = text[:marker_end].rstrip() + "\n\n" + suffix = text[marker_end:].lstrip("\n") + updated = prefix + entry + suffix + else: + updated = entry + text.lstrip("\n") + + path.write_text(updated, encoding="utf-8") + + +def get_changed_paths() -> list[Path]: + status = run( + ["git", "status", "--porcelain", "--untracked-files=all"], + capture=True, + ) + paths: list[Path] = [] + for line in status.splitlines(): + if not line: + continue + paths.append(Path(line[3:])) + return paths + + +def ensure_allowed_paths( + paths: list[Path], + *, + changelog_path: Path, + news_paths: list[Path], +) -> None: + allowed_paths = {changelog_path, *news_paths} + unexpected = [ + path + for path in paths + if path not in allowed_paths + ] + if unexpected: + raise ActionError( + "prepare-release produced unexpected file changes: " + + ", ".join(str(path) for path in unexpected) + ) + + +def create_or_update_pr( + *, + repository: str, + branch: str, + base_branch: str, + version: str, + issue_reference: str, + token: str, +) -> str: + if not repository: + raise ActionError("No GitHub repository was provided.") + if not token: + raise ActionError("No GitHub token was provided.") + + env = os.environ | {"GH_TOKEN": token} + title = f"Prepare release notes for {version}" + body = ( + f"Prepare release notes for `{version}`.\n\n" + f"Refs {issue_reference}\n\n" + "This PR was generated by `conda/actions/prepare-release`." + ) + existing = run( + [ + "gh", + "pr", + "list", + "--repo", + repository, + "--head", + branch, + "--base", + base_branch, + "--state", + "open", + "--json", + "number,url", + ], + capture=True, + env=env, + ) + prs = json.loads(existing) + + if prs: + number = str(prs[0]["number"]) + run( + [ + "gh", + "pr", + "edit", + number, + "--repo", + repository, + "--title", + title, + "--body", + body, + "--base", + base_branch, + ], + env=env, + ) + return str(prs[0]["url"]) + + return run( + [ + "gh", + "pr", + "create", + "--repo", + repository, + "--base", + base_branch, + "--head", + branch, + "--title", + title, + "--body", + body, + ], + capture=True, + env=env, + ).strip() + + +def run( + command: list[str], + *, + capture: bool = False, + env: dict[str, str] | None = None, +) -> str: + try: + result = subprocess.run( + command, + check=True, + text=True, + stdout=subprocess.PIPE if capture else None, + stderr=subprocess.PIPE if capture else None, + env=env, + ) + except subprocess.CalledProcessError as err: + detail = err.stderr.strip() if err.stderr else str(err) + raise ActionError(f"Command failed: {' '.join(command)}\n{detail}") from err + return result.stdout if capture else "" + + +def write_output(name: str, value: str) -> None: + if output := os.environ.get("GITHUB_OUTPUT"): + with Path(output).open("a", encoding="utf-8") as handle: + handle.write(f"{name}={value}\n") + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/prepare-release/test_prepare_release.py b/prepare-release/test_prepare_release.py new file mode 100644 index 00000000..f9714752 --- /dev/null +++ b/prepare-release/test_prepare_release.py @@ -0,0 +1,176 @@ +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +from prepare_release import ( + ActionError, + collect_fragments, + ensure_allowed_paths, + infer_next_version, + render_changelog_entry, + update_changelog, + verify_context, +) + + +def write_workflow_run_event( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + *, + conclusion: str = "success", + event: str = "push", + repository: str = "conda/conda", + head_repository: str = "conda/conda", + branch: str = "26.7.x", + sha: str = "abc123", +) -> None: + payload = { + "workflow_run": { + "conclusion": conclusion, + "event": event, + "head_branch": branch, + "head_sha": sha, + "head_repository": {"full_name": head_repository}, + } + } + path = tmp_path / "event.json" + path.write_text(json.dumps(payload), encoding="utf-8") + monkeypatch.setenv("GITHUB_EVENT_PATH", str(path)) + monkeypatch.setenv("GITHUB_EVENT_NAME", "workflow_run") + monkeypatch.setenv("GITHUB_REPOSITORY", repository) + + +def test_verify_context_accepts_trusted_release_push( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + write_workflow_run_event(tmp_path, monkeypatch) + + assert verify_context("[0-9]*.[0-9]*.x") == { + "head_branch": "26.7.x", + "head_sha": "abc123", + } + + +@pytest.mark.parametrize( + ("field", "value", "message"), + [ + ("conclusion", "failure", "did not conclude successfully"), + ("event", "pull_request", "must come from a push"), + ("head_repository", "someone/conda", "must come from this repository"), + ("branch", "main", "does not match"), + ], +) +def test_verify_context_rejects_untrusted_context( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + field: str, + value: str, + message: str, +) -> None: + kwargs = {field: value} + write_workflow_run_event(tmp_path, monkeypatch, **kwargs) + + with pytest.raises(ActionError, match=message): + verify_context("[0-9]*.[0-9]*.x") + + +def test_infer_next_version(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(tmp_path) + subprocess.run(["git", "init"], check=True, stdout=subprocess.PIPE) + subprocess.run(["git", "config", "user.name", "Test"], check=True) + subprocess.run(["git", "config", "user.email", "test@example.com"], check=True) + (tmp_path / "README.md").write_text("test\n", encoding="utf-8") + subprocess.run(["git", "add", "README.md"], check=True) + subprocess.run(["git", "commit", "-m", "init"], check=True, stdout=subprocess.PIPE) + subprocess.run(["git", "tag", "26.7.0"], check=True) + subprocess.run(["git", "tag", "v26.7.1"], check=True) + subprocess.run(["git", "tag", "26.8.0"], check=True) + + assert infer_next_version("26.7.x") == "26.7.2" + + +def test_collect_fragments_preserves_sections(tmp_path: Path) -> None: + news = tmp_path / "news" + news.mkdir() + (news / "123-feature").write_text( + "### Enhancements\n\n* Add feature. (#123)\n\n" + "### Bug fixes\n\n* Fix bug. (#123)\n", + encoding="utf-8", + ) + (news / "TEMPLATE").write_text("* <news item>\n", encoding="utf-8") + (news / ".DS_Store").write_text("", encoding="utf-8") + + assert collect_fragments([news / "123-feature"]) == { + "Enhancements": ["* Add feature. (#123)"], + "Bug fixes": ["* Fix bug. (#123)"], + } + + +def test_render_changelog_entry() -> None: + entry = render_changelog_entry( + "26.7.0", + "2026-06-05", + { + "Enhancements": ["* Add feature. (#123)"], + "Docs": ["* Document feature. (#123)"], + }, + ) + + assert entry == ( + "## 26.7.0 (2026-06-05)\n\n" + "### Enhancements\n\n" + "* Add feature. (#123)\n\n" + "### Docs\n\n" + "* Document feature. (#123)\n\n\n" + ) + + +def test_update_changelog_inserts_after_current_developments(tmp_path: Path) -> None: + changelog = tmp_path / "CHANGELOG.md" + changelog.write_text( + "[//]: # (current developments)\n\n## 26.6.0 (2026-05-01)\n", + encoding="utf-8", + ) + + update_changelog(changelog, "## 26.7.0 (2026-06-05)\n\n\n", "26.7.0") + + assert changelog.read_text(encoding="utf-8").startswith( + "[//]: # (current developments)\n\n" + "## 26.7.0 (2026-06-05)\n\n\n" + "## 26.6.0 (2026-05-01)\n" + ) + + +def test_update_changelog_refuses_existing_version(tmp_path: Path) -> None: + changelog = tmp_path / "CHANGELOG.md" + changelog.write_text("## 26.7.0 (2026-06-05)\n", encoding="utf-8") + + with pytest.raises(ActionError, match="already contains"): + update_changelog(changelog, "## 26.7.0 (2026-06-05)\n", "26.7.0") + + +def test_ensure_allowed_paths() -> None: + ensure_allowed_paths( + [Path("CHANGELOG.md"), Path("news/123-fix")], + changelog_path=Path("CHANGELOG.md"), + news_paths=[Path("news/123-fix")], + ) + + with pytest.raises(ActionError, match="unexpected file changes"): + ensure_allowed_paths( + [Path("conda/example.py")], + changelog_path=Path("CHANGELOG.md"), + news_paths=[Path("news/123-fix")], + ) + + with pytest.raises(ActionError, match="unexpected file changes"): + ensure_allowed_paths( + [Path("news/.DS_Store")], + changelog_path=Path("CHANGELOG.md"), + news_paths=[Path("news/123-fix")], + ) diff --git a/pyproject.toml b/pyproject.toml index 2acdb683..a322082b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,10 @@ omit = [ [tool.pytest.ini_options] addopts = [ "--color=yes", + "--cov=check-news", "--cov=combine-durations", + "--cov=news_common", + "--cov=prepare-release", "--cov=read-file", "--cov=template-files", "--cov-append", @@ -50,7 +53,10 @@ select = [ [tool.ruff.lint.isort] known-first-party = [ + "check_news", "combine_durations", + "news_common", + "prepare_release", "read_file", "template_files", ] From e960b91ef017adb589fd4cd975bcc93d66909761 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 5 Jun 2026 11:27:59 +0000 Subject: [PATCH 2/5] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- check-news/check_news.py | 4 +--- news_common/__init__.py | 11 +++-------- prepare-release/prepare_release.py | 6 +----- 3 files changed, 5 insertions(+), 16 deletions(-) diff --git a/check-news/check_news.py b/check-news/check_news.py index 5e65356a..521b80ef 100644 --- a/check-news/check_news.py +++ b/check-news/check_news.py @@ -83,9 +83,7 @@ def check_news(args: Namespace) -> None: return exempt_authors = { - author.strip() - for author in args.exempt_authors.split(",") - if author.strip() + author.strip() for author in args.exempt_authors.split(",") if author.strip() } if author and author in exempt_authors: write_summary(f"News check skipped for exempt author `{author}`.") diff --git a/news_common/__init__.py b/news_common/__init__.py index 29f71ded..4afa100b 100644 --- a/news_common/__init__.py +++ b/news_common/__init__.py @@ -35,10 +35,7 @@ def normalize_heading(value: str) -> str: return " ".join(value.strip().strip("#").strip().casefold().split()) -SECTION_ALIASES = { - normalize_heading(section): section - for section in SECTION_ORDER -} +SECTION_ALIASES = {normalize_heading(section): section for section in SECTION_ORDER} def is_news_fragment(path: str | Path, news_directory: str | Path = "news") -> bool: @@ -91,13 +88,11 @@ def parse_sectioned_news(path: str | Path, text: str) -> NewsFragment: if current_section is None: errors.append( - f"{path}:{lineno}: unknown news heading " - f"{match.group('title')!r}" + f"{path}:{lineno}: unknown news heading {match.group('title')!r}" ) elif current_section in seen_sections: errors.append( - f"{path}:{lineno}: duplicate news heading " - f"{current_section!r}" + f"{path}:{lineno}: duplicate news heading {current_section!r}" ) else: seen_sections.add(current_section) diff --git a/prepare-release/prepare_release.py b/prepare-release/prepare_release.py index 5fda1065..10682753 100644 --- a/prepare-release/prepare_release.py +++ b/prepare-release/prepare_release.py @@ -275,11 +275,7 @@ def ensure_allowed_paths( news_paths: list[Path], ) -> None: allowed_paths = {changelog_path, *news_paths} - unexpected = [ - path - for path in paths - if path not in allowed_paths - ] + unexpected = [path for path in paths if path not in allowed_paths] if unexpected: raise ActionError( "prepare-release produced unexpected file changes: " From 57cf0277047875cabce972ee872fc3e0e125c29d Mon Sep 17 00:00:00 2001 From: Jannis Leidel <jannis@leidel.info> Date: Fri, 5 Jun 2026 13:46:50 +0200 Subject: [PATCH 3/5] Remove release issue reference input --- prepare-release/README.md | 3 --- prepare-release/action.yml | 5 ----- prepare-release/prepare_release.py | 7 ++----- 3 files changed, 2 insertions(+), 13 deletions(-) diff --git a/prepare-release/README.md b/prepare-release/README.md index 4eddfa13..a534a202 100644 --- a/prepare-release/README.md +++ b/prepare-release/README.md @@ -30,7 +30,6 @@ jobs: with: news-directory: news changelog-path: CHANGELOG.md - issue-reference: conda/infrastructure#556 ``` The action checks the same security conditions internally before checkout: @@ -40,5 +39,3 @@ The action checks the same security conditions internally before checkout: - triggering workflow came from a `push` - triggering repository is the current repository - triggering branch matches the configured release branch pattern - -The generated PR body uses `Refs conda/infrastructure#556`. diff --git a/prepare-release/action.yml b/prepare-release/action.yml index dd793d8f..6613ed5c 100644 --- a/prepare-release/action.yml +++ b/prepare-release/action.yml @@ -14,9 +14,6 @@ inputs: release-branch-pattern: description: Comma- or newline-separated release branch glob patterns. default: '[0-9]*.[0-9]*.x' - issue-reference: - description: Issue reference to include in the generated PR body. - default: conda/infrastructure#556 branch-prefix: description: Prefix for the generated release-notes branch. default: release-notes- @@ -70,7 +67,6 @@ runs: --news-directory "$INPUT_NEWS_DIRECTORY" --changelog-path "$INPUT_CHANGELOG_PATH" --release-branch-pattern "$INPUT_RELEASE_BRANCH_PATTERN" - --issue-reference "$INPUT_ISSUE_REFERENCE" --branch-prefix "$INPUT_BRANCH_PREFIX" --git-author-name "$INPUT_GIT_AUTHOR_NAME" --git-author-email "$INPUT_GIT_AUTHOR_EMAIL" @@ -80,7 +76,6 @@ runs: INPUT_NEWS_DIRECTORY: ${{ inputs.news-directory }} INPUT_CHANGELOG_PATH: ${{ inputs.changelog-path }} INPUT_RELEASE_BRANCH_PATTERN: ${{ inputs.release-branch-pattern }} - INPUT_ISSUE_REFERENCE: ${{ inputs.issue-reference }} INPUT_BRANCH_PREFIX: ${{ inputs.branch-prefix }} INPUT_GIT_AUTHOR_NAME: ${{ inputs.git-author-name }} INPUT_GIT_AUTHOR_EMAIL: ${{ inputs.git-author-email }} diff --git a/prepare-release/prepare_release.py b/prepare-release/prepare_release.py index 10682753..bbab4abd 100644 --- a/prepare-release/prepare_release.py +++ b/prepare-release/prepare_release.py @@ -37,7 +37,6 @@ def parse_args(argv: list[str] | None = None) -> Namespace: add_context_args(prepare) prepare.add_argument("--news-directory", default="news") prepare.add_argument("--changelog-path", default="CHANGELOG.md") - prepare.add_argument("--issue-reference", default="conda/infrastructure#556") prepare.add_argument("--branch-prefix", default="release-notes-") prepare.add_argument("--git-author-name", default="Conda Bot") prepare.add_argument( @@ -114,7 +113,6 @@ def prepare_release(args: Namespace) -> None: branch=release_branch, base_branch=base_branch, version=version, - issue_reference=args.issue_reference, token=args.token, ) @@ -289,7 +287,6 @@ def create_or_update_pr( branch: str, base_branch: str, version: str, - issue_reference: str, token: str, ) -> str: if not repository: @@ -301,8 +298,8 @@ def create_or_update_pr( title = f"Prepare release notes for {version}" body = ( f"Prepare release notes for `{version}`.\n\n" - f"Refs {issue_reference}\n\n" - "This PR was generated by `conda/actions/prepare-release`." + "This PR updates `CHANGELOG.md` from the news fragments and " + "removes the consumed snippets." ) existing = run( [ From 46e08358e4d123b5a5a09bbe4dbbbb82b3706fcd Mon Sep 17 00:00:00 2001 From: Jannis Leidel <jannis@leidel.info> Date: Fri, 5 Jun 2026 14:59:45 +0200 Subject: [PATCH 4/5] Simplify shared news parser --- check-news/check_news.py | 18 ++++++++++----- check-news/test_check_news.py | 8 ++----- news_common/__init__.py | 36 +++++++++--------------------- prepare-release/prepare_release.py | 19 +++++++++++----- 4 files changed, 39 insertions(+), 42 deletions(-) diff --git a/check-news/check_news.py b/check-news/check_news.py index 521b80ef..336bbb84 100644 --- a/check-news/check_news.py +++ b/check-news/check_news.py @@ -2,6 +2,7 @@ import json import os +import re import subprocess import sys from argparse import ArgumentParser, Namespace @@ -9,11 +10,9 @@ from pathlib import Path from typing import Any -from news_common import ( - fragment_mentions_pr, - is_news_fragment, - parse_sectioned_news, -) +from news_common import is_news_fragment, parse_sectioned_news + +PR_RE = re.compile(r"(?<!\d)#?(?P<number>\d+)(?!\d)") @dataclass(frozen=True) @@ -211,6 +210,15 @@ def diff_name_status(before: str, after: str) -> list[ChangedFile]: return changed +def fragment_mentions_pr(path: str | Path, text: str, pr_number: int | str) -> bool: + pr_number = str(pr_number) + for value in (Path(path).name, text): + for match in PR_RE.finditer(value): + if match.group("number") == pr_number: + return True + return False + + def write_summary(text: str) -> None: print(text) if output := os.environ.get("GITHUB_OUTPUT"): diff --git a/check-news/test_check_news.py b/check-news/test_check_news.py index 26c63081..4770134d 100644 --- a/check-news/test_check_news.py +++ b/check-news/test_check_news.py @@ -6,12 +6,8 @@ import pytest -from check_news import ActionError, check_news -from news_common import ( - fragment_mentions_pr, - is_news_fragment, - parse_sectioned_news, -) +from check_news import ActionError, check_news, fragment_mentions_pr +from news_common import is_news_fragment, parse_sectioned_news if TYPE_CHECKING: from pathlib import Path diff --git a/news_common/__init__.py b/news_common/__init__.py index 4afa100b..a115930f 100644 --- a/news_common/__init__.py +++ b/news_common/__init__.py @@ -4,6 +4,13 @@ from dataclasses import dataclass, field from pathlib import Path +__all__ = [ + "NewsFragment", + "SECTION_ORDER", + "is_news_fragment", + "parse_sectioned_news", +] + SECTION_TYPES: dict[str, str] = { "Enhancements": "enhancement", "Bug fixes": "bugfix", @@ -15,7 +22,6 @@ HEADING_RE = re.compile(r"^(?P<level>#{2,6})\s+(?P<title>.+?)\s*$") BULLET_RE = re.compile(r"^\s*[*-]\s+(?P<body>.*\S)\s*$") -PR_RE = re.compile(r"(?<!\d)#?(?P<number>\d+)(?!\d)") @dataclass(frozen=True) @@ -31,11 +37,11 @@ def item_count(self) -> int: return sum(len(items) for items in self.sections.values()) -def normalize_heading(value: str) -> str: +def _normalize_heading(value: str) -> str: return " ".join(value.strip().strip("#").strip().casefold().split()) -SECTION_ALIASES = {normalize_heading(section): section for section in SECTION_ORDER} +SECTION_ALIASES = {_normalize_heading(section): section for section in SECTION_ORDER} def is_news_fragment(path: str | Path, news_directory: str | Path = "news") -> bool: @@ -59,17 +65,6 @@ def is_news_fragment(path: str | Path, news_directory: str | Path = "news") -> b return relative.suffix in {"", ".md"} -def iter_news_fragments(news_directory: str | Path = "news") -> list[Path]: - news_directory = Path(news_directory) - if not news_directory.is_dir(): - return [] - return sorted( - path - for path in news_directory.iterdir() - if path.is_file() and is_news_fragment(path, news_directory) - ) - - def parse_sectioned_news(path: str | Path, text: str) -> NewsFragment: path = Path(path) errors: list[str] = [] @@ -82,7 +77,7 @@ def parse_sectioned_news(path: str | Path, text: str) -> NewsFragment: for lineno, line in enumerate(text.splitlines(), start=1): if match := HEADING_RE.match(line): saw_heading = True - title = normalize_heading(match.group("title")) + title = _normalize_heading(match.group("title")) current_section = SECTION_ALIASES.get(title) inside_unknown = current_section is None @@ -170,13 +165,4 @@ def flush() -> None: def _is_placeholder(value: str) -> bool: - return normalize_heading(value).strip("<>") == "news item" - - -def fragment_mentions_pr(path: str | Path, text: str, pr_number: int | str) -> bool: - pr_number = str(pr_number) - for value in (Path(path).name, text): - for match in PR_RE.finditer(value): - if match.group("number") == pr_number: - return True - return False + return _normalize_heading(value).strip("<>") == "news item" diff --git a/prepare-release/prepare_release.py b/prepare-release/prepare_release.py index bbab4abd..ecf99f31 100644 --- a/prepare-release/prepare_release.py +++ b/prepare-release/prepare_release.py @@ -11,11 +11,7 @@ from pathlib import Path from typing import Any -from news_common import ( - SECTION_ORDER, - iter_news_fragments, - parse_sectioned_news, -) +from news_common import SECTION_ORDER, is_news_fragment, parse_sectioned_news VERSION_BRANCH_RE = re.compile(r"^(?P<major_minor>\d+\.\d+)\.x$") TAG_RE = re.compile(r"^v?(?P<version>\d+\.\d+\.(?P<micro>\d+))$") @@ -83,7 +79,7 @@ def prepare_release(args: Namespace) -> None: run(["git", "config", "user.name", args.git_author_name]) run(["git", "config", "user.email", args.git_author_email]) - fragment_paths = iter_news_fragments(args.news_directory) + fragment_paths = news_fragment_paths(args.news_directory) fragments = collect_fragments(fragment_paths) if not fragments: raise ActionError(f"No news fragments found under {args.news_directory!r}.") @@ -214,6 +210,17 @@ def collect_fragments(paths: list[Path]) -> dict[str, list[str]]: return {section: items for section, items in fragments.items() if items} +def news_fragment_paths(news_directory: str | Path) -> list[Path]: + news_directory = Path(news_directory) + if not news_directory.is_dir(): + return [] + return sorted( + path + for path in news_directory.iterdir() + if path.is_file() and is_news_fragment(path, news_directory) + ) + + def render_changelog_entry( version: str, release_date: str, From 85d4128bba308734e876575a6ebee245c586884d Mon Sep 17 00:00:00 2001 From: Jannis Leidel <jannis@leidel.info> Date: Fri, 5 Jun 2026 15:58:41 +0200 Subject: [PATCH 5/5] Fix news action CI checks --- prepare-release/action.yml | 3 ++- prepare-release/prepare_release.py | 6 +++++- pyproject.toml | 3 +++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/prepare-release/action.yml b/prepare-release/action.yml index 6613ed5c..7bd7b7e1 100644 --- a/prepare-release/action.yml +++ b/prepare-release/action.yml @@ -24,7 +24,7 @@ inputs: description: Git author email for the generated commit. default: 18747875+conda-bot@users.noreply.github.com token: - description: GitHub token with contents: write and pull-requests: write. + description: 'GitHub token with contents: write and pull-requests: write.' default: ${{ github.token }} outputs: version: @@ -56,6 +56,7 @@ runs: with: ref: ${{ steps.context.outputs.head-sha }} fetch-depth: 0 + persist-credentials: false token: ${{ inputs.token }} - name: Prepare Release Notes diff --git a/prepare-release/prepare_release.py b/prepare-release/prepare_release.py index ecf99f31..8c419f49 100644 --- a/prepare-release/prepare_release.py +++ b/prepare-release/prepare_release.py @@ -78,6 +78,9 @@ def prepare_release(args: Namespace) -> None: run(["git", "checkout", "-B", release_branch]) run(["git", "config", "user.name", args.git_author_name]) run(["git", "config", "user.email", args.git_author_email]) + if not args.token: + raise ActionError("No GitHub token was provided.") + git_env = os.environ | {"GH_TOKEN": args.token} fragment_paths = news_fragment_paths(args.news_directory) fragments = collect_fragments(fragment_paths) @@ -102,7 +105,8 @@ def prepare_release(args: Namespace) -> None: run(["git", "add", args.changelog_path, *map(str, fragment_paths)]) run(["git", "commit", "-m", f"Prepare release notes for {version}"]) - run(["git", "push", "--force-with-lease", "origin", release_branch]) + run(["gh", "auth", "setup-git"], env=git_env) + run(["git", "push", "--force-with-lease", "origin", release_branch], env=git_env) url = create_or_update_pr( repository=args.repository, diff --git a/pyproject.toml b/pyproject.toml index a322082b..e64d55d1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,9 @@ addopts = [ "--tb=native", "-vv", ] +pythonpath = [ + ".", +] [tool.ruff] show-fixes = true