march #203
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: march | |
| # Cloud half of the ember autonomous loop. One scheduled invocation = | |
| # one /march tick. State of truth is the repo on origin/main; every | |
| # tick reads it fresh, no memory between runs. | |
| # | |
| # See .github/CLOUD_LOOP.md and nexus/customization/bootstrap-automation.md | |
| # for setup, debugging, and the upgrade path. | |
| on: | |
| schedule: | |
| # Every 40 min UTC. Cron */40 fires at :00 and :40 each hour, so | |
| # the gap alternates 40min/20min — ≈48 scheduled ticks/day. GH | |
| # cron always runs in UTC. Bounded downstream by: | |
| # - the daily commit ceiling (60 cloud-shipped / 24h) | |
| # - the concurrency group `march` (serializes overlapping ticks) | |
| # - the 5h timeout per tick. | |
| - cron: '*/40 * * * *' | |
| workflow_dispatch: | |
| concurrency: | |
| group: march | |
| cancel-in-progress: false | |
| jobs: | |
| march: | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 300 | |
| permissions: | |
| contents: write | |
| issues: write | |
| id-token: write | |
| steps: | |
| - name: Checkout (full history for /march dispatch) | |
| uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| # PAT (not GITHUB_TOKEN) so git push commits attribute to | |
| # you, not to github-actions[bot]. Required for the uniform | |
| # git log posture (per agents.md). Needs contents:write + | |
| # issues:write + actions:write. | |
| token: ${{ secrets.ACTIONS_PAT }} | |
| - name: Set up pnpm | |
| uses: pnpm/action-setup@v4 | |
| - name: Set up Node 22 | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: 22 | |
| # pnpm cache is only valid once pnpm-lock.yaml exists. | |
| # Phase 1 lands the lockfile; until then this is empty | |
| # (which disables caching, ~5s overhead). | |
| cache: ${{ hashFiles('pnpm-lock.yaml') != '' && 'pnpm' || '' }} | |
| - name: Install dependencies | |
| if: hashFiles('pnpm-lock.yaml') != '' | |
| run: pnpm install --frozen-lockfile | |
| # Hermetic e2e cache. Phase 1+ wires the harness; until apps/e2e | |
| # exists this step is a no-op. | |
| - name: Cache Playwright browsers | |
| if: hashFiles('apps/e2e/package.json') != '' | |
| uses: actions/cache@v4 | |
| with: | |
| path: ~/.cache/ms-playwright | |
| key: playwright-${{ runner.os }}-${{ hashFiles('apps/e2e/package.json', 'pnpm-lock.yaml') }} | |
| restore-keys: | | |
| playwright-${{ runner.os }}- | |
| - name: Install Playwright browsers (chromium only) | |
| if: hashFiles('apps/e2e/package.json') != '' | |
| run: pnpm --filter "@ember/e2e" e2e:install | |
| # Supabase CLI for the hermetic e2e leg. Skipped until | |
| # supabase/config.toml lands (phase 4 — auth migrations). | |
| - name: Install Supabase CLI | |
| if: hashFiles('supabase/config.toml') != '' | |
| uses: supabase/setup-cli@v1 | |
| with: | |
| version: latest | |
| - name: Pre-start Supabase containers | |
| if: hashFiles('supabase/config.toml') != '' | |
| run: supabase start | |
| timeout-minutes: 5 | |
| # Bring the remote Postgres schema in sync with origin/main BEFORE | |
| # the agent runs, so a tick never ships against a stale DB. The | |
| # direct DB host (db.<ref>.supabase.co) is IPv6-only and not | |
| # routable from GitHub Actions, so we go through the IPv4 session | |
| # pooler with --db-url. This needs no SUPABASE_ACCESS_TOKEN / | |
| # `supabase link` — just the project id + db password (already | |
| # secrets). The pooler's AWS shard prefix isn't derivable from the | |
| # region, so we try the known candidates and use the first that | |
| # connects. Idempotent: re-applying a recorded migration is a | |
| # no-op, so this runs every tick regardless of the commit ceiling. | |
| - name: Apply Supabase migrations | |
| if: hashFiles('supabase/migrations/*.sql') != '' | |
| env: | |
| SUPABASE_PROJECT_ID: ${{ secrets.SUPABASE_PROJECT_ID }} | |
| SUPABASE_DB_PASSWORD: ${{ secrets.SUPABASE_DB_PASSWORD }} | |
| SUPABASE_REGION: ${{ vars.SUPABASE_REGION }} | |
| run: | | |
| set -euo pipefail | |
| # Pinned, no-Docker CLI install (setup-cli@v1 is gated on | |
| # config.toml above and skipped while we have none). | |
| curl -fsSL "https://github.com/supabase/cli/releases/download/v2.98.2/supabase_linux_amd64.tar.gz" \ | |
| | sudo tar -xz -C /usr/local/bin supabase | |
| # Password may contain url/shell-special chars (#, etc.). | |
| enc_pw=$(node -e 'process.stdout.write(encodeURIComponent(process.env.SUPABASE_DB_PASSWORD))') | |
| applied=false | |
| for pool in aws-1 aws-0; do | |
| host="${pool}-${SUPABASE_REGION}.pooler.supabase.com" | |
| url="postgresql://postgres.${SUPABASE_PROJECT_ID}:${enc_pw}@${host}:5432/postgres" | |
| echo "Trying ${host} (session pooler)…" | |
| if supabase db push --db-url "$url" --yes; then | |
| echo "Migrations applied via ${pool} pooler." | |
| applied=true | |
| break | |
| fi | |
| echo " ${pool} pooler did not connect; trying next." | |
| done | |
| if [ "$applied" != "true" ]; then | |
| echo "::error::supabase db push failed on all pooler endpoints" | |
| exit 1 | |
| fi | |
| - name: Daily commit ceiling check | |
| id: ceiling | |
| run: | | |
| # Cloud commits are identified by the `Cloud-Run:` trailer | |
| # the agent appends to every commit message (agents.md §2 | |
| # carve-out). Author is daretodave on both cloud AND local | |
| # commits, so we distinguish via trailer. The ceiling | |
| # bounds *cloud-shipped volume*; local commits don't count. | |
| since="$(date -u -d '24 hours ago' '+%Y-%m-%d %H:%M:%S')" | |
| count=$(git log --since="$since" --grep='Cloud-Run:' --oneline | wc -l | tr -d ' ') | |
| echo "Cloud-shipped commits in last 24h: $count" | |
| # Ceiling: 60 cloud-shipped commits / 24h — a runaway | |
| # guard, not a pacing knob. The scheduled cadence sits | |
| # well under it; the ceiling only ever fires if a tick | |
| # (or a manual-dispatch storm) goes haywire. | |
| if [ "$count" -ge 60 ]; then | |
| echo "Cloud ceiling reached (60 cloud-shipped commits / 24h). Exiting cleanly — no work this tick." | |
| echo "skip=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "skip=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| # Git author identity is set via env vars on the Claude Code | |
| # action step below, NOT via `git config` in a separate step. | |
| # The action's internal `git config user.name "claude[bot]"` | |
| # runs AFTER any preceding workflow step and would override | |
| # repo-local config silently. The only reliable override is | |
| # the GIT_AUTHOR_* / GIT_COMMITTER_* env-var path — git checks | |
| # env vars first when reading author identity at `git commit` | |
| # time, before falling back to config. | |
| - name: Run /march (cloud mode) | |
| id: claude_run | |
| if: steps.ceiling.outputs.skip != 'true' | |
| uses: anthropics/claude-code-action@v1 | |
| with: | |
| # OAuth token bills against the user's Claude Pro/Max | |
| # subscription. $0 marginal cost; quota shared with local | |
| # sessions. | |
| claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} | |
| # Sonnet 4.6 for v1. Upgrade to opus-4-7 once Ember has | |
| # phase 1+ shipped and the cloud loop has proven stable — | |
| # opus costs ~2x weekly cap weight. | |
| claude_args: | | |
| --model claude-sonnet-4-6 | |
| --dangerously-skip-permissions | |
| prompt: | | |
| You are running ember's autonomous loop in CI (GitHub Actions). | |
| Read agents.md first for the standing rules — they all apply. | |
| Cloud-mode adjustments on top of agents.md: | |
| 1. Skip /oversight entirely. It is interactive and never runs in cloud. | |
| 2. /critique may run. It no longer needs the Chrome MCP: the /critique skill runs `node scripts/critique-walk.mjs` (reader.md Path A2 — it drives the headless chromium already cached for the e2e leg) and hands the captures to the reader sub-agent for the qualitative read. The authenticated pass needs a Supabase session cookie — /critique Step 0 mints it at runtime with `node scripts/mint-cookie.mjs` from the SUPABASE_* secrets already in this job's env (service-role key + url + anon key); the cookie is short-lived and cached to .cache/e2e-cookie.json, so there is NO static cookie secret. If the mint fails, run the anonymous pass only — do not skip critique entirely. Dispatch /critique only when its gate opens (skills/critique.md §9); do not relax that gate. /critique never mutates code — it only files rows to plan/CRITIQUE.md. | |
| 3. /expand may run; candidates land in plan/PHASE_CANDIDATES.md. Promotion happens only via local /oversight — do not promote candidates from cloud. | |
| 4. The verify gate (`pnpm verify`) and deploy gate (`pnpm deploy:check`) are non-negotiable. Same as local. NEVER --no-verify. Run the gate FOREGROUND and wait for it — never `run_in_background`. Backgrounding the gate is the one thing that wedges this loop: the SDK ends your turn while the gate is still alive, the resume notification is unreliable here, and the run hangs with no commit. If the gate is too slow for one foreground call, run it as sequential foreground legs (each its own blocking call); do not background it. | |
| 5. Commit author is David Rehmat <me@dave.blue> via GIT_AUTHOR_* / GIT_COMMITTER_* env vars (already configured by the workflow). Do not run `git config user.*` to change it — the env vars take precedence anyway, but explicit overrides muddy the log. | |
| 6. EVERY commit you ship in this tick MUST end with this exact trailer (a blank line before it, then the trailer as its own line): | |
| Cloud-Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} | |
| This is how the next tick's ceiling check distinguishes cloud-shipped commits from the user's local work — both have the same author. If a commit lacks this trailer it does not count toward the cloud ceiling, which would let the loop run away. Treat the trailer as non-negotiable, like the verify gate. agents.md's "no Co-Authored-By trailers, plain commit message bodies" rule has an explicit carve-out for this single trailer; nothing else. | |
| 7. If the tick cannot ship cleanly (red verify, red deploy, blocked phase, no work to do, anything else), do NOT half-commit. Either: | |
| - exit cleanly with a one-line note in the action log explaining why no work shipped this tick, or | |
| - if there is a real failure to surface, open a GitHub issue with `gh issue create --title "Cloud march failed: <iso-date>" --body "<context + run URL>"` and exit. Do not pass `--label` — the issue lands unlabeled and the next scheduled tick's /triage classifies and labels it appropriately. | |
| The dispatch order is the standard /march order from skills/march.md: triage → critique → ship-a-phase → ship-data → expand → iterate. | |
| Note: ember is at phase 0 (bootstrap just landed). Phase 1 (Next.js substrate) is the first /ship-a-phase target. There is no app code yet; the verify gate's typecheck/build legs will fail until phase 1 ships them. | |
| Begin. One tick. One commit-and-push (or no-op). Then exit. | |
| env: | |
| # Deploy gate | |
| VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} | |
| VERCEL_PROJECT_ID: ${{ vars.VERCEL_PROJECT_ID }} | |
| VERCEL_TEAM_ID: ${{ vars.VERCEL_TEAM_ID }} | |
| # ACTIONS_PAT (not GITHUB_TOKEN) so the agent's `gh` calls | |
| # and any direct git operations identify as you, not as the | |
| # bot. | |
| GH_TOKEN: ${{ secrets.ACTIONS_PAT }} | |
| # Supabase (runtime + e2e) | |
| NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }} | |
| NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY }} | |
| NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }} | |
| SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }} | |
| SUPABASE_PROJECT_ID: ${{ secrets.SUPABASE_PROJECT_ID }} | |
| SUPABASE_DB_PASSWORD: ${{ secrets.SUPABASE_DB_PASSWORD }} | |
| # Critique bot — /critique Step 0 mints a Supabase session | |
| # for this user via scripts/mint-cookie.mjs (Path A2 authed | |
| # pass). The mint also self-provisions the user if absent. | |
| E2E_USER_EMAIL: ${{ secrets.E2E_USER_EMAIL }} | |
| # OpenAI (in-product use; available for any phase that | |
| # needs it — moderation pre-filter, prompt fallback, | |
| # /iterate research). Available but not yet consumed. | |
| OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} | |
| # Hermetic e2e: keep the Vercel beacons quiet | |
| DISABLE_ANALYTICS: '1' | |
| # Git author identity. These four env vars take precedence | |
| # over any `git config user.*` value at `git commit` time, | |
| # including the action's own internal "Set git user as | |
| # claude[bot]" step. Without them, commits author as | |
| # claude[bot]; with them, commits attribute to the human | |
| # user the way local commits do. Email matches the user's | |
| # verified GitHub address so the avatar resolves on | |
| # github.com. | |
| GIT_AUTHOR_NAME: "David Rehmat" | |
| GIT_AUTHOR_EMAIL: "me@dave.blue" | |
| GIT_COMMITTER_NAME: "David Rehmat" | |
| GIT_COMMITTER_EMAIL: "me@dave.blue" | |
| # Surface execution problems through the GitHub-issue address | |
| # loop the rest of the project uses, instead of accumulating | |
| # transcripts as artifacts on every tick. A clean tick (no | |
| # denials, no errors, action exit 0) leaves no artifact and no | |
| # issue. A problematic tick uploads the transcript and opens a | |
| # single issue linking to it. The next /triage tick classifies | |
| # the issue naturally. | |
| - name: Inspect execution for problem signals | |
| id: inspect | |
| if: always() && steps.ceiling.outputs.skip != 'true' | |
| run: | | |
| transcript="${{ steps.claude_run.outputs.execution_file }}" | |
| outcome="${{ steps.claude_run.outcome }}" | |
| reasons="" | |
| if [ -z "$transcript" ] || [ ! -f "$transcript" ]; then | |
| if [ "$outcome" = "failure" ]; then | |
| reasons="- action crashed before producing a transcript (no artifact available)" | |
| echo "has_issues=true" >> "$GITHUB_OUTPUT" | |
| echo "has_transcript=false">> "$GITHUB_OUTPUT" | |
| else | |
| echo "has_issues=false" >> "$GITHUB_OUTPUT" | |
| echo "has_transcript=false">> "$GITHUB_OUTPUT" | |
| fi | |
| else | |
| denials=$(jq '[.[] | select(.type=="result") | .permission_denials_count // 0][0] // 0' "$transcript") | |
| is_error=$(jq '[.[] | select(.type=="result") | .is_error // false][0] // false' "$transcript") | |
| api_error=$(jq -r '[.[] | select(.type=="result") | .api_error_status // empty][0] // ""' "$transcript") | |
| if [ "$denials" != "0" ]; then reasons="${reasons}- permission_denials_count: ${denials}"$'\n'; fi | |
| if [ "$is_error" = "true" ]; then reasons="${reasons}- result.is_error: true"$'\n'; fi | |
| if [ -n "$api_error" ]; then reasons="${reasons}- api_error_status: ${api_error}"$'\n'; fi | |
| if [ "$outcome" = "failure" ]; then reasons="${reasons}- action outcome: failure"$'\n'; fi | |
| if [ -n "$reasons" ]; then | |
| echo "has_issues=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "has_issues=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| echo "has_transcript=true" >> "$GITHUB_OUTPUT" | |
| fi | |
| { | |
| echo "reasons<<EOF_REASONS" | |
| printf "%s" "$reasons" | |
| echo | |
| echo "EOF_REASONS" | |
| } >> "$GITHUB_OUTPUT" | |
| echo "--- inspect summary ---" | |
| echo "outcome: $outcome" | |
| echo "has_transcript: $([ -f "$transcript" ] && echo true || echo false)" | |
| echo "reasons:" | |
| printf "%s" "$reasons" | |
| - name: Upload Claude execution transcript (only when problematic) | |
| if: steps.inspect.outputs.has_issues == 'true' && steps.inspect.outputs.has_transcript == 'true' | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: claude-execution-${{ github.run_id }} | |
| path: ${{ steps.claude_run.outputs.execution_file }} | |
| retention-days: 30 | |
| if-no-files-found: warn | |
| - name: Open GitHub issue for problematic execution | |
| if: steps.inspect.outputs.has_issues == 'true' | |
| env: | |
| GH_TOKEN: ${{ secrets.ACTIONS_PAT }} | |
| RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} | |
| REASONS: ${{ steps.inspect.outputs.reasons }} | |
| HAS_TRANSCRIPT: ${{ steps.inspect.outputs.has_transcript }} | |
| run: | | |
| existing=$(gh issue list --search 'in:title "Cloud march execution had issues" state:open' --json number --jq 'length') | |
| if [ "$existing" != "0" ]; then | |
| echo "An open 'Cloud march execution had issues' issue already exists — skipping." | |
| exit 0 | |
| fi | |
| if [ "$HAS_TRANSCRIPT" = "true" ]; then | |
| transcript_line="**Transcript:** download the \`claude-execution-${{ github.run_id }}\` artifact from the run page (Artifacts section). 30-day retention." | |
| else | |
| transcript_line="**Transcript:** unavailable — action crashed before writing one." | |
| fi | |
| body_file="$(mktemp)" | |
| { | |
| echo "The cloud /march tick at ${RUN_URL} surfaced one or more execution issues." | |
| echo | |
| echo "**Signals:**" | |
| printf '%s\n' "${REASONS}" | |
| echo | |
| echo "${transcript_line}" | |
| echo | |
| echo "The next /triage tick will classify and label this issue." | |
| } > "$body_file" | |
| gh issue create \ | |
| --title "Cloud march execution had issues: $(date -u +%Y-%m-%dT%H:%M:%SZ)" \ | |
| --body-file "$body_file" |