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
72 changes: 72 additions & 0 deletions .github/scripts/draft_release.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
#!/usr/bin/env python3
"""Print the body to publish a draft Release with, or fail if the draft names another PR.

Called by .github/workflows/release.yml when the tag it reserved already has a
draft Release:

python3 .github/scripts/draft_release.py --pr 42 --notes-file notes.txt < draft-body.txt

A draft doesn't count as released anywhere in the workflow, so the release job
publishes it rather than leave the PR owed forever. The planner and the
ordering guard know a Release by its "Source PR: #n" marker, so:

- a draft whose markers all name this PR, including one on a line of its own,
is published as it is;
- a draft with no marker, or one only inside a longer line, gets the release
notes appended, which adds a line of its own;
- a draft naming any other PR is inconsistent, since publishing it would give
the Release two sources. That exits 1, with the error on stderr, for a
person to sort out.

Markers count anywhere in a line, the same pattern release_post.source_pr_of()
uses, so every parser agrees on which PRs a body names.

Standard library only, like release_post.py.
"""

import argparse
import re
import sys

SOURCE_PR = re.compile(r"Source PR: #(\d+)")


class OtherPrError(Exception):
"""The draft names a PR other than the one being released."""


def named_prs(body):
"""Every PR number a "Source PR: #n" marker in the body names, in order."""
return [int(number) for number in SOURCE_PR.findall(body or "")]


def publish_body(body, pr_number, notes):
"""The body to publish the draft with. Raises OtherPrError if it names another PR."""
body = body or ""
others = [number for number in named_prs(body) if number != pr_number]
if others:
raise OtherPrError(f"names PR #{others[0]}, not #{pr_number}")
own_line = re.compile(rf"^[ \t]*Source PR: #{pr_number}[ \t]*\r?$", re.MULTILINE)
if own_line.search(body):
return body
return f"{body}\n\n{notes}\n" if body else f"{notes}\n"


def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--pr", type=int, required=True, help="the PR being released")
parser.add_argument("--notes-file", required=True, help="the release notes to append")
parser.add_argument("--tag", default="the draft", help="the tag, for the error message")
args = parser.parse_args(argv)
with open(args.notes_file, encoding="utf-8") as notes_file:
notes = notes_file.read().rstrip("\n")
try:
sys.stdout.write(publish_body(sys.stdin.read(), args.pr, notes))
except OtherPrError as error:
print(f"::error::The draft Release {args.tag} {error}. Fix or delete the draft, then re-run.", file=sys.stderr)
return 1
return 0


if __name__ == "__main__":
sys.exit(main())
5 changes: 4 additions & 1 deletion .github/scripts/release_queue.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,10 @@ def main(argv=None, gh=None, contains=tag_contains, main_order=None):
pulls = gh.merged_pulls(since=gh.pull(cutoff_pr)["merged_at"]) if cutoff_tag else []
trigger_pull = None
if args.pr is not None and all(pr["number"] != args.pr for pr in pulls):
trigger_pull = gh.pull(args.pr)
fetched = gh.pull(args.pr)
# A manual run can name any PR; only one merged into main can be owed a release.
if (fetched.get("base") or {}).get("ref") == "main":
trigger_pull = fetched

result = queue(
pulls,
Expand Down
97 changes: 97 additions & 0 deletions .github/scripts/tests/test_draft_release.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import io

import pytest

import draft_release as dr
import release_post as rp

NOTES = "Source PR: #15\nSource PR URL: https://example.test/pull/15"


def test_a_draft_without_a_marker_gets_the_notes_appended():
body = dr.publish_body("Hand-written notes.", 15, NOTES)

assert body == f"Hand-written notes.\n\n{NOTES}\n"


def test_an_empty_draft_gets_just_the_notes():
assert dr.publish_body("", 15, NOTES) == f"{NOTES}\n"
assert dr.publish_body(None, 15, NOTES) == f"{NOTES}\n"


def test_a_draft_naming_this_pr_on_its_own_line_is_published_unchanged():
body = "Notes.\n\n Source PR: #15 \nMerge commit: abc"

assert dr.publish_body(body, 15, NOTES) == body


def test_a_draft_with_crlf_endings_naming_this_pr_is_published_unchanged():
body = "Notes.\r\nSource PR: #15\r\nMerge commit: abc\r\n"

assert dr.publish_body(body, 15, NOTES) == body


def test_a_draft_naming_this_pr_only_inside_a_longer_line_gets_its_own_line():
body = "- Source PR: #15"

result = dr.publish_body(body, 15, NOTES)

assert result == f"{body}\n\n{NOTES}\n"
assert rp.source_pr_of({"body": result}) == 15


@pytest.mark.parametrize(
"body",
[
"Source PR: #5",
"- Source PR: #5",
"Notes.\nSource PR: #15\nSource PR: #5",
"Source PR: #150",
"Source PR: #1",
],
)
def test_a_draft_naming_another_pr_fails(body):
with pytest.raises(dr.OtherPrError):
dr.publish_body(body, 15, NOTES)


def test_numbers_match_exactly():
# PR 5 against a draft naming #15 and #50, and the reverse.
with pytest.raises(dr.OtherPrError, match="#15"):
dr.publish_body("Source PR: #15", 5, NOTES)
with pytest.raises(dr.OtherPrError, match="#5,"):
dr.publish_body("Source PR: #5", 50, NOTES)


@pytest.mark.parametrize(
"body",
["Source PR: #7", "- Source PR: #7 (manual)", "x\n Source PR: #7\n", "**Source PR:** [#7](url)"],
)
def test_markers_match_the_planner(body):
first = dr.named_prs(body)[0] if dr.named_prs(body) else None

assert first == rp.source_pr_of({"body": body})


def test_main_prints_the_body(tmp_path, monkeypatch, capsys):
notes = tmp_path / "notes.txt"
notes.write_text(f"{NOTES}\n", encoding="utf-8")
monkeypatch.setattr("sys.stdin", io.StringIO("Draft."))

code = dr.main(["--pr", "15", "--notes-file", str(notes)])

assert code == 0
assert capsys.readouterr().out == f"Draft.\n\n{NOTES}\n"


def test_main_fails_on_another_pr_with_the_error_on_stderr(tmp_path, monkeypatch, capsys):
notes = tmp_path / "notes.txt"
notes.write_text(NOTES, encoding="utf-8")
monkeypatch.setattr("sys.stdin", io.StringIO("Source PR: #5"))

code = dr.main(["--pr", "15", "--tag", "v1.2.3", "--notes-file", str(notes)])

captured = capsys.readouterr()
assert code == 1
assert captured.out == ""
assert "::error::The draft Release v1.2.3 names PR #5, not #15." in captured.err
18 changes: 17 additions & 1 deletion .github/scripts/tests/test_release_queue.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,9 @@ def merged_pulls(self, since):
return self._pulls

def pull(self, number):
return next(p for p in self._pulls if p["number"] == number)
if number in getattr(self, "extra", {}):
return self.extra[number]
return next(dict(p, base={"ref": "main"}) for p in self._pulls if p["number"] == number)

def releases(self):
return self._releases
Expand All @@ -211,6 +213,20 @@ def test_main_prints_the_queue_as_json_and_lists_merges_since_the_cutoff_pr(caps
assert gh.since == "2026-09-29T09:00:00Z"



def test_main_skips_a_manually_named_pr_merged_into_another_branch(capsys):
# A manual run can name any PR. One merged elsewhere is never owed a release from main,
# so it's left out instead of failing the run on a merge commit main doesn't have.
other = dict(pull(12, "2026-09-29T10:05:00Z"), base={"ref": "develop"})
gh = FakeGitHub(pulls=[pull(9, "2026-09-29T09:00:00Z"), pull(10, "2026-09-29T10:00:00Z")],
releases=[release("v0.0.9", 9)])
gh.extra = {12: other}

rq.main(["--repo", "octo/demo", "--pr", "12"], gh=gh, contains=lambda tag, sha: sha == "sha9",
main_order={"sha9": 0, "sha10": 1})

assert json.loads(capsys.readouterr().out) == [10]

def test_main_ignores_a_tag_whose_release_was_never_published(capsys):
# PR 10 got its tag, but the run failed before the Release: it's still owed one,
# so the cutoff stays at the last published Release (PR 9).
Expand Down
10 changes: 8 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -314,8 +314,14 @@ jobs:
IS_DRAFT=$(gh release view "$TAG" --json isDraft --jq '.isDraft' 2>/dev/null || echo "none")
if [ "$IS_DRAFT" = "true" ]; then
# A draft doesn't count as released anywhere in this workflow, so
# publish it rather than leave the PR owed forever.
gh release edit "$TAG" --draft=false
# publish it rather than leave the PR owed forever. draft_release.py
# appends these notes unless the draft already names the PR, and
# fails on a draft that names another one.
printf '%s\n' "$EXTRA_NOTES" > "$RUNNER_TEMP/release-notes.txt"
gh release view "$TAG" --json body --jq '.body' > "$RUNNER_TEMP/draft-body.txt"
BODY=$(python3 .github/scripts/draft_release.py --pr "$PR_NUMBER" --tag "$TAG" \
--notes-file "$RUNNER_TEMP/release-notes.txt" < "$RUNNER_TEMP/draft-body.txt")
gh release edit "$TAG" --draft=false --notes "$BODY"
echo "Published the draft Release ${TAG}."
elif [ "$IS_DRAFT" = "false" ]; then
echo "Release ${TAG} already exists."
Expand Down
Loading