Skip to content

Fix residual blog metadata from Ahrefs audit #38678

Fix residual blog metadata from Ahrefs audit

Fix residual blog metadata from Ahrefs audit #38678

Workflow file for this run

name: CI
on:
push:
branches:
- main
pull_request:
workflow_dispatch:
permissions:
contents: read
actions: write
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
changes:
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 5
outputs:
macos: ${{ steps.detect.outputs.macos }}
web: ${{ steps.detect.outputs.web }}
go: ${{ steps.detect.outputs.go }}
agent_session_web: ${{ steps.detect.outputs.agent_session_web }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Detect CI change areas
id: detect
env:
EVENT_NAME: ${{ github.event_name }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
set -euo pipefail
emit_all_areas() {
{
echo "macos=true"
echo "web=true"
echo "go=true"
echo "agent_session_web=true"
} >> "$GITHUB_OUTPUT"
}
if [ "$EVENT_NAME" = "pull_request" ]; then
if ! MERGE_BASE="$(git merge-base "$BASE_SHA" "$HEAD_SHA")" \
|| ! git diff --name-only "$MERGE_BASE" "$HEAD_SHA" > /tmp/cmux-ci-changed-files.txt; then
echo "Could not compute PR diff; running all CI areas." >&2
emit_all_areas
exit 0
fi
if [ ! -s /tmp/cmux-ci-changed-files.txt ]; then
echo "PR diff is empty; running all CI areas."
emit_all_areas
exit 0
fi
# This guard lives in workflow YAML, before the PR-editable detector
# runs. Router/workflow edits must fail open even if a PR breaks the
# detector's own self-change classification.
if grep -Eq '^(\.github/workflows/ci\.yml|scripts/ci/[^/]+\.py|tests/test_ci_change_areas\.py)$' /tmp/cmux-ci-changed-files.txt; then
echo "CI router changed; running all CI areas."
emit_all_areas
exit 0
fi
python3 scripts/ci/detect_ci_change_areas.py \
--event-name "$EVENT_NAME" \
--files-from /tmp/cmux-ci-changed-files.txt
exit 0
fi
python3 scripts/ci/detect_ci_change_areas.py \
--event-name "$EVENT_NAME" \
--base-sha "$BASE_SHA" \
--head-sha "$HEAD_SHA"
workflow-guard-tests:
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Validate macOS runner guards
run: ./tests/test_ci_self_hosted_guard.sh
- name: Validate release SDK build lane
run: ./tests/test_ci_release_sdk_lane.sh
- name: Validate Python test harness syntax
run: git ls-files 'tests/*.py' 'tests_v2/*.py' 'scripts/*.py' | xargs python3 -m py_compile
- name: Set up Python 3.9 for nightly prune compatibility
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: "3.9"
- name: Validate nightly prune Python compatibility
run: PYTHON_BIN=python3.9 bash ./tests/test_ci_nightly_prune_python_compat.sh
- name: Validate activation benchmark scrollback sizing
run: python3 tests/test_perf_activation_scrollback_sizing.py
- name: Validate create-dmg version pinning
run: ./tests/test_ci_create_dmg_pinned.sh
- name: Validate unit-test SwiftPM retry guard
run: ./tests/test_ci_unit_test_spm_retry.sh
- name: Validate xcodebuild noninteractive crash prompt guard
run: python3 tests/test_ci_xcodebuild_noninteractive_helper.py
- name: Validate app-host xcodebuild retry guard
run: ./tests/test_ci_app_host_xcodebuild_retry.sh
- name: Validate app-host xcodebuild attempt budget
run: ./tests/test_ci_app_host_xcodebuild_attempts.sh
- name: Validate cmux profiling support scripts
run: ./tests/test_start_cmux_profiling.sh
- name: Validate Xcode SourcePackages cache sanitizer
run: python3 tests/test_ci_sanitize_xcode_source_packages_cache.py
- name: Validate TestFlight notes generator
run: python3 tests/test_ios_testflight_notes.py
- name: Validate external TestFlight group assignment helper
run: python3 tests/test_ios_testflight_external_distribution.py
- name: Validate iOS App Store lane identity
run: python3 tests/test_ios_appstore_lane_identity.py
- name: Validate cmux scheme test configuration
run: ./tests/test_ci_scheme_testaction_debug.sh
- name: Validate cmuxTests sharding
run: |
python3 scripts/ci/cmux_unit_test_shard.py --validate
python3 tests/test_ci_cmux_unit_test_shard.py
- name: Validate GhosttyKit checksum verification
run: ./tests/test_ci_ghosttykit_checksum_verification.sh
- name: Validate Zig install without sudo
run: ./tests/test_install_zig_ci_no_sudo.sh
- name: Validate virtual display lock
run: ./tests/test_ci_virtual_display_lock.sh
- name: Validate nightly tag push auth
run: ./tests/test_ci_nightly_tag_push_auth.sh
- name: Validate nightly Xcode selection
run: ./tests/test_ci_nightly_xcode_selection.sh
- name: Validate CI Xcode selection fast path
run: ./tests/test_ci_xcode_selection_fast_path.sh
- name: Validate universal nightly workflow
run: bash ./tests/test_nightly_universal_build.sh
- name: Validate release asset guard
run: node scripts/release_asset_guard.test.js
- name: Validate CI change area filter
run: python3 tests/test_ci_change_areas.py
- name: Validate release attestation retry guard
run: ./tests/test_ci_attestation_retry.sh
- name: Validate Python R2 appcast upload guard
run: ./tests/test_ci_r2_upload_python.sh
- name: Validate release-build timeout guard
run: ./tests/test_ci_release_build_timeout.sh
- name: Validate markdown viewer asset compression
run: ./tests/test_compress_markdown_viewer_assets.sh
- name: Validate release bundle stripping
run: ./tests/test_strip_release_bundle.sh
- name: Validate current GhosttyKit checksum pin
run: ./tests/test_ci_ghosttykit_checksum_present.sh
- name: Validate Swift warning budget guard
run: ./tests/test_ci_swift_warning_budget.sh
- name: Validate Swift file length budget guard
run: ./tests/test_ci_swift_file_length_budget.sh
- name: Validate auxiliary window close shortcut lint
run: ./tests/test_ci_auxiliary_window_close_shortcuts.sh
- name: Validate pbxproj test-wiring lint
run: ./tests/test_ci_pbxproj_test_wiring.sh
- name: Validate pbxproj objectVersion pin and normalization
run: ./scripts/check-pbxproj.sh
- name: Validate workspace SPM package grouping
run: python3 scripts/check-workspace-package-groups.py --check
- name: Validate SwiftPM lockfile policy
run: python3 scripts/check-package-resolved-policy.py
- name: Validate bash shell integration job control
run: python3 tests/test_bash_integration_no_done_notifications.py
- name: Validate sidebar lazy-layout guard
run: python3 tests/test_ci_sidebar_lazy_layout_guard.py
- name: Validate bash prompt bootstrap composes with user PROMPT_COMMAND (starship)
run: python3 tests/test_issue_5164_starship_prompt_composition.py
- name: Validate Swift file length budget
env:
EVENT_NAME: ${{ github.event_name }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
BASE_BRANCH: ${{ github.event.pull_request.base.ref }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
BEFORE_SHA: ${{ github.event.before }}
run: |
set -euo pipefail
if [ "$EVENT_NAME" = "pull_request" ]; then
BASE_REF="$BASE_SHA"
if MERGE_BASE="$(git merge-base "$BASE_SHA" "$HEAD_SHA")"; then
BASE_REF="$MERGE_BASE"
fi
python3 scripts/swift_file_length_budget.py \
--budget .github/swift-file-length-budget.tsv \
--base-ref "$BASE_REF" \
--merge-ref "origin/${BASE_BRANCH:-main}" \
--merge-head "$HEAD_SHA"
elif [ "$EVENT_NAME" = "push" ] && [ -n "$BEFORE_SHA" ] && ! [[ "$BEFORE_SHA" =~ ^0+$ ]]; then
python3 scripts/swift_file_length_budget.py \
--budget .github/swift-file-length-budget.tsv \
--base-ref "$BEFORE_SHA"
elif git rev-parse --verify --quiet origin/main >/dev/null && MERGE_BASE="$(git merge-base origin/main HEAD)"; then
python3 scripts/swift_file_length_budget.py \
--budget .github/swift-file-length-budget.tsv \
--base-ref "$MERGE_BASE"
else
python3 scripts/swift_file_length_budget.py \
--budget .github/swift-file-length-budget.tsv
fi
- name: Validate feature flags (naming, owners, expiry, single use, no reuse)
run: python3 scripts/lint-feature-flags.py
- name: Validate test determinism gate
run: python3 scripts/check-test-determinism.py --strict
remote-daemon-tests:
needs: changes
if: ${{ needs.changes.outputs.go == 'true' }}
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: daemon/remote/go.mod
cache-dependency-path: daemon/remote/go.sum
- name: Run remote daemon tests
working-directory: daemon/remote
run: go test ./...
- name: Validate remote daemon release assets
run: ./tests/test_remote_daemon_release_assets.sh
web-typecheck:
needs: changes
if: ${{ needs.changes.outputs.web == 'true' }}
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
defaults:
run:
working-directory: web
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Typecheck
run: bun run typecheck
- name: Web tests
# Explicit sorted file list: bun discovers test files in filesystem
# readdir order, which differs between Linux runners and local macOS,
# so an unpinned run exercises a file order no developer can
# reproduce. Sorted order makes CI failures replayable locally.
run: bun test $(ls tests/*.test.ts tests/*.test.tsx | sort)
# Checks for in-app React webviews (currently the diff viewer; more cmux React
# surfaces will live alongside it).
react-apps-check:
needs: changes
if: ${{ needs.changes.outputs.web == 'true' }}
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
- name: Verify generated webviews assets
run: ./scripts/build-webviews-app.sh --check
- name: Verify React Compiler for webviews
run: ./scripts/check-webviews-react-compiler.mjs
- name: Typecheck webviews
working-directory: webviews
run: bun run typecheck
- name: Test webviews
working-directory: webviews
run: bun run test
- name: Oxlint webviews
working-directory: webviews
run: bun run lint:ci
- name: React Doctor
working-directory: webviews
run: bun run react-doctor:ci
web-db-migrations:
needs: changes
if: ${{ needs.changes.outputs.web == 'true' }}
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
defaults:
run:
working-directory: web
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_DB: cmux_test
POSTGRES_PASSWORD: cmux
POSTGRES_USER: cmux
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U cmux -d cmux_test"
--health-interval 1s
--health-timeout 5s
--health-retries 30
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Apply migrations
env:
DATABASE_URL: postgres://cmux:cmux@localhost:5432/cmux_test
DIRECT_DATABASE_URL: postgres://cmux:cmux@localhost:5432/cmux_test
run: bunx drizzle-kit migrate --config drizzle.config.ts
- name: Re-apply migrations
env:
DATABASE_URL: postgres://cmux:cmux@localhost:5432/cmux_test
DIRECT_DATABASE_URL: postgres://cmux:cmux@localhost:5432/cmux_test
run: bunx drizzle-kit migrate --config drizzle.config.ts
- name: Database behavior tests
env:
CMUX_DB_TEST: "1"
DATABASE_URL: postgres://cmux:cmux@localhost:5432/cmux_test
DIRECT_DATABASE_URL: postgres://cmux:cmux@localhost:5432/cmux_test
run: bun run test:db:behavior
app-host-unit-tests:
needs:
- changes
- linux-preflight
# !cancelled() disables the implicit success() gate, which GitHub evaluates
# over the transitive needs chain: linux-preflight runs behind routed linux
# jobs that legitimately skip (web/go/agent-session paths), and that
# transitive skip otherwise marks every macOS job skipped even when
# linux-preflight itself succeeds. Require the direct needs explicitly.
if: ${{ !cancelled() && needs.changes.result == 'success' && needs.linux-preflight.result == 'success' && needs.changes.outputs.macos == 'true' }}
name: app-host unit tests (${{ matrix.shard }}/4)
# App-host XCTest needs a runner that can broker testmanagerd control
# sessions. Validated head-to-head that warp-macos-15-arm64-6x runs the
# full app-host suite with 0 unexpected failures, so route through the
# shared MACOS_RUNNER_15 var like the other macOS jobs.
runs-on: ${{ vars.MACOS_RUNNER_15 || 'warp-macos-15-arm64-6x' }}
timeout-minutes: 75
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
env:
CMUX_CI_XCODE_APP: ${{ vars.CMUX_CI_XCODE_APP_MACOS_15 }}
CMUX_CI_REQUIRED_MACOS_SDK_MAJOR: "26"
CMUX_SKIP_ZIG_BUILD: "1"
# Keep one-off focused gates on the lightest measured shard so shard 1 no
# longer carries the full shard plus every extra regression guard.
CMUX_APP_HOST_FOCUSED_REGRESSION_SHARD: "4"
# XCTest app-host crashes can leave xcodebuild waiting in Swift's crash
# backtracer until the job timeout. Keep crash handling non-interactive
# and cheap so xcodebuild can restart/finish the suite.
SWIFT_BACKTRACE: "interactive=no,timeout=0s,symbolicate=off,color=no"
CMUX_XCODEBUILD_NONINTERACTIVE_POST_TEST_TIMEOUT_SECONDS: "45"
steps:
- name: Clear stale git locks (self-hosted reused workspace)
shell: bash
run: |
# Self-hosted macOS runners reuse the workspace. A job cancelled or
# killed mid-checkout can leave a stale .git/modules/*/index.lock that
# fails every later submodule checkout (e.g. ghostty). Clear them first.
ws="${GITHUB_WORKSPACE:-$PWD}"
rm -f "$ws/.git/index.lock" 2>/dev/null || true
if [ -d "$ws/.git/modules" ]; then
find "$ws/.git/modules" -type f -name "*.lock" -delete 2>/dev/null || true
fi
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
submodules: recursive
- name: Select Xcode
run: |
set -euo pipefail
./scripts/select-ci-xcode.sh
- name: Capture Ghostty revision
id: ghostty-revision
run: |
echo "sha=$(git -C ghostty rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Cache GhosttyKit.xcframework
id: cache-ghosttykit
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: GhosttyKit.xcframework
key: ghosttykit-${{ steps.ghostty-revision.outputs.sha }}
- name: Download pre-built GhosttyKit.xcframework
if: steps.cache-ghosttykit.outputs.cache-hit != 'true'
run: |
./scripts/download-prebuilt-ghosttykit.sh
- name: Install Rust
run: |
./scripts/install-rust-ci.sh
- name: Cache Swift packages
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: .ci-source-packages
key: spm-${{ hashFiles('cmux.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }}
restore-keys: spm-
- name: Sanitize Swift package cache
run: python3 scripts/ci/sanitize-xcode-source-packages-cache.py .ci-source-packages
- name: Prepare isolated DerivedData
run: |
set -euo pipefail
DERIVED_DATA_PATH="${RUNNER_TEMP}/cmux-derived-data-tests-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-shard-${{ matrix.shard }}"
rm -rf "$DERIVED_DATA_PATH"
mkdir -p "$DERIVED_DATA_PATH"
echo "CMUX_DERIVED_DATA_PATH=$DERIVED_DATA_PATH" >> "$GITHUB_ENV"
- name: Resolve Swift packages
run: |
set -euo pipefail
SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages"
mkdir -p "$SOURCE_PACKAGES_DIR"
for attempt in 1 2 3; do
if xcodebuild -project cmux.xcodeproj -scheme cmux-unit -configuration Debug \
-derivedDataPath "$CMUX_DERIVED_DATA_PATH" \
-clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \
-resolvePackageDependencies; then
# Guard against a stale/poisoned Swift-package cache: a restored
# .ci-source-packages can make resolve report success without the
# binary artifacts (Sparkle/Sentry) actually present, which then
# fails the build. Verify they materialized; if not, clear and retry.
if [ -d "$SOURCE_PACKAGES_DIR/artifacts/sparkle/Sparkle/Sparkle.xcframework" ] && [ -d "$SOURCE_PACKAGES_DIR/artifacts/sentry-cocoa/Sentry/Sentry.xcframework" ]; then
exit 0
fi
echo "Resolve succeeded but binary artifacts are missing (stale cache); clearing and retrying" >&2
rm -rf "$SOURCE_PACKAGES_DIR" # whole dir — resolve will not re-materialize artifacts into a partial tree
fi
if [ "$attempt" -eq 3 ]; then
echo "Failed to resolve Swift packages after 3 attempts" >&2
exit 1
fi
echo "Package resolution failed on attempt $attempt, retrying..."
sleep $((attempt * 5))
done
- name: Run Ghostty split-theme appearance regression
if: ${{ matrix.shard == fromJSON(env.CMUX_APP_HOST_FOCUSED_REGRESSION_SHARD) }}
run: |
set -euo pipefail
DERIVED_DATA_PATH="${RUNNER_TEMP:-/tmp}/cmux-terminal-core-split-theme"
rm -rf "$DERIVED_DATA_PATH"
(
cd Packages/macOS/CmuxTerminalCore
xcodebuild \
-scheme CmuxTerminalCore-Package \
-destination "platform=macOS" \
-derivedDataPath "$DERIVED_DATA_PATH" \
build-for-testing
)
xcrun xctest "$DERIVED_DATA_PATH/Build/Products/Debug/CmuxTerminalCoreTests.xctest"
- name: Enable XCTest automation mode
run: |
set -euo pipefail
if ! command -v automationmodetool >/dev/null 2>&1; then
echo "::warning::automationmodetool is unavailable; XCTest will use its default automation-mode setup"
exit 0
fi
if sudo -n true 2>/dev/null; then
sudo -n automationmodetool enable-automationmode-without-authentication
else
echo "::warning::Passwordless sudo unavailable; XCTest will use its default automation-mode setup"
fi
- name: Run browser system proxy mirror regression
if: ${{ matrix.shard == fromJSON(env.CMUX_APP_HOST_FOCUSED_REGRESSION_SHARD) }}
run: |
# Focused gate for https://github.com/manaflow-ai/cmux/issues/5888.
# The full "Run unit tests" step tolerates app-host crashes by
# parsing only the last test summary, so suites that run late can
# otherwise be skipped by an app-host abort that still reports
# "(0 unexpected)". Keep this targeted only-testing invocation as a
# non-tolerant guard for the system proxy mirror regression.
set -euo pipefail
SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages"
scripts/ci/run-in-console-session.sh \
scripts/ci/run-app-host-xcodebuild.sh \
-project cmux.xcodeproj -scheme cmux-unit -configuration Debug \
-derivedDataPath "$CMUX_DERIVED_DATA_PATH" \
-clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \
-disableAutomaticPackageResolution \
-destination "platform=macOS" \
CMUX_SKIP_ZIG_BUILD=1 \
-only-testing:cmuxTests/BrowserSystemProxyMirrorTests \
test
- name: Run Option/Alt sided-modifier regression
if: ${{ matrix.shard == fromJSON(env.CMUX_APP_HOST_FOCUSED_REGRESSION_SHARD) }}
run: |
set -euo pipefail
SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages"
scripts/ci/run-in-console-session.sh \
scripts/ci/run-app-host-xcodebuild.sh \
-project cmux.xcodeproj -scheme cmux-unit -configuration Debug \
-derivedDataPath "$CMUX_DERIVED_DATA_PATH" \
-clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \
-disableAutomaticPackageResolution \
-destination "platform=macOS" \
CMUX_SKIP_ZIG_BUILD=1 \
-only-testing:cmuxTests/GhosttyOptionAsAltModsTests \
test
- name: Run omnibar suggestion click regression
if: ${{ matrix.shard == fromJSON(env.CMUX_APP_HOST_FOCUSED_REGRESSION_SHARD) }}
run: |
# Focused gate for https://github.com/manaflow-ai/cmux/issues/7380.
# The tolerant full-suite step can crash-skip late suites (see #5888
# note above), so this regression gets a non-tolerant focused
# invocation.
set -euo pipefail
SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages"
scripts/ci/run-in-console-session.sh \
scripts/ci/run-app-host-xcodebuild.sh \
-project cmux.xcodeproj -scheme cmux-unit -configuration Debug \
-derivedDataPath "$CMUX_DERIVED_DATA_PATH" \
-clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \
-disableAutomaticPackageResolution \
-destination "platform=macOS" \
CMUX_SKIP_ZIG_BUILD=1 \
-only-testing:cmuxTests/BrowserOmnibarSuggestionClickRoutingTests \
test
- name: Run remote tmux mirror active-pane regression
if: ${{ matrix.shard == fromJSON(env.CMUX_APP_HOST_FOCUSED_REGRESSION_SHARD) }}
run: |
# Focused gates for https://github.com/manaflow-ai/cmux/issues/7372.
# The tolerant full-suite step can crash-skip late suites (see #5888
# note above), so the seed and immediate-update paths get a
# non-tolerant invocation.
set -euo pipefail
SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages"
scripts/ci/run-in-console-session.sh \
scripts/ci/run-app-host-xcodebuild.sh \
-project cmux.xcodeproj -scheme cmux-unit -configuration Debug \
-derivedDataPath "$CMUX_DERIVED_DATA_PATH" \
-clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \
-disableAutomaticPackageResolution \
-destination "platform=macOS" \
CMUX_SKIP_ZIG_BUILD=1 \
-only-testing:cmuxTests/RemoteTmuxWindowMirrorFocusSeedTests \
test
- name: Run remote tmux mirror layout identity regression
if: ${{ matrix.shard == fromJSON(env.CMUX_APP_HOST_FOCUSED_REGRESSION_SHARD) }}
run: |
# The tolerant full-suite step may accept expected failures or stop
# after an app-host crash. Keep #7833's identity suite non-tolerant
# so a missing incremental-reconcile regression cannot pass the shard.
set -euo pipefail
SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages"
scripts/ci/run-in-console-session.sh \
scripts/ci/run-app-host-xcodebuild.sh \
-project cmux.xcodeproj -scheme cmux-unit -configuration Debug \
-derivedDataPath "$CMUX_DERIVED_DATA_PATH" \
-clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \
-disableAutomaticPackageResolution \
-destination "platform=macOS" \
CMUX_SKIP_ZIG_BUILD=1 \
-only-testing:cmuxTests/RemoteTmuxMirrorLayoutIdentityTests \
test
- name: Run unit tests
run: |
set -euo pipefail
SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages"
SHARD_ARGS="$RUNNER_TEMP/cmux-unit-shard-${{ matrix.shard }}.args"
python3 scripts/ci/cmux_unit_test_shard.py \
--shard-index "${{ matrix.shard }}" \
--shard-total 4 \
--output "$SHARD_ARGS"
ONLY_TESTING_ARGS=()
while IFS= read -r arg; do
[ -n "$arg" ] && ONLY_TESTING_ARGS+=("$arg")
done < "$SHARD_ARGS"
run_unit_tests() {
# These app-host tests create real SwiftUI/WebKit/Ghostty windows and
# intermittently crash inside XCTest's post-test memory checker or
# leave native display/WebKit work alive on GitHub macOS runners.
# When that happens, Swift's crash/backtrace handling or the stale
# app-host process keeps xcodebuild alive until the job-level timeout,
# hiding the actual unit test summary.
# The app-host wrapper also serializes GUI XCTest ownership per Mac,
# while still allowing shards on separate Macs to run in parallel.
scripts/ci/run-in-console-session.sh \
scripts/ci/run-app-host-xcodebuild.sh \
-project cmux.xcodeproj -scheme cmux-unit -configuration Debug \
-derivedDataPath "$CMUX_DERIVED_DATA_PATH" \
-clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \
-disableAutomaticPackageResolution \
-destination "platform=macOS" \
"${ONLY_TESTING_ARGS[@]}" \
CMUX_SKIP_ZIG_BUILD=1 \
test 2>&1 &
local xcodebuild_pid=$!
local timeout_seconds="${CMUX_UNIT_TEST_TIMEOUT_SECONDS:-900}"
local deadline=$((SECONDS + timeout_seconds))
while kill -0 "$xcodebuild_pid" 2>/dev/null; do
if [ "$SECONDS" -ge "$deadline" ]; then
echo "xcodebuild unit test timeout after ${timeout_seconds}s; terminating"
kill -TERM "$xcodebuild_pid" 2>/dev/null || true
sleep 5
kill -KILL "$xcodebuild_pid" 2>/dev/null || true
wait "$xcodebuild_pid" 2>/dev/null || true
return 124
fi
sleep 5
done
wait "$xcodebuild_pid"
}
# Stream output via tee so CI logs are visible in real time, while still
# capturing for post-run analysis of expected vs unexpected failures.
TEST_OUTPUT="$RUNNER_TEMP/cmux-unit-output-shard-${{ matrix.shard }}.txt"
set +e
run_unit_tests | tee "$TEST_OUTPUT"
EXIT_CODE=${PIPESTATUS[0]}
OUTPUT=$(cat "$TEST_OUTPUT")
set -e
# SwiftPM binary artifact resolution can occasionally fail on ephemeral
# runners with "Could not resolve package dependencies". Retry once after
# clearing SwiftPM/DerivedData caches to recover from transient corruption.
if [ "$EXIT_CODE" -ne 0 ] && echo "$OUTPUT" | grep -q "Could not resolve package dependencies"; then
echo "SwiftPM package resolution failed, clearing caches and retrying once"
rm -rf ~/Library/Caches/org.swift.swiftpm
mkdir -p ~/Library/Caches/org.swift.swiftpm
rm -rf "$CMUX_DERIVED_DATA_PATH"
mkdir -p "$CMUX_DERIVED_DATA_PATH"
set +e
run_unit_tests | tee "$TEST_OUTPUT"
EXIT_CODE=${PIPESTATUS[0]}
OUTPUT=$(cat "$TEST_OUTPUT")
set -e
fi
if [ "$EXIT_CODE" -ne 0 ]; then
SUMMARY=$(echo "$OUTPUT" | grep "Executed.*tests.*with.*failures" | tail -1)
if echo "$SUMMARY" | grep -q "(0 unexpected)"; then
echo "All failures are expected, treating as pass"
else
echo "Unexpected test failures detected"
exit 1
fi
fi
- name: Run bundled Ghostty theme picker helper regression
if: ${{ matrix.shard == fromJSON(env.CMUX_APP_HOST_FOCUSED_REGRESSION_SHARD) }}
run: |
set -euo pipefail
CMUX_SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" \
./tests/test_bundled_ghostty_theme_picker_helper.sh
- name: Ensure node for wrapper regressions
id: detect-node
if: ${{ matrix.shard == fromJSON(env.CMUX_APP_HOST_FOCUSED_REGRESSION_SHARD) }}
run: |
set -euo pipefail
# The claude wrapper regressions below exec `node` from fake claude
# binaries. The tart-macos-15 runner image shipped without node on
# the runner PATH (2026-07-10), which failed every branch. Reuse an
# image-provided node when present; otherwise install one below.
for candidate in "$(command -v node || true)" /opt/homebrew/bin/node /usr/local/bin/node; do
if [ -n "$candidate" ] && [ -x "$candidate" ]; then
dirname "$candidate" >> "$GITHUB_PATH"
"$candidate" --version
echo "found=true" >> "$GITHUB_OUTPUT"
exit 0
fi
done
echo "no node runtime in the runner image; installing via setup-node"
echo "found=false" >> "$GITHUB_OUTPUT"
- name: Install node (runner image has none)
if: ${{ matrix.shard == fromJSON(env.CMUX_APP_HOST_FOCUSED_REGRESSION_SHARD) && steps.detect-node.outputs.found == 'false' }}
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "20"
- name: Run CLI no-socket regressions
if: ${{ matrix.shard == fromJSON(env.CMUX_APP_HOST_FOCUSED_REGRESSION_SHARD) }}
run: |
set -euo pipefail
CLI_BIN="$(
find "$CMUX_DERIVED_DATA_PATH" -path "*/Build/Products/Debug/cmux" -exec stat -f '%m %N' {} \; \
| sort -nr \
| head -1 \
| cut -d' ' -f2-
)"
if [ -z "${CLI_BIN:-}" ] || [ ! -x "$CLI_BIN" ]; then
echo "cmux CLI binary not found in DerivedData" >&2
exit 1
fi
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_version_memory_guard.py
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_contract_help.py
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_layout_focus_contract.py
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_socket_operation_deadline.py
python3 tests/test_stress_cli_socket_api.py
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_omo_openagent_plugin_migration.py
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_socket_autodiscovery.py
python3 tests/test_claude_wrapper_hooks.py
python3 tests/test_claude_wrapper_mutual_shim_loop.py
python3 tests/test_claude_wrapper_user_binary_resolution.py
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_claude_teams_fallback_path.py
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_claude_teams_env.py
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_claude_teams_trust_optin.py
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_omo_fallback_path.py
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_omx_fallback_path.py
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_omc_fallback_path.py
python3 tests/test_issue_2448_shell_claude_wrapper_dispatch.py
python3 tests/test_issue_6714_zsh_shim_noclobber.py
python3 tests/test_shell_git_branch_stale_cwd.py
python3 tests/test_shell_git_config_remote_url_parsing.py
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_claude_hook_stop_last_assistant.py
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_claude_hook_clear_running_status.py
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_claude_hook_push_notification.py
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_pi_extension_install.py
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_omp_extension_install.py
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_campfire_extension_install.py
tests:
name: tests
# Aggregate gate for the test/build suites in this workflow. Required by
# branch protection under the stable name "tests", and references each suite
# by its job KEY via needs:, so renaming/sharding a job's display name never
# desyncs the required-checks list (the failure mode from the #6464 sharding
# that stranded the old "tests" check). Add new ci.yml test/build jobs here.
needs:
- changes
- linux-preflight
- app-host-unit-tests
- swift-package-tests
- agent-session-web-resources
if: ${{ always() }}
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 5
steps:
- name: Check app-host unit test routing
env:
TESTS_NEEDS: ${{ toJSON(needs) }}
run: |
python3 - <<'PY'
import json
import os
import sys
needs = json.loads(os.environ["TESTS_NEEDS"])
changes = needs["changes"]
tests = needs["app-host-unit-tests"]
macos = changes.get("outputs", {}).get("macos")
if changes["result"] != "success":
print(f"changes: {changes['result']}", file=sys.stderr)
sys.exit(1)
preflight = needs["linux-preflight"]
if preflight["result"] != "success":
print(f"linux preflight did not pass: {preflight['result']}", file=sys.stderr)
sys.exit(1)
if macos == "true" and tests["result"] != "success":
print(f"app-host unit tests were required but did not pass: {tests['result']}", file=sys.stderr)
sys.exit(1)
if macos != "true" and tests["result"] not in {"success", "skipped"}:
print(f"app-host unit tests had unexpected result for macos={macos}: {tests['result']}", file=sys.stderr)
sys.exit(1)
# The remaining test/build suites in this workflow gate too. A job that
# opts out via its own path filter reports "skipped", which is fine; a
# job that actually ran and failed must block the merge.
allowed = {"success", "skipped"}
for name in ("swift-package-tests", "agent-session-web-resources"):
result = needs[name]["result"]
if result not in allowed:
print(f"{name} did not pass: {result}", file=sys.stderr)
sys.exit(1)
print(f"changes.macos={macos}")
print(f"linux-preflight={preflight['result']}")
print(f"app-host unit tests={tests['result']}")
for name in ("swift-package-tests", "agent-session-web-resources"):
print(f"{name}={needs[name]['result']}")
PY
swift-package-tests:
needs:
- changes
- linux-preflight
# !cancelled() disables the implicit success() gate, which GitHub evaluates
# over the transitive needs chain: linux-preflight runs behind routed linux
# jobs that legitimately skip (web/go/agent-session paths), and that
# transitive skip otherwise marks every macOS job skipped even when
# linux-preflight itself succeeds. Require the direct needs explicitly.
if: ${{ !cancelled() && needs.changes.result == 'success' && needs.linux-preflight.result == 'success' && needs.changes.outputs.macos == 'true' }}
# Build the release helper with SDK 15, then run package tests with SDK 26.
runs-on: ${{ vars.MACOS_RUNNER_DUAL_XCODE || 'blacksmith-6vcpu-macos-15' }}
timeout-minutes: 40
env:
CMUX_CI_XCODE_APP: ${{ vars.CMUX_CI_XCODE_APP_MACOS_15 }}
CMUX_CI_HELPER_XCODE_APP: ${{ vars.CMUX_CI_HELPER_XCODE_APP_MACOS_15 }}
CMUX_CI_REQUIRED_MACOS_SDK_MAJOR: "26"
steps:
- name: Clear stale git locks (self-hosted reused workspace)
shell: bash
run: |
# Self-hosted macOS runners reuse the workspace. A job cancelled or
# killed mid-checkout can leave a stale .git/modules/*/index.lock that
# fails every later submodule checkout (e.g. ghostty). Clear them first.
ws="${GITHUB_WORKSPACE:-$PWD}"
rm -f "$ws/.git/index.lock" 2>/dev/null || true
if [ -d "$ws/.git/modules" ]; then
find "$ws/.git/modules" -type f -name "*.lock" -delete 2>/dev/null || true
fi
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
submodules: recursive
- name: Select helper Xcode
run: |
set -euo pipefail
CMUX_CI_XCODE_APP="$CMUX_CI_HELPER_XCODE_APP" \
CMUX_CI_REQUIRED_MACOS_SDK_MAJOR=15 \
./scripts/select-ci-xcode.sh
- name: Install zig
run: ./scripts/install-zig-ci.sh
- name: Cache Zig packages
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.cache/zig
key: zig-packages-${{ hashFiles('ghostty/build.zig.zon', 'ghostty/build.zig.zon.json') }}
restore-keys: zig-packages-
- name: Build universal Ghostty CLI helper
run: |
set -euo pipefail
mkdir -p ghostty-cli-helper
./scripts/build-ghostty-cli-helper.sh --universal --output ghostty-cli-helper/ghostty
lipo ghostty-cli-helper/ghostty -verify_arch arm64 x86_64
for arch in arm64 x86_64; do
thin="ghostty-cli-helper/ghostty-$arch"
lipo ghostty-cli-helper/ghostty -thin "$arch" -output "$thin"
HELPER_SDK_VERSION="$(otool -l "$thin" | awk '/LC_BUILD_VERSION/ { in_version=1; next } in_version && /sdk / { print $2; exit }')"
echo "Ghostty helper $arch SDK version: $HELPER_SDK_VERSION"
[[ "$HELPER_SDK_VERSION" == 15.* ]]
done
- name: Upload universal Ghostty CLI helper
id: upload-ghostty-cli-helper
continue-on-error: true
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: cmux-ghostty-cli-helper
path: ghostty-cli-helper/ghostty
if-no-files-found: error
- name: Retry universal Ghostty CLI helper upload
if: steps.upload-ghostty-cli-helper.outcome == 'failure'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: cmux-ghostty-cli-helper
path: ghostty-cli-helper/ghostty
if-no-files-found: error
overwrite: true
- name: Select Xcode
run: |
set -euo pipefail
./scripts/select-ci-xcode.sh
- name: Capture Ghostty revision
id: ghostty-revision
run: |
echo "sha=$(git -C ghostty rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Cache GhosttyKit.xcframework
id: cache-ghosttykit-package-tests
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: GhosttyKit.xcframework
key: ghosttykit-${{ steps.ghostty-revision.outputs.sha }}
- name: Validate cached GhosttyKit.xcframework
id: validate-ghosttykit-package-tests
shell: bash
run: |
set -euo pipefail
if [ -f GhosttyKit.xcframework/Info.plist ]; then
echo "valid=true" >> "$GITHUB_OUTPUT"
else
rm -rf GhosttyKit.xcframework
echo "valid=false" >> "$GITHUB_OUTPUT"
fi
- name: Download pre-built GhosttyKit.xcframework
if: steps.validate-ghosttykit-package-tests.outputs.valid != 'true'
run: |
./scripts/download-prebuilt-ghosttykit.sh
- name: Install Rust
run: |
./scripts/install-rust-ci.sh
- name: Run Swift package unit tests
run: |
set -euo pipefail
# CmuxCommandPalette's nucleo FFI tests load the Rust dylib through
# CMUX_NUCLEO_FFI_LIB (they skip when it is absent, so build it here
# to keep the FFI parity suite a real gate).
cargo build --manifest-path Native/CommandPaletteNucleoFFI/Cargo.toml --release
export CMUX_NUCLEO_FFI_LIB="$PWD/Native/CommandPaletteNucleoFFI/target/release/libcmux_command_palette_nucleo_ffi.dylib"
# The cmux-unit scheme only runs the cmuxTests app-host suite; it does
# not execute the SPM package test targets. Run them here so package
# tests (settings stores, secret-file migration, socket-control
# convergence, etc.) are a real CI gate, not just compiled.
# Scoped to packages that build headlessly via SwiftPM (no GhosttyKit /
# app-target dependency). Add a package here once its `swift test`
# is confirmed to resolve standalone. The GhosttyKit-referencing
# packages (CmuxTerminalCore and the terminal packages stacked on
# it) are the exception: their binaryTarget only needs the
# xcframework present at the repo root (downloaded earlier in this
# job), and their test runners link a C stub for the @_silgen_name
# symbol instead of the GhosttyKit archive.
PACKAGES=(
CMUXAuthCore
CmuxCanvasUI
CmuxCore
CmuxRemoteDaemon
CmuxRemoteWorkspace
CmuxRemoteSession
CmuxAuthRuntime
CmuxCommandPalette
CmuxControlSocket
CmuxFoundation
CmuxGit
CmuxSettings
CmuxSettingsUI
CmuxTerminal
CmuxTerminalCore
CmuxUpdater
CMUXAgentLaunch
)
# SwiftPM emits an error-severity diagnostic while planning the
# GhosttyKit binaryTarget (the xcframework's static archive is not
# lib-prefixed: "unexpected binary name"/"unexpected binary
# framework"). The build and every test still succeed, but the
# diagnostic poisons the process exit code on a fresh .build. For
# the GhosttyKit-referencing packages only, tolerate exactly that
# case: a nonzero exit passes only when the all-tests-passed summary
# is present, no test failures are reported, and the only error
# lines are that known diagnostic. Everything else (compile errors,
# test failures, crashes) still fails the job.
for pkg in "${PACKAGES[@]}"; do
# Packages live under group folders (Packages/{Shared,iOS,macOS}/);
# resolve the actual directory so this list stays group-agnostic.
pkgdir="$(find Packages -mindepth 2 -maxdepth 2 -type d -name "$pkg" -print -quit)"
if [ -z "$pkgdir" ]; then
echo "::error::package '$pkg' not found under Packages/*/ (renamed or moved?)"
exit 1
fi
echo "::group::swift test $pkgdir"
case "$pkg" in
CmuxAuthRuntime)
suite_list="$(
find "$pkgdir/Tests" -name '*.swift' -print0 \
| xargs -0 perl -ne 'print "$1\n" if /^\s*@Suite(?:\([^)]*\))?\s+struct\s+([A-Za-z0-9_]+)/' \
| sort -u
)"
if [ -z "$suite_list" ]; then
echo "::error::no Swift Testing suites found for $pkg"
exit 1
fi
while IFS= read -r suite; do
[ -n "$suite" ] || continue
echo "swift test $pkgdir --filter $suite"
swift test --package-path "$pkgdir" --filter "$suite"
done <<< "$suite_list"
;;
CmuxTerminal|CmuxTerminalCore)
test_status=0
output="$(swift test --package-path "$pkgdir" 2>&1)" || test_status=$?
printf '%s\n' "$output"
if [ "$test_status" -ne 0 ]; then
if printf '%s\n' "$output" | grep -Eq 'Test run with [0-9]+ tests( in [0-9]+ suites)? passed' \
&& ! printf '%s\n' "$output" | grep -Eq 'with [1-9][0-9]* failures?' \
&& ! printf '%s\n' "$output" | grep -v 'unexpected binary' | grep -Eq '(^|[^a-zA-Z])error:'; then
echo "Tolerated cosmetic GhosttyKit binaryTarget diagnostic; all tests passed."
else
exit "$test_status"
fi
fi
;;
*)
swift test --package-path "$pkgdir"
;;
esac
echo "::endgroup::"
done
agent-session-web-resources:
needs: changes
if: ${{ needs.changes.outputs.agent_session_web == 'true' }}
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
- name: Build and test Agent Session web resources
run: |
set -euo pipefail
bun install --frozen-lockfile
bun run agent-session-web:build
bun run agent-session-web:test
git diff --exit-code -- Resources/agent-session-react Resources/agent-session-solid
linux-preflight:
name: linux-preflight
needs:
- changes
- workflow-guard-tests
- remote-daemon-tests
- web-typecheck
- react-apps-check
- web-db-migrations
- agent-session-web-resources
if: ${{ always() }}
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 5
steps:
- name: Check cheap CI layer before macOS runners
env:
PREFLIGHT_NEEDS: ${{ toJSON(needs) }}
run: |
python3 - <<'PY'
import json
import os
import sys
needs = json.loads(os.environ["PREFLIGHT_NEEDS"])
required = ("changes", "workflow-guard-tests")
allowed_routed = {
"remote-daemon-tests",
"web-typecheck",
"react-apps-check",
"web-db-migrations",
"agent-session-web-resources",
}
bad = {}
for name in required:
result = needs[name]["result"]
if result != "success":
bad[name] = result
outputs = needs["changes"].get("outputs", {})
routed_outputs = {
"remote-daemon-tests": "go",
"web-typecheck": "web",
"react-apps-check": "web",
"web-db-migrations": "web",
"agent-session-web-resources": "agent_session_web",
}
for name in sorted(allowed_routed):
result = needs[name]["result"]
route = routed_outputs[name]
if outputs.get(route) == "true":
if result != "success":
bad[name] = f"{result} (route {route}=true)"
elif result not in {"success", "skipped"}:
bad[name] = result
if bad:
for name, result in bad.items():
print(f"{name}: {result}", file=sys.stderr)
sys.exit(1)
print(
"routes: "
f"macos={outputs.get('macos')} "
f"web={outputs.get('web')} "
f"go={outputs.get('go')} "
f"agent_session_web={outputs.get('agent_session_web')}"
)
for name, data in sorted(needs.items()):
print(f"{name}: {data['result']}")
PY
tests-build-and-lag:
needs:
- changes
- linux-preflight
# !cancelled() disables the implicit success() gate, which GitHub evaluates
# over the transitive needs chain: linux-preflight runs behind routed linux
# jobs that legitimately skip (web/go/agent-session paths), and that
# transitive skip otherwise marks every macOS job skipped even when
# linux-preflight itself succeeds. Require the direct needs explicitly.
if: ${{ !cancelled() && needs.changes.result == 'success' && needs.linux-preflight.result == 'success' && needs.changes.outputs.macos == 'true' }}
# Build the full cmux scheme once, then run the required display/runtime
# regressions from the same DerivedData instead of queuing a second display
# runner for UI-only checks.
runs-on: ${{ vars.MACOS_RUNNER_DISPLAY || 'warp-macos-15-arm64-6x' }}
# A cold DerivedData cache (any project.pbxproj or Package.resolved change
# mints a new cache key with no restore-keys fallback) forces a full
# cmux build whose Swift codegen alone can run 20+ min. Project/package
# changes from the sidebar extension kit pushed this full build plus the
# CA, lag, and UI regressions beyond 35 min before the cache could repopulate.
timeout-minutes: 75
env:
CMUX_CI_XCODE_APP: ${{ vars.CMUX_CI_XCODE_APP_MACOS_15 }}
CMUX_CI_REQUIRED_MACOS_SDK_MAJOR: "26"
steps:
- name: Validate display runner identity
env:
REQUESTED_RUNNER: ${{ vars.MACOS_RUNNER_DISPLAY || 'warp-macos-15-arm64-6x' }}
RUNNER_CONTEXT_NAME: ${{ runner.name }}
run: |
set -euo pipefail
echo "Requested runner: $REQUESTED_RUNNER"
case "$REQUESTED_RUNNER" in
depot-*)
case "$RUNNER_CONTEXT_NAME" in
depot-*)
echo "Resolved runner matches depot-*? yes"
;;
*)
echo "Resolved runner matches depot-*? no"
echo "::error::$REQUESTED_RUNNER resolved outside Depot. Remove $REQUESTED_RUNNER from non-Depot self-hosted runners or choose a different runner."
exit 1
;;
esac
;;
*)
echo "Display runner is not Depot; skipping Depot identity guard"
;;
esac
- name: Clear stale git locks (self-hosted reused workspace)
shell: bash
run: |
# Self-hosted macOS runners reuse the workspace. A job cancelled or
# killed mid-checkout can leave a stale .git/modules/*/index.lock that
# fails every later submodule checkout (e.g. ghostty). Clear them first.
ws="${GITHUB_WORKSPACE:-$PWD}"
rm -f "$ws/.git/index.lock" 2>/dev/null || true
if [ -d "$ws/.git/modules" ]; then
find "$ws/.git/modules" -type f -name "*.lock" -delete 2>/dev/null || true
fi
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
submodules: recursive
- name: Select Xcode
run: |
set -euo pipefail
./scripts/select-ci-xcode.sh
- name: Prepare isolated DerivedData
run: |
set -euo pipefail
DERIVED_DATA_PATH="$RUNNER_TEMP/cmux-deriveddata-tests-build-and-lag"
rm -rf "$DERIVED_DATA_PATH"
mkdir -p "$DERIVED_DATA_PATH"
echo "CMUX_DERIVED_DATA_PATH=$DERIVED_DATA_PATH" >> "$GITHUB_ENV"
- name: Capture Ghostty revision
id: ghostty-revision
run: |
echo "sha=$(git -C ghostty rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Cache GhosttyKit.xcframework
id: cache-ghosttykit-lag
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: GhosttyKit.xcframework
key: ghosttykit-${{ steps.ghostty-revision.outputs.sha }}
- name: Download pre-built GhosttyKit.xcframework
if: steps.cache-ghosttykit-lag.outputs.cache-hit != 'true'
run: |
./scripts/download-prebuilt-ghosttykit.sh
- name: Install zig
run: |
./scripts/install-zig-ci.sh
- name: Install Rust
run: |
./scripts/install-rust-ci.sh
- name: Cache Zig packages
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.cache/zig
key: zig-packages-${{ hashFiles('ghostty/build.zig.zon', 'ghostty/build.zig.zon.json') }}
restore-keys: zig-packages-
- name: Cache DerivedData
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ${{ runner.temp }}/cmux-deriveddata-tests-build-and-lag
key: deriveddata-build-${{ hashFiles('cmux.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }}-${{ hashFiles('cmux.xcodeproj/project.pbxproj') }}-${{ steps.ghostty-revision.outputs.sha }}
- name: Cache Swift packages
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: .ci-source-packages
key: spm-build-${{ hashFiles('cmux.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }}
restore-keys: spm-build-
- name: Sanitize Swift package cache
run: python3 scripts/ci/sanitize-xcode-source-packages-cache.py .ci-source-packages
- name: Resolve Swift packages
run: |
set -euo pipefail
SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages"
mkdir -p "$CMUX_DERIVED_DATA_PATH"
mkdir -p "$SOURCE_PACKAGES_DIR"
for attempt in 1 2 3; do
if xcodebuild -project cmux.xcodeproj -scheme cmux -configuration Debug \
-derivedDataPath "$CMUX_DERIVED_DATA_PATH" \
-clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \
-resolvePackageDependencies; then
# Guard against a stale/poisoned Swift-package cache: a restored
# .ci-source-packages can make resolve report success without the
# binary artifacts (Sparkle/Sentry) actually present, which then
# fails the build. Verify they materialized; if not, clear and retry.
if [ -d "$SOURCE_PACKAGES_DIR/artifacts/sparkle/Sparkle/Sparkle.xcframework" ] && [ -d "$SOURCE_PACKAGES_DIR/artifacts/sentry-cocoa/Sentry/Sentry.xcframework" ]; then
exit 0
fi
echo "Resolve succeeded but binary artifacts are missing (stale cache); clearing and retrying" >&2
rm -rf "$SOURCE_PACKAGES_DIR" # whole dir — resolve will not re-materialize artifacts into a partial tree
fi
if [ "$attempt" -eq 3 ]; then
echo "Failed to resolve Swift packages after 3 attempts" >&2
exit 1
fi
echo "Package resolution failed on attempt $attempt, retrying..."
sleep $((attempt * 5))
done
- name: Build for runtime regressions
run: |
set -euo pipefail
SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages"
xcodebuild -project cmux.xcodeproj -scheme cmux -configuration Debug \
-derivedDataPath "$CMUX_DERIVED_DATA_PATH" \
-clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \
-disableAutomaticPackageResolution \
-destination "platform=macOS" build-for-testing 2>&1 | tee /tmp/cmux-build-output.txt
- name: Validate Swift warning budget
run: python3 scripts/swift_warning_budget.py --log /tmp/cmux-build-output.txt
- name: Run display UI regressions
run: scripts/ci/run-display-ui-regressions.sh
- name: Create virtual display
run: |
set -euo pipefail
HELPER_PATH="$RUNNER_TEMP/create-virtual-display"
VDISPLAY_READY="$RUNNER_TEMP/cmux-build-lag-vdisplay.ready"
VDISPLAY_ID_PATH="$RUNNER_TEMP/cmux-build-lag-vdisplay.id"
VDISPLAY_LOG="$RUNNER_TEMP/cmux-build-lag-vdisplay.log"
VDISPLAY_PID=""
clang -framework Foundation -framework CoreGraphics \
-o "$HELPER_PATH" scripts/create-virtual-display.m
cleanup_display_attempt() {
if [ -n "${VDISPLAY_PID:-}" ]; then
kill "$VDISPLAY_PID" >/dev/null 2>&1 || true
for _ in $(seq 1 50); do
kill -0 "$VDISPLAY_PID" >/dev/null 2>&1 || break
sleep 0.1
done
if kill -0 "$VDISPLAY_PID" >/dev/null 2>&1; then
kill -9 "$VDISPLAY_PID" >/dev/null 2>&1 || true
wait "$VDISPLAY_PID" >/dev/null 2>&1 || true
fi
VDISPLAY_PID=""
fi
scripts/ci/virtual-display-lock.sh reap-strays || true
scripts/ci/virtual-display-lock.sh release || true
}
for attempt in 1 2 3; do
rm -f "$VDISPLAY_READY" "$VDISPLAY_ID_PATH" "$VDISPLAY_LOG"
LOCK_ENV="$(scripts/ci/virtual-display-lock.sh acquire)"
eval "$LOCK_ENV"
export CMUX_VDISPLAY_LOCK_DIR CMUX_VDISPLAY_LOCK_TOKEN
{
echo "CMUX_VDISPLAY_LOCK_DIR=$CMUX_VDISPLAY_LOCK_DIR"
echo "CMUX_VDISPLAY_LOCK_TOKEN=$CMUX_VDISPLAY_LOCK_TOKEN"
} >> "$GITHUB_ENV"
# Now that we hold the lock, reap any leaked display helper so a
# CGVirtualDisplay orphaned by a crashed/cancelled job cannot block
# this create on persistent self-hosted runners.
scripts/ci/virtual-display-lock.sh reap-strays || true
"$HELPER_PATH" \
--ready-path "$VDISPLAY_READY" \
--display-id-path "$VDISPLAY_ID_PATH" \
>"$VDISPLAY_LOG" 2>&1 &
VDISPLAY_PID=$!
scripts/ci/virtual-display-lock.sh set-owner "$VDISPLAY_PID"
{
echo "VDISPLAY_PID=$VDISPLAY_PID"
echo "VDISPLAY_HELPER_PATH=$HELPER_PATH"
echo "VDISPLAY_READY=$VDISPLAY_READY"
echo "VDISPLAY_ID_PATH=$VDISPLAY_ID_PATH"
echo "VDISPLAY_LOG=$VDISPLAY_LOG"
} >> "$GITHUB_ENV"
for _ in $(seq 1 100); do
if [ -s "$VDISPLAY_READY" ] && [ -s "$VDISPLAY_ID_PATH" ]; then
break
fi
if ! kill -0 "$VDISPLAY_PID" 2>/dev/null; then
echo "Virtual display helper exited before readiness on attempt $attempt" >&2
cat "$VDISPLAY_LOG" >&2 || true
break
fi
sleep 0.1
done
if [ -s "$VDISPLAY_READY" ] && [ -s "$VDISPLAY_ID_PATH" ]; then
echo "Virtual display ready: $(tr -d '\n' < "$VDISPLAY_ID_PATH")"
cat "$VDISPLAY_LOG"
exit 0
fi
echo "Virtual display not ready on attempt $attempt" >&2
cat "$VDISPLAY_LOG" >&2 || true
cleanup_display_attempt
if [ "$attempt" -eq 3 ]; then
echo "Failed to create virtual display after 3 attempts" >&2
exit 1
fi
sleep 5
done
- name: Run CoreAnimation main-thread startup regression
run: |
set -euo pipefail
APP="$(
find "$CMUX_DERIVED_DATA_PATH" -path "*/Build/Products/Debug/cmux DEV.app" -exec stat -f '%m %N' {} \; \
| sort -nr \
| head -1 \
| cut -d' ' -f2-
)"
if [ -z "${APP:-}" ] || [ ! -d "$APP" ]; then
echo "cmux DEV.app not found in DerivedData" >&2
exit 1
fi
CMUX_ALLOW_UNTAGGED_CA_REGRESSION=1 \
CMUX_CA_ASSERT_HOLD_SECONDS=15 \
CMUX_TAG="ci-ca-main-thread" \
./scripts/verify-main-thread-ca-transactions.sh "$APP"
- name: Run workspace churn typing-lag regression
run: |
set -euo pipefail
APP="$(find "$CMUX_DERIVED_DATA_PATH" -path "*/Build/Products/Debug/cmux DEV.app" -print -quit)"
if [ -z "${APP:-}" ] || [ ! -d "$APP" ]; then
echo "cmux DEV.app not found in DerivedData" >&2
exit 1
fi
TAG="ci-lag"
SOCK="/tmp/cmux-debug-${TAG}.sock"
BUNDLE_ID="$(
/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$APP/Contents/Info.plist" 2>/dev/null \
|| echo 'com.cmuxterm.app.debug'
)"
pkill -x "cmux DEV" || true
rm -f "$SOCK" "/tmp/cmux-${TAG}.sock" || true
defaults write "$BUNDLE_ID" socketControlMode -string full >/dev/null 2>&1 || true
CMUX_TAG="$TAG" CMUX_SOCKET_PATH="$SOCK" CMUX_UI_TEST_MODE=1 "$APP/Contents/MacOS/cmux DEV" >/tmp/cmux-ci-lag.log 2>&1 &
APP_PID=$!
trap 'kill "$APP_PID" >/dev/null 2>&1 || true' EXIT
for _ in {1..240}; do
[ -S "$SOCK" ] && break
sleep 0.25
done
[ -S "$SOCK" ] || { echo "Socket not ready at $SOCK" >&2; exit 1; }
CMUX_SOCKET_PATH="$SOCK" \
CMUX_LAG_MAX_P95_RATIO=1.70 \
CMUX_LAG_MAX_AVG_RATIO=1.70 \
CMUX_LAG_MIN_BASELINE_P95_MS_FOR_RATIO=6.0 \
CMUX_LAG_MIN_BASELINE_AVG_MS_FOR_RATIO=4.0 \
CMUX_LAG_MAX_P95_DELTA_MS=20.0 \
CMUX_LAG_MAX_AVG_DELTA_MS=12.0 \
CMUX_LAG_MAX_CHURN_P95_MS=35 \
CMUX_LAG_KEY_EVENTS=180 \
python3 tests/test_workspace_churn_up_arrow_lag.py
- name: Cleanup virtual display
if: always()
run: |
set -euo pipefail
if [ -n "${VDISPLAY_PID:-}" ]; then
kill "$VDISPLAY_PID" >/dev/null 2>&1 || true
for _ in $(seq 1 50); do
kill -0 "$VDISPLAY_PID" >/dev/null 2>&1 || break
sleep 0.1
done
if kill -0 "$VDISPLAY_PID" >/dev/null 2>&1; then
kill -9 "$VDISPLAY_PID" >/dev/null 2>&1 || true
wait "$VDISPLAY_PID" >/dev/null 2>&1 || true
fi
fi
scripts/ci/virtual-display-lock.sh reap-strays || true
scripts/ci/virtual-display-lock.sh release || true
rm -f "${VDISPLAY_HELPER_PATH:-}" "${VDISPLAY_READY:-}" "${VDISPLAY_ID_PATH:-}" "${VDISPLAY_LOG:-}"
release-build:
needs:
- changes
- linux-preflight
- swift-package-tests
# See app-host-unit-tests: explicit direct-needs gate instead of the
# implicit success() so skipped routed linux jobs upstream of
# linux-preflight do not skip this job transitively.
if: ${{ !cancelled() && needs.changes.result == 'success' && needs.linux-preflight.result == 'success' && needs.swift-package-tests.result == 'success' && needs.changes.outputs.macos == 'true' }}
# Compile the same unsigned universal Release app that nightly builds before
# signing, notarization, and publishing. This catches DEBUG/Release boundary
# mistakes before they reach main.
# Keep the app build on macOS 26 so SDK-gated SwiftUI Liquid Glass code
# compiles into the same artifact shape as nightly and stable releases.
# Release builds need enough disk for a universal Release build plus the
# restored SwiftPM cache. Default to a clean paid macOS 26 runner instead
# of the generic persistent macOS 26 pool.
runs-on: ${{ vars.MACOS_RUNNER_26_RELEASE || 'blacksmith-6vcpu-macos-26' }}
timeout-minutes: 60
env:
CMUX_CI_XCODE_APP: ${{ vars.CMUX_CI_XCODE_APP_MACOS_26 }}
CMUX_CI_REQUIRED_MACOS_SDK_MAJOR: "26"
steps:
- name: Clear stale git locks (self-hosted reused workspace)
shell: bash
run: |
# Self-hosted macOS runners reuse the workspace. A job cancelled or
# killed mid-checkout can leave a stale .git/modules/*/index.lock that
# fails every later submodule checkout (e.g. ghostty). Clear them first.
ws="${GITHUB_WORKSPACE:-$PWD}"
rm -f "$ws/.git/index.lock" 2>/dev/null || true
if [ -d "$ws/.git/modules" ]; then
find "$ws/.git/modules" -type f -name "*.lock" -delete 2>/dev/null || true
fi
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
submodules: recursive
- name: Select Xcode
run: |
set -euo pipefail
./scripts/select-ci-xcode.sh
- name: Capture Ghostty revision
id: ghostty-revision
run: |
echo "sha=$(git -C ghostty rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Reclaim release runner disk
run: |
set -euo pipefail
echo "Disk before cleanup:"
df -h /
rm -rf build-universal .spm-cache
echo "Disk after cleanup:"
df -h /
- name: Cache GhosttyKit.xcframework
id: cache-ghosttykit-release
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: GhosttyKit.xcframework
key: ghosttykit-${{ steps.ghostty-revision.outputs.sha }}
- name: Download pre-built GhosttyKit.xcframework
if: steps.cache-ghosttykit-release.outputs.cache-hit != 'true'
run: |
./scripts/download-prebuilt-ghosttykit.sh
- name: Install Rust
run: |
./scripts/install-rust-ci.sh
- name: Cache DerivedData
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: build-universal
key: deriveddata-release-${{ hashFiles('cmux.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }}-${{ hashFiles('cmux.xcodeproj/project.pbxproj') }}-${{ steps.ghostty-revision.outputs.sha }}
- name: Cache Swift packages
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: .spm-cache
key: spm-release-${{ hashFiles('cmux.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }}
restore-keys: |
spm-release-
spm-
- name: Sanitize Swift package cache
run: python3 scripts/ci/sanitize-xcode-source-packages-cache.py .spm-cache
- name: Build universal app (Release)
run: |
set -euo pipefail
CMUX_SKIP_ZIG_BUILD=1 xcodebuild -project cmux.xcodeproj -scheme cmux -configuration Release -derivedDataPath build-universal \
-destination 'generic/platform=macOS' \
-clonedSourcePackagesDirPath .spm-cache \
ARCHS="arm64 x86_64" \
ONLY_ACTIVE_ARCH=NO \
CODE_SIGNING_ALLOWED=NO ASSETCATALOG_COMPILER_APPICON_NAME=AppIcon-Nightly build
- name: Download universal Ghostty CLI helper
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: cmux-ghostty-cli-helper
path: ghostty-cli-helper
- name: Install universal Ghostty CLI helper
run: |
set -euo pipefail
./scripts/install-prebuilt-ghostty-cli-helper.sh \
ghostty-cli-helper/ghostty \
build-universal/Build/Products/Release/cmux.app
- name: Validate Release artifact slices
run: |
set -euo pipefail
APP_BINARY="build-universal/Build/Products/Release/cmux.app/Contents/MacOS/cmux"
CLI_BINARY="build-universal/Build/Products/Release/cmux.app/Contents/Resources/bin/cmux"
HELPER_BINARY="build-universal/Build/Products/Release/cmux.app/Contents/Resources/bin/ghostty"
test -x "$APP_BINARY"
test -x "$CLI_BINARY"
test -x "$HELPER_BINARY"
file "$APP_BINARY" "$CLI_BINARY" "$HELPER_BINARY"
SDK_VERSION="$(otool -l "$APP_BINARY" | awk '/LC_BUILD_VERSION/ { in_version=1; next } in_version && /sdk / { print $2; exit }')"
echo "App SDK version: $SDK_VERSION"
lipo "$APP_BINARY" -verify_arch arm64 x86_64
lipo "$CLI_BINARY" -verify_arch arm64 x86_64
lipo "$HELPER_BINARY" -verify_arch arm64 x86_64
[[ "$SDK_VERSION" == 26.* ]]
ci-status:
needs:
- changes
- workflow-guard-tests
- remote-daemon-tests
- web-typecheck
- react-apps-check
- web-db-migrations
- linux-preflight
- app-host-unit-tests
- tests
- swift-package-tests
- agent-session-web-resources
- tests-build-and-lag
- release-build
if: ${{ always() }}
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
steps:
- name: Check routed CI jobs
env:
CI_NEEDS: ${{ toJSON(needs) }}
run: |
python3 - <<'PY'
import json
import os
import sys
needs = json.loads(os.environ["CI_NEEDS"])
allowed = {"success", "skipped"}
bad = {
name: data["result"]
for name, data in sorted(needs.items())
if data["result"] not in allowed
}
if bad:
for name, result in bad.items():
print(f"{name}: {result}", file=sys.stderr)
sys.exit(1)
for name, data in sorted(needs.items()):
print(f"{name}: {data['result']}")
PY