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
855 changes: 57 additions & 798 deletions .github/workflows/release-automation-reusable.yml

Large diffs are not rendered by default.

130 changes: 130 additions & 0 deletions release_automation/scripts/act.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
"""
Act phase of CAMARA release automation.

Executes the action the plan names and writes one JSON outcome for finalize.
The plan decides (command_policy); this module dispatches:

discard-snapshot draft_release.discard_snapshot
delete-draft draft_release.delete_draft
publish-release release_publisher.publish_draft
create-draft draft_release.create_draft, then the draft_created comment
publish-on-merge draft_release.publish_on_merge
reopen-issue issue_events.reopen_release_issue

create-snapshot runs in the snapshot job (it needs the validation toolchain);
snapshot_outcome() turns that job's step outputs into the same outcome form.

Outcome values are strings, under the output names of the former command jobs.

Usage (workflow steps):
python3 -m release_automation.scripts.act # act job
python3 -m release_automation.scripts.act snapshot # last step of the snapshot job
"""

import json
import os
import sys
from dataclasses import asdict
from typing import Any, Dict, List

from .bot_responder import post_comment
from .github_client import GitHubClientError

SNAPSHOT_FIELDS = (
"success", "snapshot_id", "snapshot_branch", "release_review_branch",
"release_pr_number", "release_pr_url", "error_message", "apis_json",
)


def _outcome(action: str, result) -> Dict[str, str]:
values = {}
for name, value in asdict(result).items():
if name == "success":
values[name] = "true" if value else "false"
elif isinstance(value, bool):
values[name] = "true" if value else ""
else:
values[name] = str(value)
return {"action": action, **values}


def act(plan: Dict[str, Any], gh, event: Dict[str, Any]) -> Dict[str, str]:
from .draft_release import create_draft, delete_draft, discard_snapshot, publish_on_merge
from .issue_events import reopen_release_issue
from .release_publisher import publish_draft

action = plan["action"]
tag = plan["release_tag"]
snapshot = plan["snapshot_branch"]
review = plan["release_review_branch"]
print(f"Action: {action} ({tag})")

match action:
case "discard-snapshot":
return _outcome(action, discard_snapshot(gh, plan["release_pr_number"], snapshot, review))
case "delete-draft":
return _outcome(action, delete_draft(gh, tag, snapshot, review))
case "publish-release":
return _outcome(action, publish_draft(gh, tag, snapshot, review, plan["src_commit_sha"]))
case "create-draft":
result = create_draft(gh, tag, snapshot, plan["release_type"])
outcome = _outcome(action, result)
if result.success and plan["release_issue_number"]:
pr = event.get("pull_request") or {}
try:
post_comment(gh, "draft_created", plan["release_issue_number"], plan["base_context"], {
"draft_release_url": result.draft_release_url,
"release_pr_number": str(pr.get("number", "")),
"release_pr_url": pr.get("html_url", ""),
"candidate_block_stripped": outcome["candidate_block_stripped"],
}, tag)
except GitHubClientError as e:
print(f"::error::Could not post draft_created: {e}")
outcome["comment_failed"] = "true"
return outcome
case "publish-on-merge":
return _outcome(action, publish_on_merge(
gh, tag, snapshot, review, plan["release_type"], plan["src_commit_sha"]
))
case "reopen-issue":
return _outcome(action, reopen_release_issue(gh, int(plan["issue_number"]), tag, plan["base_context"]))
case _:
raise ValueError(f"unknown action: {action}")


def snapshot_outcome(create: Dict[str, str], gate: Dict[str, str]) -> Dict[str, str]:
"""Outcome of the snapshot job from the Create Snapshot and validation-gate step outputs."""
outcome = {"action": "create-snapshot"}
for name in SNAPSHOT_FIELDS:
outcome[name] = create.get(name, "") or (gate.get(name, "") if name in ("success", "error_message") else "")
return outcome


def main(argv: List[str], gh=None) -> int:
from .trigger import write_outputs

env = os.environ
if argv == ["snapshot"]:
outcome = snapshot_outcome(json.loads(env.get("CREATE_JSON") or "{}"), json.loads(env.get("GATE_JSON") or "{}"))
print(f"Pre-snapshot validation: {env.get('VALIDATION_RESULT') or 'skipped'}")
for name in ("success", "snapshot_id", "snapshot_branch", "release_pr_number", "error_message"):
print(f" {name}: {outcome[name]}")
write_outputs(env["GITHUB_OUTPUT"], {"outcome": json.dumps(outcome)})
return 0

plan = json.loads(env["PLAN_JSON"])
with open(env["GITHUB_EVENT_PATH"], encoding="utf-8") as f:
event = json.load(f)
if gh is None:
from .github_client import GitHubClient
gh = GitHubClient(repo=env["GITHUB_REPOSITORY"], token=env.get("GITHUB_TOKEN") or None)

outcome = act(plan, gh, event)
write_outputs(env["GITHUB_OUTPUT"], {"outcome": json.dumps(outcome)})
print(f"Outcome: {json.dumps(outcome)}")
# A failed action fails the job; finalize runs regardless and reports it.
return 0 if outcome["success"] == "true" and not outcome.get("comment_failed") else 1


if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
113 changes: 95 additions & 18 deletions release_automation/scripts/command_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
Policy values that are expected to change (RM#675) sit in PUBLISH_MODES and
PUBLISHERS; the initial values reproduce the behaviour before this module.

A Plan names the action the snapshot or act job executes (action_for); the
plan job writes it with the release references as one JSON document.

Usage (workflow step):
python3 -m release_automation.scripts.command_policy
"""
Expand Down Expand Up @@ -62,6 +65,16 @@

WRITE_PERMISSIONS = ("admin", "maintain", "write")

# A Release Issue closed in these states is reopened
BLOCK_CLOSURE_STATES = (config.STATE_SNAPSHOT_ACTIVE, config.STATE_DRAFT_READY)

# Actions of the snapshot job (create-snapshot) and the act job (all others)
ACTION_CREATE_SNAPSHOT = "create-snapshot"
ACTION_CREATE_DRAFT = "create-draft"
ACTION_PUBLISH_ON_MERGE = "publish-on-merge"
ACTION_REOPEN_ISSUE = "reopen-issue"
COMMAND_ACTIONS = ("create-snapshot", "discard-snapshot", "delete-draft", "publish-release")

# App bots don't register as collaborators, so the permission API returns
# 'none' for them; the write intent is established by the App installation on
# the target repo. Used by the Release Automation Regression canary in
Expand Down Expand Up @@ -92,6 +105,7 @@ class Plan:
response: Template the plan job posts ("" for non-slash triggers)
base_branch: Branch the release is cut from
publish_mode: draft | on_merge, per release type (PUBLISH_MODES)
action: What the snapshot/act job executes ("" for none; action_for)
"""
trigger_type: str
command: str
Expand All @@ -102,6 +116,7 @@ class Plan:
base_branch: str = DEFAULT_BASE_BRANCH
publish_mode: str = PUBLISH_MODE_DRAFT
response: str = ""
action: str = ""

allowed = True

Expand Down Expand Up @@ -192,6 +207,21 @@ def _check_publisher(user: str, permission: str, release_type: str, client) -> O
raise ValueError(f"unknown publisher rule: {rule}")


def action_for(plan: Plan) -> str:
"""The action a plan executes, or "" when finalize alone follows."""
match plan.trigger_type:
case "slash_command":
if plan.response == RESPONSE_INTERIM and plan.command in COMMAND_ACTIONS:
return plan.command
return ""
case "pr_merge":
return ACTION_PUBLISH_ON_MERGE if plan.publish_mode == PUBLISH_MODE_ON_MERGE else ACTION_CREATE_DRAFT
case "issue_close":
return ACTION_REOPEN_ISSUE if plan.state in BLOCK_CLOSURE_STATES else ""
case _:
return ""


def evaluate(
trigger: Trigger,
release_info: ReleaseInfoResult,
Expand All @@ -210,6 +240,16 @@ def evaluate(
Returns:
Plan to execute, or Rejection to answer
"""
result = _evaluate(trigger, release_info, cache, client)
return replace(result, action=action_for(result)) if result.allowed else result


def _evaluate(
trigger: Trigger,
release_info: ReleaseInfoResult,
cache: CommonCache = CommonCache(),
client=None,
) -> Union[Plan, Rejection]:
is_slash = trigger.trigger_type == TRIGGER_SLASH_COMMAND

if not release_info.success:
Expand Down Expand Up @@ -280,27 +320,62 @@ def finalize_due(result: Union[Plan, Rejection]) -> bool:
return result.allowed and result.response != RESPONSE_PUBLISH_CONFIRMATION


def release_info_from_env(env) -> ReleaseInfoResult:
def release_info_from_state(state_outputs: dict) -> ReleaseInfoResult:
"""Rebuild a ReleaseInfoResult from the derive-release-state outputs."""
if env.get("CONFIG_ERROR"):
if state_outputs.get("config_error"):
return ReleaseInfoResult(
success=False,
config_error=ConfigurationError(
error_type=env.get("CONFIG_ERROR_TYPE", ""),
message=env["CONFIG_ERROR"],
error_type=state_outputs.get("config_error_type", ""),
message=state_outputs["config_error"],
file_path=config.RELEASE_PLAN_FILE,
),
)
state = env.get("STATE", "")
state = state_outputs.get("state", "")
return ReleaseInfoResult(
success=True,
release_tag=env.get("RELEASE_TAG", ""),
release_tag=state_outputs.get("release_tag", ""),
state=ReleaseState(state) if state else None,
release_type=env.get("RELEASE_TYPE", ""),
release_type=state_outputs.get("release_type", ""),
)


def response_context(result: Union[Plan, Rejection], trigger: Trigger, env) -> dict:
# Release references from derive-release-state that the snapshot, act and finalize jobs read
STATE_REFS = (
"snapshot_branch",
"release_review_branch",
"release_pr_number",
"release_issue_number",
"src_commit_sha",
"commonalities_release",
"identity_consent_management_release",
)


def plan_document(
result: Union[Plan, Rejection],
trigger: Trigger,
state_outputs: dict,
cache: CommonCache,
base_context: dict,
) -> dict:
"""The plan job's one output: the decision, plus for a Plan what the later jobs read."""
document = json.loads(result.to_json())
if result.allowed:
document.update(
command_args=trigger.command_args,
issue_number=trigger.issue_number,
user=trigger.user,
**{name: state_outputs.get(name, "") for name in STATE_REFS},
common_cache_status=cache.status,
common_cache_details=cache.details,
common_sync_pr_url=cache.sync_pr_url,
base_context=base_context,
)
return document


def response_context(result: Union[Plan, Rejection], trigger: Trigger, env, state_outputs: dict) -> dict:
"""Template variables the plan job's bot comment needs beyond the base context."""
run_url = (
f"{env.get('GITHUB_SERVER_URL', '')}/{env.get('GITHUB_REPOSITORY', '')}"
Expand All @@ -322,7 +397,7 @@ def response_context(result: Union[Plan, Rejection], trigger: Trigger, env) -> d
"draft_release_url": (
f"{env.get('GITHUB_SERVER_URL', '')}/{env.get('GITHUB_REPOSITORY', '')}/releases"
),
"src_commit_sha_short": env.get("SRC_COMMIT_SHA", "")[:7],
"src_commit_sha_short": state_outputs.get("src_commit_sha", "")[:7],
}
return {}

Expand All @@ -332,12 +407,18 @@ def main(client=None) -> int:

env = os.environ
trigger = Trigger(**json.loads(env["TRIGGER_JSON"]))
release_info = release_info_from_env(env)
state_outputs = json.loads(env.get("STATE_JSON") or "{}")
release_info = release_info_from_state(state_outputs)
cache = CommonCache(
status=env.get("COMMON_CACHE_STATUS", ""),
details=env.get("COMMON_CACHE_DETAILS", ""),
sync_pr_url=env.get("COMMON_SYNC_PR_URL", ""),
)
try:
base_context = json.loads(env.get("BASE_CONTEXT") or "{}")
except json.JSONDecodeError:
print("::warning::Invalid base_context JSON, ignoring")
base_context = {}
if client is None:
client = GitHubClient(repo=env["GITHUB_REPOSITORY"], token=env.get("GITHUB_TOKEN"))

Expand All @@ -347,23 +428,19 @@ def main(client=None) -> int:

result = evaluate(trigger, release_info, cache, client=client)
if result.allowed:
print("Command validated successfully")
print(f"Command validated successfully; action: {result.action or 'none'}")
else:
print(f"Rejected: {result.error_message}")

# allowed/error_message keep the meaning they had as Validate Command
# outputs: set for slash commands with a derived state, empty otherwise.
validated = trigger.trigger_type == TRIGGER_SLASH_COMMAND and release_info.success
write_outputs(env["GITHUB_OUTPUT"], {
"allowed": ("true" if result.allowed else "false") if validated else "",
"error_message": result.error_message if validated and not result.allowed else "",
"plan": result.to_json(),
"plan": json.dumps(plan_document(result, trigger, state_outputs, cache, base_context)),
"action": result.action if result.allowed else "",
"finalize": "true" if finalize_due(result) else "false",
"response": result.response,
"response_release_tag": (
"unknown" if result.response == RESPONSE_CONFIG_ERROR else (release_info.release_tag or "")
),
"response_context": json.dumps(response_context(result, trigger, env)),
"response_context": json.dumps(response_context(result, trigger, env, state_outputs)),
})
return 0

Expand Down
Loading
Loading