diff --git a/.githooks/pre-push b/.githooks/pre-push index 972a657e..04130dda 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -1,27 +1,8 @@ -#!/bin/bash -# Posts the "Validate (local)" status for each pushed branch commit that -# scripts/validate.sh has already validated (see README.md#validation). It never -# blocks the push: a commit that hasn't been validated just doesn't get the -# check, and main won't accept it until it does. -# -# Enable once per clone: git config core.hooksPath .githooks - -repo_root=$(git rev-parse --show-toplevel) -state_dir="$(cd "$repo_root" && cd "$(git rev-parse --git-common-dir)" && pwd)/validate" -mkdir -p "$state_dir/logs" - -while read -r _local_ref local_sha remote_ref _remote_sha; do - case "$remote_ref" in refs/heads/*) ;; *) continue ;; esac - case "$local_sha" in *[!0]*) ;; *) continue ;; esac # deleting the branch - - if [ -f "$state_dir/results/$local_sha" ]; then - # The commit only reaches GitHub after this hook returns, so post from the - # background once it's there. - nohup "$repo_root/scripts/validate.sh" --post "$local_sha" \ - >"$state_dir/logs/post.log" 2>&1 & - else - echo "validate: ${local_sha:0:9} (${remote_ref#refs/heads/}) hasn't been validated; run 'make validate' in Vault/ to get the green check." >&2 - fi -done - +#!/bin/sh +# Posts local-check results for pushed commits: https://github.com/badbundle/local-check +bin="$(git rev-parse --show-toplevel)/node_modules/.bin/local-check" +if [ -x "$bin" ]; then + exec "$bin" hook pre-push "$@" +fi +echo "local-check isn't installed, so results won't be posted. Run bun install." >&2 exit 0 diff --git a/.gitignore b/.gitignore index 287ee17c..bc31509b 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,6 @@ vendor/ *.p12 *.mobileprovision *.ipa + +# Bun +node_modules/ diff --git a/AGENTS.md b/AGENTS.md index d1dc2c1f..632bf730 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,7 @@ Read [`MANIFESTO.md`](./MANIFESTO.md) before proposing or implementing any featu ## Validation -There is no hosted CI. Before a PR can merge into `main`, its latest commit needs the **Validate (local)** status check, which is posted by running `make validate` in `Vault/`. See [Validation](./README.md#validation) in the README and the rules in [`Vault/AGENTS.md`](./Vault/AGENTS.md). +There is no hosted CI. Before a PR can merge into `main`, its latest commit needs the **Validate (local)** status check, which is posted by running `make validate` in `Vault/`, using the checks in [`local-check.config.ts`](./local-check.config.ts). See [Validation](./README.md#validation) in the README and the rules in [`Vault/AGENTS.md`](./Vault/AGENTS.md). ## Layout diff --git a/README.md b/README.md index a5370a5e..c4edf51c 100644 --- a/README.md +++ b/README.md @@ -58,15 +58,15 @@ As soon as we are able, we will be dropping the xcodeproj project wrapper and go There is no hosted CI. Changes are validated on the developer's Mac, and the result is posted to the commit on GitHub as the **Validate (local)** status check. `main` requires that check, so a PR can't be merged until its latest commit has passed. -1. Once per clone, enable the pre-push hook: `git config core.hooksPath .githooks` +1. Install [Bun](https://bun.com), then run `bun install` at the root of the repo, once per clone. It installs [local-check](https://github.com/badbundle/local-check), the tool that does the validating, and enables its pre-push hook. 2. Commit your changes, then run `make validate` from `/Vault`. -`make validate` ([`scripts/validate.sh`](./scripts/validate.sh)) checks out the exact commit into a separate worktree, so uncommitted changes and your usual DerivedData can't affect the result. It then runs, with Xcode 27.0: +The checks are in [`local-check.config.ts`](./local-check.config.ts). local-check checks out the exact commit into a separate worktree, so uncommitted changes and your usual DerivedData can't affect the result. With Xcode 27.0, it then runs: -- `make lint` -- the Fastlane config check (skipped, and noted on the check, if the Ruby version in `.ruby-version` isn't installed) -- a build and full run of the `iOSAllTests` test plan on a throwaway iPhone 18 Pro Max / iOS 27.0 simulator, created for the run and deleted afterwards +- `make lint`; +- the Fastlane config check, which is skipped, and noted on the check, if the Ruby version in `.ruby-version` isn't installed; +- a build and full run of the `iOSAllTests` test plan on a throwaway iPhone 18 Pro Max / iOS 27.0 simulator, created for the run and deleted afterwards. -If the commit is already on GitHub, the result is posted straight away. Otherwise it's stored, and the pre-push hook posts it when you push, so you can validate before or after pushing. Every new commit needs validating again. Logs are kept in `.git/validate/logs/`. +If the commit is already on GitHub, the result is posted straight away. Otherwise it's stored, and the pre-push hook posts it when you push, so you can validate before or after pushing. Every new commit needs validating again. Logs are kept in `.git/local-check/logs/`. The check is self-attested: it records that the commit passed on the machine that posted it, rather than on independent CI. diff --git a/Vault/AGENTS.md b/Vault/AGENTS.md index 0b0fa10c..696b132f 100644 --- a/Vault/AGENTS.md +++ b/Vault/AGENTS.md @@ -16,9 +16,10 @@ Before every commit, run `make format` and `make lint` from the `Vault/` directo ## Validating a Pull Request -There is no hosted CI. `main` only accepts a PR whose latest commit has the **Validate (local)** status check, and only `make validate` posts it (see [Validation](../README.md#validation)). +There is no hosted CI. `main` only accepts a PR whose latest commit has the **Validate (local)** status check, and only `make validate` posts it (see [Validation](../README.md#validation)). The checks it runs are in [`local-check.config.ts`](../local-check.config.ts). +- If `bun install` hasn't been run in this clone, run it at the root of the repo first. - Commit first, then run `make validate` from the `Vault/` directory. It validates the committed `HEAD` in a clean worktree, so uncommitted changes aren't covered. - Run it again after every new commit on a PR branch: each commit needs its own check. - If it fails, fix the problem, commit, and validate the new commit. Never post, edit or fake the status by hand (for example with `gh api .../statuses`), and don't work around a failing test to get a green check. -- It takes several minutes. Tell the user whether it passed, and if it didn't, which step failed and where its log is. +- It takes several minutes. Tell the user whether it passed, and if it didn't, which check failed and where its log is. diff --git a/Vault/Makefile b/Vault/Makefile index 35349038..2d806f58 100644 --- a/Vault/Makefile +++ b/Vault/Makefile @@ -20,11 +20,13 @@ lint: swift package plugin --allow-writing-to-package-directory swiftlint --strict --quiet ./Sources swift package --allow-writing-to-package-directory format --lint --sources=./ -# Validation: lint, build and test the current commit in a clean worktree, then -# post the green "Validate (local)" check to GitHub. See ../README.md#validation. +# Validation: runs the checks in ../local-check.config.ts on the current commit, +# in a clean worktree, then posts the green "Validate (local)" check to GitHub. +# Installing first keeps local-check at the version package.json pins. +# See ../README.md#validation. .PHONY: validate validate: - ../scripts/validate.sh + cd .. && bun install --frozen-lockfile --silent && bun run --silent validate # Marketing screenshots: capture raw shots of the app on throwaway simulators, # then put them in device frames with titles. See Screenshots/README.md. diff --git a/bun.lock b/bun.lock new file mode 100644 index 00000000..f76d98d0 --- /dev/null +++ b/bun.lock @@ -0,0 +1,14 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "devDependencies": { + "@badbundle/local-check": "github:badbundle/local-check#v0.1.0", + }, + }, + }, + "packages": { + "@badbundle/local-check": ["@badbundle/local-check@github:badbundle/local-check#ec24fc9", { "bin": { "local-check": "src/cli.ts" } }, "badbundle-local-check-ec24fc9", "sha512-D5hLBd6ui/hwMULjh8vsqJejvcWUZX1idPKu2rb/UCKwrtmv223RgqWCOvA9JcrfbWF2JYnHvNGOCpDNjmuyWw=="], + } +} diff --git a/local-check.config.ts b/local-check.config.ts new file mode 100644 index 00000000..69de1571 --- /dev/null +++ b/local-check.config.ts @@ -0,0 +1,58 @@ +import type { ConfigFunction, Context } from "@badbundle/local-check"; + +// The checks `make validate` runs on a commit before it posts the green +// "Validate (local)" check. See README.md#validation. +export default (({ xcode }) => { + const ios = xcode({ + version: "27.0", + // The snapshot tests check the device name, so the throwaway simulator + // has to use exactly this one. + simulator: { + name: "iPhone 18 Pro Max", + deviceType: "com.apple.CoreSimulator.SimDeviceType.iPhone-18-Pro-Max", + runtime: "com.apple.CoreSimulator.SimRuntime.iOS-27-0", + }, + }); + + return { + // SwiftLint and swift-format build into Vault/.build; keeping it makes + // lint incremental. + worktree: { keep: ["Vault/.build"] }, + setup: [ios.setup], + checks: [ + { name: "Lint", run: ["make", "-C", "Vault", "lint"] }, + { + name: "Fastlane config", + async skip(ctx) { + const wanted = (await Bun.file(`${ctx.worktree}/.ruby-version`).text()).trim(); + const { stdout } = await ctx.capture(["ruby", "-e", "print RUBY_VERSION"], { env: await rubyEnv(ctx) }); + return stdout === wanted ? undefined : `Ruby ${wanted} from .ruby-version isn't installed`; + }, + async run(ctx) { + const env = await rubyEnv(ctx); + await ctx.exec(["bundle", "install", "--quiet"], { env }); + await ctx.exec(["bundle", "exec", "ruby", "-c", "fastlane/Fastfile"], { env }); + await ctx.exec(["bundle", "exec", "fastlane", "lanes"], { env }); + }, + }, + ios.buildForTesting({ + name: "Build", + workspace: "Vault.xcworkspace", + scheme: "CI_iOS", + testPlan: "iOSAllTests", + flags: ["-skipMacroValidation", "-skipPackagePluginValidation"], + }), + ios.testWithoutBuilding(), + ], + }; +}) satisfies ConfigFunction; + +/** + * Puts rbenv's shims first on PATH. Shells that haven't run `rbenv init` + * (non-interactive ones, like an agent's) would otherwise find the system Ruby + * and skip the Fastlane check. + */ +async function rubyEnv(ctx: Context): Promise> { + const { exitCode, stdout } = await ctx.capture(["rbenv", "root"]); + return exitCode === 0 ? { PATH: `${stdout.trim()}/shims:${process.env.PATH}` } : {}; +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..7ca8ab20 --- /dev/null +++ b/package.json @@ -0,0 +1,10 @@ +{ + "private": true, + "scripts": { + "validate": "local-check", + "prepare": "local-check install-hook" + }, + "devDependencies": { + "@badbundle/local-check": "github:badbundle/local-check#v0.1.0" + } +} diff --git a/scripts/validate.sh b/scripts/validate.sh deleted file mode 100755 index 4e109993..00000000 --- a/scripts/validate.sh +++ /dev/null @@ -1,212 +0,0 @@ -#!/bin/bash -# Validates a commit on this Mac in place of hosted CI, and posts the result to -# GitHub as the "Validate (local)" commit status, which main requires before a -# PR can merge. See README.md#validation. -# -# It runs lint, the Fastlane config check, and a build and full run of the -# iOSAllTests test plan on the exact commit, in a separate worktree with its own -# DerivedData and a throwaway simulator, so uncommitted changes and your usual -# build folders and simulator can't affect the result. -# -# Usage: -# scripts/validate.sh [--clean] [] validate (default: HEAD); -# --clean rebuilds from scratch -# scripts/validate.sh --post post a stored result once the -# commit is on GitHub (used by -# .githooks/pre-push) - -set -euo pipefail - -CONTEXT="Validate (local)" -XCODE_VERSION="27.0" -# The snapshot tests check the device name, so the throwaway simulator has to -# use exactly this one. -SIMULATOR_NAME="iPhone 18 Pro Max" -SIMULATOR_TYPE="com.apple.CoreSimulator.SimDeviceType.iPhone-18-Pro-Max" -SIMULATOR_RUNTIME="com.apple.CoreSimulator.SimRuntime.iOS-27-0" - -repo_root=$(git rev-parse --show-toplevel) -state_dir="$(cd "$repo_root" && cd "$(git rev-parse --git-common-dir)" && pwd)/validate" -results_dir="$state_dir/results" -logs_dir="$state_dir/logs" -work_dir="$HOME/Library/Caches/vault-validate" -worktree="$work_dir/worktree" -derived_data="$work_dir/DerivedData" -repo=$(cd "$repo_root" && gh repo view --json nameWithOwner --jq .nameWithOwner) - -on_github() { - gh api --silent "repos/$repo/commits/$1" >/dev/null 2>&1 -} - -post_status() { # - gh api --silent -X POST "repos/$repo/statuses/$1" \ - -f state="$2" -f context="$CONTEXT" -f description="$3" -} - -if [ "${1:-}" = "--post" ]; then - sha=$(git rev-parse "${2:?usage: validate.sh --post }^{commit}") - [ -f "$results_dir/$sha" ] || exit 0 - IFS=$'\t' read -r state description <"$results_dir/$sha" - # The pre-push hook runs this just before the push lands, so wait for GitHub - # to have the commit. - for _ in $(seq 1 60); do - if on_github "$sha"; then - post_status "$sha" "$state" "$description" - exit 0 - fi - sleep 2 - done - echo "validate: gave up waiting for $sha to reach GitHub; run scripts/validate.sh --post $sha" >&2 - exit 1 -fi - -clean=false -if [ "${1:-}" = "--clean" ]; then - clean=true - shift -fi -sha=$(git rev-parse "${1:-HEAD}^{commit}") -short=$(git rev-parse --short "$sha") - -export DEVELOPER_DIR="/Applications/Xcode_$XCODE_VERSION.app/Contents/Developer" -if [ ! -d "$DEVELOPER_DIR" ]; then - echo "Xcode $XCODE_VERSION isn't installed at /Applications/Xcode_$XCODE_VERSION.app." >&2 - exit 1 -fi - -if [ -n "$(git -C "$repo_root" status --porcelain)" ]; then - echo "Note: you have uncommitted changes. Only the commit $short is validated." -fi - -mkdir -p "$results_dir" "$logs_dir" "$work_dir" -log="$logs_dir/$sha.log" -: >"$log" - -posted_pending=false -finished=false -udid="" -simulator_file="$state_dir/simulator" - -delete_simulator() { # - xcrun simctl shutdown "$1" >/dev/null 2>&1 || true - xcrun simctl delete "$1" >/dev/null 2>&1 || true - rm -f "$simulator_file" -} - -on_exit() { - if [ -n "$udid" ]; then - delete_simulator "$udid" - fi - if ! $finished && $posted_pending; then - post_status "$sha" error "Validation was interrupted" || true - fi -} -trap on_exit EXIT - -finish() { # - printf '%s\t%s\n' "$1" "$2" >"$results_dir/$sha" - finished=true - if on_github "$sha"; then - post_status "$sha" "$1" "$2" - echo "Posted \"$CONTEXT: $1\" to $short on GitHub." - else - echo "$short isn't on GitHub yet; the pre-push hook will post the result when you push it." - fi - if [ "$1" = success ]; then - exit 0 - fi - exit 1 -} - -step() { # - local name="$1" started - shift - started=$(date +%s) - printf '%s... ' "$name" - echo "=== $name" >>"$log" - if (cd "$worktree" && "$@") >>"$log" 2>&1; then - echo "done ($(($(date +%s) - started))s)" - else - echo "FAILED" - echo - tail -n 40 "$log" - echo - echo "Full log: $log" - finish failure "$name failed" - fi -} - -if on_github "$sha"; then - post_status "$sha" pending "Validating..." - posted_pending=true -fi - -echo "Validating $short ($(git log -1 --format=%s "$sha"))" -validation_started=$(date +%s) - -# A persistent worktree and DerivedData keep repeat runs incremental, like a -# CI cache; --clean starts both from scratch. -git -C "$repo_root" worktree prune -if [ ! -d "$worktree" ]; then - git -C "$repo_root" worktree add --quiet --detach "$worktree" "$sha" -fi -git -C "$worktree" checkout --quiet --detach --force "$sha" -if $clean; then - rm -rf "$derived_data" - git -C "$worktree" clean -ffdxq -else - git -C "$worktree" clean -ffdxq -e Vault/.build -fi - -# A throwaway simulator for this run, deleted when it ends, so tests start from -# a clean state and never touch the one you develop with. It's addressed by -# UDID, since it shares its name with yours. -if [ -f "$simulator_file" ]; then - delete_simulator "$(cat "$simulator_file")" # left behind by a killed run -fi -udid=$(xcrun simctl create "$SIMULATOR_NAME" "$SIMULATOR_TYPE" "$SIMULATOR_RUNTIME") -echo "$udid" >"$simulator_file" -destination="id=$udid" - -step "Lint" make -C Vault lint - -fastlane_note="" -# Shells that haven't run `rbenv init` (non-interactive ones, like an agent's) -# would otherwise find the system Ruby and skip this check. -if command -v rbenv >/dev/null; then - PATH="$(rbenv root)/shims:$PATH" -fi -ruby_version=$(cat "$worktree/.ruby-version") -if [ "$(cd "$worktree" && ruby -e 'print RUBY_VERSION' 2>/dev/null)" = "$ruby_version" ]; then - step "Fastlane config" /bin/bash -c \ - 'bundle install --quiet && bundle exec ruby -c fastlane/Fastfile && bundle exec fastlane lanes' -else - echo "Fastlane config... SKIPPED (Ruby $ruby_version from .ruby-version isn't installed)" - fastlane_note="; Fastlane check skipped (no Ruby $ruby_version)" -fi - -step "Build" xcodebuild build-for-testing \ - -workspace Vault.xcworkspace \ - -scheme CI_iOS \ - -testPlan iOSAllTests \ - -destination "$destination" \ - -derivedDataPath "$derived_data" \ - -skipMacroValidation \ - -skipPackagePluginValidation - -xctestrun=$(ls -t "$derived_data"/Build/Products/*.xctestrun 2>/dev/null | head -1) -if [ -z "$xctestrun" ]; then - echo "No .xctestrun file found in the build products." - finish failure "Build produced no test run" -fi - -# Skipping diagnostics collection keeps a failing run from hanging for up to -# 10 minutes while xcodebuild gathers them from the simulator. -step "Tests" xcodebuild test-without-building \ - -xctestrun "$xctestrun" \ - -destination "$destination" \ - -parallel-testing-enabled NO \ - -collect-test-diagnostics never - -elapsed=$(($(date +%s) - validation_started)) -finish success "Lint, build and all tests passed in $((elapsed / 60))m$((elapsed % 60))s$fastlane_note"