From 7ee715d5deca76b7cf5bc81b5e7ded5f052e74b7 Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Wed, 19 Aug 2026 18:35:23 -0400 Subject: [PATCH] Release board: what shipped, one-tap prompts, Pages twin (#239) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit autohands/board.py — thin collect (GitHub REST + PyPI JSON) + pure render (md / md-brief / html / json / badge) on the Heart dashboard pattern. The board is a past-tense record of execution: released versions from the version-scheme-max git tag (the tags API is not date-ordered and the tagged commit's date is not the ship date — the YYYY.M.D version string is), PyPI liveness/yanks, the release train's recent runs (failed runs carry a copyable /bug prompt with the run URL), the nightly cadence, and one-tap chips for /release, /release rehearse, /release validate, /build. Owner is derived from git remote and the library set from config/workspaces.yaml's new libraries: list (tenant firewall — no instance facts in organ code). Degraded API sections render 'unavailable', never fabricated. bin/autohands board verb; release_board.yml publishes the Pages page + badge.json + the README hands:begin/end strip after every 'PyAuto Release' run, daily, and on demand (configure-pages enablement: true creates the site on first deploy). README rewritten on the arc pattern (stale 'Formerly PyAutoBuild' banner dropped — the rename shipped 2026-07; released badge; Latest release auto-strip; How PyAutoHands works; CLI examples); AGENTS.md banner tense + verb-registry prose fixed; AI_POLICY/CONTRIBUTING moved under .github/. Co-Authored-By: Claude Fable 5 --- AI_POLICY.md => .github/AI_POLICY.md | 0 CONTRIBUTING.md => .github/CONTRIBUTING.md | 0 .github/workflows/release_board.yml | 118 +++++ AGENTS.md | 23 +- MIGRATION.md | 2 +- README.md | 73 ++- autohands/board.py | 541 +++++++++++++++++++++ autohands/config/workspaces.yaml | 10 + bin/autohands | 32 ++ tests/test_board.py | 134 +++++ 10 files changed, 896 insertions(+), 37 deletions(-) rename AI_POLICY.md => .github/AI_POLICY.md (100%) rename CONTRIBUTING.md => .github/CONTRIBUTING.md (100%) create mode 100644 .github/workflows/release_board.yml create mode 100644 autohands/board.py create mode 100644 tests/test_board.py diff --git a/AI_POLICY.md b/.github/AI_POLICY.md similarity index 100% rename from AI_POLICY.md rename to .github/AI_POLICY.md diff --git a/CONTRIBUTING.md b/.github/CONTRIBUTING.md similarity index 100% rename from CONTRIBUTING.md rename to .github/CONTRIBUTING.md diff --git a/.github/workflows/release_board.yml b/.github/workflows/release_board.yml new file mode 100644 index 00000000..a021ace3 --- /dev/null +++ b/.github/workflows/release_board.yml @@ -0,0 +1,118 @@ +name: Release Board + +# Publishes the release board — WHAT THE HANDS SHIPPED — three ways from one +# renderer (autohands/board.py, the Heart-dashboard pattern): +# * the GitHub Pages page (one-tap 📋 copy prompts for a phone), +# * badge.json (the shields endpoint the README badge reads), +# * the one-line README strip between the hands:begin/end markers. +# +# Past-tense record only: the board never renders a verdict or a gate — +# readiness lives with the Heart's board, which the page links. +# +# Refreshes right after every release-train run (workflow_run), daily as a +# backstop (tags/PyPI can change without a train run — e.g. a yank), and on +# demand. + +on: + workflow_run: + workflows: ["PyAuto Release"] + types: [completed] + schedule: + - cron: "30 5 * * *" + workflow_dispatch: + +# contents: write → the README strip self-commit; pages/id-token → publish. +permissions: + contents: write + pages: write + id-token: write + +concurrency: + group: release-board-pages + cancel-in-progress: false + +jobs: + board: + name: Render + publish the release board + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install PyYAML + run: pip install --quiet pyyaml + + - name: Collect the snapshot (GitHub + PyPI APIs) + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + PYTHONPATH="$PWD/autohands" python autohands/board.py --collect board_snapshot.json + + - name: Render every surface + run: | + mkdir -p _site + render() { PYTHONPATH="$PWD/autohands" python autohands/board.py --snapshot board_snapshot.json "$@"; } + render --html > _site/index.html + render --badge > _site/badge.json + render --md > board.md + render --md-brief > readme_strip.md + + - name: Write the board to the job step summary + run: | + { + cat board.md + } >> "$GITHUB_STEP_SUMMARY" + + - name: Update the README strip (own repo only, main only) + if: github.ref == 'refs/heads/main' + run: | + python - <<'PY' + import pathlib, re + readme = pathlib.Path("README.md") + text = readme.read_text() + strip = pathlib.Path("readme_strip.md").read_text().strip() + begin, end = "", "" + block = f"{begin}\n{strip}\n{end}" + if begin in text and end in text: + text = re.sub(re.escape(begin) + r".*?" + re.escape(end), block, + text, flags=re.DOTALL) + readme.write_text(text) + PY + if ! git diff --quiet README.md; then + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add README.md + git commit -m "docs(hands): auto-update release-board strip [skip ci]" + # Rebuild on the tip if a concurrent push landed; the strip is + # regenerated content, so retrying on the new tip is always safe. + for attempt in 1 2 3; do + if git push; then exit 0; fi + git pull --rebase origin main || exit 1 + done + echo "::error::could not push the README strip after 3 attempts" + exit 1 + else + echo "README strip unchanged — nothing to commit." + fi + + # enablement: true creates the Pages site on first run (no manual + # Settings → Pages step) — same shape as the Mind's publisher. + - uses: actions/configure-pages@v5 + if: github.ref == 'refs/heads/main' + with: + enablement: true + + - uses: actions/upload-pages-artifact@v3 + if: github.ref == 'refs/heads/main' + with: + path: _site + + - id: deployment + uses: actions/deploy-pages@v4 + if: github.ref == 'refs/heads/main' diff --git a/AGENTS.md b/AGENTS.md index 29b67a82..bcf0071b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,11 +1,8 @@ # PyAutoHands — Agent Guidance -> **Formerly PyAutoBuild.** This repository is being renamed PyAutoBuild → -> PyAutoHands (see [MIGRATION.md](MIGRATION.md)). The `autohands` CLI and Python -> package keep their names for now; only the repository/branding changes. The -> "Build" organ shorthand and the canonical `Brain → Heart (gate) → Build -> (execute)` call chain are updated at their source (`PyAutoBrain/ORGANISM.md`) -> in a later phase. +(The repo was renamed PyAutoBuild → PyAutoHands in 2026-07; the `autohands` +CLI/package name and the *Build* call-chain shorthand were kept — see +[MIGRATION.md](MIGRATION.md) and `PyAutoBrain/ORGANISM.md`.) PyAutoHands is the **executor** (the Hands) of the PyAuto release ecosystem: packaging, tagging, notebook generation, and PyPI publication via `release.yml`. @@ -27,12 +24,14 @@ deep `verify_install` suite, and URL hygiene all live in PyAutoHeart now; `autohands verify_install` / `autohands url_check` / `autohands watch|status| tick|fix` are thin shims that delegate to `pyauto-heart`. Build keeps only the executor primitives: the build/notebook pipeline (`pre_build`, `generate*`, -`run_all` / `run*`), the navigator catalogue (`navigator` / -`check_navigator` / `regenerate_navigator`), tagging + release -(`tag_and_merge`, `bump_colab_urls`, `release.yml`), the release-notes and -Slack tooling (`generate_release_notes`, `slack_release_notes`), assistant -seeding (`clone_seed`), and `repro_command`. See `docs/internals.md` for the -authoritative, current list. +`run_all` / `run*`), the navigator catalogue (the `navigator.py` / +`check_navigator.py` / `regenerate_navigator.py` modules — workflow-invoked, +not CLI verbs), tagging + release (`tag_and_merge`, `bump_colab_urls`, +`release.yml`), the release-notes and Slack tooling +(`generate_release_notes`, `slack_release_notes`), the release board +(`board`, published by `release_board.yml`), assistant seeding +(`clone_seed`), and `repro_command`. `bin/autohands help` is the registry of +what is a CLI verb; see `docs/internals.md` for the pipeline detail. See [`docs/internals.md`](docs/internals.md) for the build pipeline, workspace folder structure, config files, and `release.yml` details. Read it when diff --git a/MIGRATION.md b/MIGRATION.md index cd6b6e25..dd201842 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1,6 +1,6 @@ # Migrating from PyAutoBuild to PyAutoHands -The **Hands** organ of the PyAuto organism is being renamed at the repository +The **Hands** organ of the PyAuto organism was renamed (2026-07) at the repository level: **PyAutoBuild → PyAutoHands**. The name change makes the architecture read as a living organism — *the Brain decides, the Hands do* — so this repo's brand matches its role: **PyAutoHands executes work on behalf of PyAutoBrain**. diff --git a/README.md b/README.md index 6aef7bd1..a0b6bfb2 100644 --- a/README.md +++ b/README.md @@ -4,36 +4,61 @@ # PyAutoHands -> **Formerly PyAutoBuild.** The repository is being renamed PyAutoBuild → -> PyAutoHands; see [MIGRATION.md](MIGRATION.md). The `autohands` command and -> Python package keep their names for now — only the repository and its -> branding change. - [![PyAutoScientist GitHub](https://img.shields.io/badge/%F0%9F%A7%AA%20PyAutoScientist-GitHub-181717?style=flat-square)](https://github.com/PyAutoLabs/PyAutoScientist) [![PyAutoScientist ReadTheDocs](https://img.shields.io/badge/%F0%9F%93%96%20PyAutoScientist-ReadTheDocs-8CA1AF?style=flat-square)](https://pyautoscientist.readthedocs.io) -PyAutoHands is the **Hands** of the PyAuto organism: the executor that packages, -tags, builds notebooks, and releases the PyAuto libraries (PyAutoNerves, PyAutoFit, -PyAutoArray, PyAutoGalaxy, PyAutoLens) and their workspaces to PyPI. **PyAutoHands -executes work on behalf of PyAutoBrain** — the Brain decides, the Hands do. It -runs no readiness checks and makes no gate decisions — those belong to -[PyAutoHeart](https://github.com/PyAutoLabs/PyAutoHeart), whose verdict the -[PyAutoBrain](https://github.com/PyAutoLabs/PyAutoBrain) release agent -reads before dispatching a release here. +[![released](https://img.shields.io/endpoint?url=https://pyautolabs.github.io/PyAutoHands/badge.json)](https://pyautolabs.github.io/PyAutoHands/) + +**PyAutoHands is the Hands of the PyAutoScientist** — the executor that ships +the software. When a release is dispatched it packages, tags, regenerates +notebooks, and publishes the PyAuto libraries and their workspaces to PyPI. +It executes on behalf of the Brain and never decides for itself: no readiness +checks, no gate decisions — those belong to the Heart. + +See the **[PyAutoHands Release Board](https://pyautolabs.github.io/PyAutoHands/)** +(mobile phone dashboard) for what shipped: the released library versions and +their PyPI status, the release train's recent runs, and the nightly cadence — +each actionable item carrying a one-tap 📋 button that copies a ready-made +Claude command (`/release`, `/release rehearse`, `/release validate`, +`/build`; a failed train run copies a `/bug …` prompt with its run link). + +## Latest release + + + + + -Every operation is reachable through one dispatcher: +## How PyAutoHands works + +1. **The Brain decides, the Hands execute.** A release is dispatched by the + Brain's release conductor (`/release`, or the nightly driver when there is + new activity) — and only when the Heart's readiness verdict is GREEN. +2. **Rehearse first.** `release.yml` builds the five libraries, publishes to + TestPyPI, installs the wheels, and runs the test + workspace validation + suites against them — a full dress rehearsal before anything is public. +3. **Then ship.** On success the same workflow stamps the build tree, cuts + the `YYYY.M.D.minor` git tag on every library, and releases to PyPI. +4. **The workspaces follow.** Notebooks are regenerated from the workspace + scripts, Colab URLs bumped to the new tag, and every workspace repo is + tagged to match. +5. **The record is published.** Release notes land on the libraries' GitHub + Releases, Slack is told, and the [release board](https://pyautolabs.github.io/PyAutoHands/) + refreshes — a past-tense record of execution, never a verdict. + +## CLI examples + +Every operation is reachable through one dispatcher, run from this checkout +(no pip install): ```bash bash bin/autohands help # list every subcommand -bash bin/autohands help # full docstring for one -bash bin/autohands pre_build [minor] # format, generate notebooks, bump, push +bash bin/autohands pre_build [minor] # format, generate notebooks, push, dispatch release.yml bash bin/autohands run_all # run the workspace validation scripts +bash bin/autohands board --md # the release board (also --html, --badge, --json) ``` -The release pipeline (`.github/workflows/release.yml`) packages to -TestPyPI, verifies the install, runs the workspace scripts, and on success -releases to PyPI and tags the workspaces — nightly, when there is new -activity to ship. - -Boundary and agent guidance: [AGENTS.md](AGENTS.md). The organism: -[PyAutoBrain/ORGANISM.md](https://github.com/PyAutoLabs/PyAutoBrain/blob/main/ORGANISM.md), -documented in full at . +Boundary and agent guidance: [AGENTS.md](AGENTS.md); the pipeline internals: +[docs/internals.md](docs/internals.md). The organism this repo is the Hands +of is described once in +[PyAutoBrain/ORGANISM.md](https://github.com/PyAutoLabs/PyAutoBrain/blob/main/ORGANISM.md) +and documented in full at . diff --git a/autohands/board.py b/autohands/board.py new file mode 100644 index 00000000..961ec2df --- /dev/null +++ b/autohands/board.py @@ -0,0 +1,541 @@ +"""autohands/board.py — the PyAutoHands release board. + +A phone-readable record of **what the Hands shipped**: the released library +versions (from git tags — the authoritative record; version stamps are +build-tree-only and never committed), their PyPI liveness, the release train's +recent runs, and the nightly driver's outcomes. Every actionable item carries +a one-tap 📋 copy block holding the Claude Code command that drives it +(``/release``, ``/release rehearse``, ``/release validate``, ``/build``; a +failed train run copies a ``/bug`` prompt with its run URL). + +**Boundary.** Hands is a pure executor, so this board is a *past-tense record +of execution* — never a verdict, a score, or a gate. "Is it safe to release?" +lives with the Heart, whose board this page links. + +**Shape.** ``collect()`` is the only I/O (GitHub REST via ``gh api`` + the +PyPI JSON API) and degrades per-section: a source that cannot be fetched +renders as "unavailable", never as fabricated data. ``render(snapshot, fmt)`` +is pure — ``fmt = md | md-brief | html | json | badge`` — mirroring the +Heart's ``dashboard.py`` (one renderer, many surfaces). The GitHub owner is +derived from ``git remote`` and the library set from +``config/workspaces.yaml`` — organ code carries no instance facts (the +tenant firewall). + +Published by ``.github/workflows/release_board.yml``: the Pages page + +``badge.json`` after every "PyAuto Release" run and daily, plus the README +strip between the ``hands:begin/end`` markers. + +Usage: + python -m autohands.board --collect snapshot.json # gather, write, exit + python -m autohands.board [--snapshot F] --md|--md-brief|--html|--json|--badge +""" + +from __future__ import annotations + +import datetime +import html as _html +import json +import re +import subprocess +import sys +import urllib.error +import urllib.request +from pathlib import Path + +HANDS_HOME = Path(__file__).resolve().parents[1] +CONFIG_PATH = Path(__file__).resolve().parent / "config" / "workspaces.yaml" + +SCHEMA_VERSION = 1 +TRAIN_RUNS_SHOWN = 14 +NIGHTLY_RUNS_SHOWN = 10 + +# The one-tap chips: the Claude Code doors that drive the Hands. Payload is +# what 📋 copies into the clipboard, ready to paste into a Claude chat. +ACTION_CHIPS = ( + ("release", "/release"), + ("rehearse", "/release rehearse"), + ("validate", "/release validate"), + ("build", "/build"), +) + + +# --- identity (derived, never hardcoded — tenant firewall) ------------------- +def _owner_repo() -> tuple[str, str]: + """(owner, repo) from this checkout's origin URL (https or ssh).""" + out = subprocess.run( + ["git", "-C", str(HANDS_HOME), "remote", "get-url", "origin"], + capture_output=True, text=True, + ).stdout.strip() + return parse_owner_repo(out) + + +def parse_owner_repo(url: str) -> tuple[str, str]: + m = re.search(r"[:/]([^/:]+)/([^/]+?)(?:\.git)?/?$", url) + if not m: + return "", "" + return m.group(1), m.group(2) + + +def _libraries() -> list[dict]: + """The released library set from the declared config surface.""" + import yaml + + cfg = yaml.safe_load(CONFIG_PATH.read_text()) or {} + libs = cfg.get("libraries") or [] + return [ + {"name": str(l["name"]), "package": str(l["package"])} + for l in libs + if isinstance(l, dict) and l.get("name") and l.get("package") + ] + + +# --- collect (the only I/O) --------------------------------------------------- +def _gh_api(path: str) -> object: + res = subprocess.run(["gh", "api", path], capture_output=True, text=True, + timeout=60) + if res.returncode != 0: + raise RuntimeError(res.stderr.strip().splitlines()[-1] if res.stderr else "gh api failed") + return json.loads(res.stdout) + + +def _pypi_status(package: str, version: str) -> str: + """'live' | 'yanked' | 'missing' for one released version on PyPI.""" + try: + with urllib.request.urlopen( + f"https://pypi.org/pypi/{package}/json", timeout=30 + ) as resp: + data = json.load(resp) + except (urllib.error.URLError, TimeoutError, ValueError) as e: + raise RuntimeError(f"pypi {package}: {e}") from e + files = (data.get("releases") or {}).get(version) + if not files: + return "missing" + if all(f.get("yanked") for f in files): + return "yanked" + return "live" + + +# The release version scheme: YYYY.M.D.minor (+ optional .attempt). The tags +# API is NOT date-ordered, and the tagged commit's date is the last commit +# before the release, not the release itself — the version string is the +# authoritative ship date, so both come from parsing it. +_VERSION_RE = re.compile(r"^(\d{4})\.(\d{1,2})\.(\d{1,2})\.(\d+)(?:\.(\d+))?$") + + +def _version_key(tag_name: str) -> tuple | None: + m = _VERSION_RE.match(tag_name) + if not m: + return None + return tuple(int(g) if g else 0 for g in m.groups()) + + +def version_date(version: str) -> str | None: + """The ship date a YYYY.M.D.* version encodes, as ISO, else None.""" + key = _version_key(version) + if not key: + return None + try: + return datetime.date(key[0], key[1], key[2]).isoformat() + except ValueError: + return None + + +def _collect_library(owner: str, lib: dict) -> dict: + name, package = lib["name"], lib["package"] + entry = {"name": name, "package": package, "version": None, "date": None, + "pypi": "unavailable", "tag_url": None} + tags = _gh_api(f"repos/{owner}/{name}/tags?per_page=100") + versioned = [(k, str(t["name"])) for t in tags or [] + if (k := _version_key(str(t.get("name") or ""))) is not None] + if not versioned: + return entry + _, version = max(versioned) + entry["version"] = version + entry["date"] = version_date(version) + entry["tag_url"] = f"https://github.com/{owner}/{name}/releases/tag/{version}" + try: + entry["pypi"] = _pypi_status(package, version) + except RuntimeError: + entry["pypi"] = "unavailable" + return entry + + +def _collect_runs(owner: str, repo: str, workflow: str, limit: int) -> list[dict]: + data = _gh_api( + f"repos/{owner}/{repo}/actions/workflows/{workflow}/runs?per_page={limit}" + ) + runs = [] + for r in data.get("workflow_runs") or []: + created = str(r.get("created_at") or "") + updated = str(r.get("updated_at") or "") + # run_started_at restarts on re-attempts, so the duration stays the + # attempt's own, not created→updated across a multi-day gap. + started = str(r.get("run_started_at") or created) + duration = None + t0, t1 = _parse_ts(started), _parse_ts(updated) + if t0 and t1: + duration = int((t1 - t0).total_seconds()) + runs.append({ + "date": created, + "status": str(r.get("status") or ""), + "conclusion": str(r.get("conclusion") or ""), + "event": str(r.get("event") or ""), + "attempt": int(r.get("run_attempt") or 1), + "duration_s": duration, + "url": str(r.get("html_url") or ""), + }) + return runs + + +def collect() -> dict: + """Gather the snapshot. Degrades per-section; never raises.""" + owner, repo = _owner_repo() + snapshot: dict = { + "schema_version": SCHEMA_VERSION, + "generated": datetime.datetime.now(datetime.timezone.utc).isoformat(), + "owner": owner, + "repo": repo, + "libraries": [], + "train": [], + "nightly": [], + "errors": [], + } + if not owner: + snapshot["errors"].append("could not derive owner from git remote") + return snapshot + for lib in _libraries(): + try: + snapshot["libraries"].append(_collect_library(owner, lib)) + except (RuntimeError, ValueError, KeyError) as e: + snapshot["errors"].append(f"library {lib['name']}: {e}") + try: + snapshot["train"] = _collect_runs(owner, repo, "release.yml", + TRAIN_RUNS_SHOWN) + except (RuntimeError, ValueError) as e: + snapshot["errors"].append(f"release runs: {e}") + try: + snapshot["nightly"] = _collect_runs(owner, "PyAutoBrain", + "nightly-release.yml", + NIGHTLY_RUNS_SHOWN) + except (RuntimeError, ValueError) as e: + snapshot["errors"].append(f"nightly runs: {e}") + return snapshot + + +# --- pure helpers ------------------------------------------------------------- +def _parse_ts(ts: object) -> datetime.datetime | None: + try: + t = datetime.datetime.fromisoformat(str(ts).replace("Z", "+00:00")) + except (TypeError, ValueError): + return None + return t.replace(tzinfo=datetime.timezone.utc) if t.tzinfo is None else t + + +def _age(ts: object, now: datetime.datetime | None = None) -> str: + t = _parse_ts(ts) + if t is None: + return "unknown" + ref = now or datetime.datetime.now(datetime.timezone.utc) + s = (ref - t).total_seconds() + if s < 3600: + return f"{max(0, int(s // 60))}m ago" + if s < 86400: + return f"{int(s // 3600)}h ago" + return f"{int(s // 86400)}d ago" + + +def _day(ts: object) -> str: + t = _parse_ts(ts) + return t.strftime("%Y-%m-%d") if t else "?" + + +def _dur(seconds: object) -> str: + if not isinstance(seconds, int) or seconds < 0: + return "?" + return f"{seconds // 60}m{seconds % 60:02d}s" + + +def pages_url(snapshot: dict) -> str: + owner = str(snapshot.get("owner") or "").lower() + repo = snapshot.get("repo") or "" + return f"https://{owner}.github.io/{repo}/" if owner and repo else "" + + +def _heart_board_url(snapshot: dict) -> str: + owner = str(snapshot.get("owner") or "").lower() + return f"https://{owner}.github.io/PyAutoHeart/" if owner else "" + + +def _latest(snapshot: dict) -> dict | None: + """The headline: the newest released version across the library set.""" + libs = [(k, i, l) for i, l in enumerate(snapshot.get("libraries") or []) + if (k := _version_key(str(l.get("version") or ""))) is not None] + if not libs: + return None + return max(libs)[2] + + +def _last_train(snapshot: dict) -> dict | None: + for r in snapshot.get("train") or []: + if r.get("status") == "completed": + return r + return None + + +def _bug_prompt(snapshot: dict, run: dict) -> str: + return (f"/bug Release train: {snapshot.get('repo') or 'release'} " + f"release.yml run failed on {_day(run.get('date'))} — {run.get('url')}") + + +# --- renderers ---------------------------------------------------------------- +def _render_md(snapshot: dict) -> str: + lines = ["# PyAutoHands release board", "", + "_What the Hands shipped — a record of execution. Whether it is " + "safe to release lives with the Heart._", ""] + latest = _latest(snapshot) + if latest: + lines.append(f"**Latest release:** `{latest['version']}` " + f"({_day(latest.get('date'))}, {_age(latest.get('date'))})") + else: + lines.append("**Latest release:** unavailable") + lines.append("") + lines += ["| Library | Version | Shipped | PyPI |", "|---|---|---|---|"] + for l in snapshot.get("libraries") or []: + ver = f"[{l['version']}]({l['tag_url']})" if l.get("tag_url") else (l.get("version") or "?") + lines.append(f"| {l['name']} | {ver} | {_day(l.get('date'))} | {l.get('pypi', '?')} |") + if not snapshot.get("libraries"): + lines.append("| _(library data unavailable)_ | | | |") + lines += ["", "## Release train", ""] + for r in (snapshot.get("train") or [])[:TRAIN_RUNS_SHOWN]: + mark = ("✓" if r.get("conclusion") == "success" + else "✗" if r.get("conclusion") else "…") + lines.append(f"- {mark} [{_day(r.get('date'))}]({r.get('url')}) " + f"{r.get('conclusion') or r.get('status')} ({_dur(r.get('duration_s'))})") + if not snapshot.get("train"): + lines.append("- _(run history unavailable)_") + if snapshot.get("errors"): + lines += ["", "_Sections unavailable this render: " + + "; ".join(snapshot["errors"]) + "_"] + url = pages_url(snapshot) + if url: + lines += ["", f"[Release board]({url}) · [Health board]({_heart_board_url(snapshot)})"] + return "\n".join(lines) + + +def _render_md_brief(snapshot: dict) -> str: + """The README strip: one line, no heading (the README supplies it).""" + latest = _latest(snapshot) + last = _last_train(snapshot) + bits = [] + if latest: + bits.append(f"📦 **{latest['version']}** · shipped {_day(latest.get('date'))} " + f"({_age(latest.get('date'))})") + else: + bits.append("📦 latest release unavailable") + if last: + mark = "✓" if last.get("conclusion") == "success" else "✗" + bits.append(f"last train run {mark} [{_day(last.get('date'))}]({last.get('url')})") + url = pages_url(snapshot) + if url: + bits.append(f"[release board →]({url})") + return " · ".join(bits) + + +def _copy_btn(payload: str, label: str = "copy") -> str: + return (f"") + + +_PYPI_CLS = {"live": "ok", "yanked": "fail", "missing": "warn", "unavailable": "unobs"} + + +def _render_html(snapshot: dict) -> str: + latest = _latest(snapshot) + head = (f"{_html.escape(latest['version'])}" if latest else "unavailable") + head_age = _age(latest.get("date")) if latest else "" + chips = " ".join( + f"{_html.escape(label)} {_copy_btn(payload, f'copy {payload}')}" + for label, payload in ACTION_CHIPS + ) + lib_rows = [] + for l in snapshot.get("libraries") or []: + cls = _PYPI_CLS.get(str(l.get("pypi")), "unobs") + ver = (f"" + f"{_html.escape(str(l.get('version')))}" + if l.get("tag_url") else "?") + lib_rows.append( + f"" + f"{_html.escape(l['name'])}" + f"{ver}{_day(l.get('date'))}" + f"{_html.escape(str(l.get('pypi')))}" + ) + if not lib_rows: + lib_rows.append("" + "libraries" + "unavailable this render") + train_rows = [] + for r in (snapshot.get("train") or [])[:TRAIN_RUNS_SHOWN]: + ok = r.get("conclusion") == "success" + cls = "ok" if ok else ("unobs" if not r.get("conclusion") else "fail") + cell = (f"" + f"{_day(r.get('date'))} {_html.escape(r.get('conclusion') or r.get('status') or '?')}" + f" ({_dur(r.get('duration_s'))})") + if not ok and r.get("conclusion"): + cell += " " + _copy_btn(_bug_prompt(snapshot, r), + "copy a fix prompt for a Claude Code chat") + train_rows.append(f"" + f"{cell}") + if not train_rows: + train_rows.append("" + "run history unavailable this render") + nightly = "" + if snapshot.get("nightly"): + dots = " ".join( + f"" + for n in snapshot["nightly"][:NIGHTLY_RUNS_SHOWN] + ) + nightly = (f"

nightly driver, newest first: " + f"{dots}

") + errors = "" + if snapshot.get("errors"): + items = "".join(f"
  • {_html.escape(e)}
  • " for e in snapshot["errors"]) + errors = (f"

    unavailable this " + f"render:

      {items}
    ") + heart = _heart_board_url(snapshot) + return f""" + + +PyAuto releases — {head} + + +
    +

    PyAuto release board

    +

    {head} {head_age}

    +

    What the Hands shipped — a record of execution, newest first. + Whether it is safe to release lives with the + Heart's health board.

    +

    {chips}

    +

    📋 copies the command into your clipboard, ready to paste + into a Claude Code chat.

    +

    Libraries

    + {''.join(lib_rows)}
    +

    Release train

    + {''.join(train_rows)}
    + {nightly} + {errors} +
    Rendered by autohands/board.py from the GitHub + PyPI + APIs · generated {_html.escape(str(snapshot.get('generated') or '?'))} · + Hands executes, never gates.
    +
    +""" + + +def badge_endpoint(snapshot: dict) -> dict: + latest = _latest(snapshot) + if not latest: + return {"schemaVersion": 1, "label": "released", + "message": "unknown", "color": "lightgrey"} + return {"schemaVersion": 1, "label": "released", + "message": f"{latest['version']} · {_age(latest.get('date'))}", + "color": "blue"} + + +def render(snapshot: dict, fmt: str = "md") -> str: + if fmt == "md": + return _render_md(snapshot) + if fmt == "md-brief": + return _render_md_brief(snapshot) + if fmt == "html": + return _render_html(snapshot) + if fmt == "json": + return json.dumps({**snapshot, "pages_url": pages_url(snapshot)}, + indent=2, sort_keys=True) + if fmt == "badge": + return json.dumps(badge_endpoint(snapshot)) + raise ValueError(f"unknown board fmt: {fmt!r}") + + +# --- CLI ---------------------------------------------------------------------- +def main(argv: list[str] | None = None) -> int: + import argparse + + ap = argparse.ArgumentParser(prog="autohands board", description=__doc__) + g = ap.add_mutually_exclusive_group() + g.add_argument("--md", action="store_true", help="markdown board (default)") + g.add_argument("--md-brief", action="store_true", help="the README strip") + g.add_argument("--html", action="store_true", help="the Pages page") + g.add_argument("--json", action="store_true", help="the machine surface") + g.add_argument("--badge", action="store_true", help="shields.io endpoint JSON") + ap.add_argument("--collect", metavar="OUT", default=None, + help="collect a snapshot to OUT (json) and exit") + ap.add_argument("--snapshot", metavar="F", default=None, + help="render from a previously collected snapshot file") + ns = ap.parse_args(argv) + + if ns.collect: + snap = collect() + Path(ns.collect).write_text(json.dumps(snap, indent=2, sort_keys=True) + "\n") + print(f"collected → {ns.collect} ({len(snap['libraries'])} libraries, " + f"{len(snap['train'])} train runs, {len(snap['errors'])} error(s))", + file=sys.stderr) + return 0 + + snap = (json.loads(Path(ns.snapshot).read_text()) if ns.snapshot else collect()) + fmt = "md" + for name, label in (("md", "md"), ("md_brief", "md-brief"), + ("html", "html"), ("json", "json"), ("badge", "badge")): + if getattr(ns, name): + fmt = label + break + print(render(snap, fmt)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/autohands/config/workspaces.yaml b/autohands/config/workspaces.yaml index 698900a4..e71c2161 100644 --- a/autohands/config/workspaces.yaml +++ b/autohands/config/workspaces.yaml @@ -17,6 +17,16 @@ run_all: howtolens: {repo: HowToLens, report: howtolens} euclid: {repo: euclid_strong_lens_modeling_pipeline, report: euclid} +# libraries: the released library set (release.yml's matrix order). The +# release board (autohands/board.py) reads this to render versions and PyPI +# status; name = repo, package = the import/PyPI name. +libraries: + - {name: PyAutoNerves, package: autonerves} + - {name: PyAutoFit, package: autofit} + - {name: PyAutoArray, package: autoarray} + - {name: PyAutoGalaxy, package: autogalaxy} + - {name: PyAutoLens, package: autolens} + # slow_skip_check: the default audit targets when none are given on the CLI. slow_skip_default: - autofit_workspace diff --git a/bin/autohands b/bin/autohands index b7acedd5..e87221bc 100755 --- a/bin/autohands +++ b/bin/autohands @@ -35,6 +35,7 @@ SUBCOMMAND_ORDER=( run_python run_all "# Release support" + board script_matrix aggregate_results slow_skip_check @@ -61,6 +62,7 @@ declare -A SHORT_DESC=( [run]="Execute notebooks in a workspace folder" [run_python]="Execute Python scripts in a workspace folder" [run_all]="Run scripts across one or more workspaces and produce summary reports" + [board]="The release board — what shipped (versions, train runs); --md/--md-brief/--html/--json/--badge" [script_matrix]="Output a JSON matrix of {name, directory} for GitHub Actions" [aggregate_results]="Aggregate per-job JSON results into a release-readiness report" [slow_skip_check]="Surface SLOW / NEEDS_FIX entries in workspace no_run.yaml files" @@ -269,6 +271,36 @@ Used by release.yml to dynamically generate the run_scripts and run_notebooks job matrices. EOF } +help_board() { cat <") + # No external ASSETS: no src=, no , no fetches; inline