Refresh status table #292
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Refresh status table | |
| # Two-tier status refresh for the morning-triage workflow: | |
| # | |
| # 1. profile/README.md (this repo) — public org-wide table showing | |
| # every public repo's tests + today's run tally. The org page at | |
| # https://github.com/erphq surfaces this directly. | |
| # | |
| # 2. STATUS.md in each org repo that has one — per-repo "Today's CI" | |
| # block (between BEGIN/END markers) so an agent that's already | |
| # landed in a specific repo sees today's run/pass/fail/latest- | |
| # failure-link without leaving the file they're reading. | |
| # | |
| # Cadence: hourly. Light: a couple dozen gh-api reads + one edit per | |
| # repo that actually changed. | |
| # | |
| # Auth: the per-repo update step writes to OTHER repos in the org, so | |
| # it needs a PAT scoped to `repo` (or a fine-grained PAT with | |
| # Contents: Write on org repos). Store it as the repo secret | |
| # `ORG_REFRESH_PAT`. Without it, the per-repo refresh job is skipped | |
| # (the org-table job still works on the default GITHUB_TOKEN since | |
| # it only reads + edits this repo). | |
| on: | |
| schedule: | |
| # Every hour at :05 — staggered off :00 so it doesn't pile onto | |
| # the busiest minute of cron-time across GH Actions. | |
| - cron: '5 * * * *' | |
| workflow_dispatch: {} | |
| push: | |
| branches: [main] | |
| paths: | |
| - 'scripts/build-status-table.sh' | |
| - 'scripts/build-repo-status.sh' | |
| - 'scripts/update-repo-status.sh' | |
| - 'scripts/inject-ci-today.py' | |
| - '.github/workflows/refresh-status.yml' | |
| permissions: | |
| contents: write | |
| concurrency: | |
| group: refresh-status | |
| cancel-in-progress: false | |
| jobs: | |
| refresh: | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - name: Build status table | |
| env: | |
| # Prefer the cross-repo PAT; fall back to the workflow's | |
| # default token (which can only see this repo's runs). | |
| GH_TOKEN: ${{ secrets.ORG_REFRESH_PAT || secrets.GITHUB_TOKEN }} | |
| ORG: erphq | |
| run: | | |
| bash scripts/build-status-table.sh > /tmp/status-table.md | |
| echo "--- preview ---" | |
| head -20 /tmp/status-table.md | |
| - name: Inject + commit + push (race-safe) | |
| run: | | |
| git config user.name 'github-actions[bot]' | |
| git config user.email '41898282+github-actions[bot]@users.noreply.github.com' | |
| # Inject the freshly-built table into profile/README.md by | |
| # replacing whatever sits between the BEGIN/END markers. | |
| # Because the source is the marker block (not a 3-way merge), | |
| # this is safe to repeat after a pull — the previous content | |
| # is just overwritten verbatim. | |
| inject() { | |
| python3 <<'PY' | |
| import re, pathlib | |
| table = pathlib.Path('/tmp/status-table.md').read_text().rstrip() | |
| path = pathlib.Path('profile/README.md') | |
| src = path.read_text() | |
| new = re.sub( | |
| r'(<!-- BEGIN: ci-status -->\n).*?(\n<!-- END: ci-status -->)', | |
| lambda m: m.group(1) + table + m.group(2), | |
| src, | |
| flags=re.S, | |
| ) | |
| if new == src and '<!-- BEGIN: ci-status -->' not in src: | |
| raise SystemExit('marker block not found — aborting') | |
| path.write_text(new) | |
| PY | |
| } | |
| # Pull-then-inject-then-push, retrying the whole loop on race. | |
| # We re-inject after every pull so the marker block always | |
| # reflects the freshly-built table on top of the latest main — | |
| # no rebase conflict is possible because the only edits to | |
| # this region come from this workflow. | |
| for attempt in 1 2 3; do | |
| git fetch origin main | |
| git reset --hard origin/main | |
| inject | |
| if git diff --quiet profile/README.md; then | |
| echo "No changes — table already up-to-date." | |
| exit 0 | |
| fi | |
| git add profile/README.md | |
| git commit -m "chore: refresh org CI status table" | |
| if git push; then exit 0; fi | |
| echo "Push lost a race on attempt $attempt; pulling and re-injecting…" | |
| sleep 5 | |
| done | |
| echo "::error::push failed after 3 attempts" | |
| exit 1 | |
| refresh-per-repo: | |
| # Updates each org repo's STATUS.md "Today's CI" block. | |
| # Independent of the org-table job above so a per-repo failure | |
| # doesn't take down the org-page refresh. | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| # Only runs when ORG_REFRESH_PAT is configured — without it we | |
| # can't write to the other org repos (default GITHUB_TOKEN is | |
| # repo-scoped to .github only). | |
| if: ${{ vars.RUN_PER_REPO_REFRESH != 'false' }} | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - name: Update each repo's STATUS.md | |
| env: | |
| GH_TOKEN: ${{ secrets.ORG_REFRESH_PAT || secrets.GITHUB_TOKEN }} | |
| ORG: erphq | |
| run: | | |
| if [[ -z "${{ secrets.ORG_REFRESH_PAT }}" ]]; then | |
| echo "ORG_REFRESH_PAT not set — per-repo refresh would only be" | |
| echo "able to read/write this .github repo. Skipping." | |
| exit 0 | |
| fi | |
| # Discover repos that have a STATUS.md at root. Survey is | |
| # cheap (one /contents/STATUS.md HEAD per repo) and means | |
| # we don't have to maintain a hand-curated list — a new | |
| # repo gets the block as soon as it has STATUS.md. | |
| repos=$(gh api "orgs/$ORG/repos" --paginate -q '.[] | select(.archived | not) | .name') | |
| for repo in $repos; do | |
| if gh api "repos/$ORG/$repo/contents/STATUS.md" -q .name >/dev/null 2>&1; then | |
| echo "::group::$repo" | |
| # Each invocation is independent — a single repo failure | |
| # shouldn't break the rest. Use `|| true` to swallow. | |
| bash scripts/update-repo-status.sh "$repo" || echo "::warning::$repo update failed" | |
| echo "::endgroup::" | |
| fi | |
| done |