diff --git a/.github/workflows/autorelease-email.yml b/.github/workflows/autorelease-email.yml new file mode 100644 index 0000000..ca707bc --- /dev/null +++ b/.github/workflows/autorelease-email.yml @@ -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 diff --git a/AUTORELEASE.md b/AUTORELEASE.md index 535a5dc..837efee 100644 --- a/AUTORELEASE.md +++ b/AUTORELEASE.md @@ -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"} diff --git a/autorelease/_state.py b/autorelease/_state.py index 6eb57f2..1e65d8c 100644 --- a/autorelease/_state.py +++ b/autorelease/_state.py @@ -15,6 +15,7 @@ from ._validation import ( ACTION_KEY_RE, SHA256_RE, + STABLE_VERSION_RE, ControlError, canonical_json, contained_path, @@ -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, + ) + + 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, + ) + 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 "" + 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({":": "-", "/": "-"}) diff --git a/autorelease/control.py b/autorelease/control.py index 285fcbc..779cbf9 100755 --- a/autorelease/control.py +++ b/autorelease/control.py @@ -72,6 +72,8 @@ WATCH_RECOVERY_ACTION, action_filename, audit_reconstruction, + email_digest, + email_fallback, mutation_allowed, notification_decision, release_transition, @@ -180,6 +182,17 @@ def main(argv: list[str] | None = None) -> int: archive_parser.add_argument("--archive", required=True, type=pathlib.Path) archive_parser.add_argument("--version", required=True) + email_parser = subparsers.add_parser("email-digest") + email_parser.add_argument("--workflow", required=True) + email_parser.add_argument("--conclusion", required=True) + email_parser.add_argument("--run-url", required=True) + email_parser.add_argument("--repository", required=True) + # A run that crashed before retaining its state legitimately has none of these + # files; email_digest decides per conclusion whether that absence is acceptable. + email_parser.add_argument("--decision", type=pathlib.Path) + email_parser.add_argument("--plan", type=pathlib.Path) + email_parser.add_argument("--transaction", type=pathlib.Path) + subparsers.add_parser("validate-policy") args = parser.parse_args(argv) @@ -245,6 +258,30 @@ def main(argv: list[str] | None = None) -> int: elif args.command == "validate-archive": validate_archive(args.archive, args.version) print(json.dumps({"valid": True})) + elif args.command == "email-digest": + report = { + "workflow": args.workflow, + "conclusion": args.conclusion, + "runUrl": args.run_url, + "repository": args.repository, + } + # A corrupt artifact or an unclassifiable outcome must still email a + # summary rather than go silent, so rejection selects the fallback + # template instead of failing the digest run. + try: + message = email_digest( + { + **report, + "decision": load_json(args.decision) if args.decision and args.decision.exists() else None, + "plan": load_json(args.plan) if args.plan and args.plan.exists() else None, + "transaction": load_json(args.transaction) + if args.transaction and args.transaction.exists() + else None, + } + ) + except ControlError as error: + message = email_fallback(report, str(error)) + print(json.dumps(message)) elif args.command == "validate-policy": print(json.dumps(validate_support_policy(ROOT))) return 0 diff --git a/docs/repository-settings.md b/docs/repository-settings.md index ff32003..d0feca1 100644 --- a/docs/repository-settings.md +++ b/docs/repository-settings.md @@ -55,6 +55,11 @@ Required repository state: be moved, replaced, or deleted. - Set `AUTORELEASE_OWNER=loadinglucian`. - Keep distinct repository-scoped `OPENAI_API_KEY` secrets. +- For the email digest, keep the repository-scoped `RESEND_API_KEY` secret + (a Resend sending-only key) plus the `AUTORELEASE_EMAIL_FROM` and + `AUTORELEASE_EMAIL_TO` repository variables. The sender address must belong + to a domain verified in Resend. While any of the three is unset, the digest + workflow skips without failing. - Keep the `autorelease` and `attention-required` labels. CODEOWNERS covers prompts, contracts, workflows, policy invariants, authority diff --git a/tests/test_autorelease.py b/tests/test_autorelease.py index 0956266..f52692b 100644 --- a/tests/test_autorelease.py +++ b/tests/test_autorelease.py @@ -16,6 +16,8 @@ ControlError, action_filename, canonical_json, + email_digest, + email_fallback, load_plan_evidence, main as control_main, mutation_allowed, @@ -442,6 +444,185 @@ def test_published_asset_mismatch_fails_closed(self): with self.assertRaises(ControlError): release_transition(transaction, "published", root, digests) + def test_email_digest_selects_one_fixed_template_per_outcome(self): + digest = "sha256:" + "a" * 64 + base = { + "workflow": "watcher", + "conclusion": "success", + "runUrl": "https://github.com/bigpixelrocket/php-bin/actions/runs/1", + "repository": "bigpixelrocket/php-bin", + } + changed = {"modelCall": True, "manifestDigest": digest} + cases = ( + ({**base, "conclusion": "failure"}, "watcher_failed", "conclusion 'failure'"), + ( + {**base, "decision": {"modelCall": False, "manifestDigest": digest}}, + "quiet_day", + "no model call was made", + ), + ( + {**base, "decision": changed, "plan": {"action": "no_change", "actionKey": "no_change:" + "0" * 16}}, + "no_change_reviewed", + digest, + ), + ( + {**base, "decision": changed, "plan": {"action": "new_patch", "actionKey": "new_patch:8.5.9"}}, + "new_patch_started", + "PHP 8.5.9 release started", + ), + ( + {**base, "decision": changed, "plan": {"action": "new_branch", "actionKey": "new_branch:8.6"}}, + "new_branch_detected", + "mise-php records matching exact-commit readiness", + ), + ( + {**base, "decision": changed, "plan": {"action": "branch_eol", "actionKey": "branch_eol:8.1:2026-12-31"}}, + "branch_eol_started", + "PHP 8.1 reached end of life", + ), + ( + {**base, "decision": changed, "plan": {"action": "repair", "actionKey": "repair:8.5.9:deadbeef"}}, + "repair_started", + "repair:8.5.9:deadbeef", + ), + ( + {**base, "decision": changed, "plan": {"action": "reconcile_partial", "actionKey": "new_patch:8.5.9"}}, + "reconcile_started", + "last legal state", + ), + ( + {**base, "decision": changed, "plan": {"action": "needs_human", "actionKey": "auth_failure:" + "b" * 8}}, + "watcher_attention", + "needs_human", + ), + ( + {**base, "workflow": "publish", "transaction": {"released": True, "version": "8.5.9"}}, + "release_published", + "releases/tag/8.5.9", + ), + ( + { + **base, + "workflow": "publish", + "conclusion": "failure", + "transaction": {"released": True, "version": "8.5.9"}, + }, + "release_record_pending", + "recovers the record", + ), + ( + { + **base, + "workflow": "publish", + "conclusion": "failure", + "transaction": {"released": False, "version": "8.5.9"}, + }, + "publish_failed", + "Publish failed for PHP 8.5.9", + ), + ({**base, "workflow": "publish", "conclusion": "failure"}, "publish_failed", "Publish failed"), + ) + for report, template, needle in cases: + with self.subTest(template=template): + message = email_digest(report) + self.assertEqual(template, message["template"]) + self.assertTrue(message["subject"].startswith("[php-bin autorelease] ")) + self.assertIn(needle, message["subject"] + "\n" + message["body"]) + self.assertIn(base["runUrl"], message["body"]) + + def test_email_digest_rejects_unroutable_or_unvalidated_run_state(self): + digest = "sha256:" + "a" * 64 + base = { + "workflow": "watcher", + "conclusion": "success", + "runUrl": "https://github.com/bigpixelrocket/php-bin/actions/runs/1", + "repository": "bigpixelrocket/php-bin", + } + changed = {"modelCall": True, "manifestDigest": digest} + rejected = ( + {**base, "workflow": "consumer"}, + {**base, "runUrl": "https://example.invalid/run"}, + {**base, "repository": "php-bin"}, + base, + {**base, "decision": {"modelCall": False, "manifestDigest": "sha256:short"}}, + {**base, "decision": {"modelCall": False, "manifestDigest": None}}, + {**base, "decision": {"manifestDigest": "sha256:" + "a" * 64}}, + {**base, "decision": {"modelCall": 1, "manifestDigest": "sha256:" + "a" * 64}}, + {**base, "decision": changed}, + {**base, "decision": changed, "plan": {"action": "new_patch", "actionKey": "new_patch:8.5.9; rm -rf"}}, + {**base, "decision": changed, "plan": {"action": "new_patch", "actionKey": None}}, + {**base, "decision": changed, "plan": {"action": "new_patch", "actionKey": "repair:8.5.9:deadbeef"}}, + {**base, "decision": changed, "plan": {"action": "publish", "actionKey": "new_patch:8.5.9"}}, + {**base, "workflow": "publish", "transaction": {"released": True, "version": "main"}}, + {**base, "workflow": "publish"}, + {**base, "workflow": "publish", "transaction": {"released": False, "version": "8.5.9"}}, + { + **base, + "workflow": "publish", + "conclusion": "failure", + "transaction": {"released": "true", "version": "8.5.9"}, + }, + { + **base, + "workflow": "publish", + "conclusion": "failure", + "transaction": {"released": True, "version": None}, + }, + ) + for report in rejected: + with self.subTest(report=report): + with self.assertRaises(ControlError): + email_digest(report) + + def test_email_fallback_summarizes_unclassifiable_state_with_revalidated_values(self): + report = { + "workflow": "watcher", + "conclusion": "success", + "runUrl": "https://github.com/bigpixelrocket/php-bin/actions/runs/1", + "repository": "bigpixelrocket/php-bin", + } + message = email_fallback(report, "no email template exists for action: publish") + self.assertEqual("unexpected_state", message["template"]) + self.assertIn("(watcher, success)", message["subject"]) + self.assertIn("no email template exists for action: publish", message["body"]) + self.assertIn(report["runUrl"], message["body"]) + # Values that fail revalidation are replaced, never interpolated. + hostile = { + "workflow": "consumer", + "conclusion": "FAILURE; curl evil", + "runUrl": "https://example.invalid/run", + } + message = email_fallback(hostile, "email digest workflow is unknown") + self.assertIn("(unknown, unknown)", message["subject"]) + self.assertNotIn("consumer", message["body"]) + self.assertNotIn("curl evil", message["body"]) + self.assertNotIn("example.invalid", message["body"]) + + def test_email_digest_cli_falls_back_instead_of_failing(self): + with tempfile.TemporaryDirectory() as scratch: + root = pathlib.Path(scratch) + (root / "watch-decision.json").write_text( + json.dumps({"modelCall": True, "manifestDigest": "sha256:" + "a" * 64}) + ) + (root / "autorelease-plan.json").write_text("{not json") + status, output = run_control( + "email-digest", + "--workflow", + "watcher", + "--conclusion", + "success", + "--run-url", + "https://github.com/bigpixelrocket/php-bin/actions/runs/1", + "--repository", + "bigpixelrocket/php-bin", + "--decision", + str(root / "watch-decision.json"), + "--plan", + str(root / "autorelease-plan.json"), + ) + self.assertEqual(0, status) + self.assertEqual("unexpected_state", json.loads(output)["template"]) + def test_notification_replay_is_deduplicated(self): event = {"actionKey": "new_patch:8.5.9", "state": "released"} first = notification_decision(event, None)