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
17 changes: 17 additions & 0 deletions prepare-release/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
40 changes: 35 additions & 5 deletions prepare-release/prepare_release.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand All @@ -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)

Expand All @@ -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(
Expand Down
225 changes: 225 additions & 0 deletions prepare-release/test_prepare_release.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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("* <news item>\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)
Expand Down
Loading