Skip to content

Build Explainer

Build Explainer #109

name: Build Explainer
# Hosted runner for explainmyrepo: given a GitHub repo, build its explainer with the owner's
# keys (repo secrets), deploy it to its own Netlify site, and write progress back to a status
# gist that the website polls. Triggered by the /build Netlify function.
#
# ARCHITECTURE (2026-07-04 rebuild — see project memory "hosted-agentic-architecture", approved
# 2026-06-30 but never actually wired until now): this invokes bin/agentic-runner.mjs, which runs
# the real explainmyrepo SKILL as a headless Claude Code agent — the brain decides, tools/*.mjs do
# the mechanics — instead of the old deterministic orchestrator.mjs pipeline. GitHub Actions is
# still the right HOST for this (ADR-033, agent-harness-generator: triggered, autonomous, no human
# at the keyboard); what changed is that it now runs the AGENT, which adapts per-repo, instead of a
# fixed script that broke on off-mold repos (empty dep-graphs, oversized monorepos).
#
# RAILS: timeout-minutes is COMPUTED from budget_min (a pre-flight repo-size signal from build.js)
# instead of one fixed ceiling for every repo · serialized concurrency (no cost fan-out) · the
# runner enforces its own wall-clock + $ budget and stops cleanly before the outer timeout would
# SIGKILL it · on any failure it alerts the owner directly with full particulars (never silent).
on:
workflow_dispatch:
inputs:
target_repo:
description: "owner/repo to explain"
required: true
build_id:
description: "correlation id (matches the status gist)"
required: false
gist_id:
description: "status gist to write progress into"
required: false
submitter_email:
description: "email to notify when done (optional)"
required: false
budget_min:
description: "wall-clock budget in minutes, sized to repo complexity by build.js"
required: false
default: "20"
budget_usd:
description: "$ budget for this build, sized to repo complexity by build.js"
required: false
default: "6"
timeout_min:
description: "budget_min + overhead buffer, pre-summed by build.js (GH Actions expressions don't do arithmetic on inputs)"
required: false
default: "45"
concurrency:
group: build-explainer
cancel-in-progress: false
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
# The agent enforces its OWN budget (budget_min) internally and exits cleanly well before this
# outer ceiling would ever need to fire — this is just the backstop. timeout_min is budget_min +
# a fixed overhead buffer, computed in build.js (plain JS), not here: GitHub Actions expressions
# don't support arithmetic on inputs the way `fromJSON(x) + 10` implies — learned this the hard
# way, it rejects the whole dispatch at parse time, which a YAML-structure check doesn't catch
# since it's an expression-syntax error, not a YAML one.
timeout-minutes: ${{ fromJSON(inputs.timeout_min || '30') }}
steps:
- name: Checkout the explainer tool
uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Validate input (abuse guard)
env:
# Template-expanded into the env table, not spliced into the script — the raw value never
# touches the shell before validation (a target_repo containing a stray quote could
# otherwise break out of the single-quoted context at YAML-expansion time).
REPO: ${{ github.event.inputs.target_repo }}
run: |
echo "target: $REPO"
if ! printf '%s' "$REPO" | grep -qE '^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$'; then
echo "::error::invalid repo format (expected owner/name): $REPO"; exit 1
fi
- name: Install dependencies
run: |
npm ci || npm install
# Belt-and-suspenders: guarantee the 3 runtime deps are present (CI 'npm ci' was leaving
# playwright unresolved -> quality-grade failed). EXACT pinned versions + --no-save so this
# cannot drift to a newer/compromised patch or rewrite the committed lockfile (no supply-
# chain bypass; these match package-lock.json).
npm install --no-save playwright@1.61.1 @xenova/transformers@2.17.2 @ruvector/rvf@0.2.2
npx playwright install --with-deps chromium
# System tools the pipeline shells out to (scanned from tools/*.mjs), not all preinstalled
# on the runner: ImageMagick (magick/convert/identify — favicon, social card), xmllint
# (libxml2-utils — diagram SVG validation), dot (graphviz — diagram layout), zip (AI pack).
# Fonts help text render in generated imagery.
sudo apt-get update && sudo apt-get install -y imagemagick libxml2-utils graphviz zip fonts-liberation fonts-dejavu-core
# The agentic kernel itself (bin/agentic-runner.mjs spawns this headless) — pinned exact
# version, same rationale as the runtime deps above: no supply-chain drift on a bare runner.
npm install -g @anthropic-ai/claude-code@2.1.201
claude --version
# 2026-07-16 HF outage (build 79f135b3): a CloudFront outage made the mandatory embedding
# model undownloadable and the agent burned its whole budget discovering it. Cache the
# 33 MB model between runs so KB builds are offline-first; the warm step downloads it only
# when the cache is cold and VALIDATES it with one real embedding. Warm failure is a soft
# warning: the runner's own embedding preflight is the honest gate and alerts in seconds.
- name: Restore embedding model cache (offline-first KB)
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: kb/models-cache
key: kb-embed-bge-small-en-v1.5-v1
- name: Warm + validate embedding model
run: node kb/warm-model-cache.mjs || echo "::warning::embedding model warm failed — the runner preflight will stop in seconds with a clear alert if there is truly no embedding source"
- name: Build, deploy, and report status
env:
# These reach agentic-runner.mjs's OWN env (used for gist-patching, notify, and the
# failure alert) — the runner builds a SEPARATE, much narrower allowlist for the spawned
# agent process itself (see bin/agentic-runner.mjs), which never sees GH/SMTP creds.
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
# Failover lane only (2026-07-19): engaged by the runner's preflight EXCLUSIVELY when the
# primary key is usage-limited — same executor model via OpenRouter's Anthropic Skin.
# Empty when the secret is unset: the lane stays inert and behavior is unchanged.
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
SMTP_USER: ${{ secrets.GMAIL_USER }}
SMTP_PASS: ${{ secrets.GMAIL_APP_PASSWORD }}
EXPLAINER_GH_TOKEN: ${{ secrets.EXPLAINER_GH_TOKEN }}
BUILD_ID: ${{ github.event.inputs.build_id }}
GIST_ID: ${{ github.event.inputs.gist_id }}
REPO: ${{ github.event.inputs.target_repo }}
SUBMITTER: ${{ github.event.inputs.submitter_email }}
BUDGET_MIN: ${{ github.event.inputs.budget_min }}
BUDGET_USD: ${{ github.event.inputs.budget_usd }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
set +e
set -o pipefail
node bin/agentic-runner.mjs "$REPO" \
--build-dir "$RUNNER_TEMP/hosted-build" \
--budget-min "${BUDGET_MIN:-20}" \
--budget-usd "${BUDGET_USD:-6}" \
--submitter "$SUBMITTER" \
--run-url "$RUN_URL" \
--build-id "$BUILD_ID" \
--gist-id "$GIST_ID" \
2>&1 | tee /tmp/build.log
RC=${PIPESTATUS[0]}
URL=$(grep -oE '^LIVE: https://\S+' /tmp/build.log | tail -1 | sed 's/^LIVE: //')
if [ "$RC" -eq 0 ] && [ -n "$URL" ]; then
echo "LIVE: $URL"
echo "### Explainer ready → $URL" >> "$GITHUB_STEP_SUMMARY"
else
# agentic-runner.mjs already patched the gist + emailed the owner before exiting non-zero.
echo "::error::build failed (rc=$RC, url=$URL)"
echo "---- build.log tail (Actions run log only — NOT published) ----"
tail -c 2000 /tmp/build.log || true
exit 1
fi
# A failed run's assembled work is NOT garbage (learned 2026-07-15: autonomous-wealth-builder
# built a complete grounded site, stalled at B5 56/58, and the runner's _temp dir evaporated
# with the job — the whole spend was lost when a 10-minute operator cure could have finished
# it). Preserve the curable core (site + build.json + graded assets; NOT the repo clone, NOT
# logs) so the operator can download, fix, re-grade, and deploy without a rebuild.
- name: Preserve curable build on failure
if: failure()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: failed-build-${{ github.event.inputs.build_id || github.run_id }}
path: |
${{ runner.temp }}/hosted-build/site
${{ runner.temp }}/hosted-build/assets
${{ runner.temp }}/hosted-build/build.json
retention-days: 7
if-no-files-found: warn