From 42a083917ecdf956d4962b236174663064cb4749 Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Fri, 23 Oct 2026 14:39:14 +0100 Subject: [PATCH 1/3] feat(heart): token push check for the pyautolabs-bot App release identity New scheduled/dispatchable token_push_check.yml mints a pyautolabs-bot App token scoped to the five release libraries and runs heart/checks/token_push.py: git ls-remote + git push --dry-run per library (no pack sent, no ref created). The result is committed as state/token_push.json, folded into the snapshot by state.aggregate and graded by readiness: a refused push is RED, a missing, partial or >48 h sidecar is STALE (with dashboard remedies). Refs PyAutoLabs/PyAutoHands#321 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/token_push_check.yml | 99 +++++++++++++++++++ heart/checks/token_push.py | 128 +++++++++++++++++++++++++ heart/dashboard.py | 9 ++ heart/readiness.py | 39 +++++++- heart/state.py | 10 ++ tests/test_readiness.py | 67 +++++++++++++ tests/test_token_push.py | 68 +++++++++++++ 7 files changed, 419 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/token_push_check.yml create mode 100644 heart/checks/token_push.py create mode 100644 tests/test_token_push.py diff --git a/.github/workflows/token_push_check.yml b/.github/workflows/token_push_check.yml new file mode 100644 index 0000000..b766afe --- /dev/null +++ b/.github/workflows/token_push_check.yml @@ -0,0 +1,99 @@ +name: Token Push Check + +# Proves, ahead of any release, that the org-owned `pyautolabs-bot` GitHub App +# — the identity PyAutoHands release.yml tags the libraries with — can still +# push to all five release libraries. It mints a one-hour App token scoped to +# those five, installs it as git's credential helper and runs +# heart/checks/token_push.py: `git ls-remote` + `git push --dry-run` per +# library. --dry-run sends no pack and creates no ref, so nothing is pushed. +# +# The result is committed to Heart's own repo as state/token_push.json (the +# sidecar `state.aggregate` folds in and `readiness` grades: a refused push is +# RED, a missing or >48 h old sidecar is STALE). The job fails after the commit +# when any push was refused, so the Actions tab shows red too. +# +# Observer boundary: the only write is that sidecar commit to PyAutoHeart. + +on: + schedule: + # Daily 04:30 UTC — ahead of the 05:00 heart-health board, which reads it. + - cron: "30 4 * * *" + workflow_dispatch: + +# contents: write → commit the sidecar into Heart's OWN repo (GITHUB_TOKEN). +# The library probes use the App token only. +permissions: + contents: write + +concurrency: + group: token-push-check + cancel-in-progress: false + +jobs: + token-push: + name: App push credential (dry-run) + runs-on: ubuntu-latest + steps: + - name: Checkout PyAutoHeart + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Mint App token (release libraries, contents write) + id: app_token + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ vars.APP_CLIENT_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + owner: PyAutoLabs + repositories: PyAutoFit,PyAutoArray,PyAutoGalaxy,PyAutoLens,PyAutoNerves + permission-contents: write + + - name: Probe ls-remote + push --dry-run + env: + GH_TOKEN: ${{ steps.app_token.outputs.token }} + APP_SLUG: ${{ steps.app_token.outputs.app-slug }} + run: | + # gh as git's credential helper for github.com (reads GH_TOKEN). The + # probes run in a throwaway repo outside this checkout, so checkout's + # persisted GITHUB_TOKEN header (local to PyAutoHeart/.git/config) + # never reaches them. + gh auth setup-git + PYTHONPATH="$PWD" python -m heart.checks.token_push --out state/token_push.json + # The commit below pushes Heart with checkout's GITHUB_TOKEN; drop + # the App helper so it plays no part in that. + git config --global --unset-all credential.https://github.com.helper || true + + - name: Commit the sidecar (own repo only) + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add state/token_push.json + if git diff --cached --quiet; then + echo "token_push.json unchanged — nothing to commit." + exit 0 + fi + git commit -m "chore(heart): token push check sidecar [skip ci]" + # Other Heart writers (heart-health) may land first: rebase and retry. + for attempt in 1 2 3; do + if git push origin "HEAD:${GITHUB_REF_NAME}"; then + exit 0 + fi + git pull --rebase origin "${GITHUB_REF_NAME}" + done + echo "::error::could not push the token_push sidecar after 3 attempts" + exit 1 + + - name: Fail on any refused push + run: | + python3 - <<'PY' + import json, sys + data = json.load(open("state/token_push.json")) + bad = [r for r, row in data["repos"].items() if row.get("push") is not True] + for r in bad: + print(f"::error::{data['identity']} cannot push to PyAutoLabs/{r}: {data['repos'][r].get('error')}") + sys.exit(1 if bad else 0) + PY diff --git a/heart/checks/token_push.py b/heart/checks/token_push.py new file mode 100644 index 0000000..5d0ca0a --- /dev/null +++ b/heart/checks/token_push.py @@ -0,0 +1,128 @@ +"""Can the release identity push to the five release libraries? + +The PyAutoHands release job tags each library as the org-owned +``pyautolabs-bot`` GitHub App. This check proves that credential BEFORE a +release needs it: ``.github/workflows/token_push_check.yml`` mints an App +token, installs it as git's credential helper (``gh auth setup-git``) and runs +this module, which for each library does + +- ``git ls-remote`` (the repo answers), and +- ``git push --dry-run HEAD:refs/heads/token-push-check-dryrun`` — the + receive-pack handshake GitHub only grants to a writer. ``--dry-run`` sends + no pack and creates no ref, so nothing is ever pushed. + +The result is the committed sidecar ``state/token_push.json``, which +``state.aggregate`` folds into the snapshot as ``token_push`` and +``readiness`` grades (a refused push is RED; a missing or aged sidecar is an +evidence gap). Read-only on every foreign repo; never prints a credential. +""" + +from __future__ import annotations + +import datetime +import json +import os +import subprocess +import tempfile +from pathlib import Path +from typing import Any, Callable, Sequence + +HEART_HOME = Path(__file__).resolve().parents[2] +SIDECAR = HEART_HOME / "state" / "token_push.json" +SCHEMA = "token_push/1" +OWNER = "PyAutoLabs" +# The release job's library matrix (PyAutoHands release.yml `release`). +REPOS = ("PyAutoNerves", "PyAutoFit", "PyAutoArray", "PyAutoGalaxy", "PyAutoLens") +PROBE_REF = "refs/heads/token-push-check-dryrun" +# Older than this, a passing sidecar no longer vouches for the credential. +STALE_HOURS = 48 + +Runner = Callable[[Sequence[str], str], "subprocess.CompletedProcess[str]"] + + +def _run(cmd: Sequence[str], cwd: str) -> "subprocess.CompletedProcess[str]": + env = dict(os.environ, GIT_TERMINAL_PROMPT="0") + return subprocess.run(list(cmd), cwd=cwd, env=env, capture_output=True, + text=True, timeout=60) + + +def _tail(proc: "subprocess.CompletedProcess[str]") -> str: + lines = [ln.strip() for ln in (proc.stderr or proc.stdout or "").splitlines() if ln.strip()] + return lines[-1][:300] if lines else f"exit {proc.returncode}" + + +def probe(repos: Sequence[str] = REPOS, run: Runner = _run) -> dict[str, dict[str, Any]]: + """{repo: {ls_remote, push, error}} for each repo; never raises.""" + out: dict[str, dict[str, Any]] = {} + with tempfile.TemporaryDirectory(prefix="token-push-") as tmp: + # A throwaway repo with one empty commit: HEAD must name a commit for + # the dry-run refspec, and nothing about it ever leaves this machine. + for cmd in (["git", "init", "-q"], + ["git", "-c", "user.name=token-push-check", + "-c", "user.email=token-push-check@invalid", + "commit", "-q", "--allow-empty", "-m", "token push probe"]): + run(cmd, tmp) + for repo in repos: + url = f"https://github.com/{OWNER}/{repo}.git" + row: dict[str, Any] = {"ls_remote": False, "push": False, "error": None} + try: + ls = run(["git", "ls-remote", "--heads", url, "main"], tmp) + row["ls_remote"] = ls.returncode == 0 + if ls.returncode: + row["error"] = "ls-remote: " + _tail(ls) + push = run(["git", "push", "--dry-run", url, f"HEAD:{PROBE_REF}"], tmp) + row["push"] = push.returncode == 0 + if push.returncode: + row["error"] = "push --dry-run: " + _tail(push) + except (OSError, subprocess.TimeoutExpired) as exc: + row["error"] = f"{type(exc).__name__}: {exc}"[:300] + out[repo] = row + return out + + +def build(results: dict[str, dict[str, Any]], identity: str, run_url: str = "", + now: datetime.datetime | None = None) -> dict[str, Any]: + now = now or datetime.datetime.now(datetime.timezone.utc) + return { + "schema": SCHEMA, + "checked_at": now.isoformat(timespec="seconds"), + "identity": identity, + "run_url": run_url, + "repos": results, + } + + +def load(path: Path = SIDECAR) -> dict[str, Any]: + """The committed sidecar, or {} when absent/unreadable.""" + try: + data = json.loads(Path(path).read_text()) + except (OSError, ValueError): + return {} + return data if isinstance(data, dict) else {} + + +def main(argv: Sequence[str] | None = None) -> int: + import argparse + + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("--out", type=Path, default=SIDECAR) + ap.add_argument("--identity", default=os.environ.get("APP_SLUG") or "pyautolabs-bot") + ns = ap.parse_args(argv) + run_url = "" + if os.environ.get("GITHUB_RUN_ID"): + run_url = (f"{os.environ.get('GITHUB_SERVER_URL', 'https://github.com')}/" + f"{os.environ.get('GITHUB_REPOSITORY', '')}/actions/runs/" + f"{os.environ['GITHUB_RUN_ID']}") + payload = build(probe(), ns.identity, run_url) + ns.out.parent.mkdir(parents=True, exist_ok=True) + ns.out.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n") + for repo, row in payload["repos"].items(): + print(f"{repo}: ls_remote={row['ls_remote']} push={row['push']}" + + (f" ({row['error']})" if row["error"] else "")) + # Exit 0 either way: the sidecar is the result and readiness grades it; a + # failing job would leave no sidecar, which reads as "never checked". + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/heart/dashboard.py b/heart/dashboard.py index ac87ee9..d39255d 100644 --- a/heart/dashboard.py +++ b/heart/dashboard.py @@ -1617,6 +1617,7 @@ def _unobs_section(key: str, title: str) -> Section: # and the all-in-one plan are written from, so the two can never disagree. TICK_CMD = "pyauto-heart tick" VERIFY_INSTALL_CMD = "pyauto-heart verify_install --report-json" +TOKEN_PUSH_CMD = "gh workflow run token_push_check.yml -R PyAutoLabs/PyAutoHeart" REHEARSE_STEP = ("dispatch a release rehearsal with the release skill with `rehearse`, then " "`pyauto-heart validate --ingest `") @@ -1665,6 +1666,14 @@ def _unobs_section(key: str, title: str) -> Section: "step": ("re-run the deep PyPI leg " "(`python3 -m heart.checks.version_skew --pypi` from the Heart " "checkout) once PyPI answers again")}, + # the release push credential — a GitHub workflow, so the remedy dispatches + # it (the sidecar it commits lands on the next pull of Heart) + "token_push_unknown": {"command": TOKEN_PUSH_CMD, + "step": f"run the push-credential check with `{TOKEN_PUSH_CMD}`, " + "then pull PyAutoHeart"}, + "token_push_stale": {"command": TOKEN_PUSH_CMD, + "step": f"re-run the push-credential check with `{TOKEN_PUSH_CMD}`, " + "then pull PyAutoHeart (the evidence expired)"}, } # What a stale row copies when its key has no entry here (a gap added since diff --git a/heart/readiness.py b/heart/readiness.py index 30bf266..1067176 100644 --- a/heart/readiness.py +++ b/heart/readiness.py @@ -19,7 +19,10 @@ release-validation report last ingested reports ``validation_outcome == "fail"`` (a stage failed, or failing/timed-out counts were recorded). A report predating that field falls back to ``release_ready == false`` → RED, so the - gate fails closed on evidence it cannot classify. + gate fails closed on evidence it cannot classify. Also RED: the release push + credential (``heart/checks/token_push.py``, the ``pyautolabs-bot`` App's + ``git push --dry-run``) was refused by any library; a missing, partial or + older-than-48 h proof is STALE. - **YELLOW** (caution) for soft signals: workspace-validation not passing (the workspace scripts/notebooks carry standing debt, so this is advisory — never a hard block), script-timing regressions, stale open PRs, stale parked scripts, a @@ -93,6 +96,7 @@ from heart import state, validate from heart.checks.ci_status import FAILURE_CONCLUSIONS, load_required_workflows from heart.checks.test_run import counts_measured as tr_counts_measured +from heart.checks.token_push import STALE_HOURS as TOKEN_PUSH_STALE_HOURS from heart.heart_color import ( c_bold, c_fail, c_info, c_meta, c_ok, c_warn, glyph_fail, glyph_ok, glyph_warn, @@ -147,6 +151,9 @@ "validation_unknown": (12, 12), "validation_profile": (12, 12), "validation_stale": (10, 10), + "token_push_refused": (40, 40), + "token_push_unknown": (10, 10), + "token_push_stale": (10, 10), } @@ -644,6 +651,36 @@ def scope_local(msg: str, key: str) -> None: else: add_stale("no release validation for current source", "validation_absent") + # --- release push credential (RED on a refused push, STALE if unproven) --- + # heart/checks/token_push.py: the pyautolabs-bot App's `git push --dry-run` + # to each release library. A refusal means the release's tag push would + # fail — a real blocker. A missing, partial or aged sidecar is an evidence + # gap (re-run the check), never an implied pass. + tp = snapshot.get("token_push") + tp_repos = tp.get("repos") if isinstance(tp, dict) else None + if isinstance(tp_repos, dict) and tp_repos: + identity = str(tp.get("identity") or "identity unknown") + refused = [lib for lib in libs + if isinstance(tp_repos.get(lib), dict) and tp_repos[lib].get("push") is False] + unproven = [lib for lib in libs + if not isinstance(tp_repos.get(lib), dict) + or not isinstance(tp_repos[lib].get("push"), bool)] + age = _age_days(tp.get("checked_at"), ref) + if refused: + red.append(f"release push credential refused ({identity}): {', '.join(refused)}") + hit("token_push_refused") + elif unproven: + add_stale(f"release push credential unverified ({identity}): {', '.join(unproven)}", + "token_push_unknown") + elif age is None or age * 24 > TOKEN_PUSH_STALE_HOURS: + add_stale( + f"release push credential check stale ({identity}, " + + ("age unknown)" if age is None else f"{int(age * 24)}h old)"), + "token_push_stale", + ) + else: + add_stale("release push credential check not run", "token_push_unknown") + # --- script timing (YELLOW) --- timing = snapshot.get("script_timing", {}) or {} if _as_int(timing.get("red_count", 0)) > 0: diff --git a/heart/state.py b/heart/state.py index 565e11c..ebcff05 100644 --- a/heart/state.py +++ b/heart/state.py @@ -79,6 +79,12 @@ def _configured_repos() -> set[str] | None: return names +def _token_push_sidecar() -> dict[str, Any]: + from heart.checks import token_push + + return token_push.load() + + def aggregate() -> dict[str, Any]: """Collapse per-repo JSON sidecars into one state.json snapshot.""" _ensure_dirs() @@ -127,6 +133,10 @@ def aggregate() -> dict[str, Any]: "verify_install": _read_json_or_default(HEART_STATE_DIR / "verify_install.json", {}), "url_check": _read_json_or_default(HEART_STATE_DIR / "url_check.json", {}), "validation_report": _read_json_or_default(HEART_STATE_DIR / "validation_report.json", {}), + # The release push-credential proof is COMMITTED (state/token_push.json, + # written by token_push_check.yml), not a local cache: every checkout — + # dev box, the cloud board, the nightly driver — reads the same file. + "token_push": _token_push_sidecar(), } atomic_write_json(HEART_STATE_FILE, snapshot) return snapshot diff --git a/tests/test_readiness.py b/tests/test_readiness.py index 0c39ac1..d697b88 100644 --- a/tests/test_readiness.py +++ b/tests/test_readiness.py @@ -43,6 +43,16 @@ def _green_validation_report(ts: str = "2026-06-01T00:00:00+00:00") -> dict: } +def _green_token_push(ts: str = "2026-06-01T00:00:00+00:00") -> dict: + """A sidecar where the App can push to every library, checked at ``ts``.""" + return { + "schema": "token_push/1", + "checked_at": ts, + "identity": "pyautolabs-bot", + "repos": {lib: {"ls_remote": True, "push": True, "error": None} for lib in LIBS}, + } + + def make_snapshot(**overrides) -> dict: """A fully-green baseline snapshot; override slices per test.""" snap = { @@ -58,6 +68,9 @@ def make_snapshot(**overrides) -> dict: "validation_report": _green_validation_report(), } snap.update(overrides) + # fresh App push-credential proof for every library, dated at the + # snapshot's own ts (so an overridden ts never ages it into a gap) + snap.setdefault("token_push", _green_token_push(snap["ts"])) return snap @@ -899,6 +912,9 @@ def make_ci_snapshot(**overrides) -> dict: "validation_report": _green_validation_report(), } snap.update(overrides) + # fresh App push-credential proof for every library, dated at the + # snapshot's own ts (so an overridden ts never ages it into a gap) + snap.setdefault("token_push", _green_token_push(snap["ts"])) return snap @@ -1186,3 +1202,54 @@ def test_pipeline_required_smoke_gates_release(conclusion, expected): assert verdict["verdict"] == expected if expected == "red": assert "PipelineA: Smoke Tests failure on main" in verdict["red_reasons"] + + +# --------------------------------------------------------------------------- +# release push credential (heart/checks/token_push.py sidecar) +# --------------------------------------------------------------------------- + +def test_token_push_all_pushable_and_fresh_is_green(): + v = compute(make_snapshot()) + assert v["verdict"] == "green" + assert not any("push credential" in r for r in v["reasons"]) + + +def test_token_push_refused_is_red(): + tp = _green_token_push() + tp["repos"]["PyAutoArray"] = {"ls_remote": True, "push": False, + "error": "push --dry-run: 403"} + v = compute(make_snapshot(token_push=tp)) + assert v["verdict"] == "red" + assert any("push credential refused" in r and "PyAutoArray" in r + and "pyautolabs-bot" in r for r in v["red_reasons"]) + + +def test_token_push_not_run_is_stale_tier(): + v = compute(make_snapshot(token_push={})) + assert v["verdict"] == "stale" + assert any("push credential check not run" in r for r in v["stale_reasons"]) + assert any(d["key"] == "token_push_unknown" for d in v["stale_details"]) + + +def test_token_push_older_than_48h_is_stale_tier(): + tp = _green_token_push("2026-05-29T00:00:00+00:00") # 72 h before snapshot ts + v = compute(make_snapshot(token_push=tp)) + assert v["verdict"] == "stale" + assert any("push credential check stale" in r and "72h" in r for r in v["stale_reasons"]) + assert any(d["key"] == "token_push_stale" for d in v["stale_details"]) + + +def test_token_push_library_missing_is_stale_not_pass(): + tp = _green_token_push() + del tp["repos"]["PyAutoLens"] + v = compute(make_snapshot(token_push=tp)) + assert v["verdict"] == "stale" + assert any("unverified" in r and "PyAutoLens" in r for r in v["stale_reasons"]) + + +def test_token_push_refusal_dominates_staleness(): + tp = _green_token_push("2026-05-01T00:00:00+00:00") + tp["repos"]["PyAutoFit"]["push"] = False + v = compute(make_snapshot(token_push=tp)) + assert v["verdict"] == "red" + assert not any("push credential check stale" in r for r in v["stale_reasons"]) diff --git a/tests/test_token_push.py b/tests/test_token_push.py new file mode 100644 index 0000000..651d32e --- /dev/null +++ b/tests/test_token_push.py @@ -0,0 +1,68 @@ +"""tests/test_token_push.py — the release push-credential probe and sidecar.""" + +from __future__ import annotations + +import datetime +import json +import subprocess + +from heart.checks import token_push as tp + + +def _fake_runner(refuse=(), unreachable=()): + calls = [] + + def run(cmd, cwd): + calls.append(list(cmd)) + url = next((c for c in cmd if c.startswith("https://")), "") + repo = url.rsplit("/", 1)[-1].removesuffix(".git") + rc = 0 + if "ls-remote" in cmd and repo in unreachable: + rc = 128 + if "push" in cmd and (repo in refuse or repo in unreachable): + rc = 128 + return subprocess.CompletedProcess(cmd, rc, "", "remote: Permission denied (403)\n" if rc else "") + + return run, calls + + +def test_probe_reports_push_per_repo_and_only_dry_runs(): + run, calls = _fake_runner(refuse={"PyAutoGalaxy"}) + out = tp.probe(run=run) + assert set(out) == set(tp.REPOS) + assert out["PyAutoFit"] == {"ls_remote": True, "push": True, "error": None} + assert out["PyAutoGalaxy"]["push"] is False + assert out["PyAutoGalaxy"]["ls_remote"] is True + assert "403" in out["PyAutoGalaxy"]["error"] + pushes = [c for c in calls if "push" in c] + assert len(pushes) == len(tp.REPOS) + assert all("--dry-run" in c and f"HEAD:{tp.PROBE_REF}" in c for c in pushes) + # no URL ever carries userinfo — auth is the credential helper's job + assert all("@" not in c for cmd in calls for c in cmd if c.startswith("https://")) + + +def test_probe_records_unreachable_repo_without_raising(): + run, _ = _fake_runner(unreachable={"PyAutoNerves"}) + out = tp.probe(run=run) + assert out["PyAutoNerves"]["ls_remote"] is False + assert out["PyAutoNerves"]["push"] is False + + +def test_build_and_load_round_trip(tmp_path): + now = datetime.datetime(2026, 10, 23, 12, tzinfo=datetime.timezone.utc) + payload = tp.build({"PyAutoFit": {"ls_remote": True, "push": True, "error": None}}, + "pyautolabs-bot", "https://example.test/runs/1", now) + path = tmp_path / "token_push.json" + path.write_text(json.dumps(payload)) + got = tp.load(path) + assert got["schema"] == tp.SCHEMA + assert got["identity"] == "pyautolabs-bot" + assert got["checked_at"].startswith("2026-10-23T12:00:00") + assert got["repos"]["PyAutoFit"]["push"] is True + + +def test_load_missing_or_malformed_is_empty(tmp_path): + assert tp.load(tmp_path / "absent.json") == {} + bad = tmp_path / "bad.json" + bad.write_text("{not json") + assert tp.load(bad) == {} From 31464880ff74fb968b74afae6711969545db30c2 Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Fri, 23 Oct 2026 14:41:56 +0100 Subject: [PATCH 2/3] ci(heart): run token push check on PRs that change it Adds a pull_request trigger (paths: the workflow and heart/checks/token_push.py) so the real App token is exercised before merge. PR runs probe, log, upload the sidecar as an artifact and fail on refusal, but never commit. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/token_push_check.yml | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/.github/workflows/token_push_check.yml b/.github/workflows/token_push_check.yml index b766afe..ea00a8a 100644 --- a/.github/workflows/token_push_check.yml +++ b/.github/workflows/token_push_check.yml @@ -12,13 +12,23 @@ name: Token Push Check # RED, a missing or >48 h old sidecar is STALE). The job fails after the commit # when any push was refused, so the Actions tab shows red too. # -# Observer boundary: the only write is that sidecar commit to PyAutoHeart. +# It also runs on any pull request that changes this workflow or the probe +# (same-repo PR branches receive the org App secret), so the real App token is +# exercised before merge. A PR run probes, logs per-repo results, uploads the +# JSON as an artifact and fails on any refusal — but commits nothing. +# +# Observer boundary: the only write is that sidecar commit to PyAutoHeart +# (never from a pull_request run). on: schedule: # Daily 04:30 UTC — ahead of the 05:00 heart-health board, which reads it. - cron: "30 4 * * *" workflow_dispatch: + pull_request: + paths: + - .github/workflows/token_push_check.yml + - heart/checks/token_push.py # contents: write → commit the sidecar into Heart's OWN repo (GITHUB_TOKEN). # The library probes use the App token only. @@ -67,7 +77,15 @@ jobs: # the App helper so it plays no part in that. git config --global --unset-all credential.https://github.com.helper || true - - name: Commit the sidecar (own repo only) + - name: Upload the sidecar (pull_request runs) + if: github.event_name == 'pull_request' + uses: actions/upload-artifact@v4 + with: + name: token-push-sidecar + path: state/token_push.json + + - name: Commit the sidecar (own repo only, never from a PR) + if: github.event_name != 'pull_request' run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" From e6919287fe760a50fdec600bff45c231f7e3517c Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Fri, 23 Oct 2026 14:51:06 +0100 Subject: [PATCH 3/3] fix(heart): token push check reads repos and owner from config The tenant firewall rejects instance facts in organ code. token_push.py now takes the library list from readiness.load_release_gate_names() and each owner from config/repos.yaml (dashboard._repo_owners); the dashboard remedy command and the tests derive names the same way. The workflow resolves the App token's `repositories:` scope from the same list via --print-repos, so the scope and the probe can never disagree. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/token_push_check.yml | 10 ++++++- heart/checks/token_push.py | 37 ++++++++++++++++++++---- heart/dashboard.py | 4 ++- tests/test_token_push.py | 40 ++++++++++++++++++-------- 4 files changed, 72 insertions(+), 19 deletions(-) diff --git a/.github/workflows/token_push_check.yml b/.github/workflows/token_push_check.yml index ea00a8a..8484981 100644 --- a/.github/workflows/token_push_check.yml +++ b/.github/workflows/token_push_check.yml @@ -52,6 +52,14 @@ jobs: with: python-version: "3.12" + - name: Resolve the release libraries (config/repos.yaml) + # The token scope and the probe read the SAME list: the release-gating + # libraries declared in config/repos.yaml, never a second copy here. + id: libs + run: | + pip install --quiet pyyaml + echo "repos=$(PYTHONPATH="$PWD" python -m heart.checks.token_push --print-repos)" >> "$GITHUB_OUTPUT" + - name: Mint App token (release libraries, contents write) id: app_token uses: actions/create-github-app-token@v3 @@ -59,7 +67,7 @@ jobs: client-id: ${{ vars.APP_CLIENT_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} owner: PyAutoLabs - repositories: PyAutoFit,PyAutoArray,PyAutoGalaxy,PyAutoLens,PyAutoNerves + repositories: ${{ steps.libs.outputs.repos }} permission-contents: write - name: Probe ls-remote + push --dry-run diff --git a/heart/checks/token_push.py b/heart/checks/token_push.py index 5d0ca0a..7038e0b 100644 --- a/heart/checks/token_push.py +++ b/heart/checks/token_push.py @@ -30,9 +30,6 @@ HEART_HOME = Path(__file__).resolve().parents[2] SIDECAR = HEART_HOME / "state" / "token_push.json" SCHEMA = "token_push/1" -OWNER = "PyAutoLabs" -# The release job's library matrix (PyAutoHands release.yml `release`). -REPOS = ("PyAutoNerves", "PyAutoFit", "PyAutoArray", "PyAutoGalaxy", "PyAutoLens") PROBE_REF = "refs/heads/token-push-check-dryrun" # Older than this, a passing sidecar no longer vouches for the credential. STALE_HOURS = 48 @@ -40,6 +37,23 @@ Runner = Callable[[Sequence[str], str], "subprocess.CompletedProcess[str]"] +# Repo identity comes from the declared config surface (config/repos.yaml), +# never from literals — the tenant firewall keeps instance facts out of organ +# code. Imported lazily: readiness imports this module. +def release_repos() -> list[str]: + """The release-gating libraries (the release job's library set).""" + from heart.readiness import load_release_gate_names + + return load_release_gate_names() + + +def repo_owners() -> dict: + """{repo name: GitHub owner}, read the way the dashboard reads it.""" + from heart.dashboard import _repo_owners + + return _repo_owners() + + def _run(cmd: Sequence[str], cwd: str) -> "subprocess.CompletedProcess[str]": env = dict(os.environ, GIT_TERMINAL_PROMPT="0") return subprocess.run(list(cmd), cwd=cwd, env=env, capture_output=True, @@ -51,8 +65,11 @@ def _tail(proc: "subprocess.CompletedProcess[str]") -> str: return lines[-1][:300] if lines else f"exit {proc.returncode}" -def probe(repos: Sequence[str] = REPOS, run: Runner = _run) -> dict[str, dict[str, Any]]: +def probe(repos: Sequence[str] | None = None, run: Runner = _run, + owners: dict | None = None) -> dict[str, dict[str, Any]]: """{repo: {ls_remote, push, error}} for each repo; never raises.""" + repos = list(repos) if repos is not None else release_repos() + owners = owners if owners is not None else repo_owners() out: dict[str, dict[str, Any]] = {} with tempfile.TemporaryDirectory(prefix="token-push-") as tmp: # A throwaway repo with one empty commit: HEAD must name a commit for @@ -63,8 +80,13 @@ def probe(repos: Sequence[str] = REPOS, run: Runner = _run) -> dict[str, dict[st "commit", "-q", "--allow-empty", "-m", "token push probe"]): run(cmd, tmp) for repo in repos: - url = f"https://github.com/{OWNER}/{repo}.git" row: dict[str, Any] = {"ls_remote": False, "push": False, "error": None} + owner = owners.get(repo) + if not owner: + row["error"] = "no owner for this repo in config/repos.yaml" + out[repo] = row + continue + url = f"https://github.com/{owner}/{repo}.git" try: ls = run(["git", "ls-remote", "--heads", url, "main"], tmp) row["ls_remote"] = ls.returncode == 0 @@ -107,7 +129,12 @@ def main(argv: Sequence[str] | None = None) -> int: ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) ap.add_argument("--out", type=Path, default=SIDECAR) ap.add_argument("--identity", default=os.environ.get("APP_SLUG") or "pyautolabs-bot") + ap.add_argument("--print-repos", action="store_true", + help="print the comma-separated repo list (the App token scope) and exit") ns = ap.parse_args(argv) + if ns.print_repos: + print(",".join(release_repos())) + return 0 run_url = "" if os.environ.get("GITHUB_RUN_ID"): run_url = (f"{os.environ.get('GITHUB_SERVER_URL', 'https://github.com')}/" diff --git a/heart/dashboard.py b/heart/dashboard.py index d39255d..ff5d9f0 100644 --- a/heart/dashboard.py +++ b/heart/dashboard.py @@ -1617,7 +1617,9 @@ def _unobs_section(key: str, title: str) -> Section: # and the all-in-one plan are written from, so the two can never disagree. TICK_CMD = "pyauto-heart tick" VERIFY_INSTALL_CMD = "pyauto-heart verify_install --report-json" -TOKEN_PUSH_CMD = "gh workflow run token_push_check.yml -R PyAutoLabs/PyAutoHeart" +# Heart's own owner from config/repos.yaml (REPO_OWNERS above), never a literal. +TOKEN_PUSH_CMD = ("gh workflow run token_push_check.yml -R " + f"{REPO_OWNERS.get('PyAutoHeart', '')}/PyAutoHeart") REHEARSE_STEP = ("dispatch a release rehearsal with the release skill with `rehearse`, then " "`pyauto-heart validate --ingest `") diff --git a/tests/test_token_push.py b/tests/test_token_push.py index 651d32e..8552db7 100644 --- a/tests/test_token_push.py +++ b/tests/test_token_push.py @@ -8,6 +8,10 @@ from heart.checks import token_push as tp +# Names come from the module (config/repos.yaml), never literals. +REPOS = tp.release_repos() +FIRST, SECOND, THIRD = REPOS[0], REPOS[1], REPOS[2] + def _fake_runner(refuse=(), unreachable=()): calls = [] @@ -27,30 +31,42 @@ def run(cmd, cwd): def test_probe_reports_push_per_repo_and_only_dry_runs(): - run, calls = _fake_runner(refuse={"PyAutoGalaxy"}) + run, calls = _fake_runner(refuse={SECOND}) out = tp.probe(run=run) - assert set(out) == set(tp.REPOS) - assert out["PyAutoFit"] == {"ls_remote": True, "push": True, "error": None} - assert out["PyAutoGalaxy"]["push"] is False - assert out["PyAutoGalaxy"]["ls_remote"] is True - assert "403" in out["PyAutoGalaxy"]["error"] + assert set(out) == set(REPOS) + assert out[FIRST] == {"ls_remote": True, "push": True, "error": None} + assert out[SECOND]["push"] is False + assert out[SECOND]["ls_remote"] is True + assert "403" in out[SECOND]["error"] pushes = [c for c in calls if "push" in c] - assert len(pushes) == len(tp.REPOS) + assert len(pushes) == len(REPOS) assert all("--dry-run" in c and f"HEAD:{tp.PROBE_REF}" in c for c in pushes) # no URL ever carries userinfo — auth is the credential helper's job assert all("@" not in c for cmd in calls for c in cmd if c.startswith("https://")) def test_probe_records_unreachable_repo_without_raising(): - run, _ = _fake_runner(unreachable={"PyAutoNerves"}) + run, _ = _fake_runner(unreachable={THIRD}) out = tp.probe(run=run) - assert out["PyAutoNerves"]["ls_remote"] is False - assert out["PyAutoNerves"]["push"] is False + assert out[THIRD]["ls_remote"] is False + assert out[THIRD]["push"] is False + + +def test_probe_repo_without_owner_is_not_probed(): + run, calls = _fake_runner() + out = tp.probe(repos=[FIRST], run=run, owners={}) + assert out[FIRST]["push"] is False and "owner" in out[FIRST]["error"] + assert not any("push" in c for c in calls) + + +def test_print_repos_is_the_config_gate_list(capsys): + assert tp.main(["--print-repos"]) == 0 + assert capsys.readouterr().out.strip() == ",".join(REPOS) def test_build_and_load_round_trip(tmp_path): now = datetime.datetime(2026, 10, 23, 12, tzinfo=datetime.timezone.utc) - payload = tp.build({"PyAutoFit": {"ls_remote": True, "push": True, "error": None}}, + payload = tp.build({FIRST: {"ls_remote": True, "push": True, "error": None}}, "pyautolabs-bot", "https://example.test/runs/1", now) path = tmp_path / "token_push.json" path.write_text(json.dumps(payload)) @@ -58,7 +74,7 @@ def test_build_and_load_round_trip(tmp_path): assert got["schema"] == tp.SCHEMA assert got["identity"] == "pyautolabs-bot" assert got["checked_at"].startswith("2026-10-23T12:00:00") - assert got["repos"]["PyAutoFit"]["push"] is True + assert got["repos"][FIRST]["push"] is True def test_load_missing_or_malformed_is_empty(tmp_path):