Skip to content

march

march #203

Workflow file for this run

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"