|
| 1 | +#!/usr/bin/env bash |
| 2 | +# agents/release/rehearse.sh — the release-VALIDATION driver (M2). |
| 3 | +# |
| 4 | +# This is the Brain Release Agent's dispatch/poll/ingest driver for a release |
| 5 | +# *rehearsal*, distinct from `release.sh` (which drives a real release on GREEN). |
| 6 | +# It orchestrates the four-stage release-validation pipeline's build+ingest half: |
| 7 | +# |
| 8 | +# dispatch M1 TestPyPI rehearsal (Build's release.yml, rehearsal=true) |
| 9 | +# -> poll to completion |
| 10 | +# -> download the `testpypi-rehearsal-version` artifact |
| 11 | +# -> capture the current main HEAD sha of each library |
| 12 | +# -> hand it all to `pyauto-heart validate --ingest` (Heart measures) |
| 13 | +# -> consult the Health Agent for the verdict (Health judges) |
| 14 | +# |
| 15 | +# BOUNDARY. Dispatch/poll/download are GitHub actions, done via Brain's MCP |
| 16 | +# GitHub tools (cloud/mobile sessions have no `gh`). Bash cannot call MCP, so |
| 17 | +# this script owns the *local* half — ingest + consult + decision — and EMITS |
| 18 | +# the MCP dispatch/poll/download plan for the agent to execute (see AGENTS.md). |
| 19 | +# Heart never dispatches and never mutates a repo; all GitHub credentials/actions |
| 20 | +# live here in the Release Agent, never in Heart. |
| 21 | +# |
| 22 | +# Usage: |
| 23 | +# # 1. print the MCP dispatch/poll/download plan (the agent executes it): |
| 24 | +# rehearse.sh [--ref main] [--minor N] [--json] |
| 25 | +# |
| 26 | +# # 2. after the artifacts are downloaded, ingest + get the verdict: |
| 27 | +# rehearse.sh --ingest <artifacts-dir> [--commit-shas FILE] [--profile P] |
| 28 | +# [--force] [--json] |
| 29 | +# |
| 30 | +# Exit codes (ingest phase): 0 green · 2 yellow (use --force) · 3 red · 4 unknown |
| 31 | +# · 1 could not ingest. Plan phase always exits 0. |
| 32 | + |
| 33 | +set -uo pipefail |
| 34 | + |
| 35 | +HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)" |
| 36 | +source "$HERE/../_common.sh" |
| 37 | + |
| 38 | +# The Build repo + workflow the rehearsal is dispatched against (M1). |
| 39 | +BUILD_REPO="PyAutoLabs/PyAutoBuild" |
| 40 | +RELEASE_WORKFLOW="release.yml" |
| 41 | +REHEARSAL_ARTIFACT="testpypi-rehearsal-version" |
| 42 | +# The 5 libraries whose main HEADs the rehearsal is built from; readiness |
| 43 | +# confirms the ingested report's commit_shas against these. |
| 44 | +LIBRARIES=(PyAutoConf PyAutoFit PyAutoArray PyAutoGalaxy PyAutoLens) |
| 45 | + |
| 46 | +ref="main" |
| 47 | +minor="" |
| 48 | +ingest_dir="" |
| 49 | +commit_shas_file="" |
| 50 | +profile="" |
| 51 | +force=0 |
| 52 | +json_only=0 |
| 53 | + |
| 54 | +while [[ $# -gt 0 ]]; do |
| 55 | + case "$1" in |
| 56 | + --ref) ref="$2"; shift 2 ;; |
| 57 | + --ref=*) ref="${1#*=}"; shift ;; |
| 58 | + --minor) minor="$2"; shift 2 ;; |
| 59 | + --minor=*) minor="${1#*=}"; shift ;; |
| 60 | + --ingest) ingest_dir="$2"; shift 2 ;; |
| 61 | + --ingest=*) ingest_dir="${1#*=}"; shift ;; |
| 62 | + --commit-shas) commit_shas_file="$2"; shift 2 ;; |
| 63 | + --commit-shas=*) commit_shas_file="${1#*=}"; shift ;; |
| 64 | + --profile) profile="$2"; shift 2 ;; |
| 65 | + --profile=*) profile="${1#*=}"; shift ;; |
| 66 | + --force) force=1; shift ;; |
| 67 | + --json) json_only=1; shift ;; |
| 68 | + -h|--help) sed -n '2,40p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;; |
| 69 | + *) echo "release rehearse: unknown arg '$1'" >&2; exit 5 ;; |
| 70 | + esac |
| 71 | +done |
| 72 | + |
| 73 | +# --------------------------------------------------------------------------- |
| 74 | +# Phase 1: emit the MCP dispatch/poll/download plan (no --ingest given). |
| 75 | +# --------------------------------------------------------------------------- |
| 76 | +if [[ -z "$ingest_dir" ]]; then |
| 77 | + inputs='{"rehearsal": true}' |
| 78 | + [[ -n "$minor" ]] && inputs='{"rehearsal": true, "minor_version": "'"$minor"'"}' |
| 79 | + |
| 80 | + if [[ "$json_only" -eq 1 ]]; then |
| 81 | + REF="$ref" INPUTS="$inputs" REPO="$BUILD_REPO" WF="$RELEASE_WORKFLOW" \ |
| 82 | + ART="$REHEARSAL_ARTIFACT" LIBS="${LIBRARIES[*]}" python3 -c ' |
| 83 | +import json, os |
| 84 | +print(json.dumps({ |
| 85 | + "agent": "release", "mode": "rehearse", "phase": "dispatch-plan", |
| 86 | + "steps": [ |
| 87 | + {"step": "dispatch", "mcp_tool": "mcp__github__actions_run_trigger", |
| 88 | + "repo": os.environ["REPO"], "workflow": os.environ["WF"], |
| 89 | + "ref": os.environ["REF"], "inputs": json.loads(os.environ["INPUTS"])}, |
| 90 | + {"step": "poll", "mcp_tool": "mcp__github__actions_get", |
| 91 | + "until": "status == completed", "note": "artifact exists IFF all 5 wheels built+installed"}, |
| 92 | + {"step": "download", "artifact": os.environ["ART"], |
| 93 | + "note": "download into an artifacts dir (rehearsal.json + testpypi_version.txt)"}, |
| 94 | + {"step": "capture-heads", "mcp_tool": "mcp__github__get_commit", |
| 95 | + "repos": os.environ["LIBS"].split(), |
| 96 | + "note": "write {repo: sha} of each library main HEAD to commit_shas.json in the dir"}, |
| 97 | + {"step": "ingest", "cmd": "pyauto-brain release rehearse --ingest <dir> --commit-shas <dir>/commit_shas.json"}, |
| 98 | + ], |
| 99 | +}, indent=2)) |
| 100 | +' |
| 101 | + exit 0 |
| 102 | + fi |
| 103 | + |
| 104 | + cat <<EOF |
| 105 | +== release agent: release-validation rehearsal (dispatch plan) == |
| 106 | +
|
| 107 | +Bash cannot call GitHub; execute these steps with Brain's MCP GitHub tools |
| 108 | +(cloud/mobile has no gh). Heart never dispatches — this is the Release Agent's job. |
| 109 | +
|
| 110 | +1. DISPATCH the M1 TestPyPI rehearsal: |
| 111 | + mcp__github__actions_run_trigger |
| 112 | + repo: $BUILD_REPO |
| 113 | + workflow: $RELEASE_WORKFLOW |
| 114 | + ref: $ref |
| 115 | + inputs: $inputs |
| 116 | +
|
| 117 | +2. POLL the run to completion (mcp__github__actions_get / actions_list). |
| 118 | + The '$REHEARSAL_ARTIFACT' artifact is produced IFF all five wheels built, |
| 119 | + uploaded, and installed from TestPyPI — its presence IS the success signal. |
| 120 | +
|
| 121 | +3. DOWNLOAD the '$REHEARSAL_ARTIFACT' artifact into an artifacts directory |
| 122 | + (it contains rehearsal.json + testpypi_version.txt). |
| 123 | +
|
| 124 | +4. CAPTURE the current main HEAD sha of each library and write them as |
| 125 | + {repo: sha} to <dir>/commit_shas.json — so Heart can confirm the report is |
| 126 | + for THIS source (readiness matches them against the live main HEADs): |
| 127 | + mcp__github__get_commit for: ${LIBRARIES[*]} |
| 128 | +
|
| 129 | +5. INGEST + get the verdict (this script, phase 2): |
| 130 | + pyauto-brain release rehearse --ingest <dir> --commit-shas <dir>/commit_shas.json |
| 131 | +
|
| 132 | +After ingest, the Health Agent (read-only) reports GREEN/YELLOW/RED from the |
| 133 | +freshly-ingested validation_report — it does NOT dispatch anything. |
| 134 | +EOF |
| 135 | + exit 0 |
| 136 | +fi |
| 137 | + |
| 138 | +# --------------------------------------------------------------------------- |
| 139 | +# Phase 2: ingest the downloaded artifacts, then consult the Health Agent. |
| 140 | +# --------------------------------------------------------------------------- |
| 141 | +if [[ ! -d "$ingest_dir" && ! -f "$ingest_dir" ]]; then |
| 142 | + echo "release rehearse: artifacts path '$ingest_dir' not found" >&2 |
| 143 | + exit 1 |
| 144 | +fi |
| 145 | + |
| 146 | +heart="$(resolve_heart)" || exit $? |
| 147 | + |
| 148 | +ingest_args=(validate --ingest "$ingest_dir") |
| 149 | +[[ -n "$commit_shas_file" ]] && ingest_args+=(--commit-shas "$commit_shas_file") |
| 150 | +[[ -n "$profile" ]] && ingest_args+=(--profile "$profile") |
| 151 | + |
| 152 | +[[ "$json_only" -eq 1 ]] || echo "== release agent: handing artifacts to pyauto-heart validate --ingest ==" |
| 153 | +if ! "$heart" "${ingest_args[@]}"; then |
| 154 | + echo "release rehearse: pyauto-heart validate --ingest failed" >&2 |
| 155 | + exit 1 |
| 156 | +fi |
| 157 | + |
| 158 | +# Consult the sibling Health Agent (read-only) for the verdict. --refresh runs a |
| 159 | +# fresh Heart tick so state.json re-aggregates the just-written |
| 160 | +# validation_report.json before readiness recomputes the gate. |
| 161 | +[[ "$json_only" -eq 1 ]] || echo "== release agent: consulting Health Agent for the release-validation verdict ==" |
| 162 | +verdict="$(consult_health_agent_verdict --refresh)" |
| 163 | + |
| 164 | +eff="$verdict"; [[ "$eff" == "unknown" ]] && eff="yellow" |
| 165 | +decision=""; decision_code=0; blockers=(); warnings=(); next=() |
| 166 | +[[ "$verdict" == "unknown" ]] && warnings+=("Readiness verdict unknown; treated as YELLOW.") |
| 167 | + |
| 168 | +case "$eff" in |
| 169 | + green) |
| 170 | + decision="release-ready"; decision_code=0 |
| 171 | + next+=("Source built, TestPyPI-installed, and validated at release fidelity. A human/Release-Agent may promote to PyPI via Hands/Build.") |
| 172 | + ;; |
| 173 | + yellow) |
| 174 | + if [[ "$force" -eq 1 ]]; then |
| 175 | + decision="proceed-with-caution"; decision_code=0 |
| 176 | + warnings+=("Readiness YELLOW; proceeding under --force.") |
| 177 | + else |
| 178 | + decision="hold"; decision_code=2 |
| 179 | + blockers+=("Readiness is YELLOW — likely no fresh release-fidelity run yet, stale rehearsal, or source moved. See 'pyauto-brain health'. Re-run with --force to accept the caution.") |
| 180 | + fi |
| 181 | + ;; |
| 182 | + red) |
| 183 | + decision="blocked"; decision_code=3 |
| 184 | + blockers+=("Readiness is RED — a validation stage failed or a hard blocker is present. Fix before releasing.") |
| 185 | + ;; |
| 186 | + *) |
| 187 | + decision="unknown"; decision_code=4 |
| 188 | + blockers+=("Could not obtain a readiness verdict ('$verdict').") |
| 189 | + ;; |
| 190 | +esac |
| 191 | + |
| 192 | +emit_decision() { |
| 193 | + HEALTH="$verdict" DECISION="$decision" DIR="$ingest_dir" \ |
| 194 | + WARNINGS="$(printf '%s\n' "${warnings[@]:-}")" \ |
| 195 | + BLOCKERS="$(printf '%s\n' "${blockers[@]:-}")" \ |
| 196 | + NEXT="$(printf '%s\n' "${next[@]:-}")" \ |
| 197 | + python3 -c ' |
| 198 | +import json, os |
| 199 | +def lines(k): return [x for x in os.environ.get(k, "").splitlines() if x.strip()] |
| 200 | +print(json.dumps({ |
| 201 | + "agent": "release", "mode": "rehearse", "phase": "verdict", |
| 202 | + "artifacts": os.environ["DIR"], |
| 203 | + "health_status": os.environ["HEALTH"], |
| 204 | + "decision": os.environ["DECISION"], |
| 205 | + "warnings": lines("WARNINGS"), |
| 206 | + "blockers": lines("BLOCKERS"), |
| 207 | + "next_steps": lines("NEXT"), |
| 208 | +}, indent=2)) |
| 209 | +' |
| 210 | +} |
| 211 | + |
| 212 | +if [[ "$json_only" -eq 1 ]]; then |
| 213 | + emit_decision |
| 214 | +else |
| 215 | + echo "-- ReleaseValidationDecision --" |
| 216 | + emit_decision |
| 217 | + echo |
| 218 | + "$heart" readiness || true |
| 219 | +fi |
| 220 | + |
| 221 | +exit "$decision_code" |
0 commit comments