Files
tty7/.github/workflows/release.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

453 lines
20 KiB
YAML

name: Release
on:
push:
tags: ["v*"]
workflow_dispatch:
permissions:
contents: write
jobs:
build:
# The Windows installer embeds the Linux musl `tty7-server` so a WSL distro
# can be served the binary the client already shipped with, instead of
# downloading one (WSL installs nothing over the network). That
# binary comes from `server-musl`, so the two jobs can no longer run in
# parallel. Serialising all four platforms behind it costs a few minutes on
# a release — cheap next to splitting the Windows entry into its own job and
# duplicating the whole toolchain/caching preamble.
needs: server-musl
strategy:
fail-fast: false
matrix:
include:
- runner: macos-14
os: macos
arch: arm64
target: aarch64-apple-darwin
# macos-13 was retired; macos-15-intel is the remaining hosted x86_64 image.
- runner: macos-15-intel
os: macos
arch: x86_64
target: x86_64-apple-darwin
- runner: windows-latest
os: windows
arch: x86_64
target: x86_64-pc-windows-msvc
- runner: ubuntu-latest
os: linux
arch: x86_64
target: x86_64-unknown-linux-gnu
runs-on: ${{ matrix.runner }}
steps:
- name: Checkout tty7
uses: actions/checkout@v4
with:
path: tty7
# gpui-component is pulled as a git dependency (see Cargo.toml's patch
# section), so no sibling checkout is needed.
# gpui's Linux backends resolve the x11/wayland/xkb/font dev packages via
# pkg-config at build time — the same set the README documents for
# building from source on Linux.
- name: Install Linux system dependencies
if: matrix.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 libfuse2 file imagemagick
echo "LIBGSSAPI_IMPL=mit" >> "$GITHUB_ENV"
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2
with:
workspaces: tty7
# `--locked` because a release must ship the dependency set the tag
# recorded, not whatever cargo would re-resolve at build time. Safe here
# (unlike nightly) precisely because nothing rewrites Cargo.toml: this is
# a plain checkout of the tagged commit.
# The updater ships on every GUI platform now: inside the macOS bundle,
# beside the Windows app, and inside the Linux AppImage.
- name: Build
working-directory: tty7
shell: bash
run: |
cargo build --release --locked --target "${{ matrix.target }}"
cargo build --release --locked --features updater \
--bin tty7-updater --target "${{ matrix.target }}"
# ---- Packaging: one step per OS ----------------------------------------
# macOS gets a signed + notarized drag-to-Applications DMG. Windows gets
# an Inno Setup installer plus a portable zip; Linux a tarball — both
# unsigned, of the self-contained binary (fonts are embedded via
# include_bytes!; the Windows icon is compiled in via build.rs).
- name: Bundle macOS DMG
if: matrix.os == 'macos'
working-directory: tty7
env:
# macOS code signing — the cert is imported into a throwaway keychain.
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
# Notarization — required for Developer ID builds to pass Gatekeeper.
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: bash .github/scripts/bundle-macos.sh "${{ matrix.target }}" "${{ matrix.arch }}"
- name: Package Linux tarball
if: matrix.os == 'linux'
working-directory: tty7
run: bash .github/scripts/bundle-linux.sh "${{ matrix.target }}" "${{ matrix.arch }}"
# AppImage bundles the x11/wayland/xkb/font libs so it runs on Fedora/Arch/
# etc., not just Ubuntu. Kept separate from the tarball step so the tarball
# still ships even if AppImage tooling changes upstream.
- name: Package Linux AppImage
if: matrix.os == 'linux'
working-directory: tty7
run: bash .github/scripts/bundle-appimage.sh "${{ matrix.target }}" "${{ matrix.arch }}"
# The in-app updater refuses an image whose bundled helper or stamped
# version is wrong — on the user's machine, after the download. Check the
# same facts here so a packaging mistake fails the release instead.
- name: Verify Linux AppImage update package
if: matrix.os == 'linux'
working-directory: tty7
shell: bash
run: |
set -euo pipefail
VERSION="$(grep -m1 '^version = "' Cargo.toml | sed -E 's/.*"([^"]+)".*/\1/')"
IMAGE="$PWD/dist/tty7-${VERSION}-linux-${{ matrix.arch }}.AppImage"
VERIFY_ROOT="$RUNNER_TEMP/tty7-appimage-update-verify"
rm -rf "$VERIFY_ROOT"
mkdir -p "$VERIFY_ROOT"
(cd "$VERIFY_ROOT" && "$IMAGE" --appimage-extract >/dev/null)
test -x "$VERIFY_ROOT/squashfs-root/usr/bin/tty7-app"
test -x "$VERIFY_ROOT/squashfs-root/usr/bin/tty7-updater"
grep -Fxq "X-AppImage-Version=${VERSION}" \
"$VERIFY_ROOT/squashfs-root/usr/share/applications/tty7.desktop"
# The bundled server for WSL. `continue-on-error` mirrors `server-musl`'s
# own probe step: if there is no server asset, the release still ships and
# `bundle-windows.ps1` warns. It is not silent at runtime either — a WSL
# connect then fails with `MissingBundled`, naming every directory it
# searched, rather than quietly falling back to a download.
- name: Fetch the bundled Linux server
if: matrix.os == 'windows'
continue-on-error: true
uses: actions/download-artifact@v8
with:
name: release-tty7-server-linux-x86_64-musl
path: tty7/bundled-server
- name: Package Windows installer + zip
if: matrix.os == 'windows'
working-directory: tty7
shell: pwsh
run: '& ./.github/scripts/bundle-windows.ps1 "${{ matrix.target }}" "${{ matrix.arch }}"'
# The in-app updater refuses a package whose marker, helper or stamped
# version is wrong — on the user's machine, after the download. Check the
# same facts here so a packaging mistake fails the release instead.
- name: Verify Windows update package
if: matrix.os == 'windows'
working-directory: tty7
shell: pwsh
run: '& ./.github/scripts/verify-windows-package.ps1 "${{ matrix.arch }}"'
# Hand the artifacts to the assemble job rather than uploading them to the
# release here. Four parallel jobs each publishing their own slice would
# make the release "latest" the moment the *first* platform finished — the
# in-app update check (src/core/update.rs) reads /releases/latest, so users
# would be prompted to download a release that was still missing most of
# its assets. Same glob list as before: the bundle scripts leave
# intermediates in dist/ (tty7.app, entitlements.plist, the Windows staging
# dir) that must not reach the release assets.
- uses: actions/upload-artifact@v7
with:
name: release-${{ matrix.os }}-${{ matrix.arch }}
path: |
tty7/dist/*.dmg
tty7/dist/*.tar.gz
tty7/dist/*.zip
tty7/dist/*-setup.exe
tty7/dist/*.AppImage
if-no-files-found: error
# The headless server binary remote workspaces install on the far machine
# (decision D10). Statically linked against musl so a single binary runs
# on any distro whatever its glibc vintage, and shipped as a bare executable
# rather than an archive so the client can fetch exactly one file and verify it
# against checksums.txt. The asset names are a contract with the installer:
# `tty7_core::daemon::install::asset` derives them from `uname -sm`.
#
# Separate from the `build` matrix above because it shares nothing with it: no
# GUI toolchain, no bundling, no code signing, two targets off one runner.
server-musl:
strategy:
fail-fast: false
# `target` is the build triple; `asset` is the published filename. They
# are deliberately not the same string — the triple's vendor field is
# `unknown`, which says nothing to anyone reading the releases page. See
# `install::asset::ASSET_X86_64`, which pins these two names as literals.
matrix:
include:
- target: x86_64-unknown-linux-musl
asset: tty7-server-linux-x86_64-musl
- target: aarch64-unknown-linux-musl
asset: tty7-server-linux-aarch64-musl
runs-on: ubuntu-latest
env:
RUSTFLAGS: -C strip=symbols
steps:
- name: Checkout tty7
uses: actions/checkout@v4
with:
path: tty7
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
# zig provides the musl sysroot and the C cross-compiler for both targets
# from one x86_64 runner — see the same job in ci.yml for why the
# alternatives (cross, musl-tools) do not cope with aws-lc-rs' cmake build.
- 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:
workspaces: tty7
key: ${{ matrix.target }}
# Until the crate split lands there is no tty7-server to build. Skip
# rather than fail, so this workflow can ship ahead of the split; the
# release simply carries no server assets until it arrives.
- name: Look for the tty7-server package
id: probe
working-directory: tty7
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 "::warning::tty7-server is not a workspace member yet — this release will carry no remote-server assets"
fi
# `--locked` for the same reason the GUI build uses it: a release ships the
# dependency set the tag recorded. `-p tty7-server` both addresses the
# package independently of its path and keeps feature unification off the
# GUI's `gssapi` feature, which cannot build under musl.
- name: Build static tty7-server
if: steps.probe.outputs.present == 'true'
working-directory: tty7
run: cargo zigbuild --release --locked -p tty7-server --target ${{ matrix.target }}
- name: Assert the binary is static
if: steps.probe.outputs.present == 'true'
working-directory: tty7
run: bash .github/scripts/assert-static.sh "target/${{ matrix.target }}/release/tty7-server"
# Flat, version-free asset name — the tag in the download URL carries the
# version. See `install::asset` for the contract the client
# installer derives this name from.
- name: Stage the asset
if: steps.probe.outputs.present == 'true'
working-directory: tty7
run: |
set -euo pipefail
mkdir -p dist
cp "target/${{ matrix.target }}/release/tty7-server" \
"dist/${{ matrix.asset }}"
chmod +x "dist/${{ matrix.asset }}"
- uses: actions/upload-artifact@v7
if: steps.probe.outputs.present == 'true'
with:
name: release-${{ matrix.asset }}
path: tty7/dist/${{ matrix.asset }}
if-no-files-found: error
# The same server for remote Macs, published under the same contract: flat,
# version-free names that `install::asset` derives from `uname -sm`. Separate
# from `server-musl` because an Apple target needs an Apple runner, and both
# slices come off one arm64 runner — the Xcode SDK carries both. See ci.yml's
# `server-macos` for the rest of the reasoning; keep the three in sync.
#
# Not code-signed or notarized, and deliberately: this binary is never
# downloaded by the destination Mac. The client fetches it, verifies it against
# checksums.txt and writes it over SFTP, which sets no quarantine attribute, so
# Gatekeeper is not in the path at all. The ad-hoc signature the linker applies
# is all arm64 asks for, and `assert-macho.sh` checks it is there.
server-macos:
strategy:
fail-fast: false
matrix:
include:
- target: aarch64-apple-darwin
arch: arm64
asset: tty7-server-macos-aarch64
- target: x86_64-apple-darwin
arch: x86_64
asset: tty7-server-macos-x86_64
runs-on: macos-latest
env:
RUSTFLAGS: -C strip=symbols
steps:
- name: Checkout tty7
uses: actions/checkout@v4
with:
path: tty7
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2
with:
workspaces: tty7
key: ${{ matrix.target }}
- name: Look for the tty7-server package
id: probe
working-directory: tty7
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 "::warning::tty7-server is not a workspace member yet — this release will carry no macOS remote-server assets"
fi
- name: Build tty7-server
if: steps.probe.outputs.present == 'true'
working-directory: tty7
run: cargo build --release --locked -p tty7-server --target ${{ matrix.target }}
# Ad-hoc — no identity, no secrets, and nothing to do with the notarized
# signing the GUI bundles get. It is what the arm64 kernel requires before
# it will execute a binary at all: the linker signs the arm64 slice
# itself, x86_64 comes out bare, and x86_64 is what a Rosetta shell on an
# Apple Silicon Mac asks for. `--force` makes it idempotent across both.
- name: Ad-hoc sign the binary
if: steps.probe.outputs.present == 'true'
working-directory: tty7
run: codesign --force --sign - "target/${{ matrix.target }}/release/tty7-server"
- name: Assert the binary is self-contained
if: steps.probe.outputs.present == 'true'
working-directory: tty7
run: |
bash .github/scripts/assert-macho.sh \
"target/${{ matrix.target }}/release/tty7-server" "${{ matrix.arch }}"
- name: Stage the asset
if: steps.probe.outputs.present == 'true'
working-directory: tty7
run: |
set -euo pipefail
mkdir -p dist
cp "target/${{ matrix.target }}/release/tty7-server" \
"dist/${{ matrix.asset }}"
chmod +x "dist/${{ matrix.asset }}"
- uses: actions/upload-artifact@v7
if: steps.probe.outputs.present == 'true'
with:
name: release-${{ matrix.asset }}
path: tty7/dist/${{ matrix.asset }}
if-no-files-found: error
# Single assembly step, after every GUI platform and every server slice
# succeeds. The release object is
# created as a **draft** and left that way: a draft is invisible to both
# /releases/latest and the releases page, so nothing can prompt a user to
# download a version whose asset set is incomplete or whose notes are still
# empty. Publishing is the release skill's job — it verifies the platform assets and
# writes the body first, then flips the draft. See .claude/skills/release/SKILL.md.
draft-release:
needs: [build, server-musl, server-macos]
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ github.token }}
steps:
- uses: actions/download-artifact@v8
with:
path: dist
merge-multiple: true
# sha256 over every asset, so the remote-server installer can verify what
# it downloaded before writing it to someone else's machine (a mismatch
# aborts the install outright). Generated here rather than in
# the build jobs because only this job sees the complete asset set, and a
# per-job fragment would have to be concatenated in a deterministic order
# anyway. GNU coreutils format ("<hex> <name>"), bare filenames, sorted —
# see `install::checksums` for the format the client parses.
- name: Generate checksums.txt
run: |
set -euo pipefail
cd dist
rm -f checksums.txt
# `find -type f` rather than a glob: nested files (should any appear)
# would otherwise be silently skipped, leaving an asset unverifiable.
#
# Built in $RUNNER_TEMP and moved in, rather than redirected straight
# into dist/: the `>` redirect creates its target *before* find walks
# the directory, so a file written in place would end up hashing
# itself as a zero-byte entry — a line that can never verify.
#
# `xargs -r` — without it an empty dist/ would leave sha256sum reading
# stdin and the job would hang rather than fail.
find . -type f -printf '%P\n' \
| LC_ALL=C sort | xargs -r sha256sum > "$RUNNER_TEMP/checksums.txt"
[ -s "$RUNNER_TEMP/checksums.txt" ] || { echo "::error::no assets to checksum"; exit 1; }
mv "$RUNNER_TEMP/checksums.txt" checksums.txt
sha256sum -c checksums.txt
cat checksums.txt
# Reuse an existing release rather than failing: re-triggering a tag
# (force-push after a fixed platform) must top up the same draft. If the
# release was already published, --clobber just replaces its assets and it
# stays published.
#
# Existence is probed with `release list`, not `release view`: GitHub's
# get-release-by-tag endpoint does not return drafts, so a view-based check
# could miss the very draft a previous run left behind and create a second
# one (GitHub happily allows duplicate drafts on one tag).
- name: Assemble the draft release
run: |
set -euo pipefail
# Captured into a variable, not piped into `grep -q`: -q exits on the
# first match, and the resulting SIGPIPE would make `pipefail` report
# the pipeline as failed — i.e. "found" would read as "not found".
# `release list` includes drafts (cf. its --exclude-drafts flag).
EXISTING=$(gh release list --repo "$GITHUB_REPOSITORY" --limit 100 \
--json tagName -q '.[].tagName')
if grep -Fxq "$GITHUB_REF_NAME" <<<"$EXISTING"; then
echo "release $GITHUB_REF_NAME already exists; reusing it"
else
gh release create "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" \
--draft --title "$GITHUB_REF_NAME" --notes ""
fi
gh release upload "$GITHUB_REF_NAME" dist/* --clobber --repo "$GITHUB_REPOSITORY"