fix(#4784): Xcode gates pass -project and derive the destination from available simulators (#4879)

* test(#4784): failing-first — Xcode gates must pass -project and derive the destination from available simulators

* fix(#4784): Xcode gates pass -project '$XCODEPROJ' and resolve the destination from available simulators, skipping loudly when none exist

Also surfaces the -collect-test-diagnostics never / workflow.test_gate_timeout
guidance on a 124 timeout (a real iOS suite measured ~600s of sysdiagnose
collection after the tests passed).

* test(#4784): reachability-honest test shapes — apostrophe-confused prose (the path the false positive actually reaches past the splitter) and the file's single-quote convention

* fix(#4784): review fold-ins — unanchor the simulator extraction (real simctl lines end in a state suffix), escape apostrophes for the bash -c layer, assign the command variables on the skip path, behavioral extraction test

The isolated reviewer's Finding 1 was critical: the first cut's $-anchored sed
matched ZERO real simctl lines (every device line ends in (Shutdown)/(Booted)),
so the gates would have skipped on every machine. The 46,454-test green could
not see it (content assertions do not execute the pipeline); the new behavioral
test runs the gate's own extraction against a real-format line.

* chore(#4784): backfill changeset PR number (4879)

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-09-19 19:18:30 -04:00
committed by GitHub
parent d435723c95
commit 6dcc0428dd
4 changed files with 142 additions and 8 deletions

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 4879
---
**The post-merge gate's Xcode detection works on real project layouts** — the gate resolved the .xcodeproj path but built its commands without -project (any project one directory down failed with exit 66) and hardcoded the iPhone 16 simulator name (a machine property); commands now carry -project, the destination resolves from the machine's available simulators, gates skip loudly when none exists, and the timeout message names the sysdiagnose collector. (#4784)

View File

@@ -16,10 +16,33 @@ if [ -z "$BUILD_CMD" ]; then
if [ -n "$XCODEPROJ" ]; then
# Xcode project: get first scheme from xcodebuild -list -json
XCODE_SCHEME=$(xcodebuild -list -json -project "$XCODEPROJ" 2>/dev/null | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('project',{}).get('schemes',[None])[0] or '')" 2>/dev/null || true)
if [ -n "$XCODE_SCHEME" ]; then
BUILD_CMD="xcodebuild build -scheme '$XCODE_SCHEME' -destination 'platform=iOS Simulator,name=iPhone 16'"
# #4784: a concrete simulator NAME is machine state, not project state — the
# hardcoded 'iPhone 16' can never match on machines whose simulators were
# never created. Resolve the first available iOS simulator (id= works for
# both build and test); with none available, skip the Xcode gates loudly
# instead of running commands that are guaranteed to fail. The sed takes the
# LAST ≥8-char hex/dash parenthetical WITHOUT a $ anchor: real simctl lines
# end in a (Shutdown)/(Booted) state suffix (often with trailing space), and
# the state word is never in the hex class, so the UDID is always the last
# in-class group even when the device name carries its own parentheses.
XCODE_SIM=$(xcrun simctl list devices available 2>/dev/null | sed -n 's/.*(\([A-F0-9-]\{8,\}\)).*/\1/p' | head -1)
if [ -z "$XCODE_SIM" ]; then
echo "⚠ No available iOS Simulator found — skipping the Xcode build gate. Set workflow.build_command to run it anyway."
BUILD_CMD=""
XCODEPROJ=""
else
BUILD_CMD="xcodebuild build -destination 'platform=iOS Simulator,name=iPhone 16'"
XCODE_DEST="id=$XCODE_SIM"
# #4784 review F2: the command string is re-parsed by `bash -c`, so an
# apostrophe in a repo/scheme name would terminate the quote (unbalanced-
# quote failure; a crafted path shape reaches command injection). Escape
# for the single-quoted span at the only boundary GSD owns.
XCODEPROJ=$(printf '%s' "$XCODEPROJ" | sed "s/'/'\\\\''/g")
XCODE_SCHEME=$(printf '%s' "$XCODE_SCHEME" | sed "s/'/'\\\\''/g")
fi
if [ -n "$XCODEPROJ" ] && [ -n "$XCODE_SCHEME" ]; then
BUILD_CMD="xcodebuild build -project '$XCODEPROJ' -scheme '$XCODE_SCHEME' -destination '${XCODE_DEST}'"
elif [ -n "$XCODEPROJ" ]; then
BUILD_CMD="xcodebuild build -project '$XCODEPROJ' -destination '${XCODE_DEST}'"
fi
elif [ -f "Makefile" ] && grep -q "^build:" Makefile; then
BUILD_CMD="make build"
@@ -68,14 +91,28 @@ TEST_CMD=$(gsd_run query config-get workflow.test_command --default "" --raw 2>/
if [ -z "$TEST_CMD" ]; then
XCODEPROJ=$(find . -maxdepth 2 -name "*.xcodeproj" -not -path "*/node_modules/*" 2>/dev/null | head -1)
if [ -n "$XCODEPROJ" ]; then
# Xcode project: reuse scheme detected above (or re-detect)
# Xcode project: reuse scheme + simulator resolved above (or re-detect)
if [ -z "${XCODE_SCHEME:-}" ]; then
XCODE_SCHEME=$(xcodebuild -list -json -project "$XCODEPROJ" 2>/dev/null | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('project',{}).get('schemes',[None])[0] or '')" 2>/dev/null || true)
fi
if [ -n "$XCODE_SCHEME" ]; then
TEST_CMD="xcodebuild test -scheme '$XCODE_SCHEME' -destination 'platform=iOS Simulator,name=iPhone 16'"
# #4784: reuse the simulator resolved by the build gate; re-detect if this
# block runs in a fresh shell. No simulator → skip loudly, same as Step A.
if [ -z "${XCODE_SIM:-}" ]; then
XCODE_SIM=$(xcrun simctl list devices available 2>/dev/null | sed -n 's/.*(\([A-F0-9-]\{8,\}\)).*/\1/p' | head -1)
fi
if [ -z "$XCODE_SIM" ]; then
echo "⚠ No available iOS Simulator found — skipping the Xcode test gate. Set workflow.test_command to run it anyway."
TEST_CMD=""
XCODEPROJ=""
else
TEST_CMD="xcodebuild test -destination 'platform=iOS Simulator,name=iPhone 16'"
XCODE_DEST="id=$XCODE_SIM"
XCODEPROJ=$(printf '%s' "$XCODEPROJ" | sed "s/'/'\\\\''/g")
XCODE_SCHEME=$(printf '%s' "$XCODE_SCHEME" | sed "s/'/'\\\\''/g")
fi
if [ -n "$XCODEPROJ" ] && [ -n "$XCODE_SCHEME" ]; then
TEST_CMD="xcodebuild test -project '$XCODEPROJ' -scheme '$XCODE_SCHEME' -destination '${XCODE_DEST}'"
elif [ -n "$XCODEPROJ" ]; then
TEST_CMD="xcodebuild test -project '$XCODEPROJ' -destination '${XCODE_DEST}'"
fi
elif [ -f "Makefile" ] && grep -q "^test:" Makefile; then
TEST_CMD="make test"
@@ -105,7 +142,7 @@ TEST_EXIT=$?
if [ "${TEST_EXIT}" -eq 0 ]; then
echo "✓ Post-merge test gate passed — no cross-plan conflicts"
elif [ "${TEST_EXIT}" -eq 124 ]; then
echo "⚠ POST-MERGE TEST GATE TIMED OUT after ${TEST_GATE_TIMEOUT}s — the runner did not exit, likely stuck in watch/dev mode (e.g. vitest without 'run'). Verify tests with a one-shot command (e.g. 'vitest run') or raise workflow.test_gate_timeout."
echo "⚠ POST-MERGE TEST GATE TIMED OUT after ${TEST_GATE_TIMEOUT}s — the runner did not exit, likely stuck in watch/dev mode (e.g. vitest without 'run'). Verify tests with a one-shot command (e.g. 'vitest run') or raise workflow.test_gate_timeout. For Xcode suites: xcodebuild collects a sysdiagnose into the .xcresult by default (~10 minutes on a passing suite) — add '-collect-test-diagnostics never' to the test command or raise workflow.test_gate_timeout."
else
echo "✗ Post-merge test gate failed (exit code ${TEST_EXIT})"
WAVE_FAILURE_COUNT=$((WAVE_FAILURE_COUNT + 1))

View File

@@ -255,6 +255,7 @@ module.exports = {
"tests/state-todos-render.test.cjs",
"tests/state.test.cjs",
"tests/teams-status.test.cjs",
"tests/test-gate-watch-mode.test.cjs",
"tests/todos-workstream-scope.test.cjs",
"tests/undo-commit-selection-4465.test.cjs",
"tests/unreachable-guard-drift.test.cjs",

View File

@@ -31,6 +31,12 @@ const ROOT = path.join(__dirname, '..');
const REGRESSION_GATE = path.join(ROOT, 'gsd-core', 'workflows', 'execute-phase', 'steps', 'regression-gate.md');
const REGRESSION_GATE_RUN = path.join(ROOT, 'gsd-core', 'workflows', 'execute-phase', 'steps', 'regression-gate-run.md');
const POST_MERGE_GATE = path.join(ROOT, 'gsd-core', 'workflows', 'execute-phase', 'steps', 'post-merge-gate.md');
// #4784: bounds the one-shot sed pipeline the behavioral extraction test runs
// against a fixture line (a pure text filter on ~50 lines — never the 30s-plus
// subprocess classes in timeouts.cjs; named so the two copies in this file
// cannot drift).
const SIMCTL_EXTRACT_TIMEOUT_MS = 10_000;
const AUDIT_FIX = path.join(ROOT, 'gsd-core', 'workflows', 'audit-fix.md');
const EXECUTE_PHASE = path.join(ROOT, 'gsd-core', 'workflows', 'execute-phase.md');
@@ -124,3 +130,88 @@ describe('#2350: every gate resolves build/test commands with --raw', () => {
});
}
});
// ─── #4784: the Xcode arms carry -project and a machine-independent destination ──
describe('#4784: Xcode gate construction', () => {
const gate = read(POST_MERGE_GATE);
test('#4784: the Xcode gates pass -project to every constructed xcodebuild command', () => {
// The gate resolves XCODEPROJ (find . -maxdepth 2) and even uses it for
// `xcodebuild -list -json -project` — but the build/test commands used to
// drop it, so any project one directory down (App/App.xcodeproj) failed
// with exit 66: xcodebuild does not search subdirectories.
const constructed = gate.match(/xcodebuild (?:build|test)[^\n]*'/g) || [];
assert.ok(constructed.length >= 4, `expected the four Xcode command constructions, got: ${JSON.stringify(constructed)}`);
for (const cmd of constructed) {
assert.ok(
cmd.includes("-project '$XCODEPROJ'"),
`every constructed xcodebuild command must carry -project '$XCODEPROJ', got: ${cmd}`,
);
}
});
test('#4784: the Xcode destination derives from the machine\'s available simulators', () => {
// A concrete device NAME is machine state (the reporter's machine had zero
// devices matching the hardcoded name). The gate must resolve the first
// available simulator (id= destination form) and must not pin a name.
assert.ok(
gate.includes('simctl list devices available'),
'the gate must resolve the destination from simctl list devices available',
);
assert.ok(gate.includes('id=$XCODE_SIM'), 'the destination must reference the resolved simulator id');
assert.ok(
!gate.includes('name=iPhone 16'),
'the hardcoded simulator name must be gone',
);
});
test('#4784: with no available simulator the gates skip loudly instead of guaranteed failure', () => {
// Pin BOTH skip echoes: the build arm names workflow.build_command, the
// test arm names workflow.test_command (a deleting-the-echo edit must go red).
assert.ok(
/No available iOS Simulator[^\n]*workflow\.build_command/.test(gate),
'build-arm skip must name workflow.build_command',
);
assert.ok(
/No available iOS Simulator[^\n]*workflow\.test_command/.test(gate),
'test-arm skip must name workflow.test_command',
);
// set -u robustness: the skip paths must leave the command variables ASSIGNED.
const skipArms = gate.match(/if \[ -z "\$XCODE_SIM" \]; then[\s\S]*?\n {4}fi/g) || [];
assert.ok(skipArms.length >= 2, `expected both skip arms, got ${skipArms.length}`);
for (const arm of skipArms) {
assert.ok(
/(?:BUILD|TEST)_CMD=""/.test(arm),
'each skip arm must assign its command variable (a set -u agent shell must not abort on an unbound variable)',
);
}
});
test('#4784: the gate\'s simulator-extraction pipeline yields a UDID from a real-format simctl line', () => {
// Adversarial-review Finding 1 (behavioral, would have been red): the first
// cut anchored the sed at end-of-line, but real simctl lines end with a
// (Shutdown)/(Booted) state suffix (often + trailing space) — the anchored
// form matched NOTHING and the gates skipped on every machine. Extract the
// gate's OWN pipeline and execute it against a real-format fixture line.
const pipeMatch = gate.match(/simctl list devices available 2[^\n]*?(sed -n '[^']+')[^\n]*?head -1/);
assert.ok(pipeMatch, 'gate must contain the simctl→sed→head extraction pipeline');
const realFormatLine = ' iPhone 17 Pro (94B8198C-8CF1-4860-994A-5669D3388BE8) (Shutdown) ';
const { execFileSync } = require('node:child_process');
const udid = execFileSync('sh', ['-c', `printf '%s\\n' "$1" | ${pipeMatch[1]} | head -1`, 'sh', realFormatLine], {
encoding: 'utf-8',
timeout: SIMCTL_EXTRACT_TIMEOUT_MS,
}).trim();
assert.match(udid, /^[A-F0-9-]{8,}$/, `the pipeline must extract the UDID from a real-format line, got: ${JSON.stringify(udid)}`);
});
test('#4784: the test-gate timeout guidance names the Xcode diagnostics collector', () => {
// A real iOS suite measured 854s wall clock, ~600s of it simctl diagnose
// collecting a sysdiagnose AFTER the suite passed — the default timeout
// returns 124 on a passing suite. The gate text must surface the flag.
assert.ok(
gate.includes('-collect-test-diagnostics never'),
'the gate must mention -collect-test-diagnostics never for Xcode suites',
);
});
});