Skip to content

GitHub Actions for harness checks#3

Merged
adamlutz merged 15 commits into
mainfrom
feat/ci-001-actions
May 20, 2026
Merged

GitHub Actions for harness checks#3
adamlutz merged 15 commits into
mainfrom
feat/ci-001-actions

Conversation

@adamlutz

Copy link
Copy Markdown
Member

Adds a two-job GitHub Actions workflow that runs the harness's gates on every push and PR — the CI equivalent of `./init.sh && npm run verify` plus the simulator E2E flow from PR #2.

Also codifies the project's naming standards (feature ID, branch, commit) and tightens the validators so consistency is enforced mechanically rather than by discipline alone.

Summary

`.github/workflows/harness.yml`

Two jobs, gated by `concurrency:` so superseded PR pushes don't pile up runner minutes.

Job Runner Cost Required? Triggers
`static` ubuntu-latest ~1 min yes every push + PR
`e2e-ios` macos-latest ~10–20 min advisory (`continue-on-error: true`) main pushes + PRs labelled `e2e`

`static` runs: `npm ci` → `typecheck` → `lint` → `jest` → `feature_list.json` schema → `scripts/harness-ci-checks.sh`.

`e2e-ios` runs: install Maestro → boot iOS sim → `expo prebuild` + `xcodebuild` for the iphonesimulator SDK → install + launch on booted sim → run `.maestro/flows/home.yaml` → upload `tmp/diagnostics/` as a workflow artifact.

`scripts/harness-ci-checks.sh`

Six invariants beyond what tsc / lint / jest catch:

  1. `claude-progress.md` has a "Next action" block
  2. No `.only` / `.skip` / `xit` / `debugger;` in committed code
  3. Every `done` feature has a non-empty `commitSha`
  4. All required harness files present (AGENTS.md, CLAUDE.md, init.sh, docs/HARNESS.md, …)
  5. No `.env` / `.p12` / `.jks` / `.key` files committed
  6. Current branch name matches `/-` and the embedded feature ID exists in `feature_list.json` (skips `main` / `release/` / `hotfix/`)

Naming standards (docs/HARNESS.md)

Artefact Format Example
Feature ID `-` (3-digit zero-padded) `ui-001`, `e2e-001`
Branch `/-` `feat/ui-001-home`
Commit subject `(): ` `feat(e2e-001): …`

A single `git log --grep='(ui-002)'` now returns the full story for `ui-002` across all three forms of state.

Validators

  • `scripts/feature-list-check.js` — feature-ID regex tightened to `^[a-z][a-z0-9](-[a-z][a-z0-9])*-\d{3}$`. Non-conforming IDs fail `init.sh`, `npm run verify`, and the CI `static` job.
  • `scripts/harness-ci-checks.sh` step 6 (above) — validates branch names and cross-references against `feature_list.json`.

What this PR itself verifies

Opening this PR triggers the `static` workflow against the branch's own files — that is the verification step that flips `ci-001` to `done`. After observing one green CI run, update `feature_list.json[].commitSha` to the merge commit SHA.

To exercise `e2e-ios`: add the `e2e` label to this PR. Once it has been green for a stretch, drop `continue-on-error: true` to make it required.

Notes

  • The `e2e-ios` job assumes `ios.bundleIdentifier` is `com.anonymous.rnharness` (set via `$APP_ID` env in the workflow). `app.json` does not yet set this explicitly — `expo prebuild` will derive a default. When `ui-001` / `ui-002` work begins, set this in `app.json` and align the workflow.
  • Workflow uses Node 22 (not 20 or 23) to avoid the `EBADENGINE` warning from `eslint-visitor-keys`.

Merge order

Third in the chain. PR #1 (merged) → PR #2 → this PR. The `e2e-ios` job will only have anything to run against once PR #2 lands.

🤖 Generated with Claude Code

adamlutz added a commit that referenced this pull request May 19, 2026
The harness has been silently relying on humans (or me, prompted) to
remember to open PRs. The "if it's not in a file, it doesn't exist"
principle from Lecture 03 says that's a hole. This commit fills it.

AGENTS.md → End Of Session — adds step 6 (open a PR if the branch is
reviewable) and step 7 (paste the PR URL into the session entry).

docs/SESSION.md → adds a new "Opening a PR" section with:
  - When to open (branch represents coherent work; not gated on the
    feature being fully verified)
  - The gh pr create template (simple human-readable title, body with
    summary / verification status / to-test-locally / merge order)
  - After-opening responsibilities (paste URL, watch first CI run)
  - Naming guidance (title in plain English; feature ID lives in the
    body, not the title — matches PR #1 "Initial harness setup", PR #2
    "Maestro end-to-end verification", PR #3 "GitHub Actions for harness
    checks")

claude-progress.md → adds `Opened:` line to the session handoff template
so future sessions record the PR they opened (or note explicitly that
no PR was warranted).

Why this lives on ci-001 and not on its own docs branch: opening a PR
is *what makes the CI gates from this feature useful*. The two work
together — the workflow + the discipline of triggering it. Same scope.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
adamlutz added a commit that referenced this pull request May 19, 2026
PR #2 ("Maestro end-to-end verification") merged into main, conflicting
on claude-progress.md (Next-action + Sessions blocks) and
feature_list.json (the 4th feature slot).

Resolution:
- claude-progress.md — kept this branch's narrative (ci-001 in flight,
  PR #3 open); appended the e2e merge + scaffolding sessions from main
  so the full history is preserved.
- feature_list.json — kept BOTH features (e2e-001 from main + ci-001 from
  this branch). Now 5 features total: harness-001 done, ui-001/ui-002
  todo, e2e-001 todo, ci-001 in_progress.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
adamlutz and others added 3 commits May 19, 2026 18:54
Two-job workflow at .github/workflows/harness.yml:

  static (ubuntu-latest, ~1 min, required)
    - npm ci
    - typecheck, lint, jest, feature_list.json schema
    - scripts/harness-ci-checks.sh — 5 harness-specific invariants:
        1. claude-progress.md has a "Next action" block
        2. no .only / .skip / xit / debugger in committed source
        3. every status=done feature has a non-empty commitSha
        4. all required harness files present
        5. no .env / .p12 / .jks / .key files committed

  e2e-ios (macos-latest, ~10–20 min, advisory)
    - install Maestro
    - boot iOS sim (scripts/sim-ios.sh)
    - expo prebuild + xcodebuild for the iphonesimulator SDK
    - install + launch on the booted sim
    - run .maestro/flows/home.yaml via scripts/run-maestro.sh
    - upload tmp/diagnostics/ as a workflow artifact

The e2e-ios job is `continue-on-error: true` until it has been green
for a stretch; it also only runs on main pushes or PRs labelled `e2e`
(macOS minutes are ~10× ubuntu's). Both gates are gated by
`concurrency:` so superseded PR pushes don't queue up runs.

Adds feature `ci-001` to feature_list.json (status=in_progress; flipped
to done once the workflow is observed running on GitHub). WIP=1
preserved — harness-001 is done; this branch holds the only in_progress
feature.

The e2e-ios job depends on .maestro/* and scripts/run-maestro.sh from
feat/e2e-maestro. Merge order: harness-001-close → e2e-maestro →
ci-harness → main.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the "Naming standards" section to docs/HARNESS.md and wires it into
the validators so consistency is enforced mechanically rather than by
discipline alone.

The standard, in one sentence: the feature ID is the load-bearing
identifier across `feature_list.json`, the branch name, and the commit
subject. All three must carry the same ID so a single `git log
--grep='(ui-002)'` returns the full story.

  | Artefact       | Format
  | -------------- | ---------------------------------
  | Feature ID     | <category>-<NNN>          (3-digit zero-padded)
  | Branch name    | <type>/<feature-id>-<slug>
  | Commit subject | <type>(<feature-id>): <subject>

Enforcement:
- scripts/feature-list-check.js — feature id regex tightened to
  ^[a-z][a-z0-9]*(-[a-z][a-z0-9]*)*-\d{3}$
- scripts/harness-ci-checks.sh — new step 6 validates the current branch
  name against the <type>/<feature-id>-<slug> pattern and confirms the
  embedded id exists in feature_list.json (skips main / release / hotfix).
- AGENTS.md routing map flags HARNESS.md as the home of naming standards.

Also: claude-progress.md updated for the three branch renames that
follow in subsequent push operations.

These conventions are project-specific, not from the harness course. The
course (Lecture 08) only mandates feature-IDs-as-primitives; carrying
the ID into branches and commits is a project extension to keep the
three forms of state queryable as one system.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR #4 split claude-progress.md into PROGRESS.md + DECISIONS.md and deleted
the speculative lib/ and __tests__/ directories plus several docs. Update
harness-ci-checks.sh so its invariants point at what now exists:

- Check 1/6: PROGRESS.md → "## Next steps" (was claude-progress.md →
  "Next action").
- Check 2/6: scan only app/components/hooks for stray debug markers; lib/
  and __tests__/ no longer exist.
- Check 4/6: REQUIRED set now AGENTS.md, CLAUDE.md, init.sh,
  feature_list.json, PROGRESS.md, DECISIONS.md, docs/HARNESS.md,
  docs/SESSION.md, docs/ARCHITECTURE.md, docs/RN_PLATFORM.md (drops
  claude-progress.md, docs/VERIFICATION.md, docs/E2E_TESTING.md;
  adds PROGRESS.md, DECISIONS.md, docs/SESSION.md).

Verified locally: scripts/harness-ci-checks.sh exits 0 and npm run verify
is green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@adamlutz adamlutz force-pushed the feat/ci-001-actions branch from 337fb7b to 8c50302 Compare May 19, 2026 23:57
@adamlutz adamlutz added the e2e Run the e2e-ios job on this PR (Maestro smoke on macOS sim) label May 20, 2026
adamlutz and others added 12 commits May 19, 2026 19:00
The e2e-ios job's `if:` condition reacts to the `e2e` label, but
`pull_request` defaults to `[opened, synchronize, reopened]` — so adding
the label after the PR exists wouldn't re-evaluate the gate. Adding
`labeled` to the trigger types closes that gap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First e2e-ios run failed at `xcrun simctl install booted "$APP_PATH"` with
"No such file or directory". Two bugs:

1. APP_PATH was captured relative to ios/ (the build step does `cd ios`),
   but the install step runs from the repo root. Capture the full
   absolute path via $PWD.

2. APP_ID was hardcoded as com.anonymous.rnharness via job env, but the
   actual built .app uses whatever CFBundleIdentifier is in its
   Info.plist (Expo's default is derived from the scheme name). Read
   the real bundle ID via PlistBuddy and export it; the env-level value
   is now just a fallback if the read fails.

Also adds an explicit existence check + echoed "Resolved APP_PATH/APP_ID"
lines so the next failure (if any) is easier to diagnose.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The second run got cancelled at 30m with install + launch only 30s in.
Build alone took ~28 min on a cold macos-latest runner (16 min on the
prior run — significant variability). With install + Maestro + diagnostics
needing another 5–10 min, 30 min is too tight without caching.

Raising to 60 min lets us actually observe the Maestro flow run while
caching is being designed. Plan to drop back to 30 once CocoaPods +
DerivedData caching is in place — the build itself should be 5–10 min
warm.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Last e2e-ios run (#26133345614) hit the 30 min timeout entirely in the
build step. expo prebuild + xcodebuild from a cold runner takes ~30 min
because there's no cache and --clean blew away ios/ every time. This
commit fixes both.

Adds actions/cache@v4 over the four paths that dominate native build
time:
  - ios/                                  prebuild is idempotent
  - .expo/                                Expo prebuild fingerprint
  - ~/Library/Caches/CocoaPods            pod source/binary cache
  - ~/Library/Developer/Xcode/DerivedData Xcode incremental build cache

Cache key: ios-build-<os>-<hash(package-lock.json, app.json)>, with a
restore-keys prefix so a near-miss still recovers most of the build.
On a cache hit the build step should drop from ~25–30 min → ~8–12 min,
roughly 1/3 the macOS budget per run on the free tier.

Drops `--clean` from `expo prebuild` so a restored ios/ is actually
reused. If a config plugin change ever leaks a stale ios/, bust the
cache by changing app.json (which is in the key) rather than re-adding
--clean here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Run 26134632326 finally reached the Maestro step and failed at command
parsing with 'TypeError: Cannot read property "exp" of undefined'.
Cause: `appId: ${APP_ID:-host.exp.exponent}` uses bash parameter
expansion, but Maestro evaluates `${...}` through its GraalVM JS engine.
JS reads `APP_ID:-host.exp.exponent` as a labelled statement + unary
negation + member access on undefined `host`, so the whole flow aborts
before any step runs.

Fix: keep `appId: ${APP_ID}` in the YAML and move the fallback into
scripts/run-maestro.sh (`export APP_ID="${APP_ID:-host.exp.exponent}"`).
CI runs with APP_ID already set to the built bundle's CFBundleIdentifier,
so this only changes the no-env-set default for local Expo-Go runs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Run 26135477495 got past the parse failure but died at flow start with
"Unable to launch app undefined". Maestro's GraalVM JS engine does not
automatically inherit the shell environment — variables referenced as
\${APP_ID} in YAML are resolved from JS globals, not the OS env. The
correct passthrough is `maestro test -e APP_ID=...`.

Also echoes the resolved APP_ID so the next failure (if any) shows
exactly what was passed in.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Run 26136224883 failed before the build even ran with "hashFiles couldn't
finish within 120 seconds". hashFiles patterns are matched as recursive
globs from GITHUB_WORKSPACE, so `hashFiles('package-lock.json', 'app.json')`
walked node_modules looking for nested matches and timed out.

Compute the digest explicitly with shasum over just the two intended files
and pass it as the cache key via a step output. Same auto-bust semantics
(key changes when either file changes), no traversal of node_modules.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Run 26136489399 finally got Maestro to exercise the live screen for 51s
before failing on "Assertion is false: \"Welcome!\" is visible". Root
cause: the build was -configuration Debug, which expects a running Metro
server to serve the JS bundle. CI has no Metro, so the app launches into
a blank/red-screen state and every assertVisible fails.

Switch to -configuration Release — react-native-xcode.sh now runs at
build time and embeds the JS bundle in the .app, so the app is
self-contained on the simulator. Also update the .app find path from
Debug-iphonesimulator → Release-iphonesimulator.

Trade-off: Release builds optimise (slightly slower than Debug, but with
the cache the delta is small) in exchange for a CI-runnable app.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Run 26137558247's Release build let Maestro actually exercise the live
screen. "Welcome!" and "Step 1: Try it" passed; "Step 2: Explore" failed
because iPhone 16 Pro renders Step 2 below the fold and `assertVisible`
only checks the current viewport.

Use `scrollUntilVisible` for Steps 2 and 3 — this both fixes the
assertion and provides incidental proof that ParallaxScrollView is
interactive, not just statically rendered.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Latest run (#26138282501) failed on `scrollUntilVisible: "Step 2: Explore"`
after 36s. The user-applied scrollUntilVisible approach scrolled the
viewport but still couldn't find the text.

Diagnosis: the Expo scaffold wraps "Step 2: Explore" in:

    <Link href="/modal">
      <Link.Trigger>
        <ThemedText type="subtitle">Step 2: Explore</ThemedText>
      </Link.Trigger>
      ...

`Link.Trigger` from expo-router 6 surfaces the text as part of a
context-menu trigger rather than a plain queryable node — Maestro's
accessibility view can't match the literal text, so neither
`assertVisible` nor `scrollUntilVisible` find it regardless of scroll
position.

The Step 1/2/3 lines are scaffold cruft. Once `ui-001` lands they go
away entirely. Dropping them now removes a false-failure source and
keeps the smoke flow honest. What stays:

  - assertVisible "Welcome!"     bundle loaded, home rendered
  - tab bar "Home" / "Explore"   Tabs navigator mounted
  - tapOn "Explore"              router actually works
  - takeScreenshot               visual record for the agent

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Run #26164917513 launched the app and passed `assertVisible: "Welcome!"`
(bundle loads, home renders), then failed on `text: "Home"` for the tab
bar label after 31s. Same shape as the Step 2/3 failure — Maestro's
accessibility introspection can't find the label text, either because
of iOS 26's redesigned tab bar layout or the project's HapticTab
wrapper swallowing it.

Trimming to the minimum that reliably proves the toolchain works:

  - launchApp
  - assertVisible: "Welcome!"   bundle loaded, home rendered
  - takeScreenshot: home        visual record

The dropped checks (Step N, tab labels, tap on Explore) are nice-to-have
but not core smoke value — they will move to `ui-001.yaml` once that
feature lands with harness-specific UI worth asserting on, at which
point we can also use stable accessibility IDs instead of label text.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previous runs all showed "Cache not found" on every restore because
actions/cache@v4 only writes its post-step on full-job success, and
every prior run failed at Maestro before that step could run. The most
recent run (#26166345238) finally passed end-to-end and did save the
cache — but any future failure between build and Maestro would throw
the build away again.

Splitting into actions/cache/restore@v4 (top of job) +
actions/cache/save@v4 (with `if: always() && cache-hit != 'true'` at
the end) is the canonical robust pattern. After this commit:

  - the very next run will be a CACHE HIT (the prior run saved
    ios-build-macOS-2aa375aaad3bfec0) → build should drop from ~20 min
    to ~8–12 min
  - if a future Maestro change breaks the flow but the build still
    succeeds, the cache will save anyway and the iteration after that
    benefits

Path list duplicated between restore and save (no YAML anchor support
in GHA). Comment in both blocks notes the constraint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@adamlutz adamlutz merged commit c8bb4c9 into main May 20, 2026
2 checks passed
adamlutz added a commit that referenced this pull request May 20, 2026
The repo had drifted from project-specific notes ("the bring-up is
complete", "next available work is ui-001") toward template-shaped
content (everything in scripts/, docs/, .maestro/ is generic harness
machinery). This commit makes the intent explicit.

README rewrite:
- Opens with a "GitHub template — Use this template" callout so the
  github.com button is discoverable.
- New "What's included" section listing harness subsystems, init
  phase, baseline gate, end-of-session gate, naming standard,
  Maestro, sim helpers, GitHub Actions CI.
- New "Conspicuously not included" section — calls out the absence of
  lib/, __tests__/, design system, networking. The harness is about
  HOW, not WHAT.
- New "First things to edit" section walking a user through the
  setup tasks after cloning (README, app.json, feature_list.json,
  PROGRESS.md, DECISIONS.md, package.json).

GitHub template flag set via `gh repo edit --template`. Verified:
  $ gh repo view --json isTemplate
  {"isTemplate": true}

feature_list.json:
- Flipped harness-002 to done at SHA be68289 (was still in_progress
  from PR #4's working state; the merge to main happened but the
  bookkeeping commit was missing — fixing here).
- Added docs-001 (this feature) with status in_progress. WIP=1 ✓.

PROGRESS.md:
- Updated to reflect harness-002 done, docs-001 in progress, PR #3
  still open. Next steps point at landing PR #3 before this PR so
  the README's GitHub Actions reference is accurate on main.

The README mentions .github/workflows/harness.yml which lands when
PR #3 merges — this PR should merge after that one.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
adamlutz added a commit that referenced this pull request May 20, 2026
PR #3 merged into main while this branch was open; rebase resolved the
feature_list.json conflict (kept ci-001 from main, flipped it to done
at c8bb4c9; kept docs-001 from this branch). PROGRESS.md was
automatically merged but still referenced PR #3 as "open" — fixing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
adamlutz added a commit that referenced this pull request May 20, 2026
Per user direction: PROGRESS.md's only durable value (the next-best-step
pointer) is derivable from features/ — pick the highest-priority
in_progress feature, else the first todo. Everything else PROGRESS.md
tried to carry was duplicated elsewhere (status in features/, history
in git log, decisions in DECISIONS.md, per-branch context in PR
descriptions).

WHAT'S REMOVED
- PROGRESS.md (deleted)
- All references across AGENTS.md, CLAUDE.md, README.md, init.sh,
  docs/{HARNESS,SESSION,ARCHITECTURE}.md, scripts/check-clean-state.sh,
  scripts/harness-ci-checks.sh

WHAT'S NEW: CLOSE-BEFORE-MERGE CONVENTION
The in-flight feature's status is flipped to `done` in the LAST commit
on the branch BEFORE the merge, with `commitSha` set to that commit
(the latest implementation commit). The PR's merge then carries the
closed feature into main as part of its own diff. No follow-up
bookkeeping commit.

This is the harness's answer to the recurring post-merge bookkeeping
gap that bit ci-actions (PR #3), harness-features (PR #6), and
docs-template (PR #7) — three consecutive PRs, same shape, same fix
forgotten every time.

Documented in docs/SESSION.md → "The close-before-merge convention"
and surfaced in AGENTS.md's End-of-session steps. scripts/check-clean-state.sh
check 4 now warns if the in-flight feature is still in_progress
(soft warning — the work may legitimately not be ready to merge).

OTHER CHANGES
- init.sh: 7 steps → 6 (no PROGRESS tail at the end)
- scripts/harness-ci-checks.sh: 6 checks → 5 (dropped PROGRESS-has-Next-steps)
- scripts/check-clean-state.sh: check 4 reshaped to check feature status
- DECISIONS.md: replaced the 'trim PROGRESS.md' entry with the
  'remove PROGRESS.md' decision documenting the convention

NOTE ON THIS PR'S OWN CLOSE-BEFORE-MERGE
This commit is the implementation. The close-before-merge flip
happens in the next commit on this branch (referencing this commit's
SHA as commitSha).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

e2e Run the e2e-ios job on this PR (Maestro smoke on macOS sim)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant