diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 000000000..972a657e1 --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,27 @@ +#!/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 + +exit 0 diff --git a/.github/workflows/validate-all.yml b/.github/workflows/validate-all.yml deleted file mode 100644 index 8ec2722a1..000000000 --- a/.github/workflows/validate-all.yml +++ /dev/null @@ -1,203 +0,0 @@ -# These jobs are run on everything, including main and PRs. -# -# TEMPORARILY DISABLED — automatic triggers are commented out below. -# -# The project builds and tests against Xcode 27 / iOS 27.0, and the snapshot -# references are recorded on that runtime. No GitHub-hosted GA image provides -# Xcode 27 yet: `macos-26` tops out at Xcode 26.6 and carries no iOS 27.0 -# simulator runtime. The beta `xcode-27` image does have both, but its runner -# pool could not absorb this 14-way test matrix — every shard sat queued -# indefinitely. -# -# RE-ENABLE when Xcode 27 is available on the `macos-26` runner image (or a -# `macos-27` GA image ships): uncomment the `pull_request` and `push` triggers -# below. Everything else in this file is already configured for Xcode 27 and -# iOS 27.0, so no other change should be needed. -# -# Track availability at https://github.com/actions/runner-images -# -# Until then the workflow can still be run on demand via `workflow_dispatch` -# (Actions tab → Validate → Run workflow, or `gh workflow run validate-all.yml`). -name: Validate - -on: - workflow_dispatch: - # pull_request: - # branches: [main] - # push: - # branches: [main] - -env: - XCODE_VERSION: "27.0" - -jobs: - release-config: - name: Release Config - timeout-minutes: 5 - runs-on: macos-26 - - steps: - - uses: actions/checkout@v6.0.3 - - # The repo Actions allowlist permits GitHub-owned actions only, so this - # uses the Ruby version already installed on the macOS runner. - - name: Install Ruby dependencies - run: | - ruby -v - bundle install - - - name: Validate Ruby and Fastlane config - run: | - ruby -v - bundle exec ruby -c fastlane/Fastfile - bundle exec fastlane --version - bundle exec fastlane lanes - - lint: - name: Lint - timeout-minutes: 2 - runs-on: macos-26 - defaults: - run: - working-directory: ./Vault - - steps: - - uses: actions/checkout@v6.0.3 - - run: sudo xcode-select -s /Applications/Xcode_${{ env.XCODE_VERSION }}.app/Contents/Developer - - - name: Cache SPM Dependencies - uses: actions/cache@v5.0.5 - with: - path: | - ~/Library/Caches/org.swift.swiftpm/repositories - Vault/.build - key: ${{ runner.os }}-spm-lint-xcode-${{ env.XCODE_VERSION }}-${{ hashFiles('Vault/Package.resolved') }} - restore-keys: | - ${{ runner.os }}-spm-lint-xcode-${{ env.XCODE_VERSION }}- - - - name: Check Linting & Formatting - run: make lint - - ci-ios-build: - name: CI_iOS Build - timeout-minutes: 20 - runs-on: macos-26 - - steps: - - uses: actions/checkout@v6.0.3 - - run: sudo xcode-select -s /Applications/Xcode_${{ env.XCODE_VERSION }}.app/Contents/Developer - - - name: Log Xcode Version - run: xcodebuild -version - - - name: Cache SPM Dependencies - uses: actions/cache@v5.0.5 - with: - path: ~/Library/Caches/org.swift.swiftpm/repositories - key: ${{ runner.os }}-spm-ci-ios-xcode-${{ env.XCODE_VERSION }}-${{ hashFiles('Vault/Package.resolved') }} - restore-keys: | - ${{ runner.os }}-spm-ci-ios-xcode-${{ env.XCODE_VERSION }}- - - - name: Build CI_iOS - run: | - xcodebuild build-for-testing \ - -workspace Vault.xcworkspace \ - -scheme CI_iOS \ - -testPlan iOSAllTests \ - -destination 'platform=iOS Simulator,name=iPhone 18 Pro Max,OS=27.0' \ - -derivedDataPath "$RUNNER_TEMP/ci-ios-derived-data" \ - -skipMacroValidation \ - -skipPackagePluginValidation - - - name: Package CI_iOS Test Products - run: tar -czf "$RUNNER_TEMP/ci-ios-test-products.tgz" -C "$RUNNER_TEMP/ci-ios-derived-data/Build" Products - - - name: Upload CI_iOS Test Products - uses: actions/upload-artifact@v7.0.1 - with: - name: ci-ios-test-products - path: ${{ runner.temp }}/ci-ios-test-products.tgz - if-no-files-found: error - retention-days: 1 - - ci-ios-tests: - name: CI_iOS Tests (${{ matrix.suite }}) - timeout-minutes: 20 - runs-on: macos-26 - needs: ci-ios-build - strategy: - fail-fast: false - matrix: - include: - - suite: VaultiOSTests - only_testing: VaultiOSTests - - suite: VaultSettingsTests - only_testing: VaultSettingsTests - - suite: FoundationExtensionsTests - only_testing: FoundationExtensionsTests - - suite: ImageToolsTests - only_testing: ImageToolsTests - - suite: VaultiOSAutofillTests - only_testing: VaultiOSAutofillTests - - suite: VaultiOSWidgetsTests - only_testing: VaultiOSWidgetsTests - - suite: VaultFeedTests - only_testing: VaultFeedTests - - suite: VaultKeygenTests - only_testing: VaultKeygenTests - - suite: VaultCoreTests - only_testing: VaultCoreTests - - suite: VaultKeygenSpeedtestCompileTests - only_testing: VaultKeygenSpeedtestCompileTests - - suite: VaultAppIconGeneratorTests - only_testing: VaultAppIconGeneratorTests - - suite: VaultExportTests - only_testing: VaultExportTests - - suite: VaultBackupTests - only_testing: VaultBackupTests - - suite: CryptoEngineTests - only_testing: CryptoEngineTests - - steps: - - uses: actions/checkout@v6.0.3 - - run: sudo xcode-select -s /Applications/Xcode_${{ env.XCODE_VERSION }}.app/Contents/Developer - - - name: Download CI_iOS Test Products - uses: actions/download-artifact@v8.0.1 - with: - name: ci-ios-test-products - path: ${{ runner.temp }} - - - name: Run CI_iOS Tests - run: | - mkdir -p "$RUNNER_TEMP/ci-ios-derived-data/Build" - tar -xzf "$RUNNER_TEMP/ci-ios-test-products.tgz" -C "$RUNNER_TEMP/ci-ios-derived-data/Build" - - XCTESTRUN="$(find "$RUNNER_TEMP/ci-ios-derived-data/Build/Products" -maxdepth 1 -name '*.xctestrun' -print -quit)" - - if [[ -z "$XCTESTRUN" ]]; then - echo "No .xctestrun file found in downloaded CI_iOS test products." - exit 1 - fi - - xcodebuild test-without-building \ - -xctestrun "$XCTESTRUN" \ - -destination 'platform=iOS Simulator,name=iPhone 18 Pro Max,OS=27.0' \ - -only-testing:${{ matrix.only_testing }} \ - -parallel-testing-enabled NO - - ci-ios: - name: CI_iOS - timeout-minutes: 5 - runs-on: ubuntu-latest - needs: [ci-ios-build, ci-ios-tests] - if: always() - - steps: - - name: Check CI_iOS Shards - run: | - if [[ "${{ needs.ci-ios-build.result }}" != "success" || "${{ needs.ci-ios-tests.result }}" != "success" ]]; then - echo "CI_iOS build result: ${{ needs.ci-ios-build.result }}" - echo "CI_iOS tests result: ${{ needs.ci-ios-tests.result }}" - exit 1 - fi diff --git a/AGENTS.md b/AGENTS.md index d19adf18a..d1dc2c1f9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,10 @@ This root-level file only covers things that apply across the whole repo (the Sw Read [`MANIFESTO.md`](./MANIFESTO.md) before proposing or implementing any feature that touches killphrases, search passphrases, lock state, authentication, telemetry, backups, exports, or anything in the Danger Zone. The manifesto is normative — when a proposed change conflicts with it, the manifesto wins unless it is amended first via a dedicated `MANIFESTO:` PR. +## 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). + ## Layout - [`Vault/`](./Vault) — Swift Package with all targets, tests, and tooling. Open `Vault.xcworkspace` to work on it. diff --git a/README.md b/README.md index ecf968d39..a5370a5e4 100644 --- a/README.md +++ b/README.md @@ -53,3 +53,20 @@ As soon as we are able, we will be dropping the xcodeproj project wrapper and go - `Vault.xcworkspace` what you should open - `/Vault` Swift Package that defines targets used by the app, build settings, tooling. - `/VaultApp` minimal wrapper that packages this into an executable application. + +### Validation + +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` +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: + +- `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 + +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/`. + +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 bb05f4de0..0b0fa10ce 100644 --- a/Vault/AGENTS.md +++ b/Vault/AGENTS.md @@ -13,3 +13,12 @@ Use the simulator configuration specified in `README.md` for all builds and test ## Committing Before every commit, run `make format` and `make lint` from the `Vault/` directory to ensure code is properly formatted and passes linting. + +## 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)). + +- 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. diff --git a/Vault/Makefile b/Vault/Makefile index 905386f37..35349038e 100644 --- a/Vault/Makefile +++ b/Vault/Makefile @@ -20,6 +20,12 @@ 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. +.PHONY: validate +validate: + ../scripts/validate.sh + # Marketing screenshots: capture raw shots of the app on throwaway simulators, # then put them in device frames with titles. See Screenshots/README.md. .PHONY: screenshots diff --git a/Vault/README.md b/Vault/README.md index d0e0454de..8804e458a 100644 --- a/Vault/README.md +++ b/Vault/README.md @@ -125,6 +125,10 @@ You shouldn't need to manually change the locale or any other simulator setting Lint Sources make lint + + Validate the current commit and post the green check to GitHub (see Validation) + make validate + Force clean existing build artifacts make clean diff --git a/scripts/validate.sh b/scripts/validate.sh new file mode 100755 index 000000000..de92a2b89 --- /dev/null +++ b/scripts/validate.sh @@ -0,0 +1,207 @@ +#!/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="" +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"