feat(theme): update color palette [ci: tags=theme exclude=snapshot]
feat(theme): update color palette [ci: tags=theme exclude=snapshot]
feat(theme): update color palette [ci: tags=theme exclude=snapshot]
[ci: tags=<t1,t2> exclude=<t1,t2> runner=<name> final record-snapshots]
[ci: tags=<t1,t2> exclude=<t1,t2> runner=<name> final record-snapshots]
[ci: tags=<t1,t2> exclude=<t1,t2> runner=<name> final record-snapshots]
# Parse [ci: ...] block from commit message
CI_BLOCK=$(echo "$MSG" | grep -oE '\[ci:[^]]+\]' | head -1)
TAGS=$(echo "$CI_BLOCK" | grep -oE 'tags=[^ ]+' | sed 's/tags=//')
RUNNER=$(echo "$CI_BLOCK" | grep -oE 'runner=[^ ]+' | sed 's/runner=//')
# Parse [ci: ...] block from commit message
CI_BLOCK=$(echo "$MSG" | grep -oE '\[ci:[^]]+\]' | head -1)
TAGS=$(echo "$CI_BLOCK" | grep -oE 'tags=[^ ]+' | sed 's/tags=//')
RUNNER=$(echo "$CI_BLOCK" | grep -oE 'runner=[^ ]+' | sed 's/runner=//')
# Parse [ci: ...] block from commit message
CI_BLOCK=$(echo "$MSG" | grep -oE '\[ci:[^]]+\]' | head -1)
TAGS=$(echo "$CI_BLOCK" | grep -oE 'tags=[^ ]+' | sed 's/tags=//')
RUNNER=$(echo "$CI_BLOCK" | grep -oE 'runner=[^ ]+' | sed 's/runner=//')
@Test(.tags(.calibration))
func calibrationConvergesWithinTolerance() { ... } @Test(.tags(.bluetoothManager))
func connectDisconnectCycle() { ... }
@Test(.tags(.calibration))
func calibrationConvergesWithinTolerance() { ... } @Test(.tags(.bluetoothManager))
func connectDisconnectCycle() { ... }
@Test(.tags(.calibration))
func calibrationConvergesWithinTolerance() { ... } @Test(.tags(.bluetoothManager))
func connectDisconnectCycle() { ... }
# tags=calibration,homePage becomes:
xcodebuild test \ -only-testing-tags calibration \ -only-testing-tags homePage
# tags=calibration,homePage becomes:
xcodebuild test \ -only-testing-tags calibration \ -only-testing-tags homePage
# tags=calibration,homePage becomes:
xcodebuild test \ -only-testing-tags calibration \ -only-testing-tags homePage
fix(ble): stabilize BLE tests [ci: tags=bluetoothManager,bptManager runner=daksh-personal]
fix(ble): stabilize BLE tests [ci: tags=bluetoothManager,bptManager runner=daksh-personal]
fix(ble): stabilize BLE tests [ci: tags=bluetoothManager,bptManager runner=daksh-personal]
$ scripts/ci/runner-name.sh
daksh-personal
$ scripts/ci/runner-name.sh
daksh-personal
$ scripts/ci/runner-name.sh
daksh-personal
git commit -m "fix(ble): stabilize tests [ci: tags=bluetoothManager runner=$(scripts/ci/runner-name.sh)]"
git commit -m "fix(ble): stabilize tests [ci: tags=bluetoothManager runner=$(scripts/ci/runner-name.sh)]"
git commit -m "fix(ble): stabilize tests [ci: tags=bluetoothManager runner=$(scripts/ci/runner-name.sh)]"
{ "agentId": 22, "agentName": "daksh-personal", "poolId": 1, "poolName": "Default", "serverUrl": "https://pipelines...", "gitHubUrl": "https://github.com/your-org/your-repo", "workFolder": "_work" }
{ "agentId": 22, "agentName": "daksh-personal", "poolId": 1, "poolName": "Default", "serverUrl": "https://pipelines...", "gitHubUrl": "https://github.com/your-org/your-repo", "workFolder": "_work" }
{ "agentId": 22, "agentName": "daksh-personal", "poolId": 1, "poolName": "Default", "serverUrl": "https://pipelines...", "gitHubUrl": "https://github.com/your-org/your-repo", "workFolder": "_work" }
# runner-name.sh — prints the local runner name python3 -c " import json config = open('actions-runner/.runner', 'rb').read().decode('utf-8-sig') print(json.loads(config)['agentName']) "
# runner-name.sh — prints the local runner name python3 -c " import json config = open('actions-runner/.runner', 'rb').read().decode('utf-8-sig') print(json.loads(config)['agentName']) "
# runner-name.sh — prints the local runner name python3 -c " import json config = open('actions-runner/.runner', 'rb').read().decode('utf-8-sig') print(json.loads(config)['agentName']) "
runs-on: ${{ inputs.runner-name != '' && inputs.runner-name || 'macos-26' }}
runs-on: ${{ inputs.runner-name != '' && inputs.runner-name || 'macos-26' }}
runs-on: ${{ inputs.runner-name != '' && inputs.runner-name || 'macos-26' }}
{ "hooks": { "PostToolUse": [ { "matcher": "Bash", "if": "Bash(git push*)", "hooks": [ { "type": "command", "command": "./scripts/ci/start-self-hosted-runner.sh" } ] } ] }
}
{ "hooks": { "PostToolUse": [ { "matcher": "Bash", "if": "Bash(git push*)", "hooks": [ { "type": "command", "command": "./scripts/ci/start-self-hosted-runner.sh" } ] } ] }
}
{ "hooks": { "PostToolUse": [ { "matcher": "Bash", "if": "Bash(git push*)", "hooks": [ { "type": "command", "command": "./scripts/ci/start-self-hosted-runner.sh" } ] } ] }
}
cd /path/to/your-project/.. # parent of the repo
mkdir actions-runner && cd actions-runner # Go to repo Settings → Actions → Runners → "New self-hosted runner"
# Select macOS / ARM64, then follow the download + extract instructions:
curl -o actions-runner-osx-arm64-X.Y.Z.tar.gz -L <URL_FROM_GITHUB>
tar xzf ./actions-runner-osx-arm64-X.Y.Z.tar.gz
cd /path/to/your-project/.. # parent of the repo
mkdir actions-runner && cd actions-runner # Go to repo Settings → Actions → Runners → "New self-hosted runner"
# Select macOS / ARM64, then follow the download + extract instructions:
curl -o actions-runner-osx-arm64-X.Y.Z.tar.gz -L <URL_FROM_GITHUB>
tar xzf ./actions-runner-osx-arm64-X.Y.Z.tar.gz
cd /path/to/your-project/.. # parent of the repo
mkdir actions-runner && cd actions-runner # Go to repo Settings → Actions → Runners → "New self-hosted runner"
# Select macOS / ARM64, then follow the download + extract instructions:
curl -o actions-runner-osx-arm64-X.Y.Z.tar.gz -L <URL_FROM_GITHUB>
tar xzf ./actions-runner-osx-arm64-X.Y.Z.tar.gz
./config.sh --url https://github.com/your-org/your-repo --token <TOKEN_FROM_SETTINGS_PAGE>
# Pick any name you want (e.g. "daksh-personal", "janes-studio")
# This name gets written to .runner and is what you'll use in commit messages
./config.sh --url https://github.com/your-org/your-repo --token <TOKEN_FROM_SETTINGS_PAGE>
# Pick any name you want (e.g. "daksh-personal", "janes-studio")
# This name gets written to .runner and is what you'll use in commit messages
./config.sh --url https://github.com/your-org/your-repo --token <TOKEN_FROM_SETTINGS_PAGE>
# Pick any name you want (e.g. "daksh-personal", "janes-studio")
# This name gets written to .runner and is what you'll use in commit messages
cd /path/to/your-repo
RUNNER_NAME=$(scripts/ci/runner-name.sh)
RUNNER_ID=$(gh api repos/your-org/your-repo/actions/runners \ --jq ".runners[] | select(.name==\"$RUNNER_NAME\") | .id")
gh api -X POST repos/your-org/your-repo/actions/runners/$RUNNER_ID/labels \ --input - <<< "{\"labels\":[\"$RUNNER_NAME\"]}"
cd /path/to/your-repo
RUNNER_NAME=$(scripts/ci/runner-name.sh)
RUNNER_ID=$(gh api repos/your-org/your-repo/actions/runners \ --jq ".runners[] | select(.name==\"$RUNNER_NAME\") | .id")
gh api -X POST repos/your-org/your-repo/actions/runners/$RUNNER_ID/labels \ --input - <<< "{\"labels\":[\"$RUNNER_NAME\"]}"
cd /path/to/your-repo
RUNNER_NAME=$(scripts/ci/runner-name.sh)
RUNNER_ID=$(gh api repos/your-org/your-repo/actions/runners \ --jq ".runners[] | select(.name==\"$RUNNER_NAME\") | .id")
gh api -X POST repos/your-org/your-repo/actions/runners/$RUNNER_ID/labels \ --input - <<< "{\"labels\":[\"$RUNNER_NAME\"]}"
git commit -m "fix(ble): stabilize [ci: tags=bluetoothManager runner=$(scripts/ci/runner-name.sh)]"
git push
# Hook fires → runner starts → picks up the job → exits when done
git commit -m "fix(ble): stabilize [ci: tags=bluetoothManager runner=$(scripts/ci/runner-name.sh)]"
git push
# Hook fires → runner starts → picks up the job → exits when done
git commit -m "fix(ble): stabilize [ci: tags=bluetoothManager runner=$(scripts/ci/runner-name.sh)]"
git push
# Hook fires → runner starts → picks up the job → exits when done - No shared build box to maintain,
- no daemons running on anyone's laptop,
- no hardcoded machine names in CI configs. - Checks out the repo (needs git history)
- Reads the latest non-merge commit message
- Extracts the [ci: ...] block with a simple grep/sed pipeline
- Outputs structured values (tags, exclude, runner-name, etc.) for downstream jobs - Each developer registers their Mac as a GitHub Actions self-hosted runner, picking any name they want (daksh-personal, janes-studio, build-mac-01 — whatever) and adding that name as a runner label
- When you run ./config.sh, the GitHub Actions runner writes a .runner JSON file to the runner directory. This is a standard part of the runner infrastructure — we didn't create it. It looks like this: - The workflow uses a dynamic runs-on — if the commit says runner=daksh-personal, the job lands on exactly that machine: - On self-hosted runners, we skip setup-xcode and cache steps (unnecessary — everything's already there)
- A post-push hook automatically starts the runner in single-job mode (./run.sh --once) - Reads the last commit message and extracts the runner=<name> field from the [ci: ...] block.
- Reads the local runner name from actions-runner/.runner — a JSON config file written once during ./config.sh setup — and checks if it matches the directive. If this machine isn't the target (or there's no directive at all), it exits silently. No noise, no side effects. Every developer's machine is self-aware: the script doesn't need to know who you are, it reads the identity from the runner config that already exists on disk.
- If this machine is the target, it launches the runner in background, single-job mode: ./run.sh --once &. The runner registers with GitHub, picks up exactly one job, executes it, and exits. - Build job: Compiles once, packages DerivedData as an artifact
- Logic tests job: Downloads the artifact, runs xcodebuild test-without-building
- Snapshot tests job: Same artifact, runs only snapshot-tagged tests - SPM packages stay resolved. No re-downloading, no re-linking. The -skipPackageUpdates flag in quick mode skips the resolution step entirely.
- Incremental builds. If you changed one file, xcodebuild recompiles that file — not the entire project.
- Build-graph validation, not recompilation. Test jobs run build-for-testing before test-without-building to validate that the build products are still valid. This takes 65–90 seconds — not zero, but far less than a cold build. - Before a PR merges. A [ci: final] commit (or the equivalent on the merge commit) runs the entire suite as the pre-merge gate. Nothing lands on main without it.
- On a regular cadence for long-lived branches, so drift doesn't pile up silently.
- Always before an App Store submission. Shipping to users is the one place where "fast feedback" loses to "zero surprises" — the full suite runs, snapshots and all, no exceptions. - Tag your tests by feature area (Swift Testing, pytest markers, Jest tags — whatever your framework supports)
- Parse the commit message in a cheap Linux job before spinning up expensive runners
- Default to not running — opt-in is cheaper than opt-out
- Let devs use their own machines for fast iteration via self-hosted runners in single-job mode — read the runner identity from the local config so nothing is hardcoded per-developer - .claude/settings.json — a post-push hook that auto-starts the runner when your commit includes runner=<your-name>
- scripts/ci/runner-name.sh — reads your runner name from the local config so you never hardcode it
- scripts/ci/start-self-hosted-runner.sh — matches the commit directive against the local runner and starts it in single-job mode - Path-based triggers vs commit directives — we picked commits because they're explicit and reviewable, but paths: filters are simpler. When has one clearly beaten the other for you?
- Self-hosted runners at scale — we have a handful of developer Macs. Does this pattern hold up with 20+ engineers, or does it fall apart on coordination?
- The "no CI by default" call — is this reckless on a larger team, or is the pre-merge gate enough of a safety net?
- Something we haven't even considered — Bazel remote cache? Merge queues? Monorepo-style affected-test detection? Tell me what we're missing.