Skip to content

Commit cabbd26

Browse files
committed
feat: per-repo STATUS.md 'Today's CI' refresher
Adds a second tier to the morning-triage workflow: every org repo that has a STATUS.md at root gets a 'Today's CI' h2 section (between BEGIN: ci-today / END: ci-today markers) that mirrors the org-table view but scoped to that one repo — same window (00:00 UTC), plus a link to the latest failed run if any. Designed around the morning-agent loop: agent opens github.com/erphq → sees the org table → clicks into a red repo → opens STATUS.md → 'Today's CI' block tells them which run failed + where to look. The block lives above the human-curated TL;DR because that's the order the agent reads in. Mechanics: - scripts/build-repo-status.sh: emits the block markdown for one repo. Pulls test count from the README's tests-N badge, workflow runs since 00:00 UTC by conclusion, the latest failed-run URL. - scripts/inject-ci-today.py: idempotent insert/replace via BEGIN/END markers. Bootstraps a new section right after the H1 line on first run; replaces only the marker block on subsequent runs. - scripts/update-repo-status.sh: clones the target repo shallow, runs inject, commits + pushes. Race-safe (rebase-and-amend loop). Skips silently if the repo has no STATUS.md. - refresh-status.yml: a second job (refresh-per-repo) discovers STATUS.md-bearing repos via gh-api and runs the updater on each. Gated on the ORG_REFRESH_PAT secret because the default GITHUB_TOKEN can't write to other org repos. Without the PAT the per-repo job is a no-op; the org-table job still runs. Initial seed (the 11 STATUS.md repos: codegraph, FFS, processmind, build-host, smriti, pm-bench, vahini, karta, bija, crucible, flow) was committed to each repo separately before this workflow change landed, so the first cron fire just no-ops on them until tomorrow.
1 parent b3b2fca commit cabbd26

4 files changed

Lines changed: 289 additions & 9 deletions

File tree

.github/workflows/refresh-status.yml

Lines changed: 60 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,25 @@
11
name: Refresh status table
22

3-
# Rebuilds the org-wide CI status block in profile/README.md so the
4-
# org page (https://github.com/erphq) always shows yesterday's run
5-
# tally + current per-repo test counts. The intended audience is an
6-
# agent that opens the org page each morning to triage what to fix.
3+
# Two-tier status refresh for the morning-triage workflow:
74
#
8-
# Cadence: hourly during the day, plus a manual trigger. Hourly is
9-
# light: ~30 small gh-api reads + one repo edit at most.
5+
# 1. profile/README.md (this repo) — public org-wide table showing
6+
# every public repo's tests + today's run tally. The org page at
7+
# https://github.com/erphq surfaces this directly.
108
#
11-
# Auth: cross-repo reads of private org repos need a PAT scoped to
12-
# `repo`. Store it as the org/repo secret `ORG_REFRESH_PAT`. Without
13-
# it, the workflow still runs but private-repo run tallies show zero.
9+
# 2. STATUS.md in each org repo that has one — per-repo "Today's CI"
10+
# block (between BEGIN/END markers) so an agent that's already
11+
# landed in a specific repo sees today's run/pass/fail/latest-
12+
# failure-link without leaving the file they're reading.
13+
#
14+
# Cadence: hourly. Light: a couple dozen gh-api reads + one edit per
15+
# repo that actually changed.
16+
#
17+
# Auth: the per-repo update step writes to OTHER repos in the org, so
18+
# it needs a PAT scoped to `repo` (or a fine-grained PAT with
19+
# Contents: Write on org repos). Store it as the repo secret
20+
# `ORG_REFRESH_PAT`. Without it, the per-repo refresh job is skipped
21+
# (the org-table job still works on the default GITHUB_TOKEN since
22+
# it only reads + edits this repo).
1423

1524
on:
1625
schedule:
@@ -22,6 +31,9 @@ on:
2231
branches: [main]
2332
paths:
2433
- 'scripts/build-status-table.sh'
34+
- 'scripts/build-repo-status.sh'
35+
- 'scripts/update-repo-status.sh'
36+
- 'scripts/inject-ci-today.py'
2537
- '.github/workflows/refresh-status.yml'
2638

2739
permissions:
@@ -98,3 +110,42 @@ jobs:
98110
done
99111
echo "::error::push failed after 3 attempts"
100112
exit 1
113+
114+
refresh-per-repo:
115+
# Updates each org repo's STATUS.md "Today's CI" block.
116+
# Independent of the org-table job above so a per-repo failure
117+
# doesn't take down the org-page refresh.
118+
runs-on: ubuntu-latest
119+
timeout-minutes: 15
120+
# Only runs when ORG_REFRESH_PAT is configured — without it we
121+
# can't write to the other org repos (default GITHUB_TOKEN is
122+
# repo-scoped to .github only).
123+
if: ${{ vars.RUN_PER_REPO_REFRESH != 'false' }}
124+
steps:
125+
- uses: actions/checkout@v6
126+
127+
- name: Update each repo's STATUS.md
128+
env:
129+
GH_TOKEN: ${{ secrets.ORG_REFRESH_PAT || secrets.GITHUB_TOKEN }}
130+
ORG: erphq
131+
run: |
132+
if [[ -z "${{ secrets.ORG_REFRESH_PAT }}" ]]; then
133+
echo "ORG_REFRESH_PAT not set — per-repo refresh would only be"
134+
echo "able to read/write this .github repo. Skipping."
135+
exit 0
136+
fi
137+
138+
# Discover repos that have a STATUS.md at root. Survey is
139+
# cheap (one /contents/STATUS.md HEAD per repo) and means
140+
# we don't have to maintain a hand-curated list — a new
141+
# repo gets the block as soon as it has STATUS.md.
142+
repos=$(gh api "orgs/$ORG/repos" --paginate -q '.[] | select(.archived | not) | .name')
143+
for repo in $repos; do
144+
if gh api "repos/$ORG/$repo/contents/STATUS.md" -q .name >/dev/null 2>&1; then
145+
echo "::group::$repo"
146+
# Each invocation is independent — a single repo failure
147+
# shouldn't break the rest. Use `|| true` to swallow.
148+
bash scripts/update-repo-status.sh "$repo" || echo "::warning::$repo update failed"
149+
echo "::endgroup::"
150+
fi
151+
done

scripts/build-repo-status.sh

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
#!/usr/bin/env bash
2+
# Builds the "Today's CI" status block for a single repo. Goes between
3+
# the BEGIN: ci-today / END: ci-today markers in that repo's STATUS.md.
4+
# Companion to scripts/build-status-table.sh (which builds the org-wide
5+
# table that lives in this repo's profile/README.md).
6+
#
7+
# Usage:
8+
# ORG=erphq scripts/build-repo-status.sh REPO_NAME > /tmp/repo-status.md
9+
#
10+
# Pulls live from the GitHub API:
11+
# - test count: from README's "tests-N passing" shields.io badge
12+
# - run tally since 00:00 UTC today, by conclusion
13+
# - the latest failed workflow run (with URL) — what an agent landing
14+
# in this repo each morning needs to start triaging
15+
# - open PRs whose head SHA has a failed check — the work to do
16+
#
17+
# Requires: gh (authed with cross-repo read), jq.
18+
19+
set -euo pipefail
20+
21+
ORG="${ORG:-erphq}"
22+
REPO="${1:?usage: ORG=erphq $0 REPO_NAME}"
23+
SINCE="$(date -u +%Y-%m-%dT00:00:00Z)"
24+
TODAY="$(date -u +%Y-%m-%d)"
25+
26+
# Test count from the README badge. Returns "—" if the repo has no
27+
# badge (e.g. neo-releases, skills — repos that don't ship code tests).
28+
fetch_test_count() {
29+
local body
30+
body=$(gh api "repos/$ORG/$REPO/readme" -q .content 2>/dev/null | base64 -d 2>/dev/null || true)
31+
if [[ -z "$body" ]]; then
32+
echo ""
33+
return
34+
fi
35+
local n
36+
n=$(printf '%s' "$body" | sed -nE 's|.*tests-([0-9]+)%20passing.*|\1|p' | head -1)
37+
if [[ -z "$n" ]]; then
38+
echo ""
39+
else
40+
echo "$n"
41+
fi
42+
}
43+
44+
# Pull all workflow runs created since 00:00 UTC today, then summarise.
45+
runs_json=$(gh api "repos/$ORG/$REPO/actions/runs?created=>=$SINCE&per_page=100" 2>/dev/null || echo '{"workflow_runs":[]}')
46+
47+
total=$(printf '%s' "$runs_json" | jq '.workflow_runs | length')
48+
pass=$(printf '%s' "$runs_json" | jq '[.workflow_runs[] | select(.conclusion == "success")] | length')
49+
fail=$(printf '%s' "$runs_json" | jq '[.workflow_runs[] | select(.conclusion == "failure")] | length')
50+
other=$((total - pass - fail))
51+
tests=$(fetch_test_count)
52+
53+
# Latest failed run today (if any), for the "where to look first" link.
54+
latest_fail_url=$(printf '%s' "$runs_json" | jq -r '[.workflow_runs[] | select(.conclusion == "failure")] | sort_by(.created_at) | reverse | .[0].html_url // ""')
55+
latest_fail_name=$(printf '%s' "$runs_json" | jq -r '[.workflow_runs[] | select(.conclusion == "failure")] | sort_by(.created_at) | reverse | .[0].name // ""')
56+
latest_fail_branch=$(printf '%s' "$runs_json" | jq -r '[.workflow_runs[] | select(.conclusion == "failure")] | sort_by(.created_at) | reverse | .[0].head_branch // ""')
57+
58+
# Open PRs with at least one failing check. Cap at 5 so the block stays
59+
# tight; if there are more, the link to the org PR list covers the rest.
60+
prs_json=$(gh pr list -R "$ORG/$REPO" --state open --limit 30 --json number,title,headRefOid,statusCheckRollup 2>/dev/null || echo '[]')
61+
red_prs=$(printf '%s' "$prs_json" | jq -r '
62+
[.[] | select(
63+
(.statusCheckRollup // []) | map(
64+
(.conclusion // .status // "") | ascii_downcase
65+
) | any(. == "failure" or . == "fail")
66+
)] | .[0:5] | .[] |
67+
" - [#" + (.number | tostring) + "](https://github.com/'"$ORG"'/'"$REPO"'/pull/" + (.number | tostring) + ") — " + .title
68+
')
69+
70+
# Render. Trailing newline-free so the marker block stays compact.
71+
cat <<EOF
72+
_Auto-refreshed by [\`erphq/.github/refresh-status.yml\`](https://github.com/erphq/.github/blob/main/.github/workflows/refresh-status.yml). Window: runs created since 00:00 UTC on $TODAY (\`$SINCE\`)._
73+
74+
| Tests | Runs today | ✅ pass | ❌ fail | ⚠️ other |
75+
|---:|---:|---:|---:|---:|
76+
| **$tests** | **$total** | **$pass** | **$fail** | **$other** |
77+
EOF
78+
79+
if [[ -n "$latest_fail_url" ]]; then
80+
echo
81+
echo "**Latest failed run:** [$latest_fail_name on \`$latest_fail_branch\`]($latest_fail_url)"
82+
fi
83+
84+
if [[ -n "$red_prs" ]]; then
85+
echo
86+
echo "**Open PRs with failing checks:**"
87+
echo "$red_prs"
88+
fi

scripts/inject-ci-today.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
#!/usr/bin/env python3
2+
# Reads $BLOCK_FILE and injects its contents into STATUS.md (in CWD)
3+
# between the BEGIN: ci-today / END: ci-today markers. If the markers
4+
# don't exist yet, prepends a new "Today's CI" h2 right after the
5+
# first H1 line. Exit 42 if STATUS.md is already up-to-date.
6+
#
7+
# Used by update-repo-status.sh — kept as its own file so the bash
8+
# script doesn't have to bash-interpolate arbitrary content into a
9+
# python heredoc (block content includes backticks, brackets, pipes,
10+
# could later include quote characters).
11+
12+
import os
13+
import pathlib
14+
import re
15+
import sys
16+
17+
block = pathlib.Path(os.environ['BLOCK_FILE']).read_text().strip()
18+
path = pathlib.Path('STATUS.md')
19+
src = path.read_text()
20+
21+
BEGIN = '<!-- BEGIN: ci-today -->'
22+
END = '<!-- END: ci-today -->'
23+
24+
if BEGIN in src and END in src:
25+
new = re.sub(
26+
re.escape(BEGIN) + r'\n.*?\n' + re.escape(END),
27+
BEGIN + '\n' + block + '\n' + END,
28+
src,
29+
count=1,
30+
flags=re.S,
31+
)
32+
else:
33+
# Insert a fresh "Today's CI" h2 right after the first H1 line.
34+
# Picked the position because every STATUS.md in the org follows
35+
# the "# Title \n one-line summary \n ## first section" shape, so
36+
# the "Today" block lands above the human-curated TL;DR — that's
37+
# the order an agent doing a morning triage wants to read.
38+
section = (
39+
'\n## Today’s CI\n\n'
40+
+ BEGIN + '\n'
41+
+ block + '\n'
42+
+ END + '\n'
43+
)
44+
lines = src.splitlines(keepends=True)
45+
insert_at = 0
46+
for i, line in enumerate(lines):
47+
if line.startswith('# '):
48+
insert_at = i + 1
49+
break
50+
if insert_at < len(lines) and lines[insert_at].strip() == '':
51+
insert_at += 1
52+
new = ''.join(lines[:insert_at]) + section + ''.join(lines[insert_at:])
53+
54+
repo = os.environ.get('REPO_NAME', '?')
55+
if new == src:
56+
print(f'[{repo}] no change')
57+
sys.exit(42)
58+
path.write_text(new)
59+
print(f'[{repo}] updated')

scripts/update-repo-status.sh

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
#!/usr/bin/env bash
2+
# Updates one repo's STATUS.md "Today's CI" block in place.
3+
# - If the repo has STATUS.md and the BEGIN: ci-today / END: ci-today
4+
# markers are already present: replace the block content.
5+
# - If the repo has STATUS.md but no markers: prepend a new "Today" h2
6+
# section with the markers right after the title line (first H1).
7+
# - If the repo has no STATUS.md: skip (no-op exit 0).
8+
#
9+
# Commits + pushes via gh-cli auth. Skips the commit if the block
10+
# content is byte-identical to what's already in the repo (idempotent).
11+
#
12+
# Usage:
13+
# ORG=erphq scripts/update-repo-status.sh REPO_NAME
14+
15+
set -euo pipefail
16+
17+
ORG="${ORG:-erphq}"
18+
REPO="${1:?usage: ORG=erphq $0 REPO_NAME}"
19+
20+
HERE="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
21+
22+
# Bail early if the repo doesn't have STATUS.md at the root.
23+
if ! gh api "repos/$ORG/$REPO/contents/STATUS.md" -q .name >/dev/null 2>&1; then
24+
echo "[$REPO] no STATUS.md at root — skip"
25+
exit 0
26+
fi
27+
28+
# Build the block, write to a temp file (safer than bash-interpolating
29+
# arbitrary content into a python heredoc — content includes backticks,
30+
# pipes, brackets, and could later include quote characters).
31+
block_file=$(mktemp)
32+
trap 'rm -f "$block_file"' EXIT
33+
ORG="$ORG" "$HERE/build-repo-status.sh" "$REPO" > "$block_file"
34+
export BLOCK_FILE="$block_file"
35+
export REPO_NAME="$REPO"
36+
37+
# Clone shallow; .git stays for the commit.
38+
work=$(mktemp -d)
39+
trap 'rm -rf "$work"' EXIT
40+
gh repo clone "$ORG/$REPO" "$work" -- --depth=1 --quiet
41+
cd "$work"
42+
43+
python3 "$HERE/inject-ci-today.py"
44+
status=$?
45+
if [[ $status -eq 42 ]]; then
46+
echo "[$REPO] no change — skip commit"
47+
exit 0
48+
fi
49+
if [[ $status -ne 0 ]]; then
50+
echo "[$REPO] inject failed"
51+
exit "$status"
52+
fi
53+
54+
git config user.name 'github-actions[bot]'
55+
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
56+
git add STATUS.md
57+
if git diff --cached --quiet; then
58+
echo "[$REPO] no change after stage — skip"
59+
exit 0
60+
fi
61+
git commit -m "chore(status): refresh today's CI block"
62+
# Race-safe: if push loses, re-pull and re-inject. We can do that here
63+
# because no human edits the marker block between BEGIN/END.
64+
for attempt in 1 2 3; do
65+
if git push; then
66+
echo "[$REPO] pushed"
67+
exit 0
68+
fi
69+
echo "[$REPO] push raced (attempt $attempt) — pulling and retrying"
70+
git fetch origin --quiet
71+
git reset --hard origin/HEAD --quiet || git reset --hard "origin/$(git rev-parse --abbrev-ref HEAD)" --quiet
72+
# Re-run the same inject path on the new base.
73+
python3 "$HERE/inject-ci-today.py" || true
74+
if git diff --quiet STATUS.md; then
75+
echo "[$REPO] no diff after rebase — done"
76+
exit 0
77+
fi
78+
git add STATUS.md
79+
git commit --amend --no-edit >/dev/null
80+
done
81+
echo "::error::[$REPO] push failed after 3 attempts"
82+
exit 1

0 commit comments

Comments
 (0)