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
91 changes: 91 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -191,3 +191,94 @@ jobs:
pkill -9 -f '\.xctest' || true
killall -9 Simulator || true
xcrun simctl shutdown all || true

# Non-required signal for the regress-matrix rows that need fixture artifact
# generation, a Bazel fetch of a fixture's pinned rules, or an iOS simulator
# (//previewsmcp/Tests/CLIIntegrationTests:RegressToolGuardTests, tagged
# `manual` so the required `ci` job's `//...` expansion never runs it). Runs
# on the same triggers to build a standing flake record; promote into the
# required gate by dropping the `manual` tag once that record is clean. This
# check is NOT in the ruleset's required contexts — a red here never blocks
# auto-merge. `needs: ci` keeps the single serial runner working the required
# gate first, so this job never delays a merge.
regress-tools:
needs: ci
if: >-
github.event_name != 'pull_request' ||
github.event.label.name == 'ci' ||
github.event.label.name == 'merge'
runs-on: [self-hosted, macOS, ARM64]
# Above the target's `eternal` (3600s) bazel test timeout, like `ci`'s
# 120 vs its heaviest target: a hung test must hit bazel's timeout (a
# step failure(), which runs the diagnostics step) before the job-level
# cancellation, which would skip it.
timeout-minutes: 90
permissions:
contents: read
checks: read
env:
DEVELOPER_DIR: /Applications/Xcode-26.2.0.app/Contents/Developer
# Same required-gate coverage signal as `ci`: a host that cannot
# provision the dedicated simulator (or is missing bazel) FAILS the row
# instead of silently skipping it (RequiredGateEnforcement.enforced in
# TestSupport, read by RegressToolGuardTests.requireTool and the sim
# resolver).
PREVIEWSMCP_REQUIRE_DEDICATED_SIM: "1"
steps:
# Dedup like `ci`: a `merge` label after a green `ci`-label run skips
# the second identical pass.
- name: Skip suite if this commit is already green
id: dedup
if: github.event_name == 'pull_request' && github.event.label.name == 'merge'
env:
GH_TOKEN: ${{ github.token }}
SHA: ${{ github.event.pull_request.head.sha }}
run: |
if gh api "repos/$GITHUB_REPOSITORY/commits/$SHA/check-runs" \
--jq '.check_runs[] | select(.name=="regress-tools" and .conclusion=="success") | .id' \
| grep -q .; then
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "A green regress-tools check already exists for $SHA — skipping the suite."
fi

- if: steps.dedup.outputs.skip != 'true'
uses: actions/checkout@v7

# Same clean-host reset as `ci` — this job boots simulators too.
- name: Reset simulator and daemon state
if: steps.dedup.outputs.skip != 'true'
run: |
pkill -9 -f 'previewsmcp serve' || true
pkill -9 -f 'previewsmcp run' || true
pkill -9 -x PreviewAgent || true
pkill -9 -f '\.xctest' || true
killall -9 Simulator || true
xcrun simctl shutdown all || true
launchctl kickstart -k "gui/$(id -u)/com.apple.CoreSimulator.CoreSimulatorService" 2>/dev/null \
|| launchctl kickstart -k "system/com.apple.CoreSimulator.CoreSimulatorService" 2>/dev/null || true
xcrun simctl delete unavailable || true

- name: Test (regress tool-guard rows)
if: steps.dedup.outputs.skip != 'true'
run: >-
bazel test //previewsmcp/Tests/CLIIntegrationTests:RegressToolGuardTests
--test_output=errors

- name: Failure diagnostics
if: failure()
run: |
xcrun simctl list devices booted || true
pgrep -fl "previewsmcp|simctl" || true
for f in "${TMPDIR:-/tmp}"previewsmcp-test-*.log /tmp/previewsmcp-test-*.log; do
[ -f "$f" ] && { echo "=== $f (tail)"; tail -100 "$f"; }
done || true

- name: Reap leaked processes and simulators
if: always()
run: |
pkill -9 -f 'previewsmcp serve' || true
pkill -9 -f 'previewsmcp run' || true
pkill -9 -x PreviewAgent || true
pkill -9 -f '\.xctest' || true
killall -9 Simulator || true
xcrun simctl shutdown all || true
13 changes: 13 additions & 0 deletions docs/ci.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,19 @@ tier failing still blocks the merge through the same required `ci` check. The
`integration` tag is only a selection label; existing `exclusive` tags and
runtime simulator locks continue to control scheduling and isolation.

## The non-required `regress-tools` signal

A second job, `regress-tools`, runs
`//previewsmcp/Tests/CLIIntegrationTests:RegressToolGuardTests` — the
regress-matrix guard rows that need fixture artifact generation, a Bazel fetch
of a fixture's pinned rules, or an iOS simulator. The target is tagged
`manual`, so neither required tier's `bazel test //...` expansion ever runs
it; only this job does. The check is deliberately NOT in the ruleset's
required contexts: it runs on the same labels to build a standing flake
record, and a red never blocks auto-merge. `needs: ci` keeps the single serial
runner working the required gate first. Promote the target into the required
gate by dropping its `manual` tag once the record is clean.

## Normal flow

1. Open the PR. Nothing runs.
Expand Down
58 changes: 57 additions & 1 deletion previewsmcp/Tests/CLIIntegrationTests/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -1,9 +1,22 @@
load("@rules_swift//swift:swift.bzl", "swift_test")

# Shared subprocess/daemon harness. CLIIntegrationTests picks these up via
# its glob; RegressToolGuardTests compiles them again as its own sources.
HARNESS_SRCS = [
"CLIRunner.swift",
"CoreSimulatorHygiene.swift",
"DaemonTestLock.swift",
"PipeBuffer.swift",
"RegressRowAsserts.swift",
]

swift_test(
name = "CLIIntegrationTests",
timeout = "eternal",
srcs = glob(["**/*.swift"]),
srcs = glob(
["**/*.swift"],
exclude = ["RegressToolGuardTests.swift"],
),
copts = [
"-swift-version",
"6",
Expand Down Expand Up @@ -34,3 +47,46 @@ swift_test(
visibility = ["//visibility:public"],
deps = ["//previewsmcp/Tests/TestSupport"],
)

# Guards for regress-matrix rows that need build tooling the deterministic
# target above must not depend on: fixture artifact generation, a Bazel
# fetch of the fixture's pinned rules, or an iOS simulator. Tagged `manual`
# so `bazel test //...` (both required-gate tiers) never expands to it; the
# non-required `regress-tools` job in .github/workflows/ci.yml names it
# explicitly. Promote into the required gate by dropping `manual` once its
# flake record is clean.
swift_test(
name = "RegressToolGuardTests",
timeout = "eternal",
srcs = HARNESS_SRCS + ["RegressToolGuardTests.swift"],
copts = [
"-swift-version",
"6",
],
data = [
"//:ios_jit_resources",
"//:orc_rt_archive",
"//previewsmcp/PreviewAgent",
"//previewsmcp/cli:previewsmcp",
],
env = {
"PREVIEWSMCP_AGENT": "$(rlocationpath //previewsmcp/PreviewAgent)",
"PREVIEWSMCP_BINARY": "$(rlocationpath //previewsmcp/cli:previewsmcp)",
"PREVIEWSMCP_IOS_JIT_DIR": "$(rlocationpath //:ios_jit_resources)",
"PREVIEWSMCP_ORC_RT": "$(rlocationpath //:orc_rt_archive)",
# Bazel scrubs PATH to /bin:/usr/bin:/usr/local/bin in test actions,
# which hides Homebrew's bazelisk from the CLI's `/usr/bin/env bazel`
# (the reason SnapshotCommandTests' bazel test tool-gate skips under
# `bazel test`). B01 must not skip, so this target pins an explicit
# PATH that includes /opt/homebrew/bin.
"PATH": "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin",
},
tags = [
"exclusive",
"integration",
"local",
"manual",
],
visibility = ["//visibility:public"],
deps = ["//previewsmcp/Tests/TestSupport"],
)
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,11 @@ import os
/// shut every simulator down and bounce CoreSimulatorService so the next boot
/// starts from a clean service. Call while holding `SimulatorTestLock` (which
/// serializes sim-booting tests host-wide, so this reset cannot kill another
/// workspace's live run — #336) and `DaemonTestLock`, which serializes the
/// sim-touching suites WITHIN this target (its flock path is per-target);
/// across targets within one invocation the guard is this target's
/// `exclusive` tag.
/// workspace's live run — #336) and with no live daemon session in this
/// process — either inside `DaemonTestLock.run` (whose teardown fences the
/// daemon dead before releasing) or between run blocks, where that fence
/// has already run; across targets within one invocation the guard is this
/// target's `exclusive` tag.
enum CoreSimulatorHygiene {
private static let didReset = OSAllocatedUnfairLock(initialState: false)

Expand Down
Loading
Loading