Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .github/workflows/makefile.yml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
56 changes: 53 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
136 changes: 136 additions & 0 deletions tests/makefile.sh
Original file line number Diff line number Diff line change
@@ -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
# "<folder> <command>" 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 "<folder> <command>" 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"