From b4dbf612b41e1e946a3640d2b12a636406328c45 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sun, 25 Oct 2026 17:03:44 -0700 Subject: [PATCH 1/7] CI: add a GitHub-hosted fallback for while the mini is offline Setting the repo variable KEYPATH_CI_RUNNER=hosted runs build-and-test and code-quality on GitHub's xcode-27 image (macOS 27, Xcode 27.0 27A266a). Unset, both jobs run on the mini exactly as before. Job names, and so the required checks, don't change. On hosted runs: - the disk-reserve check is skipped - the timeout is 60 minutes, since every run is cold - each job gets its own concurrency group - mise and SwiftLint are installed from Homebrew Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 29 ++++++++++++++++++++++++----- AGENTS.md | 5 +++++ 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d1ddc0251..55ae0bce7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,17 @@ concurrency: group: ci-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true +# Hosted fallback for while the mini's runners are unavailable: set the repo +# variable KEYPATH_CI_RUNNER=hosted and both jobs run on GitHub's macOS 27 / +# Xcode 27 image (`xcode-27`) instead. Delete the variable to return to the +# mini. Job names, and so the required checks, are the same either way. +# gh variable set KEYPATH_CI_RUNNER --repo malpern/KeyPath --body hosted +# gh variable delete KEYPATH_CI_RUNNER --repo malpern/KeyPath +env: + # The hosted image's Xcode 27.0 is build 27A266a, the same build the Macs use. + # Empty on the mini, where the scripts select Xcode as usual. + KEYPATH_DEV_XCODE_DEVELOPER_DIR: ${{ vars.KEYPATH_CI_RUNNER == 'hosted' && '/Applications/Xcode_27.app/Contents/Developer' || '' }} + jobs: build-and-test: # macOS 27 AppSSO trapped SwiftPM binary-artifact requests from a headless @@ -24,11 +35,12 @@ jobs: # 2026-10-19 the swiftpm-safe runner is a daemon too, because clawd has no GUI # session; the persistent scratch below means Sparkle is fetched only on a # cold scratch, not on every job. - runs-on: [self-hosted, macOS, keypath, swiftpm-safe] + runs-on: ${{ fromJSON(vars.KEYPATH_CI_RUNNER == 'hosted' && '"xcode-27"' || '["self-hosted", "macOS", "keypath", "swiftpm-safe"]') }} # Warm runs normally finish in 2-3 minutes. A measured cold run spent about # 3 minutes on setup/Kanata and was still compiling Swift tests after another # 12 minutes, before test execution. Keep a finite bound with real headroom. - timeout-minutes: 25 + # Every hosted run is cold, on a smaller machine. + timeout-minutes: ${{ vars.KEYPATH_CI_RUNNER == 'hosted' && 60 || 25 }} # Separate from the workflow-level concurrency group above (which cancels # stale runs of the *same* PR): this queues jobs from *different* PRs onto # the single physical runner instead of letting them execute in parallel. @@ -37,7 +49,7 @@ jobs: # CLIPackCRUDTests "could not enable associated rule collection" failures # when two PRs' CI happened to overlap. concurrency: - group: keypath-self-hosted-runner + group: ${{ vars.KEYPATH_CI_RUNNER == 'hosted' && format('hosted-{0}-{1}', github.workflow, github.run_id) || 'keypath-self-hosted-runner' }} cancel-in-progress: false steps: @@ -53,6 +65,8 @@ jobs: persist-credentials: false - name: Verify runner disk reserve + # Guards the mini's shared disk; a hosted runner is a fresh VM per job. + if: vars.KEYPATH_CI_RUNNER != 'hosted' env: KEYPATH_MIN_FREE_DISK_GIB: "40" run: ./Scripts/lab/host-disk-reserve @@ -233,13 +247,13 @@ jobs: echo "- Narrow coverage enforcement moved to the scheduled/manual \`KeyPath Coverage\` workflow." >> $GITHUB_STEP_SUMMARY code-quality: - runs-on: [self-hosted, macOS, keypath] + runs-on: ${{ fromJSON(vars.KEYPATH_CI_RUNNER == 'hosted' && '"xcode-27"' || '["self-hosted", "macOS", "keypath"]') }} timeout-minutes: 15 # This job does not share test state, but it does share the only physical # runner. Queue it with builds so SwiftFormat gets enough CPU to finish # before its fixed per-rule safety timeout. concurrency: - group: keypath-self-hosted-runner + group: ${{ vars.KEYPATH_CI_RUNNER == 'hosted' && format('hosted-{0}-{1}', github.workflow, github.run_id) || 'keypath-self-hosted-runner' }} cancel-in-progress: false steps: @@ -257,6 +271,11 @@ jobs: - name: Add Homebrew to PATH run: echo "/opt/homebrew/bin" >> $GITHUB_PATH + - name: Install mise and SwiftLint (hosted runner) + # The mini has both; GitHub's image has neither. + if: vars.KEYPATH_CI_RUNNER == 'hosted' + run: brew install mise swiftlint + - name: Install pinned SwiftFormat run: mise install swiftformat diff --git a/AGENTS.md b/AGENTS.md index 2026c2b3f..d01d9cdf4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -130,6 +130,11 @@ Notes: ### PR Workflow & Git Safety +- CI normally runs on the mini's self-hosted runners. While they are + unavailable, the repo variable `KEYPATH_CI_RUNNER=hosted` moves both required + jobs to GitHub's `xcode-27` image (see the header of `.github/workflows/ci.yml`). + Delete the variable when the mini is back. + - Follow the invariants in `docs/process/agent-pr-invariants.md`. Step-by-step reference: `docs/process/agent-pr-workflow.md`. - After merging a PR, always pull `master` and deploy from `master` before From ffbe908ace560725bf9b948b1a0480c41cf42d3b Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sun, 25 Oct 2026 17:12:39 -0700 Subject: [PATCH 2/7] Address review: Xcode version check, job groups, mise trust, contract test - Hosted runs now pin Xcode 27.0 through the stable-Xcode path, which keeps the scripts' version check, instead of an override that skips it. - Each hosted job gets its own concurrency group, so the two jobs run in parallel. - The disk check runs on hosted too, with a smaller floor (12 GiB). - code-quality gets 30 minutes on hosted. - mise is told to trust mise.toml. - The test-runner crash allowance is off on hosted. - The contract test now reads the runner expression and checks both jobs switch together. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 56 ++++++++++++++----- .../Lint/DeploymentScriptContractTests.swift | 7 ++- 2 files changed, 47 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 55ae0bce7..a52279a5a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,10 +23,8 @@ concurrency: # mini. Job names, and so the required checks, are the same either way. # gh variable set KEYPATH_CI_RUNNER --repo malpern/KeyPath --body hosted # gh variable delete KEYPATH_CI_RUNNER --repo malpern/KeyPath -env: - # The hosted image's Xcode 27.0 is build 27A266a, the same build the Macs use. - # Empty on the mini, where the scripts select Xcode as usual. - KEYPATH_DEV_XCODE_DEVELOPER_DIR: ${{ vars.KEYPATH_CI_RUNNER == 'hosted' && '/Applications/Xcode_27.app/Contents/Developer' || '' }} +# Only ci.yml has the fallback; coverage, cache-warm and runner-health are +# scheduled jobs that stay on the mini and don't gate PRs. jobs: build-and-test: @@ -42,14 +40,15 @@ jobs: # Every hosted run is cold, on a smaller machine. timeout-minutes: ${{ vars.KEYPATH_CI_RUNNER == 'hosted' && 60 || 25 }} # Separate from the workflow-level concurrency group above (which cancels - # stale runs of the *same* PR): this queues jobs from *different* PRs onto - # the single physical runner instead of letting them execute in parallel. + # stale runs of the *same* PR): on the mini this queues jobs from *different* + # PRs onto the single physical runner instead of letting them execute in + # parallel. Hosted jobs each get their own VM, so their group is unique. # Concurrent swift test runs share unsandboxed local state (e.g. # RuleCollectionsManager's config file), which caused spurious # CLIPackCRUDTests "could not enable associated rule collection" failures # when two PRs' CI happened to overlap. concurrency: - group: ${{ vars.KEYPATH_CI_RUNNER == 'hosted' && format('hosted-{0}-{1}', github.workflow, github.run_id) || 'keypath-self-hosted-runner' }} + group: ${{ vars.KEYPATH_CI_RUNNER == 'hosted' && format('hosted-{0}-{1}-{2}', github.workflow, github.run_id, github.job) || 'keypath-self-hosted-runner' }} cancel-in-progress: false steps: @@ -64,11 +63,22 @@ jobs: submodules: true persist-credentials: false + - name: Select Xcode 27.0 (hosted runner) + # GitHub's image defaults to a newer Xcode; its Xcode 27.0 is build 27A266a, + # the one the Macs use. The stable-Xcode path keeps the scripts' version + # check, and DEVELOPER_DIR covers tools that ask xcode-select. + if: vars.KEYPATH_CI_RUNNER == 'hosted' + run: | + xcode=/Applications/Xcode_27.app/Contents/Developer + "$xcode/usr/bin/xcodebuild" -version + echo "KEYPATH_STABLE_XCODE_DEVELOPER_DIR=$xcode" >> "$GITHUB_ENV" + echo "DEVELOPER_DIR=$xcode" >> "$GITHUB_ENV" + - name: Verify runner disk reserve - # Guards the mini's shared disk; a hosted runner is a fresh VM per job. - if: vars.KEYPATH_CI_RUNNER != 'hosted' env: - KEYPATH_MIN_FREE_DISK_GIB: "40" + # A hosted VM has far less disk than the mini; fail clearly if a cold + # Kanata and SwiftPM build won't fit, rather than partway through. + KEYPATH_MIN_FREE_DISK_GIB: ${{ vars.KEYPATH_CI_RUNNER == 'hosted' && '12' || '40' }} run: ./Scripts/lab/host-disk-reserve - name: Ensure Metal toolchain @@ -167,7 +177,8 @@ jobs: KEYPATH_BUNDLED_SIMULATOR_OVERRIDE: /opt/homebrew/bin/kanata-simulator # SwiftPM can wrap XCTest post-run SIGABRT as exit 1 on the self-hosted runner; # run-tests-safe.sh still fails if it parses any test failures and emits a warning when this path is used. - KEYPATH_ALLOW_TEST_RUNNER_CRASH_SUCCESS: "1" + # Not on hosted runners, where that quirk hasn't been seen. + KEYPATH_ALLOW_TEST_RUNNER_CRASH_SUCCESS: ${{ vars.KEYPATH_CI_RUNNER == 'hosted' && '0' || '1' }} KEYPATH_TEST_ENFORCE_CLEAN_SUMMARY: "1" KEYPATH_TEST_RESET_MODULE_CACHE: "0" KEYPATH_SWIFT_BUILD_JOBS: "4" @@ -248,12 +259,13 @@ jobs: code-quality: runs-on: ${{ fromJSON(vars.KEYPATH_CI_RUNNER == 'hosted' && '"xcode-27"' || '["self-hosted", "macOS", "keypath"]') }} - timeout-minutes: 15 + # Hosted runs also install mise and SwiftLint on a smaller machine. + timeout-minutes: ${{ vars.KEYPATH_CI_RUNNER == 'hosted' && 30 || 15 }} # This job does not share test state, but it does share the only physical # runner. Queue it with builds so SwiftFormat gets enough CPU to finish # before its fixed per-rule safety timeout. concurrency: - group: ${{ vars.KEYPATH_CI_RUNNER == 'hosted' && format('hosted-{0}-{1}', github.workflow, github.run_id) || 'keypath-self-hosted-runner' }} + group: ${{ vars.KEYPATH_CI_RUNNER == 'hosted' && format('hosted-{0}-{1}-{2}', github.workflow, github.run_id, github.job) || 'keypath-self-hosted-runner' }} cancel-in-progress: false steps: @@ -271,10 +283,24 @@ jobs: - name: Add Homebrew to PATH run: echo "/opt/homebrew/bin" >> $GITHUB_PATH + - name: Select Xcode 27.0 (hosted runner) + # GitHub's image defaults to a newer Xcode; its Xcode 27.0 is build 27A266a, + # the one the Macs use. The stable-Xcode path keeps the scripts' version + # check, and DEVELOPER_DIR covers tools that ask xcode-select. + if: vars.KEYPATH_CI_RUNNER == 'hosted' + run: | + xcode=/Applications/Xcode_27.app/Contents/Developer + "$xcode/usr/bin/xcodebuild" -version + echo "KEYPATH_STABLE_XCODE_DEVELOPER_DIR=$xcode" >> "$GITHUB_ENV" + echo "DEVELOPER_DIR=$xcode" >> "$GITHUB_ENV" + - name: Install mise and SwiftLint (hosted runner) - # The mini has both; GitHub's image has neither. + # The mini has both; GitHub's image has neither. mise ignores a repo config + # it hasn't been told to trust, which would skip the SwiftFormat pin. if: vars.KEYPATH_CI_RUNNER == 'hosted' - run: brew install mise swiftlint + run: | + brew install mise swiftlint + mise trust mise.toml - name: Install pinned SwiftFormat run: mise install swiftformat diff --git a/Tests/KeyPathTests/Lint/DeploymentScriptContractTests.swift b/Tests/KeyPathTests/Lint/DeploymentScriptContractTests.swift index fa8102ba7..1b0097edf 100644 --- a/Tests/KeyPathTests/Lint/DeploymentScriptContractTests.swift +++ b/Tests/KeyPathTests/Lint/DeploymentScriptContractTests.swift @@ -250,11 +250,16 @@ final class DeploymentScriptContractTests: XCTestCase { let installer = try contents(of: root.appendingPathComponent("Scripts/install-ci-runner-service.sh")) let safeRunner = "runs-on: [self-hosted, macOS, keypath, swiftpm-safe]" + // ci.yml selects its runner with an expression so a repo variable can move + // it to GitHub-hosted runners; the self-hosted branch keeps the safe label. + let safeRunnerChoice = #"'["self-hosted", "macOS", "keypath", "swiftpm-safe"]'"# XCTAssertEqual( - ci.components(separatedBy: safeRunner).count - 1, + ci.components(separatedBy: safeRunnerChoice).count - 1, 1, "Only build-and-test should require the SwiftPM-safe interactive runner in ci.yml." ) + XCTAssertEqual(ci.components(separatedBy: #"vars.KEYPATH_CI_RUNNER == 'hosted' && '"xcode-27"'"#).count - 1, 2, + "Both required jobs move to the hosted image together.") XCTAssertTrue(cacheWarm.contains(safeRunner)) XCTAssertEqual( coverage.components(separatedBy: safeRunner).count - 1, From a7c1403d171a811da2ae7c62819b22bf170f9cf9 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sun, 25 Oct 2026 17:19:07 -0700 Subject: [PATCH 3/7] Assert the hosted Xcode version, name the SDK, keep the literal-runner guard Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 32 +++++++++++++------ .../Lint/DeploymentScriptContractTests.swift | 2 ++ 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a52279a5a..7ec9c0a22 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,14 +70,21 @@ jobs: if: vars.KEYPATH_CI_RUNNER == 'hosted' run: | xcode=/Applications/Xcode_27.app/Contents/Developer - "$xcode/usr/bin/xcodebuild" -version + version=$("$xcode/usr/bin/xcodebuild" -version | sed -n 's/^Xcode //p' | head -1) + if [[ "$version" != "27.0" ]]; then + echo "::error::$xcode is Xcode '$version', not 27.0; the image changed" + exit 1 + fi echo "KEYPATH_STABLE_XCODE_DEVELOPER_DIR=$xcode" >> "$GITHUB_ENV" echo "DEVELOPER_DIR=$xcode" >> "$GITHUB_ENV" + # cargo reaches the compiler through cc, which needs the SDK named. + echo "SDKROOT=$xcode/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk" >> "$GITHUB_ENV" - name: Verify runner disk reserve env: - # A hosted VM has far less disk than the mini; fail clearly if a cold - # Kanata and SwiftPM build won't fit, rather than partway through. + # A hosted VM has far less disk than the mini. 12 GiB is a floor, not a + # measurement of what a cold run uses; raise it once a hosted run has + # shown the real number. KEYPATH_MIN_FREE_DISK_GIB: ${{ vars.KEYPATH_CI_RUNNER == 'hosted' && '12' || '40' }} run: ./Scripts/lab/host-disk-reserve @@ -141,11 +148,12 @@ jobs: ./Scripts/lint-no-subprocess-in-autofixer.sh - name: Prepare persistent SwiftPM scratch - # Persistent per runner: runner.workspace (_work/KeyPath) survives between - # jobs, while runner.temp is wiped after every job and made each build cold. - # One runner runs one job at a time, so jobs never share this directory - # concurrently. It is cleared when the toolchain changes or it grows past - # the cap; the 40 GiB host disk reserve above still applies. + # Persistent per runner on the mini: runner.workspace (_work/KeyPath) + # survives between jobs, while runner.temp is wiped after every job and + # made each build cold. One runner runs one job at a time, so jobs never + # share this directory concurrently. It is cleared when the toolchain + # changes or it grows past the cap. A hosted VM starts empty every time, + # so there it is always cold. env: KEYPATH_SCRATCH_CAP_GIB: "30" run: | @@ -290,9 +298,15 @@ jobs: if: vars.KEYPATH_CI_RUNNER == 'hosted' run: | xcode=/Applications/Xcode_27.app/Contents/Developer - "$xcode/usr/bin/xcodebuild" -version + version=$("$xcode/usr/bin/xcodebuild" -version | sed -n 's/^Xcode //p' | head -1) + if [[ "$version" != "27.0" ]]; then + echo "::error::$xcode is Xcode '$version', not 27.0; the image changed" + exit 1 + fi echo "KEYPATH_STABLE_XCODE_DEVELOPER_DIR=$xcode" >> "$GITHUB_ENV" echo "DEVELOPER_DIR=$xcode" >> "$GITHUB_ENV" + # cargo reaches the compiler through cc, which needs the SDK named. + echo "SDKROOT=$xcode/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk" >> "$GITHUB_ENV" - name: Install mise and SwiftLint (hosted runner) # The mini has both; GitHub's image has neither. mise ignores a repo config diff --git a/Tests/KeyPathTests/Lint/DeploymentScriptContractTests.swift b/Tests/KeyPathTests/Lint/DeploymentScriptContractTests.swift index 1b0097edf..c199d2d8f 100644 --- a/Tests/KeyPathTests/Lint/DeploymentScriptContractTests.swift +++ b/Tests/KeyPathTests/Lint/DeploymentScriptContractTests.swift @@ -260,6 +260,8 @@ final class DeploymentScriptContractTests: XCTestCase { ) XCTAssertEqual(ci.components(separatedBy: #"vars.KEYPATH_CI_RUNNER == 'hosted' && '"xcode-27"'"#).count - 1, 2, "Both required jobs move to the hosted image together.") + XCTAssertFalse(ci.contains(safeRunner), + "A literal safe-runner job in ci.yml would bypass the single-job check above.") XCTAssertTrue(cacheWarm.contains(safeRunner)) XCTAssertEqual( coverage.components(separatedBy: safeRunner).count - 1, From 26e3285802c64efff3a77016570f04d99cfa05b2 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sun, 25 Oct 2026 17:26:25 -0700 Subject: [PATCH 4/7] Install ripgrep on hosted runners; fail the lints when rg is missing Both lint scripts read a missing rg as "no hits" and passed without checking anything. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 6 +++++- Scripts/lint-no-sleep.sh | 3 +++ Scripts/lint-no-subprocess-in-autofixer.sh | 3 +++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7ec9c0a22..85157fc4c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -142,6 +142,10 @@ jobs: kanata --version kanata-simulator --version + - name: Install ripgrep (hosted runner) + if: vars.KEYPATH_CI_RUNNER == 'hosted' + run: brew install ripgrep + - name: Lint WizardAutoFixer for forbidden subprocess usage run: | chmod +x ./Scripts/lint-no-subprocess-in-autofixer.sh @@ -313,7 +317,7 @@ jobs: # it hasn't been told to trust, which would skip the SwiftFormat pin. if: vars.KEYPATH_CI_RUNNER == 'hosted' run: | - brew install mise swiftlint + brew install mise swiftlint ripgrep mise trust mise.toml - name: Install pinned SwiftFormat diff --git a/Scripts/lint-no-sleep.sh b/Scripts/lint-no-sleep.sh index 60105fa14..a417b105e 100755 --- a/Scripts/lint-no-sleep.sh +++ b/Scripts/lint-no-sleep.sh @@ -1,6 +1,9 @@ #!/usr/bin/env bash set -euo pipefail +# A missing rg exits 127, which the if below would read as "no hits". +command -v rg >/dev/null || { echo "❌ ripgrep (rg) is required" >&2; exit 1; } + ROOT="$(cd -- "$(dirname -- "$0")/.." && pwd)" # Disallow Task.sleep in wizard code and Swift tests. diff --git a/Scripts/lint-no-subprocess-in-autofixer.sh b/Scripts/lint-no-subprocess-in-autofixer.sh index c43ddaf2f..cbbd487c9 100755 --- a/Scripts/lint-no-subprocess-in-autofixer.sh +++ b/Scripts/lint-no-subprocess-in-autofixer.sh @@ -1,6 +1,9 @@ #!/bin/bash set -euo pipefail +# A missing rg exits 127, which the if below would read as "no hits". +command -v rg >/dev/null || { echo "❌ ripgrep (rg) is required" >&2; exit 1; } + FILE="Sources/KeyPathAppKit/InstallationWizard/Core/WizardAutoFixer.swift" if rg --no-heading "SubprocessRunner|PrivilegedOperationsCoordinator|launchctl|osascript" "$FILE" > /tmp/autofixer-badrefs.txt; then echo "❌ Forbidden API found in $FILE:" >&2 From b077a10f3cc74d40c2187109408de7a869d7a8d7 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sun, 25 Oct 2026 17:32:51 -0700 Subject: [PATCH 5/7] Treat an rg error as a lint failure, not a clean result rg exits 2 on an error such as a missing path, and both lints read that as "no hits". The autofixer lint had been passing that way since WizardAutoFixer.swift was deleted; it now says so explicitly. Co-Authored-By: Claude Opus 5.5 --- Scripts/lint-no-sleep.sh | 18 +++++++++++++++--- Scripts/lint-no-subprocess-in-autofixer.sh | 16 ++++++++++++++-- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/Scripts/lint-no-sleep.sh b/Scripts/lint-no-sleep.sh index a417b105e..9c74cfd00 100755 --- a/Scripts/lint-no-sleep.sh +++ b/Scripts/lint-no-sleep.sh @@ -1,20 +1,32 @@ #!/usr/bin/env bash set -euo pipefail -# A missing rg exits 127, which the if below would read as "no hits". +# A missing rg exits 127, which would otherwise read as "no hits". command -v rg >/dev/null || { echo "❌ ripgrep (rg) is required" >&2; exit 1; } ROOT="$(cd -- "$(dirname -- "$0")/.." && pwd)" +# rg exits 0 on a match, 1 on none, and 2 on an error such as a missing path. +# Only 1 is a pass. +search() { + local rc=0 + rg "$@" >/tmp/lint_no_sleep_hits || rc=$? + if (( rc > 1 )); then + echo "❌ rg failed (exit $rc)" >&2 + exit 1 + fi + return "$rc" +} + # Disallow Task.sleep in wizard code and Swift tests. -if rg "Task\\.sleep" "$ROOT/Sources/KeyPathAppKit/InstallationWizard" "$ROOT/Sources/KeyPathWizardCore" >/tmp/lint_no_sleep_hits 2>/dev/null; then +if search "Task\\.sleep" "$ROOT/Sources/KeyPathAppKit/InstallationWizard" "$ROOT/Sources/KeyPathWizardCore"; then echo "❌ Task.sleep found in wizard sources:" cat /tmp/lint_no_sleep_hits echo "Replace with readiness polling / health checks." exit 1 fi -if rg "Task\\.sleep|Thread\\.sleep|usleep" "$ROOT/Tests" -g '*.swift' >/tmp/lint_no_sleep_hits 2>/dev/null; then +if search "Task\\.sleep|Thread\\.sleep|usleep" "$ROOT/Tests" -g '*.swift'; then echo "❌ Real sleeps found in Swift tests:" cat /tmp/lint_no_sleep_hits echo "Use injected clocks, expectations, Task.yield(), or DEBUG-only drains instead." diff --git a/Scripts/lint-no-subprocess-in-autofixer.sh b/Scripts/lint-no-subprocess-in-autofixer.sh index cbbd487c9..5f2c1df0d 100755 --- a/Scripts/lint-no-subprocess-in-autofixer.sh +++ b/Scripts/lint-no-subprocess-in-autofixer.sh @@ -1,14 +1,26 @@ #!/bin/bash set -euo pipefail -# A missing rg exits 127, which the if below would read as "no hits". +# A missing rg exits 127, which would otherwise read as "no hits". command -v rg >/dev/null || { echo "❌ ripgrep (rg) is required" >&2; exit 1; } FILE="Sources/KeyPathAppKit/InstallationWizard/Core/WizardAutoFixer.swift" -if rg --no-heading "SubprocessRunner|PrivilegedOperationsCoordinator|launchctl|osascript" "$FILE" > /tmp/autofixer-badrefs.txt; then +if [[ ! -e "$FILE" ]]; then + # WizardAutoFixer was removed in favour of InstallerEngine.runSingleAction(). + echo "✅ $FILE no longer exists; nothing to check" + exit 0 +fi + +# rg exits 0 on a match, 1 on none, and 2 on an error. Only 1 is a pass. +rc=0 +rg --no-heading "SubprocessRunner|PrivilegedOperationsCoordinator|launchctl|osascript" "$FILE" > /tmp/autofixer-badrefs.txt || rc=$? +if (( rc == 0 )); then echo "❌ Forbidden API found in $FILE:" >&2 cat /tmp/autofixer-badrefs.txt >&2 exit 1 +elif (( rc > 1 )); then + echo "❌ rg failed on $FILE (exit $rc)" >&2 + exit 1 fi echo "✅ WizardAutoFixer clean (no direct subprocess/privileged calls)" From 5641668915b7cf0973f1773a12dfa3de25c06c1e Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sun, 25 Oct 2026 17:37:45 -0700 Subject: [PATCH 6/7] Remove the WizardAutoFixer lint; use a private temp file in the sleep lint WizardAutoFixer.swift was deleted on 2026-05-02 (9d62b118), so the lint has had nothing to check since. The sleep lint wrote to a fixed /tmp path, which overlapping jobs could share. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 9 -------- Scripts/lint-no-sleep.sh | 8 ++++--- Scripts/lint-no-subprocess-in-autofixer.sh | 26 ---------------------- Scripts/test-ci-locally.sh | 4 ---- 4 files changed, 5 insertions(+), 42 deletions(-) delete mode 100755 Scripts/lint-no-subprocess-in-autofixer.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 85157fc4c..f5041d748 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -142,15 +142,6 @@ jobs: kanata --version kanata-simulator --version - - name: Install ripgrep (hosted runner) - if: vars.KEYPATH_CI_RUNNER == 'hosted' - run: brew install ripgrep - - - name: Lint WizardAutoFixer for forbidden subprocess usage - run: | - chmod +x ./Scripts/lint-no-subprocess-in-autofixer.sh - ./Scripts/lint-no-subprocess-in-autofixer.sh - - name: Prepare persistent SwiftPM scratch # Persistent per runner on the mini: runner.workspace (_work/KeyPath) # survives between jobs, while runner.temp is wiped after every job and diff --git a/Scripts/lint-no-sleep.sh b/Scripts/lint-no-sleep.sh index 9c74cfd00..c14f59ca9 100755 --- a/Scripts/lint-no-sleep.sh +++ b/Scripts/lint-no-sleep.sh @@ -5,12 +5,14 @@ set -euo pipefail command -v rg >/dev/null || { echo "❌ ripgrep (rg) is required" >&2; exit 1; } ROOT="$(cd -- "$(dirname -- "$0")/.." && pwd)" +HITS="$(mktemp)" +trap 'rm -f "$HITS"' EXIT # rg exits 0 on a match, 1 on none, and 2 on an error such as a missing path. # Only 1 is a pass. search() { local rc=0 - rg "$@" >/tmp/lint_no_sleep_hits || rc=$? + rg "$@" >"$HITS" || rc=$? if (( rc > 1 )); then echo "❌ rg failed (exit $rc)" >&2 exit 1 @@ -21,14 +23,14 @@ search() { # Disallow Task.sleep in wizard code and Swift tests. if search "Task\\.sleep" "$ROOT/Sources/KeyPathAppKit/InstallationWizard" "$ROOT/Sources/KeyPathWizardCore"; then echo "❌ Task.sleep found in wizard sources:" - cat /tmp/lint_no_sleep_hits + cat "$HITS" echo "Replace with readiness polling / health checks." exit 1 fi if search "Task\\.sleep|Thread\\.sleep|usleep" "$ROOT/Tests" -g '*.swift'; then echo "❌ Real sleeps found in Swift tests:" - cat /tmp/lint_no_sleep_hits + cat "$HITS" echo "Use injected clocks, expectations, Task.yield(), or DEBUG-only drains instead." exit 1 fi diff --git a/Scripts/lint-no-subprocess-in-autofixer.sh b/Scripts/lint-no-subprocess-in-autofixer.sh deleted file mode 100755 index 5f2c1df0d..000000000 --- a/Scripts/lint-no-subprocess-in-autofixer.sh +++ /dev/null @@ -1,26 +0,0 @@ -#!/bin/bash -set -euo pipefail - -# A missing rg exits 127, which would otherwise read as "no hits". -command -v rg >/dev/null || { echo "❌ ripgrep (rg) is required" >&2; exit 1; } - -FILE="Sources/KeyPathAppKit/InstallationWizard/Core/WizardAutoFixer.swift" -if [[ ! -e "$FILE" ]]; then - # WizardAutoFixer was removed in favour of InstallerEngine.runSingleAction(). - echo "✅ $FILE no longer exists; nothing to check" - exit 0 -fi - -# rg exits 0 on a match, 1 on none, and 2 on an error. Only 1 is a pass. -rc=0 -rg --no-heading "SubprocessRunner|PrivilegedOperationsCoordinator|launchctl|osascript" "$FILE" > /tmp/autofixer-badrefs.txt || rc=$? -if (( rc == 0 )); then - echo "❌ Forbidden API found in $FILE:" >&2 - cat /tmp/autofixer-badrefs.txt >&2 - exit 1 -elif (( rc > 1 )); then - echo "❌ rg failed on $FILE (exit $rc)" >&2 - exit 1 -fi - -echo "✅ WizardAutoFixer clean (no direct subprocess/privileged calls)" diff --git a/Scripts/test-ci-locally.sh b/Scripts/test-ci-locally.sh index c43ee4854..c2e2f057e 100755 --- a/Scripts/test-ci-locally.sh +++ b/Scripts/test-ci-locally.sh @@ -3,10 +3,6 @@ set -euo pipefail # Local runner to approximate CI steps -echo "🔍 Lint: WizardAutoFixer subprocess guard" -chmod +x ./Scripts/lint-no-subprocess-in-autofixer.sh -./Scripts/lint-no-subprocess-in-autofixer.sh - echo "🔍 Lint: Wizard sleep guard" chmod +x ./Scripts/lint-no-sleep.sh ./Scripts/lint-no-sleep.sh From b3e1422794c5f3eabd6394c9a67fb667698330eb Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sun, 25 Oct 2026 17:58:45 -0700 Subject: [PATCH 7/7] Give the Karabiner conflict tests their own subprocess fake The suite shared SubprocessRunnerFake.shared with nine other test files. Swift Testing runs suites in parallel, so another suite's reset() could clear the command log between the service's pgrep and the assertion. On the first hosted run, isRunning was true but the pgrep command was missing from the log. Each test now creates its own fake. Co-Authored-By: Claude Opus 5.5 --- .../Services/KarabinerConflictServiceTests.swift | 16 ++++++++-------- .../TestHelpers/SubprocessRunnerFake.swift | 5 ++++- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/Tests/KeyPathTests/Services/KarabinerConflictServiceTests.swift b/Tests/KeyPathTests/Services/KarabinerConflictServiceTests.swift index 6963d59df..8d22dba89 100644 --- a/Tests/KeyPathTests/Services/KarabinerConflictServiceTests.swift +++ b/Tests/KeyPathTests/Services/KarabinerConflictServiceTests.swift @@ -10,8 +10,8 @@ struct KarabinerConflictServiceTests { @Test("Karabiner grabber detection uses injected SystemStateProvider") func karabinerGrabberDetectionUsesInjectedSystemStateProvider() async { TestSingletonReset.resetAll() - let runner = SubprocessRunnerFake.shared - await runner.reset() + // Its own fake: a parallel suite resetting `shared` would clear the command log. + let runner = SubprocessRunnerFake() await runner.configurePgrepResult { pattern in pattern == "karabiner_grabber" ? [1111] : [] } @@ -32,8 +32,8 @@ struct KarabinerConflictServiceTests { @Test("Karabiner 16 installed without grabber is not a conflict") func karabiner16WithoutGrabberIsNotAConflict() async { TestSingletonReset.resetAll() - let runner = SubprocessRunnerFake.shared - await runner.reset() + // Its own fake: a parallel suite resetting `shared` would clear the command log. + let runner = SubprocessRunnerFake() await runner.configurePgrepResult { pattern in // Modern Karabiner releases may run harmless supporting agents, // but only the input grabber competes with Kanata for exclusive @@ -63,8 +63,8 @@ struct KarabinerConflictServiceTests { Date().addingTimeInterval(-60), serviceID: "com.keypath.karabiner-vhiddaemon" ) - let runner = SubprocessRunnerFake.shared - await runner.reset() + // Its own fake: a parallel suite resetting `shared` would clear the command log. + let runner = SubprocessRunnerFake() await runner.configurePgrepResult { pattern in pattern == "VirtualHIDDevice-Daemon" ? [2222] : [] } @@ -85,8 +85,8 @@ struct KarabinerConflictServiceTests { @Test("Stopped-process verification uses injected SystemStateProvider") func stoppedProcessVerificationUsesInjectedSystemStateProvider() async { TestSingletonReset.resetAll() - let runner = SubprocessRunnerFake.shared - await runner.reset() + // Its own fake: a parallel suite resetting `shared` would clear the command log. + let runner = SubprocessRunnerFake() await runner.configurePgrepResult { pattern in pattern == "Karabiner-DriverKit-VirtualHIDDevice" ? [3333] : [] } diff --git a/Tests/KeyPathTests/TestHelpers/SubprocessRunnerFake.swift b/Tests/KeyPathTests/TestHelpers/SubprocessRunnerFake.swift index c73c7f6d1..b4fed7e9c 100644 --- a/Tests/KeyPathTests/TestHelpers/SubprocessRunnerFake.swift +++ b/Tests/KeyPathTests/TestHelpers/SubprocessRunnerFake.swift @@ -49,7 +49,10 @@ actor SubprocessRunnerFake: SubprocessRunning { // MARK: - Initialization - private init() {} + /// Tests that inject the fake (through `systemProbeClient()`) should create + /// their own: suites run in parallel, and another suite's `reset()` on + /// `shared` clears `executedCommands` mid-test. + init() {} // MARK: - Reset