Skip to content

Nightly Release

Nightly Release #44

name: Nightly Release
# The scheduled-nightly release driver (PyAutoHands/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 PyAutoHands'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'.
# ARMED 2026-07-09 (human-directed, PyAutoBuild#127): the manual live release
# 2026.7.9.1 succeeded and the human flipped the default the same day —
# checklist steps 2-3 were consciously waived; open release-blocker issues
# (e.g. PyAutoBuild#126) still stop every night at step 3 until closed or
# de-labelled. Pausing is one act: unset NIGHTLY_RELEASES.
#
# OUTCOME CONTRACT (2026-08-04). The driver's exit codes already distinguished
# "a gate stopped the night" from "the driver broke", but this workflow flattened
# every non-zero into a red run — so eight consecutive nights of the gate working
# correctly looked identical to a broken driver, and the channel became one
# nobody watched. The mapping is now explicit:
#
# exit 0 shipped / skipped / dry-run -> job SUCCESS
# exit 2|3 blocked at a gate, no release -> job SUCCESS + ::warning:: + the
# "Blocked at a gate" step below
# exit 1|* driver error, night NOT judged -> job FAILURE
#
# Red is therefore reserved for "the driver itself is broken" — the one state
# that needs a human to look at THIS workflow. A blocked night is a normal,
# expected outcome: Slack carries which gate stopped it, and the run keeps a
# named step + job summary so it is never silently green.
#
# `bin/overnight_status.sh` reads that step name to report a blocked night
# distinctly in the morning glance — keep the step name in sync if it changes.
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: "false"
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
id: 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 'false' — ARMED 2026-07-09
# (design §9; the kill switch is the NIGHTLY_RELEASES repo var).
DRY_RUN: ${{ inputs.dry_run || 'false' }}
PYAUTO_ROOT: ${{ github.workspace }}
# `set +e` first: the default shell is `bash -e`, which would abort on
# the driver's exit code before it can be classified (see OUTCOME
# CONTRACT above). This step never fails — the two steps below decide.
run: |
set +e
bash agents/conductors/release/nightly.sh
rc=$?
set -e
echo "rc=$rc" >> "$GITHUB_OUTPUT"
case "$rc" in
0) echo "outcome=reported" >> "$GITHUB_OUTPUT" ;;
2|3) echo "outcome=blocked" >> "$GITHUB_OUTPUT" ;;
*) echo "outcome=driver-error" >> "$GITHUB_OUTPUT" ;;
esac
# Named, not just annotated: overnight_status.sh keys the morning glance
# off this step, so a blocked night is never reported as a plain green.
- name: Blocked at a gate — no release was made
if: steps.driver.outputs.outcome == 'blocked'
run: |
echo "::warning title=Nightly release blocked::The driver stopped at a gate (exit ${{ steps.driver.outputs.rc }}); no release was made. This is the gate working — Slack carries which one."
{
echo "## ⏸ Blocked at a gate — no release was made"
echo
echo "The driver stopped deliberately (exit \`${{ steps.driver.outputs.rc }}\`):"
echo "\`2\` = a gate blocked the night, \`3\` = readiness was not GREEN."
echo
echo "This is the gate doing its job, not a driver fault, so the run is"
echo "green. The Slack page names which gate stopped it."
} >> "$GITHUB_STEP_SUMMARY"
- name: Driver error — the night was NOT judged
if: steps.driver.outputs.outcome == 'driver-error'
run: |
echo "::error title=Nightly driver error::The driver failed with exit ${{ steps.driver.outputs.rc }} — the night was NOT judged and no gate verdict exists."
{
echo "## 🚨 Driver error — the night was NOT judged"
echo
echo "\`nightly.sh\` exited \`${{ steps.driver.outputs.rc }}\`, which is not a gate"
echo "outcome. No release was made AND no gate verdict was reached —"
echo "this workflow needs a human."
} >> "$GITHUB_STEP_SUMMARY"
exit 1