diff --git a/prepare-release/README.md b/prepare-release/README.md index 60b20311..b80ab44a 100644 --- a/prepare-release/README.md +++ b/prepare-release/README.md @@ -14,6 +14,11 @@ on: branches: - '[0-9]*.[0-9]*.x' +concurrency: + group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch }} + queue: max + cancel-in-progress: false + permissions: contents: read @@ -39,3 +44,15 @@ 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 + +An existing news directory with no eligible fragments is a successful no-op. +A missing news directory or a malformed fragment fails the action. + +Before pushing, the action verifies that the triggering SHA is still the tip of +the release branch. A stale workflow run exits successfully without pushing or +creating or updating a pull request. Authentication or branch lookup failures +fail the action. + +The concurrency group serializes runs for each release branch. `queue: max` +keeps every pending run, while `cancel-in-progress: false` prevents a later run +from cancelling one that is already preparing a release. diff --git a/prepare-release/prepare_release.py b/prepare-release/prepare_release.py index 5970957f..81b34581 100644 --- a/prepare-release/prepare_release.py +++ b/prepare-release/prepare_release.py @@ -72,6 +72,17 @@ def main(argv: list[str] | None = None) -> int: def prepare_release(args: Namespace) -> None: context = verify_context(args.release_branch_pattern) base_branch = context["head_branch"] + + news_directory = Path(args.news_directory) + if not news_directory.is_dir(): + raise ActionError(f"News directory does not exist: {news_directory}") + + fragment_paths = news_fragment_paths(news_directory) + if not fragment_paths: + print(f"No news fragments found under {str(news_directory)!r}. Nothing to do.") + return + fragments = collect_fragments(fragment_paths) + version = infer_next_version(base_branch) release_date = datetime.now(UTC).date().isoformat() release_branch = f"{args.branch_prefix}{version}" @@ -83,11 +94,6 @@ def prepare_release(args: Namespace) -> None: 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) - 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) @@ -107,6 +113,30 @@ 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(["gh", "auth", "setup-git"], env=git_env) + + remote_ref = f"refs/heads/{base_branch}" + remote = run( + [ + "git", + "ls-remote", + "--exit-code", + "--heads", + "origin", + remote_ref, + ], + capture=True, + env=git_env, + ).split() + if len(remote) != 2 or remote[1] != remote_ref: + raise ActionError(f"Could not determine remote head for {base_branch!r}.") + remote_head = remote[0] + if remote_head != context["head_sha"]: + print( + f"Skipping stale workflow run for {base_branch}: " + f"{context['head_sha']} is no longer the branch tip." + ) + return + run(["git", "push", "--force-with-lease", "origin", release_branch], env=git_env) url = create_or_update_pr( diff --git a/prepare-release/test_prepare_release.py b/prepare-release/test_prepare_release.py index 1b7dcc71..047dc7e5 100644 --- a/prepare-release/test_prepare_release.py +++ b/prepare-release/test_prepare_release.py @@ -2,15 +2,18 @@ import json import subprocess +from argparse import Namespace from pathlib import Path import pytest +import prepare_release as prepare_release_module from prepare_release import ( ActionError, collect_fragments, ensure_allowed_paths, infer_next_version, + prepare_release, render_changelog_entry, update_changelog, verify_context, @@ -44,6 +47,74 @@ def write_workflow_run_event( monkeypatch.setenv("GITHUB_REPOSITORY", repository) +def prepare_args() -> Namespace: + return Namespace( + release_branch_pattern="[0-9]*.[0-9]*.x", + news_directory="news", + changelog_path="CHANGELOG.md", + branch_prefix="release-notes-", + git_author_name="Conda Bot", + git_author_email="conda-bot@example.com", + repository="conda/conda", + token="test-token", + ) + + +def write_release_files(tmp_path: Path) -> None: + news = tmp_path / "news" + news.mkdir() + (news / "123-fix").write_text( + "### Bug fixes\n\n* Fix the thing. (#123)\n", + encoding="utf-8", + ) + (tmp_path / "CHANGELOG.md").write_text( + "[//]: # (current developments)\n", + encoding="utf-8", + ) + + +def mock_prepare_commands( + monkeypatch: pytest.MonkeyPatch, + *, + remote_sha: str = "a" * 40, + auth_error: bool = False, + lookup_error: bool = False, +) -> tuple[list[tuple[list[str], dict[str, str] | None]], list[dict[str, object]]]: + calls: list[tuple[list[str], dict[str, str] | None]] = [] + pull_requests: list[dict[str, object]] = [] + + def fake_run( + command: list[str], + *, + capture: bool = False, + env: dict[str, str] | None = None, + ) -> str: + calls.append((command, env)) + if command == ["gh", "auth", "setup-git"] and auth_error: + raise ActionError("GitHub authentication failed.") + if command[:3] == ["git", "tag", "--list"]: + return "" + if command[:3] == ["git", "status", "--porcelain"]: + return " M CHANGELOG.md\n D news/123-fix\n" + if command[:2] == ["git", "ls-remote"]: + if lookup_error: + raise ActionError("Remote branch lookup failed.") + return f"{remote_sha}\trefs/heads/26.7.x\n" + return "" + + def fake_create_or_update_pr(**kwargs: object) -> str: + pull_requests.append(kwargs) + return "https://github.com/conda/conda/pull/123" + + monkeypatch.setattr(prepare_release_module, "run", fake_run) + monkeypatch.setattr( + prepare_release_module, + "create_or_update_pr", + fake_create_or_update_pr, + ) + return calls, pull_requests + + def test_verify_context_accepts_trusted_release_push( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -79,6 +150,160 @@ def test_verify_context_rejects_untrusted_context( verify_context("[0-9]*.[0-9]*.x") +def test_prepare_release_noops_without_fragments( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + monkeypatch.chdir(tmp_path) + write_workflow_run_event(tmp_path, monkeypatch) + news = tmp_path / "news" + news.mkdir() + (news / "TEMPLATE").write_text("* \n", encoding="utf-8") + monkeypatch.setattr( + prepare_release_module, + "run", + lambda *args, **kwargs: pytest.fail("No commands should run."), + ) + + prepare_release(prepare_args()) + + assert ( + "No news fragments found under 'news'. Nothing to do." + in capsys.readouterr().out + ) + + +def test_prepare_release_rejects_missing_news_directory( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.chdir(tmp_path) + write_workflow_run_event(tmp_path, monkeypatch) + + with pytest.raises(ActionError, match="News directory does not exist: news"): + prepare_release(prepare_args()) + + +def test_prepare_release_rejects_malformed_fragment( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.chdir(tmp_path) + write_workflow_run_event(tmp_path, monkeypatch) + news = tmp_path / "news" + news.mkdir() + (news / "123-fix").write_text("not a news fragment\n", encoding="utf-8") + + with pytest.raises(ActionError, match="no news headings found"): + prepare_release(prepare_args()) + + +def test_prepare_release_skips_stale_workflow_run( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + monkeypatch.chdir(tmp_path) + write_workflow_run_event(tmp_path, monkeypatch, sha="a" * 40) + write_release_files(tmp_path) + calls, pull_requests = mock_prepare_commands(monkeypatch, remote_sha="b" * 40) + + prepare_release(prepare_args()) + + commands = [command for command, _ in calls] + assert commands[-2:] == [ + ["gh", "auth", "setup-git"], + [ + "git", + "ls-remote", + "--exit-code", + "--heads", + "origin", + "refs/heads/26.7.x", + ], + ] + assert not any(command[:2] == ["git", "push"] for command in commands) + assert not pull_requests + assert "Skipping stale workflow run for 26.7.x" in capsys.readouterr().out + + +def test_prepare_release_publishes_when_remote_head_matches( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.chdir(tmp_path) + write_workflow_run_event(tmp_path, monkeypatch, sha="a" * 40) + write_release_files(tmp_path) + calls, pull_requests = mock_prepare_commands(monkeypatch) + + prepare_release(prepare_args()) + + commands = [command for command, _ in calls] + assert commands[-3:] == [ + ["gh", "auth", "setup-git"], + [ + "git", + "ls-remote", + "--exit-code", + "--heads", + "origin", + "refs/heads/26.7.x", + ], + [ + "git", + "push", + "--force-with-lease", + "origin", + "release-notes-26.7.0", + ], + ] + lookup_env = calls[-2][1] + assert lookup_env is not None + assert lookup_env["GH_TOKEN"] == "test-token" + assert pull_requests == [ + { + "repository": "conda/conda", + "branch": "release-notes-26.7.0", + "base_branch": "26.7.x", + "version": "26.7.0", + "token": "test-token", + } + ] + + +@pytest.mark.parametrize( + ("auth_error", "lookup_error", "message"), + [ + (True, False, "GitHub authentication failed"), + (False, True, "Remote branch lookup failed"), + ], +) +def test_prepare_release_fails_closed_when_publish_check_fails( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + auth_error: bool, + lookup_error: bool, + message: str, +) -> None: + monkeypatch.chdir(tmp_path) + write_workflow_run_event(tmp_path, monkeypatch, sha="a" * 40) + write_release_files(tmp_path) + calls, pull_requests = mock_prepare_commands( + monkeypatch, + auth_error=auth_error, + lookup_error=lookup_error, + ) + + with pytest.raises(ActionError, match=message): + prepare_release(prepare_args()) + + commands = [command for command, _ in calls] + assert ["gh", "auth", "setup-git"] in commands + assert not any(command[:2] == ["git", "push"] for command in commands) + assert not pull_requests + + 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)