name: CI # Compile + test on every push/PR. The Windows and Linux jobs are the # compile-feedback loop for the platform-specific code a macOS dev machine # never builds (`cfg(windows)` transport / process detach / config dir, # the Linux `/proc` queries, the x11/wayland gpui backends). The macOS job # guards against regressing the original target. on: push: branches: [main] pull_request: workflow_dispatch: # Every job here checks out, builds, tests and greps — none of them write # anything back to the repo, so the token needs nothing but read. The repository # default is already `read`, but that is a settings toggle anyone with admin can # flip; declaring it in the file is what actually pins it, and it is what # CodeQL's `actions/missing-workflow-permissions` asks for. permissions: contents: read # A superseded PR run is dead weight the moment the next push lands, and a run # left going is not free: the account's concurrent-job budget is shared, and # macOS slots are the scarce ones. A zombie Windows job (see the `Test` step's # timeout below) once held a slot for the full six-hour job limit while an # unrelated PR's macOS job queued behind it for two and a half hours. # # Pushes to main are exempt: each commit's own run is the record of whether that # commit was green, and cancelling one to start the next would erase it. concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: fmt: name: rustfmt runs-on: ubuntu-latest timeout-minutes: 15 steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable with: components: rustfmt - run: cargo fmt --check # `ui::` and `terminal::` must not reach the filesystem or git # directly, because once a workspace can be remote those calls answer for the # wrong machine. The allowlist of genuinely-local paths lives in the # script, next to the reason each one is exempt. # # A standalone job on purpose, and one that must stay *non-required*: main's # required checks are `rustfmt` and the three `build & test ()` names, # and folding this into either would make it required the moment it lands — # wedging every open PR on a check they have never seen. Same reasoning as # `server-musl` below. Cheap enough (a checkout and a grep) that it does not # need caching or a toolchain. host-boundary: name: host boundary runs-on: ubuntu-latest timeout-minutes: 15 steps: - uses: actions/checkout@v4 - run: bash .github/scripts/check-host-boundary.sh build: name: build & test (${{ matrix.target }}) strategy: fail-fast: false matrix: include: - runner: macos-14 target: aarch64-apple-darwin - runner: windows-latest target: x86_64-pc-windows-msvc - runner: ubuntu-latest target: x86_64-unknown-linux-gnu runs-on: ${{ matrix.runner }} # Backstop under the per-step timeouts below. Without any timeout at all a # hung test runs to GitHub's six-hour job limit, which is how a 90-second # step turned into a six-hour one three times in a day. timeout-minutes: 60 steps: - name: Checkout tty7 uses: actions/checkout@v4 # gpui-component is a git dependency (see Cargo.toml), so no sibling # checkout is needed. The Windows backend (gpui_windows + DirectWrite/D3D) # ships with the windows-latest runner's SDK — no extra system deps. # gpui's Linux backends need the x11/wayland/xkb/font dev packages at # build time (build scripts resolve them via pkg-config). Same set the # README documents for building from source on Linux. - name: Install Linux system dependencies if: runner.os == 'Linux' run: | sudo apt-get update sudo apt-get install -y pkg-config cmake clang libxkbcommon-dev \ libxkbcommon-x11-dev libfontconfig1-dev libfreetype6-dev \ libwayland-dev libx11-dev libxcb1-dev libzstd-dev libssl-dev \ libkrb5-dev echo "LIBGSSAPI_IMPL=mit" >> "$GITHUB_ENV" - uses: dtolnay/rust-toolchain@stable with: targets: ${{ matrix.target }} - uses: Swatinem/rust-cache@v2 # `--locked` on the build so a Cargo.lock that disagrees with Cargo.toml # fails here instead of being silently rewritten. Without it the drift is # invisible to CI and lands on contributors instead: every local `cargo` # run rewrites the lock, leaving a permanently dirty working tree that has # to be re-discarded before every commit. The release workflow locks its # build too; only nightly stays unlocked, because it stamps Cargo.toml's # version and relies on cargo refreshing the lock's own root entry. - name: Build # Cold-cache builds have been observed at ~10 min; warm ones at ~90s. timeout-minutes: 30 run: cargo build --locked --target ${{ matrix.target }} # `cargo test` has no timeout of its own, so one hung test is # indistinguishable from a slow suite until the job hits GitHub's six-hour # limit. The suite intermittently hangs here — roughly one run in ten, on # any branch, while the same commit passes on a re-run — and every time it # did, the job held a runner slot for hours and reported nothing. # # Seen on Windows three times and on Linux once, so it is not a # platform-specific test: `Build` succeeds, `Test` starts, and nothing # further is ever written. # # The step's honest budget is small: ~75s warm and ~3.5 min when this step # also has to compile the test targets, on every platform. 20 minutes is # pure headroom, so a trip means a hang, not a slow runner. - name: Test timeout-minutes: 20 run: cargo test --locked --target ${{ matrix.target }} - name: Test desktop updater timeout-minutes: 10 run: cargo test --locked --features updater --bin tty7-updater --target ${{ matrix.target }} # There is deliberately no post-mortem step here, and that is worth # recording, because an earlier version of this file had one: on failure it # dumped the process table to find the surviving test binary. # # It does not work, and cannot. GitHub kills the step's whole process tree # when `timeout-minutes` trips, *before* the next step runs, so on the one # failure that matters the dump comes back empty — verified on run # 30526538997, where it printed two headers and nothing between them. # # Nor is it needed. libtest already prints " has been running for # over 60 seconds" for a test that has not returned, and that line was # sitting in every hung run all along. The obstacle was never the # diagnostic: a job's log cannot be fetched while the job is in progress # (the API 404s), and nobody cancels a six-hour run to read one. The `Test` # timeout above is what fixes that — the step *fails*, so the log lands, # with the name in it. # Static musl builds of the headless server binary that remote workspaces push # onto the far machine (decision D10). # One binary has to run on any distro without regard to the target's glibc # version, so it is linked fully static against musl rather than built per # distro. Compile-only — this job publishes nothing; release.yml and # nightly.yml carry the same job with an upload step. # # Deliberately a *separate* job rather than two more rows in the `build` matrix # above: those three `build & test ()` names are main's required # checks, and reshaping that matrix would wedge branch protection on every open # PR. Keep this job non-required until it has a few weeks of green. server-musl: name: tty7-server musl (${{ matrix.target }}) runs-on: ubuntu-latest timeout-minutes: 45 strategy: fail-fast: false matrix: target: - x86_64-unknown-linux-musl - aarch64-unknown-linux-musl # `-C strip=symbols` is applied by rustc through the linker, so it strips the # aarch64 output from an x86_64 runner — plain `strip(1)` would not. Set here # rather than in [profile.release] because the release profile is shared with # the GUI builds, which keep their symbols. env: RUSTFLAGS: -C strip=symbols steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable with: targets: ${{ matrix.target }} # zig supplies both the musl sysroot and the C cross-compiler, which is # what makes one x86_64 runner able to emit both musl targets. russh's # default crypto backend (aws-lc-rs) builds a sizable C/asm library through # cmake, and that is the part every other approach trips over: `cross` # needs a custom image to get cmake into the sandbox, and Ubuntu's # `musl-tools` only ships an x86_64 `musl-gcc` wrapper with no C++ driver # and nothing at all for aarch64. Verified locally: both targets link # statically and the resulting binaries run under Alpine. # # If setup-zig ever becomes a problem (its GitHub repo is a mirror of a # Codeberg original), cargo-zigbuild also accepts zig from PyPI — # `pip3 install ziglang`, which it finds via CARGO_ZIGBUILD_PYTHON_PATH — # so this can drop to zero third-party actions without changing anything # else. - uses: mlugg/setup-zig@v2 with: version: 0.16.0 - uses: taiki-e/install-action@v2 with: tool: cargo-zigbuild - uses: Swatinem/rust-cache@v2 with: key: ${{ matrix.target }} # The crate split lands separately; until `tty7-server` exists as a # workspace member this job has nothing to build. Skip cleanly rather than # fail, so the workflow can land before the split and simply start working # once it arrives. `--no-deps` keeps this to a manifest parse — no # resolution, no network. - name: Look for the tty7-server package id: probe run: | set -euo pipefail if cargo metadata --no-deps --format-version 1 \ | jq -e '[.packages[].name] | index("tty7-server")' >/dev/null; then echo "present=true" >> "$GITHUB_OUTPUT" else echo "present=false" >> "$GITHUB_OUTPUT" echo "::notice::tty7-server is not a workspace member yet (crate split, M1) — nothing to build" fi # `-p tty7-server` addresses the package by name, so this survives whatever # directory layout the split settles on. It also keeps feature unification # scoped to the server's own dependency graph: the GUI crate is the one # that turns on tty7-core's `gssapi` feature, and that feature cannot build # under musl (libgssapi-sys wants a system MIT/Heimdal krb5). Building the # whole workspace here would drag it in and fail. - name: Build static tty7-server if: steps.probe.outputs.present == 'true' run: cargo zigbuild --release --locked -p tty7-server --target ${{ matrix.target }} - name: Assert the binary is static if: steps.probe.outputs.present == 'true' run: bash .github/scripts/assert-static.sh "target/${{ matrix.target }}/release/tty7-server" # The same headless server for remote Macs. A second job rather than more rows # in `server-musl`: that one exists to cross-compile from Linux with zig, and # neither half of it applies here — an Apple target needs an Apple runner for # the SDK, and there is no musl to link. # # Both slices come off one arm64 runner. Cross-compiling to x86_64 needs # nothing but the rustup target: the Xcode SDK carries both architectures, and # the linker is the same one. # # Compile-only, like `server-musl` — release.yml and nightly.yml carry the # same job with an upload step. Non-required until it has a few weeks of # green, for the branch-protection reason spelled out above. server-macos: name: tty7-server macOS (${{ matrix.target }}) runs-on: macos-latest timeout-minutes: 45 strategy: fail-fast: false matrix: include: - target: aarch64-apple-darwin arch: arm64 - target: x86_64-apple-darwin arch: x86_64 env: RUSTFLAGS: -C strip=symbols steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable with: targets: ${{ matrix.target }} - uses: Swatinem/rust-cache@v2 with: key: ${{ matrix.target }} - name: Look for the tty7-server package id: probe run: | set -euo pipefail if cargo metadata --no-deps --format-version 1 \ | jq -e '[.packages[].name] | index("tty7-server")' >/dev/null; then echo "present=true" >> "$GITHUB_OUTPUT" else echo "present=false" >> "$GITHUB_OUTPUT" echo "::notice::tty7-server is not a workspace member yet (crate split, M1) — nothing to build" fi # `-p tty7-server` for the same two reasons as the musl job: it addresses # the package by name whatever the layout, and it keeps the GUI's `gssapi` # feature out of the unified feature set. That feature does resolve on # macOS, unlike under musl, but linking Heimdal into a binary that gets # pushed to someone else's Mac is exactly the kind of dependency # assert-macho.sh is there to refuse. - name: Build tty7-server if: steps.probe.outputs.present == 'true' run: cargo build --release --locked -p tty7-server --target ${{ matrix.target }} # Ad-hoc, so it needs no identity and no secrets — this is not the code # signing the GUI bundles get, and it buys nothing with Gatekeeper. It is # the signature the arm64 kernel requires before it will execute anything # at all. The linker already applies one to the arm64 slice; x86_64 comes # out bare, and that is the slice a Rosetta shell on an Apple Silicon Mac # asks for. `--force` makes this idempotent across both. - name: Ad-hoc sign the binary if: steps.probe.outputs.present == 'true' run: codesign --force --sign - "target/${{ matrix.target }}/release/tty7-server" - name: Assert the binary is self-contained if: steps.probe.outputs.present == 'true' run: | bash .github/scripts/assert-macho.sh \ "target/${{ matrix.target }}/release/tty7-server" "${{ matrix.arch }}"