Files
tty7/.github/workflows/ci.yml
T
cd5bf5508c ci: seed rust-cache from main, skip Rust jobs on docs-only PRs (#1022)
* ci: seed rust-cache from main, skip Rust jobs on docs-only PRs

Every PR branch saved its own ~2 GB rust-cache per target, readable only
by that branch, so the repo sat over its 10 GB quota with nothing from
main and every branch built cold. Save from main pushes and manual runs
only, keep the cache when a test step fails, and build with
line-tables-only debug info so the cached target dir is smaller.

Docs-only PRs skip the Rust work: a changes job diffs the merge commit
against its base, and build skips its steps rather than the job, so the
required build & test checks still report instead of pending forever.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ci: count both sides of a rename in the docs-only check

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-09-30 09:21:09 +08:00

396 lines
18 KiB
YAML

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 (<target>)` 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
# Docs-only PRs skip the Rust jobs: no markdown or docs/ file is compiled in.
# Workflow-level `paths-ignore` would leave the required checks pending
# forever, and a matrix job skipped by a job-level `if` reports under an
# unexpanded name, so `build` runs and skips its steps instead.
changes:
name: changes
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
code: ${{ steps.diff.outputs.code }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2
# A PR checkout is the merge commit, so its first parent is the base.
# `--no-renames` lists both sides of a move, or `src/x.rs` -> `docs/x.md`
# would show only the docs path.
- id: diff
run: |
files=$(git diff --name-only --no-renames HEAD^1 HEAD)
if [ "${{ github.event_name }}" != pull_request ] \
|| grep -qvE '^docs/|\.mdx?$' <<<"$files"; then
echo "code=true" >> "$GITHUB_OUTPUT"
else
echo "code=false" >> "$GITHUB_OUTPUT"
echo "::notice::docs-only change — Rust jobs skipped"
fi
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
needs: changes
runs-on: ${{ matrix.runner }}
# Line tables keep file:line in backtraces but shrink the cached target dir.
env:
CARGO_PROFILE_DEV_DEBUG: line-tables-only
# 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
if: needs.changes.outputs.code == 'true'
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: needs.changes.outputs.code == 'true' && 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
if: needs.changes.outputs.code == 'true'
with:
targets: ${{ matrix.target }}
# Saved only from main (and manual runs): a cache saved on a PR ref is
# readable by that ref alone, while main's is readable by every branch,
# and per-branch saves of ~2 GB per target blow the 10 GB repo quota.
- uses: Swatinem/rust-cache@v2
if: needs.changes.outputs.code == 'true'
with:
save-if: ${{ github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch' }}
cache-on-failure: true
# `--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
if: needs.changes.outputs.code == 'true'
# Cold-cache builds have been observed at ~10 min; warm ones at ~90s.
timeout-minutes: 30
run: cargo build --locked --target ${{ matrix.target }}
# A binary that imports VCRUNTIME140.dll cannot start on a Windows
# machine that has never installed the Visual C++ Redistributable — the
# loader fails before `main`, which is what winget's install validation
# hit on 26.8.2 (#902). `.cargo/config.toml` links the CRT statically;
# this is the check that it still does.
#
# It runs here, on the debug build, rather than only at release time:
# every machine that builds tty7 already has the redistributable, so
# nothing else in the pipeline can notice the regression, and the
# dependency can come back from a single prebuilt native library or a stray
# RUSTFLAGS (`cc`-built code follows `+crt-static` on its own).
# Named files rather than the target directory — build scripts and
# proc-macro artifacts under it are host units, which are built without
# the target's rustflags and legitimately import the CRT dynamically.
- name: Assert the Windows binaries need no VC++ redistributable
if: needs.changes.outputs.code == 'true' && runner.os == 'Windows'
shell: pwsh
run: |
& ./.github/scripts/assert-no-vcruntime.ps1 `
"target/${{ matrix.target }}/debug/tty7-app.exe" `
"target/${{ matrix.target }}/debug/tty7.exe"
# `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
if: needs.changes.outputs.code == 'true'
timeout-minutes: 20
run: cargo test --locked --target ${{ matrix.target }}
- name: Test desktop updater
if: needs.changes.outputs.code == 'true'
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 "<test> 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 (<target>)` 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:
needs: changes
if: needs.changes.outputs.code == 'true'
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 }}
save-if: ${{ github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch' }}
# 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:
needs: changes
if: needs.changes.outputs.code == 'true'
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 }}
save-if: ${{ github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch' }}
- 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 }}"