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
2 changes: 1 addition & 1 deletion release_automation/docs/repository-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ The README must contain release information section delimiters:
<!-- CAMARA:RELEASE-INFO:END -->
```

These delimiters are used by the release automation to update the Release Information section during snapshot creation and post-release sync. If missing, the README update step will fail.
These delimiters are used by the release automation to update the Release Information section during snapshot creation and post-release sync. If they are missing, the README update is skipped with a warning in the workflow log and the snapshot and the sync PR are created without it; to get the section into the release, add the delimiters on `main`, then `/discard-snapshot` and `/create-snapshot` again.

The delimiters are distributed by the `campaign-release-info` campaign in `project-administration`.

Expand Down
20 changes: 20 additions & 0 deletions release_automation/scripts/bot_responder.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,3 +186,23 @@ def merge_comment_context(base_context: Any, delta: Dict[str, Any]) -> Dict[str,
apis = []
raw["apis"] = apis if isinstance(apis, list) else []
return build_context(**raw)


def post_comment(
gh,
template_name: str,
issue_number: Any,
base_context: Any,
delta: Dict[str, Any],
release_tag: str,
comment_id: str = "",
) -> int:
"""Render a bot comment and post it, or replace comment_id's body; returns the comment id."""
body = BotResponder().render_comment(template_name, base_context, delta, release_tag)
if comment_id:
gh.update_comment(int(comment_id), body)
print(f"Updated comment {comment_id} ({template_name})")
return int(comment_id)
new_id = gh.create_comment(int(issue_number), body)
print(f"Created comment {new_id} on #{issue_number} ({template_name})")
return new_id
279 changes: 279 additions & 0 deletions release_automation/scripts/draft_release.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,279 @@
"""
Release PR merge and draft cleanup for CAMARA release automation.

On a Release PR merge the snapshot branch's CHANGELOG loses its candidate
changes block (a commit on the snapshot branch) and its "## Release Notes"
section becomes the release body. What follows depends on the plan's
publish_mode:

- draft: create_draft() — a draft release; /publish-release publishes it
- on_merge: publish_on_merge() — release_date commit, published release and
tag, then the same post-publication steps as /publish-release

discard_snapshot() and delete_draft() undo a snapshot or a draft; both keep the
release-review branch as <branch>-preserved so the next /create-snapshot can
reuse the name.

The plan decides which of these runs; this module only executes.
"""

import re
import time
from dataclasses import dataclass
from typing import List, Optional, Tuple

from .github_client import GitHubClientError
from .release_publisher import PublishOutcome, ReleasePublisher, complete_publication

CANDIDATE_START = "<!-- BEGIN:AUTOGENERATED:CANDIDATE_CHANGES -->"
CANDIDATE_END = "<!-- END:AUTOGENERATED:CANDIDATE_CHANGES -->"
CHANGELOG_CLEANUP_MESSAGE = "chore: auto-remove candidate changes block from CHANGELOG"

# The snapshot-branch commit triggers ruleset evaluation, which GitHub can
# abort with this transient timeout; only this error is retried.
RULESET_TIMEOUT = re.compile(r"rule was unable to be completed", re.IGNORECASE)
RETRY_DELAYS = (5, 10)

_sleep = time.sleep


@dataclass(frozen=True)
class ChangelogResult:
notes: str = ""
stripped: bool = False


@dataclass(frozen=True)
class DraftOutcome:
success: bool
draft_release_url: str = ""
candidate_block_stripped: bool = False
error_message: str = ""


@dataclass(frozen=True)
class CleanupOutcome:
"""Result of /discard-snapshot or /delete-draft."""
success: bool
error_message: str = ""
renamed_review_branch: str = ""


def is_prerelease(release_type: str) -> bool:
return release_type.startswith("pre-release")


# ── CHANGELOG ────────────────────────────────────────────────────────────────


def changelog_candidates(release_tag: str) -> List[str]:
"""CHANGELOG/CHANGELOG-rX.md of the meta-release cycle first, then CHANGELOG.md."""
match = re.match(r"(r\d+)", release_tag)
return ([f"CHANGELOG/CHANGELOG-{match.group(1)}.md"] if match else []) + ["CHANGELOG.md"]


def strip_candidate_block(text: str) -> Tuple[str, bool]:
"""Remove the candidate changes block, or an orphan marker line of it."""
start = text.find(CANDIDATE_START)
end = text.find(CANDIDATE_END)
if start != -1 and end != -1 and end > start:
stripped = text[:start] + text[end + len(CANDIDATE_END):]
print("Stripped full candidate changes block")
return re.sub(r"\n{3,}", "\n\n", stripped), True
if start != -1 or end != -1:
# A codeowner deleted part of the block; the marker line alone is safe to drop.
orphan = CANDIDATE_START if start != -1 else CANDIDATE_END
print(f"Stripped orphan marker: {orphan}")
return "\n".join(line for line in text.split("\n") if line.strip() != orphan), True
return text, False


def extract_release_notes(text: str) -> str:
"""From "## Release Notes" to the next "# rX.Y" heading or EOF, without trailing blank lines."""
lines = text.split("\n")
start = next((i for i, line in enumerate(lines) if re.match(r"## Release Notes", line, re.IGNORECASE)), -1)
if start == -1:
return ""
end = next((i for i in range(start + 1, len(lines)) if re.match(r"# r\d+", lines[i])), len(lines))
while end > start and lines[end - 1].strip() == "":
end -= 1
return "\n".join(lines[start:end])


def _commit_with_retry(gh, path: str, content: str, message: str, branch: str) -> None:
for delay in RETRY_DELAYS + (None,):
try:
gh.update_file(path=path, content=content, message=message, branch=branch)
return
except GitHubClientError as e:
if delay is None or not RULESET_TIMEOUT.search(str(e)):
raise
print(f"::warning::Ruleset validation timed out on {branch}; retrying in {delay}s")
_sleep(delay)


def prepare_changelog(gh, release_tag: str, snapshot_branch: str) -> ChangelogResult:
"""
Strip the candidate block on the snapshot branch and extract the release notes.

Raises:
GitHubClientError: If the cleanup commit fails
"""
content: Optional[str] = None
path = ""
for candidate in changelog_candidates(release_tag):
content = gh.get_file_content(candidate, ref=snapshot_branch)
if content is not None:
path = candidate
print(f"Fetched {path} ({len(content)} chars)")
break
print(f"{candidate} not found, trying next...")
if content is None:
print("::warning::No CHANGELOG file found on snapshot branch")
return ChangelogResult()

content, stripped = strip_candidate_block(content)
if stripped:
_commit_with_retry(gh, path, content, CHANGELOG_CLEANUP_MESSAGE, snapshot_branch)
print(f"Committed cleaned CHANGELOG to {snapshot_branch}")

notes = extract_release_notes(content)
if notes:
print(f"Extracted release notes: {len(notes)} chars")
else:
print(f'::warning::No "## Release Notes" section found in {path}')
return ChangelogResult(notes=notes, stripped=stripped)


def release_body(release_tag: str, release_type: str, notes: str) -> str:
if notes:
return notes
return (
f"## Release {release_tag}\n\n"
f"**Release type:** {release_type}\n\n"
"---\n\n"
"_CHANGELOG.md content was not available — please add release notes manually._\n"
)


# ── Release PR merge ─────────────────────────────────────────────────────────


def create_draft(gh, release_tag: str, snapshot_branch: str, release_type: str) -> DraftOutcome:
"""publish_mode draft: CHANGELOG cleanup, then a draft release on the snapshot branch."""
print(f"Creating draft release for {release_tag}")
print(f" Snapshot branch: {snapshot_branch}")
print(f" Release type: {release_type}")
try:
changelog = prepare_changelog(gh, release_tag, snapshot_branch)
except GitHubClientError as e:
print(f"::error::CHANGELOG cleanup failed: {e}")
return DraftOutcome(success=False, error_message=f"CHANGELOG cleanup failed: {e}")
try:
release = gh.create_release(
release_tag, snapshot_branch, release_tag,
release_body(release_tag, release_type, changelog.notes),
draft=True, prerelease=is_prerelease(release_type),
)
except GitHubClientError as e:
print(f"::error::Draft release creation failed: {e}")
return DraftOutcome(success=False, candidate_block_stripped=changelog.stripped, error_message=str(e))
print(f"Draft release created: {release['html_url']}")
return DraftOutcome(success=True, draft_release_url=release["html_url"], candidate_block_stripped=changelog.stripped)


def publish_on_merge(
gh, release_tag: str, snapshot_branch: str, review_branch: str, release_type: str, src_commit_sha: str
) -> PublishOutcome:
"""publish_mode on_merge: CHANGELOG cleanup, release_date commit, published release and tag."""
print(f"Publishing {release_tag} on Release PR merge")
try:
changelog = prepare_changelog(gh, release_tag, snapshot_branch)
except GitHubClientError as e:
print(f"::error::CHANGELOG cleanup failed: {e}")
return PublishOutcome(success=False, error_message=f"CHANGELOG cleanup failed: {e}")
publisher = ReleasePublisher(gh)
result = publisher.publish_new_release(
release_tag, snapshot_branch,
release_body(release_tag, release_type, changelog.notes),
is_prerelease(release_type),
)
return complete_publication(publisher, result, release_tag, src_commit_sha, snapshot_branch, review_branch)


# ── discard and delete ───────────────────────────────────────────────────────


def _delete_snapshot_branch(gh, snapshot_branch: str) -> None:
print(f"Deleting snapshot branch: {snapshot_branch}...")
if not gh.delete_branch(snapshot_branch):
raise GitHubClientError(f"Snapshot branch {snapshot_branch} not found")
print("Snapshot branch deleted")


def preserve_review_branch(gh, review_branch: str) -> str:
"""Rename the review branch to <branch>-preserved; returns the new name, "" if not renamed."""
if not review_branch:
return ""
preserved = f"{review_branch}-preserved"
try:
sha = gh.get_branch_sha(review_branch)
if not sha:
raise GitHubClientError(f"{review_branch} not found")
# Repeated cycles: replace the previous preserved copy
if gh.delete_branch(preserved):
print(f"Deleted previous {preserved}")
gh.create_branch_at_sha(preserved, sha)
gh.delete_branch(review_branch)
except GitHubClientError as e:
print(f"Could not rename review branch: {e}")
return ""
print(f"Renamed: {review_branch} → {preserved}")
return preserved


def discard_snapshot(gh, release_pr_number: str, snapshot_branch: str, review_branch: str) -> CleanupOutcome:
"""/discard-snapshot: close the Release PR unmerged, delete the snapshot branch."""
print(f" Snapshot branch: {snapshot_branch}")
print(f" Release PR: #{release_pr_number}")
try:
if release_pr_number:
print(f"Closing Release PR #{release_pr_number}...")
gh.close_pr(int(release_pr_number))
print("Release PR closed")
if snapshot_branch:
_delete_snapshot_branch(gh, snapshot_branch)
except GitHubClientError as e:
print(f"::error::Discard failed: {e}")
return CleanupOutcome(success=False, error_message=str(e))
return CleanupOutcome(success=True, renamed_review_branch=preserve_review_branch(gh, review_branch))


def delete_draft(gh, release_tag: str, snapshot_branch: str, review_branch: str) -> CleanupOutcome:
"""/delete-draft: delete the draft release, its tag if any, and the snapshot branch."""
print(f"Deleting draft release for {release_tag}")
print(f" Snapshot branch: {snapshot_branch}")
try:
release_id = gh.get_release_id(release_tag, draft_only=True)
if release_id:
print(f"Deleting draft release {release_tag} (ID: {release_id})")
gh.delete_release(release_id)
print("Draft release deleted")
else:
print(f"Warning: No draft release found for tag {release_tag}")

try:
if gh.delete_tag(release_tag):
print(f"Deleted tag: {release_tag}")
else:
print(f"Tag {release_tag} not found or already deleted")
except GitHubClientError as e:
# A draft has no tag; any tag error is informational, as before
print(f"Tag {release_tag} not found or already deleted ({e})")

if snapshot_branch:
_delete_snapshot_branch(gh, snapshot_branch)
except GitHubClientError as e:
print(f"::error::Delete draft failed: {e}")
return CleanupOutcome(success=False, error_message=str(e))
return CleanupOutcome(success=True, renamed_review_branch=preserve_review_branch(gh, review_branch))
10 changes: 2 additions & 8 deletions release_automation/scripts/finalize.py
Original file line number Diff line number Diff line change
Expand Up @@ -264,20 +264,14 @@ class Report:


def post_comment(clients: Clients, run: Run, template: str, issue: str, delta: Dict[str, Any], comment_id: str = "") -> None:
from .bot_responder import BotResponder
from .bot_responder import post_comment as post

try:
base_context = json.loads(run.plan("base_context") or "{}")
except json.JSONDecodeError:
print("::warning::Invalid base_context JSON, ignoring")
base_context = {}
body = BotResponder().render_comment(template, base_context, delta, run.plan("release_tag"))
if comment_id:
clients.gh.update_comment(int(comment_id), body)
print(f"Updated comment {comment_id} ({template})")
else:
new_id = clients.gh.create_comment(int(issue), body)
print(f"Created comment {new_id} on #{issue} ({template})")
post(clients.gh, template, issue, base_context, delta, run.plan("release_tag"), comment_id=comment_id)


def run_sync_pr(run: Run, clients: Clients) -> SyncPrOutcome:
Expand Down
Loading
Loading