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
109 changes: 109 additions & 0 deletions .github/workflows/autorelease-email.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
name: Autorelease email digest

# One deterministic TL;DR email per completed pipeline run. The digest is
# rendered by `./autorelease/control.py email-digest` from retained run state
# only, so a template is selected — never written — at runtime, and no
# model-authored prose can reach the outbound channel.
on:
workflow_run:
workflows:
- PHP autorelease watcher
- Autorelease publish transaction
types:
- completed

permissions:
contents: read
actions: read

concurrency:
group: autorelease-email-${{ github.event.workflow_run.id }}
cancel-in-progress: false

defaults:
run:
shell: bash

jobs:
digest:
name: Send run digest
runs-on: ubuntu-latest
timeout-minutes: 10
env:
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
EMAIL_FROM: ${{ vars.AUTORELEASE_EMAIL_FROM }}
EMAIL_TO: ${{ vars.AUTORELEASE_EMAIL_TO }}
RUN_ID: ${{ github.event.workflow_run.id }}
RUN_URL: ${{ github.event.workflow_run.html_url }}
RUN_NAME: ${{ github.event.workflow_run.name }}
RUN_CONCLUSION: ${{ github.event.workflow_run.conclusion }}
GH_TOKEN: ${{ github.token }}
steps:
- name: Decide whether delivery is configured
id: gate
# An unconfigured repository skips quietly instead of failing, so the
# digest can merge ahead of the Resend secret and variables existing.
run: |
if [[ -n "$RESEND_API_KEY" && -n "$EMAIL_FROM" && -n "$EMAIL_TO" ]]; then
echo "configured=true" >> "$GITHUB_OUTPUT"
else
echo "configured=false" >> "$GITHUB_OUTPUT"
echo "RESEND_API_KEY, AUTORELEASE_EMAIL_FROM, or AUTORELEASE_EMAIL_TO is unset; no digest is sent."
fi
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
if: steps.gate.outputs.configured == 'true'
with:
persist-credentials: false
- name: Download the retained run state
id: state
if: steps.gate.outputs.configured == 'true'
run: |
mkdir -p email-run/state
case "$RUN_NAME" in
"PHP autorelease watcher")
echo "workflow=watcher" >> "$GITHUB_OUTPUT"
artifact="autorelease-investigation-$RUN_ID"
;;
"Autorelease publish transaction")
echo "workflow=publish" >> "$GITHUB_OUTPUT"
artifact="release-transaction-state-$RUN_ID"
;;
*)
echo "unrouted triggering workflow: $RUN_NAME" >&2
exit 1
;;
esac
# A run that crashed before retaining its state has no artifact; the
# renderer then only accepts that absence for the failure templates.
if ! gh run download "$RUN_ID" --repo "${{ github.repository }}" \
--name "$artifact" --dir email-run/state; then
echo "No retained state artifact exists for $artifact."
fi
- name: Render the deterministic digest
if: steps.gate.outputs.configured == 'true'
run: |
./autorelease/control.py email-digest \
--workflow "${{ steps.state.outputs.workflow }}" \
--conclusion "$RUN_CONCLUSION" \
--run-url "$RUN_URL" \
--repository "${{ github.repository }}" \
--decision email-run/state/watch-decision.json \
--plan email-run/state/autorelease-plan.json \
--transaction email-run/state/transaction-state.json \
> email-run/digest.json
echo "template=$(jq -r .template email-run/digest.json)"
- name: Send the digest through Resend
if: steps.gate.outputs.configured == 'true'
# The secret reaches exactly one place: the Authorization header.
run: |
jq \
--arg from "$EMAIL_FROM" \
--arg to "$EMAIL_TO" \
'{from: $from, to: [$to], subject: .subject, text: .body}' \
email-run/digest.json > email-run/payload.json
curl --fail-with-body --silent --show-error \
--connect-timeout 10 --max-time 30 \
--request POST https://api.resend.com/emails \
--header "Authorization: Bearer $RESEND_API_KEY" \
--header "Content-Type: application/json" \
--data @email-run/payload.json
Comment thread
coderabbitai[bot] marked this conversation as resolved.
11 changes: 11 additions & 0 deletions AUTORELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,17 @@ Failures use one deduplicated issue per action key, assigned to the username in
action, or final-result change adds a comment. Critical failures stop mutation.
GitHub Actions failure email is an independent fallback.

`Autorelease email digest` additionally sends one fixed-template TL;DR email
after every completed watcher or publish run, including quiet healthy days, so
silence stops being ambiguous between "no change" and "the schedule stopped".
The template is selected by `email-digest` in `autorelease/control.py` from
retained run state alone and delivered through Resend; no model-authored prose
reaches the outbound channel, and the workflow skips quietly until the
`RESEND_API_KEY` secret and the email variables exist. Run state that matches
no template — including a corrupt retained artifact — still sends a fallback
summary naming the exact rejection reason, so the channel cannot go silent on
precisely the runs that need a look.

```mermaid
flowchart TD
job["Any autorelease phase"] --> result{"Result"}
Expand Down
224 changes: 224 additions & 0 deletions autorelease/_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from ._validation import (
ACTION_KEY_RE,
SHA256_RE,
STABLE_VERSION_RE,
ControlError,
canonical_json,
contained_path,
Expand Down Expand Up @@ -157,6 +158,229 @@ def retained_notification_issue(prior: dict[str, Any] | None) -> dict[str, Any]
return None


EMAIL_SUBJECT_PREFIX = "[php-bin autorelease]"
# Repository slugs reach the digest from `github.repository`, never from run state.
EMAIL_REPOSITORY_RE = re.compile(r"^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$")
# ACTION_KEY_RE fixes key syntax only, so each version-deriving action also pins
# the key family it may carry. reconcile_partial reuses the incomplete action's
# key and blocked/needs_human carry whatever key failed, so they accept any.
EMAIL_ACTION_KEY_PREFIXES = {
"no_change": {"no_change"},
"new_patch": {"new_patch"},
"new_branch": {"new_branch"},
"branch_eol": {"branch_eol"},
"repair": {"repair"},
}


def _email(template: str, subject: str, *paragraphs: str, run_url: str) -> dict[str, Any]:
return {
"template": template,
"subject": f"{EMAIL_SUBJECT_PREFIX} {subject}",
"body": "\n\n".join([*paragraphs, f"Run: {run_url}"]),
}


def email_digest(report: dict[str, Any]) -> dict[str, Any]:
"""Select and fill the one fixed email template for a completed pipeline run.

Every value interpolated into a subject or body is validated against the
same shape rules admission enforces, so model-authored prose never reaches
the outbound channel — a plan only ever picks which fixed sentence is sent.
An outcome with no template is rejected instead of guessed at.
"""
workflow = report.get("workflow")
require(workflow in {"watcher", "publish"}, "email digest workflow is unknown")
conclusion = report.get("conclusion")
require(isinstance(conclusion, str) and bool(conclusion), "email digest conclusion is missing")
run_url = report.get("runUrl", "")
require(run_url.startswith("https://github.com/"), "email digest run url is invalid")
repository = report.get("repository", "")
require(bool(EMAIL_REPOSITORY_RE.fullmatch(repository)), "email digest repository is invalid")

if workflow == "publish":
transaction = report.get("transaction")
version = ""
released = False
if transaction is not None:
require(isinstance(transaction, dict), "release transaction state must be an object")
version = transaction.get("version")
require(
isinstance(version, str) and bool(STABLE_VERSION_RE.fullmatch(version)),
"release transaction version is invalid",
)
released = transaction.get("released")
require(isinstance(released, bool), "release transaction released flag is invalid")
# A publish job only succeeds after recording a released transaction, so a
# green run without one is inconsistent state, not a failed release.
require(
conclusion != "success" or released is True,
"a successful publish run must retain released transaction state",
)
if released and conclusion == "success":
return _email(
"release_published",
f"PHP {version} published",
f"The immutable PHP {version} release for macOS arm64 is live and fresh public "
f"installs of it were verified: https://github.com/{repository}/releases/tag/{version}.",
run_url=run_url,
)
if released:
return _email(
"release_record_pending",
f"PHP {version} published; record recovery pending",
f"The PHP {version} release went live, but the publish run failed after publication, "
"so its durable event record is missing. Tomorrow's watcher recovers the record "
"automatically; nothing needs doing unless that recovery also fails.",
run_url=run_url,
)
return _email(
"publish_failed",
f"Publish failed{f' for PHP {version}' if version else ''}",
"The publish transaction stopped before any release went live, so nothing was "
"published and nothing needs rolling back. A critical GitHub issue has been filed "
"with the failing run; after the cause is fixed, the release re-runs through the "
"normal admitted path.",
run_url=run_url,
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if conclusion != "success":
return _email(
"watcher_failed",
f"Watcher run failed ({conclusion})",
f"The daily autorelease watcher finished with conclusion '{conclusion}'. If the "
"failure was actionable, a critical GitHub issue has been filed and assigned. The "
"watcher is idempotent, so re-dispatching it after the cause is fixed is safe.",
run_url=run_url,
)
decision = report.get("decision")
require(isinstance(decision, dict), "a successful watcher run must supply its watch decision")
digest = decision.get("manifestDigest")
require(
isinstance(digest, str) and bool(SHA256_RE.fullmatch(digest)),
"watch decision manifest digest is invalid",
)
model_call = decision.get("modelCall")
require(isinstance(model_call, bool), "watch decision model call flag is invalid")
if not model_call:
return _email(
"quiet_day",
"Watcher: no upstream changes",
"The watcher captured fresh upstream evidence and it matches the last reviewed "
"capture, so no model call was made and nothing was changed.",
f"Evidence manifest: {digest}",
run_url=run_url,
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
plan = report.get("plan")
require(isinstance(plan, dict), "a successful watcher model call must supply its admitted plan")
action = plan.get("action")
action_key = plan.get("actionKey")
require(
isinstance(action_key, str) and bool(ACTION_KEY_RE.fullmatch(action_key)),
"admitted plan action key is invalid",
)
allowed_prefixes = EMAIL_ACTION_KEY_PREFIXES.get(action)
require(
allowed_prefixes is None or action_key.split(":")[0] in allowed_prefixes,
"admitted plan action key does not match its action",
)
version = action_key.split(":")[1] if ":" in action_key else ""
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if action == "no_change":
return _email(
"no_change_reviewed",
"Watcher: evidence changed, no release needed",
f"Upstream evidence changed and the investigation classified it as requiring no "
f"release work ({action_key}). The reviewed evidence snapshot was recorded on main, "
"so tomorrow's run compares against today's state.",
f"Evidence manifest: {digest}",
run_url=run_url,
)
if action == "new_patch":
return _email(
"new_patch_started",
f"Watcher: PHP {version} release started",
f"Upstream published PHP {version} and the watcher admitted a release plan for it "
f"({action_key}). The implementation and publish phases were dispatched; a separate "
"email confirms publication or reports the failure.",
run_url=run_url,
)
if action == "new_branch":
return _email(
"new_branch_detected",
f"Watcher: new PHP branch {version} detected",
f"A new PHP branch was detected and admitted as {action_key}. Its first publication "
"proceeds automatically once mise-php records matching exact-commit readiness; until "
"then the watcher re-checks daily and mutates nothing.",
run_url=run_url,
)
if action == "branch_eol":
return _email(
"branch_eol_started",
f"Watcher: PHP {version} reached end of life",
f"The support policy retired PHP {version} ({action_key}). Support cleanup completes "
"automatically once mise-php records matching EOL readiness; published releases for "
"the branch stay immutable and installable.",
run_url=run_url,
)
if action == "repair":
return _email(
"repair_started",
f"Watcher: repair started for PHP {version}",
f"The investigation admitted a bounded repair plan ({action_key}) and dispatched it. "
"The exact evidence and allowed paths are retained with the run's admitted plan "
"artifact.",
run_url=run_url,
)
if action == "reconcile_partial":
return _email(
"reconcile_started",
"Watcher: reconciling a partial prior run",
f"An earlier run left {action_key} incomplete and the watcher admitted a "
"reconciliation for it. The event record resumes from its last legal state; no work "
"is repeated and nothing is overwritten.",
run_url=run_url,
)
if action in {"blocked", "needs_human"}:
return _email(
"watcher_attention",
f"Watcher needs attention ({action})",
f"The investigation stopped at '{action}' for {action_key} and mutated nothing. A "
"GitHub issue has been filed or updated with the exact evidence and the required "
"next step.",
run_url=run_url,
)
raise ControlError(f"no email template exists for action: {action}")


def email_fallback(report: dict[str, Any], reason: str) -> dict[str, Any]:
"""Render the last-resort digest for run state no template accepts.

The daily email must not go silent exactly when the pipeline does something
novel, so rejection by `email_digest` still produces a message. Only values
that revalidate here are interpolated; everything else is replaced with
'unknown', and the stated reason is this module's own rejection text.
"""
workflow = report.get("workflow")
if workflow not in {"watcher", "publish"}:
workflow = "unknown"
conclusion = report.get("conclusion")
if not isinstance(conclusion, str) or not re.fullmatch(r"[a-z_]{1,32}", conclusion):
conclusion = "unknown"
run_url = report.get("runUrl", "")
if not isinstance(run_url, str) or not run_url.startswith("https://github.com/"):
run_url = "unknown (see the Actions history)"
return _email(
"unexpected_state",
f"Pipeline outcome needs a look ({workflow}, {conclusion})",
f"A {workflow} run finished with conclusion '{conclusion}', but its retained state "
"matched no known outcome, so this summary is a fallback rather than a classification. "
f"The digest was rejected because: {reason}",
"Check the run and its retained artifacts directly. If this recurs for a legitimate "
"outcome, the digest template table needs a new case.",
run_url=run_url,
)


ACTION_FILENAME_MAP = str.maketrans({":": "-", "/": "-"})


Expand Down
Loading
Loading