Files
tty7/.github/workflows/ci.yml
T
webdev ac3c95a647 feat(update): install verified Linux AppImage releases in app (#306) (#652)
The last platform from #306: a Linux install running as an AppImage can
now download, verify, and apply a release from inside the app, through
the same tty7-updater helper the macOS (#309) and Windows (#330) paths
use. Tarball and distro installs are deliberately untouched — they keep
the named-package hint and the release page, because replacing a file a
package manager may own is not this code's call to make.

The installed artifact is one file, the path $APPIMAGE names, so the
install is the simplest of the three platforms: stage the download
beside the image (two renames only stay atomic on one filesystem),
verify, swap, relaunch, and restore the preserved previous image if the
new one does not survive its launch grace. What is Linux-shaped about
it is the mount: the image the GUI runs from is FUSE-mounted by the
AppImage runtime and torn down when the app exits, which is the moment
the installer starts working — so the GUI copies the helper out of the
mount into staging and runs the copy, the way the Windows path runs a
private copy because Setup replaces the installed one. The daemon is
left running throughout, as on macOS: nothing on Linux locks a running
executable's file, and the panes it serves are the reason the update
restarts only the GUI. The swap also carries the installed image's own
mode onto its replacement, so a 0700 image stays private and the
download's missing execute bit never reaches the installation.

Verification holds the issue's requirements with what an unsigned ELF
can offer: the bytes must match the release's checksums.txt, the file
must actually be a type-2 AppImage — a mis-published asset fails with a
name instead of at launch — and the image must state the version it
claims. That statement is new: bundle-appimage.sh stamps
X-AppImage-Version into the desktop entry, and the updater reads it
back with one --appimage-extract, answered by the runtime before any
application code and without FUSE. The same pass requires the new image
to bundle its own tty7-updater, because an image without one would
install fine and then be the last version that ever could.

release.yml and nightly.yml now build the updater on the Linux leg and
bundle it into the AppImage, and both check the packaged image for the
same facts the updater checks on a user's machine — helper present,
version stamped — so a packaging mistake fails the workflow instead of
the update. The first release carrying this can only bootstrap: images
already installed predate the helper and keep the manual hint, so the
first complete in-app update is the release after it.
2026-08-16 17:43:52 +08:00

316 lines
14 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:
# 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
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 "<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:
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 }}"