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
75 changes: 75 additions & 0 deletions .github/workflows/nightly-release.yml
Original file line number Diff line number Diff line change
@@ -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
21 changes: 16 additions & 5 deletions agents/_common.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
Expand Down
116 changes: 116 additions & 0 deletions agents/conductors/release/activity_gate.py
Original file line number Diff line number Diff line change
@@ -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 <version>: …".
# 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())
Loading