diff --git a/.github/workflows/makefile.yml b/.github/workflows/makefile.yml new file mode 100644 index 000000000..d2d9e0610 --- /dev/null +++ b/.github/workflows/makefile.yml @@ -0,0 +1,33 @@ +name: Makefile + +on: + push: + branches: [main] + paths: + - "Makefile" + - "tests/makefile.sh" + - ".github/workflows/makefile.yml" + pull_request: + paths: + - "Makefile" + - "tests/makefile.sh" + - ".github/workflows/makefile.yml" + +permissions: + contents: read + +concurrency: + group: makefile-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + makefile-dispatch: + name: Makefile dispatch + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + # Stub suites only; no toolchain is installed and no suite runs. + - run: bash tests/makefile.sh diff --git a/AGENTS.md b/AGENTS.md index 34e3e520d..af4a8b6f5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,7 +51,7 @@ A user's instruction outranks a skill. ## Working in the repository -- `make check` runs every suite. `make ios`, `make server` and `make web` run one each. +- `make check` runs every suite on the branch. `make ios`, `make server`, `make web`, `make scoring`, `make measure-lab`, `make evals`, `make recon` and `make meter-closeup` run one each. - Branch from `main`, keep one writer per branch, and open a pull request. People merge, and agents never push to `main` or merge, so a person sees every change before it lands. - Several agents share one Mac. Exit code 137 means the system killed the process, usually for memory. Find the large allocation before rerunning, because one runaway process can freeze the whole machine. - Put `DEVELOPMENT_TEAM` in `ios/Config/Local.xcconfig` (copy `Local.xcconfig.example`), not in Xcode's Signing & Capabilities pane. The pane writes into `project.pbxproj`, and CI fails on that drift. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0ce41fefe..ced70102c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -29,11 +29,12 @@ The `size:*` label describes the effective diff. It is a review signal, not a me | Server checks | `server/` changes | `make server` | | Web checks | `web/` changes | `make web` | | iOS build | `ios/` changes | `make ios` | +| Makefile dispatch | `Makefile` or `tests/makefile.sh` changes | `bash tests/makefile.sh` | | Sync label definitions | `.github/labels.json` changes on `main` | none | | Label PR size | a pull request opens or updates | none | | TestFlight | someone runs it from the Actions tab | none | -`make check` runs the three local suites. They need uv, Node 24 with pnpm, and Xcode 26 or newer; each directory's README has details. No check is required by branch rules yet. Don't call one required until the rules require its status. +`make check` runs the server, web and iOS suites, then `make scoring`, `make measure-lab`, `make evals`, `make recon` and `make meter-closeup` for each of those folders the branch has. They need uv, Node 24 with pnpm, and Xcode 26 or newer; each directory's README has details. No check is required by branch rules yet. Don't call one required until the rules require its status. Keep workflows that run pull-request code away from production credentials and destructive external systems. A green CI run is evidence for the checks it ran, not proof that a capture works on a real house. diff --git a/Makefile b/Makefile index 13bca16c8..cf6183cd7 100644 --- a/Makefile +++ b/Makefile @@ -1,13 +1,34 @@ # Each suite target runs the commands of its CI job in .github/workflows/. -# `make check` runs all three. +# `make check` runs server, web and ios, then scoring, measure-lab, evals, recon +# and meter-closeup where the branch has them. +# tests/makefile.sh checks this dispatch with stub suites. XCODEGEN ?= xcodegen # Must match XCODEGEN_VERSION in .github/workflows/ios.yml; other versions write a different project. XCODEGEN_VERSION := 2.46.0 -.PHONY: check server web ios ios-project +# The file each experiment suite's target needs. These suites arrive in separate pull requests, so +# `check` includes one only when its file exists and the Makefile works before and after each +# merge. Once all five are on main, list them in `check` directly so a deleted folder fails. +SCORING := experiments/scoring/pyproject.toml +MEASURE_LAB := experiments/measure-lab/Makefile +EVALS := experiments/evals/Makefile +RECON := recon/Makefile +METER_CLOSEUP := experiments/meter-closeup/Makefile -check: server web ios +# $(call present,FILE,TARGET) is TARGET when FILE exists, otherwise nothing. +present = $(if $(wildcard $(1)),$(2)) +# $(call require,FILE,PR): stop before any command runs when the suite's file is missing. +require = @test -f $(1) || { echo "make $@: $(1) is missing; the suite arrives with pull request $(2)." >&2; exit 1; } + +.PHONY: check server web ios ios-project scoring measure-lab evals recon meter-closeup + +check: server web ios \ + $(call present,$(SCORING),scoring) \ + $(call present,$(MEASURE_LAB),measure-lab) \ + $(call present,$(EVALS),evals) \ + $(call present,$(RECON),recon) \ + $(call present,$(METER_CLOSEUP),meter-closeup) server: cd server && uv sync --locked @@ -34,3 +55,32 @@ ios-project: exit 1; \ fi $(XCODEGEN) generate --spec ios/project.yml + +# The commands of .github/workflows/scoring.yml. The folder has no Makefile of its own. +scoring: + $(call require,$(SCORING),#4) + cd experiments/scoring && uv sync --locked + cd experiments/scoring && uv run ruff check . + cd experiments/scoring && uv run ruff format --check . + cd experiments/scoring && uv run pytest -q + +# The Geometry package's tests, then the app build. +measure-lab: + $(call require,$(MEASURE_LAB),#7) + $(MAKE) -C experiments/measure-lab check + +# The folder's `make test` lints and tests but leaves out the `uv sync --locked` that +# .github/workflows/evals.yml runs first, and a plain `uv run` rewrites a stale uv.lock instead of failing. +evals: + $(call require,$(EVALS),#12) + cd experiments/evals && uv sync --locked + $(MAKE) -C experiments/evals test + +recon: + $(call require,$(RECON),#20) + $(MAKE) -C recon test + +# Lint and unit tests only. The separate leak scan needs a private key and must not run here. +meter-closeup: + $(call require,$(METER_CLOSEUP),#16) + $(MAKE) -C experiments/meter-closeup check diff --git a/README.md b/README.md index 22dfdf177..9887cda1c 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,6 @@ Paths marked with a pull request exist only on that branch until it merges. - The demo server at https://house-scanning-server.vercel.app runs the engine from PR #11 with public rules only, and every answer says so. `GET /health` shows which rules are loaded. A second deployment loads Base's rules and requires a key. - TestFlight builds of the app and Measure Lab start by hand from the Actions tab, as [CONTRIBUTING.md](CONTRIBUTING.md) describes. -- `make check` runs the server, web and iOS suites that CI runs. +- `make check` runs CI's local checks for the server, web and iOS, plus the scoring, Measure Lab, evals, recon and meter close-up folders when the branch has them. This repository is public. Materials Base gave the team stay in the git-ignored `private/` folder, and photos of real homes never enter git. diff --git a/tests/makefile.sh b/tests/makefile.sh new file mode 100755 index 000000000..9e7552bd5 --- /dev/null +++ b/tests/makefile.sh @@ -0,0 +1,136 @@ +#!/usr/bin/env bash +# Checks which suites the root Makefile runs, without running any of them. Each case copies the +# Makefile into an empty temporary tree, adds stub experiment folders, and puts stub uv, pnpm and +# xcodebuild first on PATH. Every stub, including the stub folder Makefiles, appends +# " " to a log, so the log shows which suites ran and with what commands. +# Run it from anywhere: bash tests/makefile.sh +set -euo pipefail + +root=$(cd "$(dirname "$0")/.." && pwd) +# Resolved, because the stubs compare it with $PWD, and macOS's /var is a link to /private/var. +work=$(cd "$(mktemp -d)" && pwd -P) +trap 'rm -rf "$work"' EXIT +export LOG="$work/log" TREE="$work/tree" FAIL_AT="" +failures=0 + +# A stub fails when FAIL_AT is " " for it, such as "experiments/evals make". +mkdir "$work/bin" +for tool in uv pnpm xcodebuild; do + cat >"$work/bin/$tool" <<'EOF' +#!/bin/sh +dir=${PWD#"$TREE"/} +[ "$dir" = "$PWD" ] && dir=. +echo "$dir $(basename "$0") $*" >>"$LOG" +[ "$dir $(basename "$0")" != "$FAIL_AT" ] +EOF + chmod +x "$work/bin/$tool" +done +export PATH="$work/bin:$PATH" + +suites="scoring measure-lab evals recon meter-closeup" +folder() { + case $1 in + recon) echo recon ;; + *) echo "experiments/$1" ;; + esac +} + +# Makes a tree with the root Makefile and the named experiment suites. Every suite but scoring +# gets a stub Makefile with the target the root calls; scoring gets only its pyproject.toml. +make_tree() { + rm -rf "$TREE" "$LOG" + mkdir -p "$TREE/server" "$TREE/web" + cp "$root/Makefile" "$TREE/" + : >"$LOG" + local suite dir target + for suite in "$@"; do + dir=$(folder "$suite") + mkdir -p "$TREE/$dir" + case $suite in + scoring) touch "$TREE/$dir/pyproject.toml"; continue ;; + measure-lab | meter-closeup) target=check ;; + *) target=test ;; + esac + printf '%s:\n\t@echo "%s make %s" >>"$$LOG"\n\t@test "$$FAIL_AT" != "%s make"\n' \ + "$target" "$dir" "$target" "$dir" >"$TREE/$dir/Makefile" + done +} + +run_make() { make -C "$TREE" "$@" >"$work/out" 2>&1; } + +fail() { + echo "FAIL: $*" >&2 + sed 's/^/ log: /' "$LOG" >&2 + sed 's/^/ make: /' "$work/out" >&2 + failures=$((failures + 1)) +} + +# 1. `make check` runs server, web and ios, then exactly the experiment suites present, in order, +# for all 32 combinations. "." is the ios build, which runs from the root. +for mask in $(seq 0 31); do + present=() + expected="server web ." + i=0 + for suite in $suites; do + if (((mask >> i) & 1)); then + present+=("$suite") + expected="$expected $(folder "$suite")" + fi + i=$((i + 1)) + done + make_tree ${present[@]+"${present[@]}"} + if ! run_make check; then + fail "check with [${present[*]-}] exited non-zero" + continue + fi + got=$(awk '{print $1}' "$LOG" | uniq | tr '\n' ' ' | sed 's/ $//') + [ "$got" = "$expected" ] || fail "check with [${present[*]-}] ran [$got], expected [$expected]" +done + +# 2. Each suite target runs its CI job's commands, in order. +expect_commands() { + local suite=$1 expected=$2 + make_tree "$suite" + if ! run_make "$suite"; then + fail "make $suite exited non-zero" + return + fi + [ "$(cat "$LOG")" = "$expected" ] || fail "make $suite ran the wrong commands" +} +expect_commands scoring "experiments/scoring uv sync --locked +experiments/scoring uv run ruff check . +experiments/scoring uv run ruff format --check . +experiments/scoring uv run pytest -q" +expect_commands measure-lab "experiments/measure-lab make check" +expect_commands evals "experiments/evals uv sync --locked +experiments/evals make test" +expect_commands recon "recon make test" +expect_commands meter-closeup "experiments/meter-closeup make check" + +# 3. Naming a missing suite fails loudly and runs nothing. +for suite in $suites; do + make_tree + if run_make "$suite"; then + fail "make $suite succeeded without its folder" + elif [ -s "$LOG" ] || ! grep -q "make $suite: .* is missing" "$work/out"; then + fail "make $suite without its folder ran commands or gave no reason" + fi +done + +# 4. A failing command inside a present suite fails `make check`, and nothing runs after it. +for FAIL_AT in "experiments/scoring uv" "experiments/measure-lab make" "experiments/evals uv" \ + "experiments/evals make" "recon make" "experiments/meter-closeup make"; do + make_tree $suites + if run_make check; then + fail "check passed although $FAIL_AT failed" + elif [ "$(tail -n 1 "$LOG" | cut -d' ' -f1-2)" != "$FAIL_AT" ]; then + fail "check kept going after $FAIL_AT failed" + fi +done +FAIL_AT="" + +if [ "$failures" -ne 0 ]; then + echo "$failures Makefile dispatch case(s) failed" >&2 + exit 1 +fi +echo "Makefile dispatch: all cases pass"