From c1bd9bcadcd8ef8073a5408effc5a5f8f9d229d6 Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Thu, 9 Jul 2026 19:27:27 +0100 Subject: [PATCH] feat: the scheduled-nightly release driver (release nightly) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of PyAutoLabs/PyAutoBuild#127 — implements the merged design (PyAutoBuild docs/nightly_release_design.md §3-§6, §10): nightly.sh sequences kill switch → same-day guard → activity gate → release-blocker check → the M1-M4 validate choreography (executing validate.sh's emitted dispatch plans with gh, available in CI unlike the MCP-only sessions the plans were written for) → the release-ci readiness gate (GREEN only, no force input) → live dispatch of Build's release.yml, one Slack outcome per terminal path. activity_gate.py holds the unit-tested window / self-commit judgment; nightly-release.yml is the 02:00 UTC scheduler (dry_run defaults true until the arming checklist flips it); consult_vitals_verdict gains --profile passthrough; routed as `pyauto-brain release nightly`. Co-Authored-By: Claude Fable 5 --- .github/workflows/nightly-release.yml | 75 ++++ agents/_common.sh | 21 +- agents/conductors/release/activity_gate.py | 116 +++++++ agents/conductors/release/nightly.sh | 379 +++++++++++++++++++++ agents/conductors/release/release.sh | 10 + bin/pyauto-brain | 5 +- tests/test_activity_gate.py | 100 ++++++ 7 files changed, 699 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/nightly-release.yml create mode 100644 agents/conductors/release/activity_gate.py create mode 100644 agents/conductors/release/nightly.sh create mode 100644 tests/test_activity_gate.py diff --git a/.github/workflows/nightly-release.yml b/.github/workflows/nightly-release.yml new file mode 100644 index 0000000..a71ff84 --- /dev/null +++ b/.github/workflows/nightly-release.yml @@ -0,0 +1,75 @@ +name: Nightly Release + +# The scheduled-nightly release driver (PyAutoBuild/docs/nightly_release_design.md +# §3) — the ONLY path authorised to ship a live PyPI release without a +# per-release human, under PyAutoBrain/AUTONOMY.md's standing grant (2026-07-09). +# +# This workflow is a scheduler, nothing more: all sequencing, gating and +# notification logic lives in agents/conductors/release/nightly.sh (testable +# locally via `pyauto-brain release nightly`). Scheduling authority moved HERE +# from PyAutoBuild's release.yml (whose cron was removed in the same change) — +# Build executes releases, it does not decide when. +# +# Arming (design §9, all human acts, in order): +# 1. the next manual live release succeeds end-to-end; +# 2. PyAutoBuild#126 closed / de-labelled (`release-blocker`); +# 3. a dry_run=true nightly observed GREEN end-to-end; +# 4. set the repo Actions variable NIGHTLY_RELEASES=true and flip the +# DRY_RUN default below from 'true' to 'false'. +# Pausing forever after is one act: unset NIGHTLY_RELEASES. + +on: + schedule: + # Every night: quiet nights exit at the activity gate in seconds, so + # weekend scheduling costs nothing and weekend merges release on time. + - cron: "0 2 * * *" + workflow_dispatch: + inputs: + dry_run: + description: "true = run every gate but log instead of dispatching the live release" + type: choice + options: ["true", "false"] + default: "true" + +permissions: + contents: read + +# One night at a time; a manual dispatch queues behind a scheduled run rather +# than racing it (two live releases of the same date must be impossible). +concurrency: + group: nightly-release + cancel-in-progress: false + +jobs: + nightly: + runs-on: ubuntu-latest + # Rehearsal (~1h) + release-fidelity integration (~2h) + live release (~1.5h). + timeout-minutes: 350 + steps: + - name: Checkout PyAutoBrain + uses: actions/checkout@v4 + + - name: Checkout PyAutoHeart (sibling — resolve_heart finds it via PYAUTO_ROOT) + uses: actions/checkout@v4 + with: + repository: PyAutoLabs/PyAutoHeart + path: PyAutoHeart + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install PyYAML (Heart's only dependency) + run: pip install --quiet pyyaml + + - name: Run the nightly driver + env: + GH_TOKEN: ${{ secrets.PAT_PYAUTOLABS }} + PYAUTO_RELEASE_WEBHOOK_URL: ${{ secrets.PYAUTO_RELEASE_WEBHOOK_URL }} + NIGHTLY_RELEASES: ${{ vars.NIGHTLY_RELEASES }} + # Scheduled runs have no inputs: default 'true' (dry-run) until the + # arming checklist flips this literal to 'false' (design §9). + DRY_RUN: ${{ inputs.dry_run || 'true' }} + PYAUTO_ROOT: ${{ github.workspace }} + run: bash agents/conductors/release/nightly.sh diff --git a/agents/_common.sh b/agents/_common.sh index 7c3f7dc..bac9b38 100755 --- a/agents/_common.sh +++ b/agents/_common.sh @@ -91,15 +91,24 @@ _agents_dir() { # faculty was renamed health -> vitals, and the loop conductor took the name # `health`.) # -# --refresh ask the vitals faculty to refresh Heart's state first (a fresh -# gate); release-grade work uses this, ordinary build work does not. +# --refresh ask the vitals faculty to refresh Heart's state first (a fresh +# gate); release-grade work uses this, ordinary build work does not. +# --profile P evaluate under a readiness evidence profile (e.g. `release-ci` +# for the scheduled-nightly gate — heart/readiness.py "Profiles"). # # Echoes one of: green | yellow | red | unknown. Never fails the caller — an # unresolvable/again-unknown verdict is reported as "unknown" (treated as YELLOW # by callers), never silently as green. consult_vitals_verdict() { - local refresh=0 - [[ "${1:-}" == "--refresh" ]] && refresh=1 + local refresh=0 profile="" + while [[ $# -gt 0 ]]; do + case "$1" in + --refresh) refresh=1; shift ;; + --profile) profile="$2"; shift 2 ;; + --profile=*) profile="${1#*=}"; shift ;; + *) shift ;; + esac + done local vitals vitals="$(_agents_dir)/faculties/vitals/vitals.sh" if [[ ! -f "$vitals" ]]; then @@ -113,8 +122,10 @@ consult_vitals_verdict() { # `set -o pipefail`, under which a non-zero exit from the (possibly # Heart-less) vitals faculty would otherwise double-fire a fallback. The python # below always prints exactly one token, even on empty/garbage input. + local args=(readiness --json) + [[ -n "$profile" ]] && args+=(--profile "$profile") local out - out="$(bash "$vitals" readiness --json 2>/dev/null | python3 -c ' + out="$(bash "$vitals" "${args[@]}" 2>/dev/null | python3 -c ' import json, sys try: v = json.load(sys.stdin).get("verdict", "unknown") diff --git a/agents/conductors/release/activity_gate.py b/agents/conductors/release/activity_gate.py new file mode 100644 index 0000000..85e3022 --- /dev/null +++ b/agents/conductors/release/activity_gate.py @@ -0,0 +1,116 @@ +"""agents/conductors/release/activity_gate.py — the nightly activity gate. + +Pure decision logic for the scheduled-nightly release driver (nightly.sh), +implementing design §4 of ``PyAutoBuild/docs/nightly_release_design.md``: + + qualifying activity = at least one commit on ``main``, in any of the + release-relevant repos, since the last nightly outcome, that is not a + pipeline self-commit. + +The shell driver fetches raw GitHub commit objects; everything judgment-shaped +lives here so it is unit-testable (tests/test_activity_gate.py). No network, +no subprocess — pure functions over the API payloads. + +CLI: a single JSON object ``{repo_name: [commit, ...], ...}`` on stdin (values +are GitHub ``GET /repos/{o}/{r}/commits`` list items); prints a verdict JSON +``{"active": bool, "counts": {repo: n}, "excluded": n, "summary": str}``. +""" + +from __future__ import annotations + +import json +import re +import sys +from typing import Any + +# The release-relevant set (design §4): the repos whose merged work a nightly +# release ships or regenerates. Mirrors PyAutoMind/repos.yaml roles and the +# release.yml build/workspace matrices. +RELEASE_RELEVANT_REPOS: tuple[str, ...] = ( + "PyAutoConf", + "PyAutoFit", + "PyAutoArray", + "PyAutoGalaxy", + "PyAutoLens", + "autofit_workspace", + "autogalaxy_workspace", + "autolens_workspace", + "HowToFit", + "HowToGalaxy", + "HowToLens", +) + +# Pipeline self-commit contract (design §4) — a release must never count as +# the next night's activity. Post-#118/#120 the pipeline no longer stamps +# versions into the libraries, so its only main commits are notebook +# regeneration / Colab URL bumps / the assistant API baseline, all made with +# the git identity release.yml configures and messaged "Release : …". +# Both signals are pipeline-controlled; tests pin them so drift is caught. +PIPELINE_COMMITTER_NAMES = frozenset({"GitHub Actions bot", "github-actions[bot]"}) +PIPELINE_COMMITTER_EMAILS = frozenset({"richard@rghsoftware.co.uk"}) +RELEASE_MESSAGE_RE = re.compile(r"^Release \d{4}\.") + + +def is_pipeline_commit(commit: dict[str, Any]) -> bool: + """True if a GitHub commit object is a release-pipeline self-commit.""" + body = commit.get("commit") or {} + message = str(body.get("message") or "") + if RELEASE_MESSAGE_RE.match(message): + return True + for who in (body.get("committer") or {}, body.get("author") or {}): + if str(who.get("name") or "") in PIPELINE_COMMITTER_NAMES: + return True + if str(who.get("email") or "").lower() in PIPELINE_COMMITTER_EMAILS: + return True + return False + + +def qualifying(commits: list[Any]) -> list[dict[str, Any]]: + """The commits that count as activity (non-pipeline, well-formed).""" + return [ + c for c in commits + if isinstance(c, dict) and not is_pipeline_commit(c) + ] + + +def judge(commits_by_repo: dict[str, list[Any]]) -> dict[str, Any]: + """The gate verdict over per-repo commit lists. + + ``counts`` holds only repos with qualifying activity; ``excluded`` is the + total number of pipeline self-commits filtered out (reported so a night + that was quiet *only because of exclusions* says so explicitly). + """ + counts: dict[str, int] = {} + excluded = 0 + for repo, commits in sorted((commits_by_repo or {}).items()): + if not isinstance(commits, list): + continue + kept = qualifying(commits) + excluded += sum(1 for c in commits if isinstance(c, dict)) - len(kept) + if kept: + counts[repo] = len(kept) + active = bool(counts) + if active: + summary = "activity: " + ", ".join(f"{r} ({n})" for r, n in counts.items()) + else: + summary = "no qualifying activity" + if excluded: + summary += f" ({excluded} pipeline self-commit(s) excluded)" + return {"active": active, "counts": counts, "excluded": excluded, "summary": summary} + + +def main() -> int: + try: + payload = json.load(sys.stdin) + except json.JSONDecodeError as exc: + print(f"activity_gate: unreadable stdin JSON: {exc}", file=sys.stderr) + return 1 + if not isinstance(payload, dict): + print("activity_gate: expected a {repo: [commits]} object", file=sys.stderr) + return 1 + print(json.dumps(judge(payload), indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/agents/conductors/release/nightly.sh b/agents/conductors/release/nightly.sh new file mode 100644 index 0000000..a6a940d --- /dev/null +++ b/agents/conductors/release/nightly.sh @@ -0,0 +1,379 @@ +#!/usr/bin/env bash +# agents/conductors/release/nightly.sh — the scheduled-nightly release driver. +# +# Implements the nightly sequence of PyAutoBuild/docs/nightly_release_design.md +# (§3, steps 0-7): the ONLY path authorised to ship a live PyPI release without +# a per-release human, under the AUTONOMY.md standing grant of 2026-07-09 +# (activity-gated, Heart-GREEN-gated, kill-switchable, loud on every outcome). +# +# This driver is deterministic shell — no LLM in the release gate. It COMPOSES +# the existing conductors rather than re-implementing them: +# +# step 0 kill switch vars.NIGHTLY_RELEASES (scheduled runs only) +# step 1 same-day guard a YYYY.M.D.* release tag already on PyAutoLens +# step 2 activity gate activity_gate.py over the release-relevant repos +# step 3 known-red open `release-blocker`-labelled issues +# step 4 validate validate.sh Phases A→C — the emitted dispatch +# plans are EXECUTED here with `gh` (in CI, unlike +# the MCP-only cloud sessions the plans were written +# for, gh is available to bash) +# step 5 gate vitals faculty → `pyauto-heart readiness +# --profile release-ci` — GREEN only; STALE/YELLOW/ +# RED stop and page; there is NO force input +# step 6 live release dispatch Build's release.yml (rehearsal=false, +# minor_version=1) — or a log line under dry-run +# step 7 report one Slack message per terminal outcome +# +# Activity-window anchor: the PyAutoBrain repo Actions variable +# NIGHTLY_LAST_WINDOW_END. It advances ONLY when the window's activity was +# shipped (released) or the window was empty (skipped) — dry-runs and stops +# leave it, so unshipped merges are never swallowed (design §4). +# +# Environment (the workflow provides these; local runs may set them): +# GH_TOKEN PAT for dispatch/poll/label/commit reads (required +# beyond --no-dispatch if gh is not already authed) +# PYAUTO_RELEASE_WEBHOOK_URL Slack webhook (missing → loud stderr warning) +# NIGHTLY_RELEASES kill switch; scheduled runs exit unless "true" +# DRY_RUN "true" (default) → step 6 logs instead of shipping +# GITHUB_EVENT_NAME "schedule" engages the kill switch; anything else +# (manual dispatch, local) proceeds +# +# Usage: +# nightly.sh [--dry-run] [--no-dispatch] +# --dry-run force DRY_RUN=true regardless of environment +# --no-dispatch stop after step 3, strictly read-only: no dispatch, no +# Slack post (printed instead), no anchor write +# +# NIGHTLY_WINDOW_START (env) — override the activity-window start (ISO 8601) +# for local verification; the persisted/24h anchor is used when unset. +# +# Exit: 0 on a reported outcome (shipped / skipped / dry-run) — 2 blocked at a +# gate (paged) — 3 not GREEN / preflight red (paged) — 1 driver error (paged). + +set -uo pipefail + +HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)" +source "$HERE/../../_common.sh" + +BRAIN_REPO="PyAutoLabs/PyAutoBuild" # where release-blocker labels are curated first +ANCHOR_REPO="PyAutoLabs/PyAutoBrain" +ANCHOR_VAR="NIGHTLY_LAST_WINDOW_END" +TAG_REPO="PyAutoLabs/PyAutoLens" # release tags land on every library; one suffices +BUILD_REPO="PyAutoLabs/PyAutoBuild" +RELEASE_WORKFLOW="release.yml" +# Repos whose open `release-blocker` issues stop the night (the release-relevant +# set from activity_gate.py plus the release/health organs). +BLOCKER_EXTRA_REPOS=(PyAutoBuild PyAutoHeart) + +DRY_RUN="${DRY_RUN:-true}" +no_dispatch=0 +while [[ $# -gt 0 ]]; do + case "$1" in + --dry-run) DRY_RUN=true; shift ;; + --no-dispatch) no_dispatch=1; shift ;; + -h|--help) + awk 'NR==1{next} /^#/{sub(/^# ?/,""); print; next} {exit}' "${BASH_SOURCE[0]}" + exit 0 ;; + *) echo "release nightly: unknown arg '$1'" >&2; exit 1 ;; + esac +done + +WORK="$(mktemp -d "${TMPDIR:-/tmp}/pyauto-nightly.XXXXXX")" +ART_DIR="$WORK/artifacts" +mkdir -p "$ART_DIR" +RUN_URL="${GITHUB_SERVER_URL:-https://github.com}/${GITHUB_REPOSITORY:-PyAutoLabs/PyAutoBrain}/actions/runs/${GITHUB_RUN_ID:-local}" +WINDOW_END="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + +log() { printf '== nightly: %s\n' "$*"; } + +# notify — one Slack message per terminal outcome (design §6). +# A missing webhook is a loud warning, never a crash: the run's own log + +# morning-digest watchdog still cover it. +notify() { + local emoji="$1" text="$2" + printf '%s %s\n' "$emoji" "$text" + if [[ "$no_dispatch" -eq 1 ]]; then + echo "nightly: --no-dispatch — outcome printed only, not posted" + return 0 + fi + if [[ -z "${PYAUTO_RELEASE_WEBHOOK_URL:-}" ]]; then + echo "nightly: PYAUTO_RELEASE_WEBHOOK_URL not set — outcome NOT posted to Slack" >&2 + return 0 + fi + jq -n --arg t "$emoji $text" '{text: $t}' | curl -sS -X POST \ + -H "Content-Type: application/json" --fail-with-body --data @- \ + "$PYAUTO_RELEASE_WEBHOOK_URL" >/dev/null \ + || echo "nightly: Slack POST failed — outcome NOT delivered" >&2 +} + +# advance_anchor — persist WINDOW_END as the next night's window start. Only +# called for shipped / empty-skip outcomes (see header). Failure is a warning: +# the fallback window (24h) self-heals. +advance_anchor() { + if [[ "$no_dispatch" -eq 1 ]]; then + echo "nightly: --no-dispatch — anchor left at its persisted value" + return 0 + fi + gh api -X PATCH "repos/$ANCHOR_REPO/actions/variables/$ANCHOR_VAR" \ + -f name="$ANCHOR_VAR" -f value="$WINDOW_END" >/dev/null 2>&1 \ + || gh api -X POST "repos/$ANCHOR_REPO/actions/variables" \ + -f name="$ANCHOR_VAR" -f value="$WINDOW_END" >/dev/null 2>&1 \ + || echo "nightly: could not persist $ANCHOR_VAR (window falls back to 24h)" >&2 +} + +page() { # page — the 🚨 severity; never advances the anchor. + notify "🚨" "*nightly release stopped* — $1 +<$RUN_URL|nightly run>. No release was made." +} + +# --------------------------------------------------------------------------- +# Step 0 — kill switch (scheduled runs only; manual/local runs proceed). +# Pausing is a human act, so the pause itself was the notification: exit silently. +# --------------------------------------------------------------------------- +if [[ "${GITHUB_EVENT_NAME:-}" == "schedule" && "${NIGHTLY_RELEASES:-}" != "true" ]]; then + log "kill switch: NIGHTLY_RELEASES != 'true' — paused, exiting silently" + exit 0 +fi + +# --------------------------------------------------------------------------- +# Step 1 — same-day guard: never double-release a date (design §8). +# --------------------------------------------------------------------------- +today="$(date -u +'%Y.%-m.%-d')" +today_re="^${today//./\\.}\\.[0-9]+$" +if gh api "repos/$TAG_REPO/tags?per_page=50" --jq '.[].name' 2>/dev/null \ + | grep -Eq "$today_re"; then + log "a $today.* release tag already exists on $TAG_REPO" + notify "💤" "*nightly release skipped* — already released today ($today). <$RUN_URL|nightly run>" + exit 0 +fi + +# --------------------------------------------------------------------------- +# Step 2 — activity gate (design §4). Window: since the persisted anchor, +# falling back to 24h; judgment in activity_gate.py. +# --------------------------------------------------------------------------- +anchor="${NIGHTLY_WINDOW_START:-}" +[[ -z "$anchor" ]] && anchor="$(gh api "repos/$ANCHOR_REPO/actions/variables/$ANCHOR_VAR" --jq .value 2>/dev/null || true)" +[[ -z "$anchor" ]] && anchor="$(date -u -d '24 hours ago' +%Y-%m-%dT%H:%M:%SZ)" +log "activity window: $anchor → $WINDOW_END" + +repos="$(PYTHONPATH="$HERE" python3 -c 'from activity_gate import RELEASE_RELEVANT_REPOS as R; print("\n".join(R))')" +commits_json="$WORK/commits.json" +{ + printf '{' + first=1 + while IFS= read -r repo; do + [[ -z "$repo" ]] && continue + body="$(gh api "repos/PyAutoLabs/$repo/commits?sha=main&since=$anchor&per_page=100" 2>/dev/null || echo '[]')" + [[ "$first" -eq 0 ]] && printf ',' + first=0 + printf '%s' "\"$repo\":" + printf '%s' "$body" + done <<< "$repos" + printf '}' +} > "$commits_json" + +gate="$(PYTHONPATH="$HERE" python3 "$HERE/activity_gate.py" < "$commits_json")" || { + page "activity gate could not be evaluated (driver error)" + exit 1 +} +active="$(printf '%s' "$gate" | python3 -c 'import json,sys; print("1" if json.load(sys.stdin)["active"] else "0")')" +summary="$(printf '%s' "$gate" | python3 -c 'import json,sys; print(json.load(sys.stdin)["summary"])')" +log "$summary" + +if [[ "$active" != "1" ]]; then + notify "💤" "*nightly release skipped* — no activity since $anchor. $summary. <$RUN_URL|nightly run>" + advance_anchor + exit 0 +fi + +# --------------------------------------------------------------------------- +# Step 3 — known-red: any open `release-blocker` issue stops the night before +# any dispatch (design §5). A human labels an issue; that click pages every +# scheduled night until it closes. +# --------------------------------------------------------------------------- +blockers="" +while IFS= read -r repo; do + [[ -z "$repo" ]] && continue + found="$(gh api "repos/PyAutoLabs/$repo/issues?labels=release-blocker&state=open&per_page=10" \ + --jq '.[] | "\(.html_url) (\(.title))"' 2>/dev/null || true)" + [[ -n "$found" ]] && blockers+="$found"$'\n' +done <<< "$repos"$'\n'"$(printf '%s\n' "${BLOCKER_EXTRA_REPOS[@]}")" + +if [[ -n "${blockers//[[:space:]]/}" ]]; then + log "release-blocker issue(s) open:" + printf '%s' "$blockers" + page "release-blocked by open issue(s): +$blockers" + exit 2 +fi + +if [[ "$no_dispatch" -eq 1 ]]; then + log "--no-dispatch: gates passed (activity present, no blockers); stopping before any dispatch" + exit 0 +fi + +# --------------------------------------------------------------------------- +# Step 4 — validate: run validate.sh's Phases A→C, executing each emitted +# dispatch plan with gh (this is the CI incarnation of the plans' MCP steps; +# parameters are parsed FROM the plans so nothing is duplicated here). +# --------------------------------------------------------------------------- + +# plan_field — tiny extractor. +plan_field() { + PLAN="$1" python3 -c " +import json, os +plan = json.loads(os.environ['PLAN']) +print($2) +" +} + +# dispatch_and_await [ ] +# Dispatches, resolves the run id, watches to completion (fails loudly), and +# optionally downloads an artifact. Echoes the run URL on success. +dispatch_and_await() { + local repo="$1" wf="$2" inputs="$3" artifact="${4:-}" dest="${5:-}" + local before run_id rc + before="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + local fargs=() + while IFS=$'\t' read -r k v; do + [[ -z "$k" ]] && continue + fargs+=(-f "$k=$v") + done < <(printf '%s' "$inputs" | python3 -c ' +import json, sys +for k, v in (json.load(sys.stdin) or {}).items(): + print(f"{k}\t{str(v).lower() if isinstance(v, bool) else v}") +') + gh workflow run "$wf" -R "$repo" --ref main "${fargs[@]}" || return 1 + run_id="" + for _ in $(seq 1 18); do + sleep 10 + run_id="$(gh run list -R "$repo" --workflow "$wf" --created ">=$before" \ + -L 1 --json databaseId --jq '.[0].databaseId // empty' 2>/dev/null)" + [[ -n "$run_id" ]] && break + done + if [[ -z "$run_id" ]]; then + echo "nightly: dispatched $repo/$wf but could not find the run" >&2 + return 1 + fi + echo "https://github.com/$repo/actions/runs/$run_id" >&2 + gh run watch "$run_id" -R "$repo" --interval 60 --exit-status >/dev/null + rc=$? + LAST_RUN_URL="https://github.com/$repo/actions/runs/$run_id" + [[ $rc -ne 0 ]] && return 2 + if [[ -n "$artifact" ]]; then + gh run download "$run_id" -R "$repo" -n "$artifact" -D "$dest" || return 3 + fi + return 0 +} + +log "step 4 — validate (Stages 0-3 via validate.sh)" +phase_a="$(bash "$HERE/validate.sh" --json)" +rc=$? +if [[ $rc -eq 3 ]]; then + page "validation preflight RED: +$(plan_field "$phase_a" "chr(10).join(plan.get('blockers', []))")" + exit 3 +elif [[ $rc -ne 0 || -z "$phase_a" ]]; then + page "validation Phase A failed (exit $rc)" + exit 1 +fi + +s2_repo="$(plan_field "$phase_a" "plan['stage2_plan']['steps'][0]['repo']")" +s2_wf="$(plan_field "$phase_a" "plan['stage2_plan']['steps'][0]['workflow']")" +s2_inputs="$(plan_field "$phase_a" "json.dumps(plan['stage2_plan']['steps'][0]['inputs'])")" +s2_artifact="$(plan_field "$phase_a" "[s for s in plan['stage2_plan']['steps'] if s['step']=='download'][0]['artifact']")" +head_repos="$(plan_field "$phase_a" "chr(10).join([s for s in plan['stage2_plan']['steps'] if s['step']=='capture-heads'][0]['repos'])")" + +log "step 4a — Stage 2 rehearsal: dispatch $s2_repo/$s2_wf" +if ! dispatch_and_await "$s2_repo" "$s2_wf" "$s2_inputs" "$s2_artifact" "$ART_DIR"; then + page "Stage 2 (TestPyPI rehearsal) failed — <${LAST_RUN_URL:-$RUN_URL}|run>" + exit 2 +fi + +# capture-heads: {bare repo name: main HEAD sha} — Stage 2's commit_shas.json +# is the single source of truth downstream (validate.sh header). +python3 - "$ART_DIR/commit_shas.json" <" + exit 2 +fi + +# --------------------------------------------------------------------------- +# Step 4c + 5 — assemble the CI snapshot Heart's release-ci profile evaluates +# (design §5): cloud-safe checks + the just-produced validation artifacts, +# then the gate through the vitals faculty. GREEN only; no force input exists. +# --------------------------------------------------------------------------- +log "step 4c — ingest + assemble the CI readiness snapshot" +heart="$(resolve_heart)" || { page "pyauto-heart not resolvable"; exit 1; } +HEART_DIR="$(cd "$(dirname "$(readlink -f "$(command -v "$heart" || echo "$heart")")")/.." && pwd)" +export HEART_STATE_DIR="${HEART_STATE_DIR:-$WORK/heart-state}" +mkdir -p "$HEART_STATE_DIR" + +bash "$HEART_DIR/heart/checks/ci_status.sh" || echo "nightly: ci_status check failed (verdict will show unknowns)" >&2 +bash "$HEART_DIR/heart/checks/open_prs.sh" || echo "nightly: open_prs check failed" >&2 +"$heart" validate --ingest "$ART_DIR" --commit-shas "$ART_DIR/commit_shas.json" || { + page "could not ingest the validation artifacts into Heart" + exit 1 +} +PYTHONPATH="$HEART_DIR" python3 -c "from heart import state; state.aggregate()" || { + page "could not aggregate the Heart snapshot" + exit 1 +} + +log "step 5 — readiness gate (release-ci profile; GREEN only)" +verdict="$(consult_vitals_verdict --profile release-ci)" +"$heart" readiness --profile release-ci || true +if [[ "$verdict" != "green" ]]; then + reasons="$("$heart" readiness --json --profile release-ci 2>/dev/null \ + | python3 -c 'import json,sys; print("; ".join(json.load(sys.stdin).get("reasons", [])[:6]))' 2>/dev/null || true)" + page "Heart is ${verdict^^}, not GREEN: ${reasons:-see the run log}" + exit 3 +fi +log "Heart GREEN — release authorised by the standing grant" + +# --------------------------------------------------------------------------- +# Step 6 — the live release (or the dry-run log line). +# --------------------------------------------------------------------------- +version="$today.1" +if [[ "$DRY_RUN" == "true" ]]; then + notify "🧪" "*nightly dry-run complete* — all gates GREEN; would have dispatched the live release ($version). $summary. <$RUN_URL|nightly run>" + # Deliberately NOT advancing the anchor: nothing shipped, so tonight's + # activity stays in the next real night's window (see header). + exit 0 +fi + +log "step 6 — dispatching the LIVE release ($version)" +if ! dispatch_and_await "$BUILD_REPO" "$RELEASE_WORKFLOW" \ + '{"rehearsal": "false", "minor_version": "1"}'; then + # release.yml's announce_release also pages on a live failure; this adds the + # nightly attribution so the two reports correlate. + page "live release run failed — <${LAST_RUN_URL:-$RUN_URL}|release run>" + exit 2 +fi + +notify "📦" "*nightly release shipped* — $version. $summary (window $anchor → $WINDOW_END). <${LAST_RUN_URL:-$RUN_URL}|release run>" +advance_anchor +exit 0 diff --git a/agents/conductors/release/release.sh b/agents/conductors/release/release.sh index bacec7f..e75d91c 100755 --- a/agents/conductors/release/release.sh +++ b/agents/conductors/release/release.sh @@ -36,6 +36,16 @@ if [[ "${1:-}" == "validate" ]]; then exec bash "$HERE/validate.sh" "$@" fi +# `release nightly ...` is the scheduled-nightly driver — the activity-gated, +# Heart-GREEN-gated unattended live-release path (design: +# PyAutoBuild/docs/nightly_release_design.md; standing grant: ../../AUTONOMY.md). +# Local runs default to dry-run; the cron lives in +# .github/workflows/nightly-release.yml. +if [[ "${1:-}" == "nightly" ]]; then + shift + exec bash "$HERE/nightly.sh" "$@" +fi + force=0 forward=() while [[ $# -gt 0 ]]; do diff --git a/bin/pyauto-brain b/bin/pyauto-brain index 9ad86de..33e16a0 100755 --- a/bin/pyauto-brain +++ b/bin/pyauto-brain @@ -18,7 +18,8 @@ # pyauto-brain build [args] (conductor) coordinate execution: consult vitals, run Build # pyauto-brain release [args] (conductor) release door → Build Agent release mode (gate + pre_build) # (release rehearse: Stage-2 TestPyPI rehearsal; -# release validate: full Stages 0-3 orchestrator) +# release validate: full Stages 0-3 orchestrator; +# release nightly: the scheduled-nightly driver) # pyauto-brain health [args] (conductor) the organism's clinician: run the health # loop with a human, dispatch by dispatch, toward green # pyauto-brain vitals [args] (faculty) read-only: read the Heart's pulse (the verdict) @@ -62,7 +63,7 @@ declare -A AGENT_DESC=( [refactor]="The renewal function: plan behaviour-preserving restructuring — RefactorDecision; default-safe under --auto" [profiling]="The proprioceptive function — the organism's sense of its own effort: campaign/ingest/triage plans over the autolens_profiling workspace — ProfilingDecision" [build]="Coordinate execution: consult the vitals faculty, then delegate to PyAutoBuild" - [release]="Release door → the Build Agent release mode (single gate); 'release rehearse'/'release validate' drive release validation" + [release]="Release door → the Build Agent release mode (single gate); 'release rehearse'/'release validate' drive release validation; 'release nightly' is the scheduled-nightly driver" [health]="The organism's clinician: run the health loop with a human, dispatch by dispatch, toward green" [vitals]="Read-only: read the Heart's pulse — the PyAutoHeart readiness verdict (consulted by the conductors)" [review]="Read-only: prepare the branch ReviewSurface — the reviewing agent maps it to CLEAN/FINDINGS/BLOCKED (the ship gate's review leg)" diff --git a/tests/test_activity_gate.py b/tests/test_activity_gate.py new file mode 100644 index 0000000..6a6f83d --- /dev/null +++ b/tests/test_activity_gate.py @@ -0,0 +1,100 @@ +"""tests/test_activity_gate.py — the nightly activity gate's decision logic. + +Pins the pipeline self-commit contract (design §4 of +PyAutoBuild/docs/nightly_release_design.md): both exclusion signals are +pipeline-controlled, so a drift in release.yml's git identity or commit +message prefix fails here instead of silently re-counting releases as +activity. +""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +MODULE_PATH = ( + Path(__file__).resolve().parents[1] + / "agents" / "conductors" / "release" / "activity_gate.py" +) +spec = importlib.util.spec_from_file_location("activity_gate", MODULE_PATH) +activity_gate = importlib.util.module_from_spec(spec) +sys.modules["activity_gate"] = activity_gate +spec.loader.exec_module(activity_gate) + + +def commit(message="fix: a real change", name="Jam", email="dev@example.com"): + """A minimal GitHub `GET /repos/{o}/{r}/commits` list item.""" + return { + "sha": "a" * 40, + "commit": { + "message": message, + "author": {"name": name, "email": email}, + "committer": {"name": name, "email": email}, + }, + } + + +def test_normal_commit_counts(): + assert not activity_gate.is_pipeline_commit(commit()) + v = activity_gate.judge({"PyAutoLens": [commit()]}) + assert v["active"] is True + assert v["counts"] == {"PyAutoLens": 1} + + +def test_release_message_prefix_is_excluded(): + c = commit(message="Release 2026.7.9.1: update notebooks and version") + assert activity_gate.is_pipeline_commit(c) + v = activity_gate.judge({"autolens_workspace": [c]}) + assert v["active"] is False + assert v["excluded"] == 1 + assert "self-commit" in v["summary"] + + +def test_bot_identity_is_excluded_regardless_of_message(): + for name, email in [ + ("GitHub Actions bot", "dev@example.com"), + ("github-actions[bot]", "dev@example.com"), + ("Someone", "richard@rghsoftware.co.uk"), + ]: + c = commit(message="regenerate notebooks", name=name, email=email) + assert activity_gate.is_pipeline_commit(c), (name, email) + + +def test_release_like_but_human_message_counts(): + # "Release notes" prose, not the pipeline's "Release :" stamp. + c = commit(message="Release notes cleanup for the docs page") + assert not activity_gate.is_pipeline_commit(c) + + +def test_mixed_repo_night(): + v = activity_gate.judge({ + "PyAutoFit": [commit(), commit(message="Release 2026.7.8.1: bump Colab URL tag refs")], + "HowToLens": [], + "PyAutoArray": [commit(message="perf: faster kernels")], + }) + assert v["active"] is True + assert v["counts"] == {"PyAutoArray": 1, "PyAutoFit": 1} + assert v["excluded"] == 1 + assert "PyAutoArray (1)" in v["summary"] and "PyAutoFit (1)" in v["summary"] + + +def test_empty_window_is_quiet(): + v = activity_gate.judge({r: [] for r in activity_gate.RELEASE_RELEVANT_REPOS}) + assert v["active"] is False + assert v["counts"] == {} + assert v["summary"] == "no qualifying activity" + + +def test_malformed_entries_never_crash_or_count(): + v = activity_gate.judge({"PyAutoLens": [None, "junk", {}], "bad": "not-a-list"}) + # {} is a well-formed-enough commit dict (not pipeline) — it counts; + # non-dicts never do, and a non-list repo value is ignored. + assert v["counts"] == {"PyAutoLens": 1} + + +def test_release_relevant_set_is_the_design_set(): + # Design §4: 5 libraries + 3 workspaces + 3 HowTo repos. + assert len(activity_gate.RELEASE_RELEVANT_REPOS) == 11 + for repo in ("PyAutoConf", "autolens_workspace", "HowToFit"): + assert repo in activity_gate.RELEASE_RELEVANT_REPOS