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
125 changes: 125 additions & 0 deletions .github/workflows/token_push_check.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
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.
#
# 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.
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: 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
with:
client-id: ${{ vars.APP_CLIENT_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
owner: PyAutoLabs
repositories: ${{ steps.libs.outputs.repos }}
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: 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"
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
155 changes: 155 additions & 0 deletions heart/checks/token_push.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
"""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 <url> 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"
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]"]


# 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,
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] | 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
# 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:
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
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")
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')}/"
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())
11 changes: 11 additions & 0 deletions heart/dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -1617,6 +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"
# 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', '<owner>')}/PyAutoHeart")
REHEARSE_STEP = ("dispatch a release rehearsal with the release skill with `rehearse`, then "
"`pyauto-heart validate --ingest <artifacts>`")

Expand Down Expand Up @@ -1665,6 +1668,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
Expand Down
39 changes: 38 additions & 1 deletion heart/readiness.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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),
}


Expand Down Expand Up @@ -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:
Expand Down
10 changes: 10 additions & 0 deletions heart/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading