From 3c8efbc33d6d19f1c100e567d07b669bac616f2f Mon Sep 17 00:00:00 2001 From: thomas Date: Thu, 30 Jul 2026 20:45:32 +0800 Subject: [PATCH] fix(release): name the server assets for whoever downloads them, not for cargo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tty7-server-x86_64-unknown-linux-musl` was never a name anyone chose. Both workflows staged the file as `tty7-server-${{ matrix.target }}`, so the build triple went straight into a published filename — and the triple's *vendor* field, for a Linux target with no particular vendor, is the literal word `unknown`. It has been sitting on the releases page reading like a failed lookup. Of the triple's four fields only two say anything to whoever downloads this: the architecture, which is what `asset_for_uname` picks by, and `musl`, which is why one file runs on any distribution. So: tty7-server-x86_64-unknown-linux-musl → tty7-server-linux-x86_64-musl tty7-server-aarch64-unknown-linux-musl → tty7-server-linux-aarch64-musl `-` in that order because that is what the GUI assets in the same release already use (`tty7--linux-x86_64.tar.gz`). One release should be one naming scheme; it was two. The triple stays everywhere it really is a build target — `cargo zigbuild --target`, the `target//release` path, the rust-cache key, ci.yml's matrix. The workflows now carry both: `target` for the build, `asset` for the filename, deliberately not the same string. This name is a contract with more than the release step, and all of it moves together: - `install::asset::{ASSET_X86_64, ASSET_AARCH64}`, which is what the client appends to a release URL. - `bundle-windows.ps1`, which stages the musl binary for WSL. `wsl.rs` looks for `/` with nothing translating, so the *filename* is as much a contract as the `server/` directory is — now said out loud in both places, along with the consequence for `TTY7_BUNDLED_SERVER_DIR`: a cross-compile has to be copied to the asset name, not left as `tty7-server`. - The GUI's install prompt fixture, the checksum manifest fixtures, and the `MissingBundled` assertions. Nothing globs the old shape: `gh release upload dist/*`, `checksums.txt`'s `find`, and the installer's `server\*` are all name-agnostic. A new test pins both names as literals — the module header already says this naming is "a *literal* contract with the release workflow", and asserting the consts against themselves asserted nothing. It also fails on the substring `unknown`, since that word only ever arrived here by way of `matrix.target`, and checks neither name contains the other, which is what `checksums::expected_digest` says out loud that it relies on. Compatibility: a stable client asks its own frozen tag, which keeps whichever name it shipped with, so every released client keeps working. The rolling `nightly` tag is replaced each night and its prune step drops assets the run did not upload — so an *already installed* nightly client 404s on the server download until it updates itself. Accepted deliberately; the next release is what has to be right. Co-Authored-By: Claude Opus 5 (1M context) --- .github/scripts/bundle-windows.ps1 | 6 ++- .github/workflows/nightly.yml | 21 +++++--- .github/workflows/release.yml | 22 ++++++--- crates/tty7-core/src/daemon/install/asset.rs | 49 +++++++++++++++++-- .../tty7-core/src/daemon/install/checksums.rs | 12 ++--- crates/tty7-core/src/daemon/install/mod.rs | 2 +- crates/tty7-core/src/daemon/install/wsl.rs | 22 ++++++--- src/ui/remote_connect.rs | 2 +- 8 files changed, 100 insertions(+), 36 deletions(-) diff --git a/.github/scripts/bundle-windows.ps1 b/.github/scripts/bundle-windows.ps1 index e82a8a0c..40fd3cae 100644 --- a/.github/scripts/bundle-windows.ps1 +++ b/.github/scripts/bundle-windows.ps1 @@ -33,7 +33,11 @@ Copy-Item README.md "$Stage/README.md" # change on its own. Missing is a warning, not an error, matching `server-musl`'s # own skip-don't-fail probe; WSL then fails at connect time with a message # naming the directories it searched. -$ServerAsset = "tty7-server-x86_64-unknown-linux-musl" +# +# The *filename* is a contract too, not just the directory: `wsl.rs` looks for +# `/`, so this string has to stay whatever +# `install::asset::ASSET_X86_64` says it is. +$ServerAsset = "tty7-server-linux-x86_64-musl" $ServerSrc = "bundled-server/$ServerAsset" if (Test-Path $ServerSrc) { New-Item -ItemType Directory -Force -Path "$Stage/server" | Out-Null diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 43baa178..e152ae33 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -143,7 +143,7 @@ jobs: continue-on-error: true uses: actions/download-artifact@v7 with: - name: nightly-server-x86_64-unknown-linux-musl + name: nightly-tty7-server-linux-x86_64-musl path: tty7/bundled-server - name: Package Windows installer + zip @@ -174,10 +174,15 @@ jobs: if: needs.plan.outputs.build == 'true' strategy: fail-fast: false + # `target` is the build triple; `asset` is the published filename. Kept + # apart for the reason release.yml spells out: the triple's vendor field is + # `unknown`, and a download name has no business carrying it. matrix: - target: - - x86_64-unknown-linux-musl - - aarch64-unknown-linux-musl + 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 @@ -245,14 +250,14 @@ jobs: set -euo pipefail mkdir -p dist cp "target/${{ matrix.target }}/release/tty7-server" \ - "dist/tty7-server-${{ matrix.target }}" - chmod +x "dist/tty7-server-${{ matrix.target }}" + "dist/${{ matrix.asset }}" + chmod +x "dist/${{ matrix.asset }}" - uses: actions/upload-artifact@v7 if: steps.probe.outputs.present == 'true' with: - name: nightly-server-${{ matrix.target }} - path: tty7/dist/tty7-server-${{ matrix.target }} + name: nightly-${{ matrix.asset }} + path: tty7/dist/${{ matrix.asset }} if-no-files-found: error # Single publish step after all platforms succeed, so the rolling release is diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5fbc5f20..b5a3c182 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -121,7 +121,7 @@ jobs: continue-on-error: true uses: actions/download-artifact@v7 with: - name: release-server-x86_64-unknown-linux-musl + name: release-tty7-server-linux-x86_64-musl path: tty7/bundled-server - name: Package Windows installer + zip @@ -161,10 +161,16 @@ jobs: 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: - target: - - x86_64-unknown-linux-musl - - aarch64-unknown-linux-musl + 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 @@ -234,14 +240,14 @@ jobs: set -euo pipefail mkdir -p dist cp "target/${{ matrix.target }}/release/tty7-server" \ - "dist/tty7-server-${{ matrix.target }}" - chmod +x "dist/tty7-server-${{ matrix.target }}" + "dist/${{ matrix.asset }}" + chmod +x "dist/${{ matrix.asset }}" - uses: actions/upload-artifact@v7 if: steps.probe.outputs.present == 'true' with: - name: release-server-${{ matrix.target }} - path: tty7/dist/tty7-server-${{ matrix.target }} + name: release-${{ matrix.asset }} + path: tty7/dist/${{ matrix.asset }} if-no-files-found: error # Single assembly step, after all four platforms succeed. The release object is diff --git a/crates/tty7-core/src/daemon/install/asset.rs b/crates/tty7-core/src/daemon/install/asset.rs index 1dbff30d..ce65ea3a 100644 --- a/crates/tty7-core/src/daemon/install/asset.rs +++ b/crates/tty7-core/src/daemon/install/asset.rs @@ -10,9 +10,23 @@ use std::fmt; /// The release asset for a 64-bit x86 Linux box. -pub const ASSET_X86_64: &str = "tty7-server-x86_64-unknown-linux-musl"; -/// The release asset for a 64-bit ARM Linux box. -pub const ASSET_AARCH64: &str = "tty7-server-aarch64-unknown-linux-musl"; +/// +/// **`--musl`, not the Rust target triple.** These names used to be +/// `${{ matrix.target }}` pasted into a filename, which put `unknown` — the +/// triple's *vendor* field, meaning "no particular vendor" — in front of anyone +/// reading the releases page. Of the triple's four fields only two say anything +/// to whoever downloads this: the architecture, which is what `asset_for_uname` +/// picks by, and `musl`, which is why one file runs on any distribution. The +/// order matches the GUI assets the same release publishes +/// (`tty7--linux-x86_64.tar.gz`), so one release is one naming scheme. +/// +/// The build target keeps the triple wherever it really is one — `cargo +/// zigbuild --target`, the `target//release` path, the cache key. This +/// is a *download* name, and the two are no longer spelled the same on purpose. +pub const ASSET_X86_64: &str = "tty7-server-linux-x86_64-musl"; +/// The release asset for a 64-bit ARM Linux box. See [`ASSET_X86_64`] for the +/// naming. +pub const ASSET_AARCH64: &str = "tty7-server-linux-aarch64-musl"; /// The sha256 manifest published beside every asset in a release. pub const CHECKSUMS_ASSET: &str = "checksums.txt"; @@ -382,7 +396,7 @@ mod tests { fn download_urls_point_at_the_release_the_tag_names() { assert_eq!( download_url(&release_tag("26.7.5"), ASSET_X86_64), - "https://github.com/l0ng-ai/tty7/releases/download/v26.7.5/tty7-server-x86_64-unknown-linux-musl" + "https://github.com/l0ng-ai/tty7/releases/download/v26.7.5/tty7-server-linux-x86_64-musl" ); assert_eq!( download_url(&release_tag("26.7.6-nightly.20260727"), CHECKSUMS_ASSET), @@ -390,6 +404,33 @@ mod tests { ); } + /// **The asset names, pinned as literals.** + /// + /// They are one half of a contract whose other half is a `cp` in two + /// workflow files, and checking them against the consts they come from + /// would assert nothing. A literal here is what makes changing one side + /// without the other a failing test rather than a 404 on a user's machine. + /// + /// Including the absence of `unknown`: that word only ever reached these + /// names by way of `${{ matrix.target }}`, and a build triple pasted into a + /// download name is worth failing on rather than explaining again. + #[test] + fn asset_names_are_the_ones_the_release_workflow_publishes() { + assert_eq!(ASSET_X86_64, "tty7-server-linux-x86_64-musl"); + assert_eq!(ASSET_AARCH64, "tty7-server-linux-aarch64-musl"); + for asset in [ASSET_X86_64, ASSET_AARCH64] { + assert!( + !asset.contains("unknown"), + "{asset} carries the triple's vendor field" + ); + } + // `checksums::expected_digest` matches the filename field whole, and + // says outright that it relies on no asset name being a substring of + // another. Two names is the whole set, so check it here. + assert!(!ASSET_X86_64.contains(ASSET_AARCH64)); + assert!(!ASSET_AARCH64.contains(ASSET_X86_64)); + } + /// Path construction, including the `mkdir` chain. Asserted literally: these /// strings are what an SFTP server sees, and a `\` in any of them (which is /// what `PathBuf::join` would produce on a Windows client) would create a file diff --git a/crates/tty7-core/src/daemon/install/checksums.rs b/crates/tty7-core/src/daemon/install/checksums.rs index 8a9960a9..0c403943 100644 --- a/crates/tty7-core/src/daemon/install/checksums.rs +++ b/crates/tty7-core/src/daemon/install/checksums.rs @@ -96,9 +96,9 @@ fn parse_hex(s: &str) -> Option { /// The digest `manifest` records for `asset`. /// /// **The filename field is matched whole, never by substring.** -/// `tty7-server-x86_64-unknown-linux-musl` happens not to be a substring of any -/// other asset today, but that is an accident of the current release contents, -/// not a property anyone maintains — and a substring match that drifted would +/// `tty7-server-linux-x86_64-musl` happens not to be a substring of any other +/// asset today, but that is an accident of the current release contents, not a +/// property anyone maintains — and a substring match that drifted would /// silently verify one binary's bytes against another's digest. /// /// The coreutils format is ``; the second space is `*` @@ -239,9 +239,9 @@ mod tests { #[test] fn a_malformed_entry_aborts() { for bad in [ - "abc tty7-server-x86_64-unknown-linux-musl", - "zz786850e387550fdab836ed7e6dc881de23001b4b4d8ec3a1a0b9d5e0d5c0f1x tty7-server-x86_64-unknown-linux-musl", - " tty7-server-x86_64-unknown-linux-musl", + "abc tty7-server-linux-x86_64-musl", + "zz786850e387550fdab836ed7e6dc881de23001b4b4d8ec3a1a0b9d5e0d5c0f1x tty7-server-linux-x86_64-musl", + " tty7-server-linux-x86_64-musl", ] { let err = expected_digest(bad, ASSET_X86_64).unwrap_err(); assert!( diff --git a/crates/tty7-core/src/daemon/install/mod.rs b/crates/tty7-core/src/daemon/install/mod.rs index b9f2e09f..f2aef501 100644 --- a/crates/tty7-core/src/daemon/install/mod.rs +++ b/crates/tty7-core/src/daemon/install/mod.rs @@ -396,7 +396,7 @@ pub struct InstallRequest { pub host: String, /// The version about to be installed (= the client's own version). pub version: String, - /// The release asset name, e.g. `tty7-server-x86_64-unknown-linux-musl`. + /// The release asset name, e.g. `tty7-server-linux-x86_64-musl`. pub asset: &'static str, /// The URL it was downloaded from. pub source_url: String, diff --git a/crates/tty7-core/src/daemon/install/wsl.rs b/crates/tty7-core/src/daemon/install/wsl.rs index 40f7a815..bbd0eb94 100644 --- a/crates/tty7-core/src/daemon/install/wsl.rs +++ b/crates/tty7-core/src/daemon/install/wsl.rs @@ -768,13 +768,24 @@ impl RemoteOps for WslRemoteOps { /// Overrides where the bundled Linux server binaries are looked for. Exists so /// this can be tested, and so a developer running an unpackaged build can point /// at a `cargo build --target x86_64-unknown-linux-musl` output. +/// +/// **The file in it has to carry the asset name**, not cargo's — [`Self::locate`] +/// joins the directory with +/// [`asset::ASSET_X86_64`](crate::daemon::install::asset::ASSET_X86_64) and +/// nothing translates, so a cross-compile has to be copied to +/// `tty7-server-linux-x86_64-musl` rather than left as plain `tty7-server`. +/// +/// [`Self::locate`]: BundledServerBinary::locate pub const BUNDLED_DIR_ENV: &str = "TTY7_BUNDLED_SERVER_DIR"; /// The subdirectory beside the client executable that a packaged build puts the /// Linux server binaries in. **This is the contract with the release workflow**: /// the Windows installer must place -/// `/server/tty7-server-x86_64-unknown-linux-musl` (and the -/// `aarch64` one, for WSL on ARM Windows). +/// `/server/tty7-server-linux-x86_64-musl` (and the `aarch64` one, +/// for WSL on ARM Windows) — the filenames +/// [`asset::ASSET_X86_64`](crate::daemon::install::asset::ASSET_X86_64) names, +/// because [`BundledServerBinary::locate`] joins the directory with the asset +/// name and nothing translates between the two. pub const BUNDLED_SUBDIR: &str = "server"; /// Directories a bundled Linux server binary is looked for in, most specific @@ -1681,10 +1692,7 @@ mod tests { .load("26.7.5", super::super::asset::ASSET_X86_64) .expect_err("nothing bundled yet"); let msg = err.to_string(); - assert!( - msg.contains("tty7-server-x86_64-unknown-linux-musl"), - "{msg}" - ); + assert!(msg.contains("tty7-server-linux-x86_64-musl"), "{msg}"); assert!(msg.contains(&tmp.display().to_string()), "{msg}"); assert!(matches!(err, InstallError::MissingBundled { .. })); @@ -2074,7 +2082,7 @@ mod tests { .run() .expect_err("no aarch64 binary bundled"); let msg = err.to_string(); - assert!(msg.contains("aarch64-unknown-linux-musl"), "{msg}"); + assert!(msg.contains("linux-aarch64-musl"), "{msg}"); // With it bundled, the same install goes through. std::fs::write(dir.join(super::super::asset::ASSET_AARCH64), b"\x7fELF arm").unwrap(); diff --git a/src/ui/remote_connect.rs b/src/ui/remote_connect.rs index 89496160..9a40463b 100644 --- a/src/ui/remote_connect.rs +++ b/src/ui/remote_connect.rs @@ -1151,7 +1151,7 @@ mod tests { InstallRequest { host: "me@build-box:22".into(), version: "0.9.1".into(), - asset: "tty7-server-x86_64-unknown-linux-musl", + asset: "tty7-server-linux-x86_64-musl", source_url: "https://example.invalid/v0.9.1/tty7-server".into(), remote_path: "/home/me/.local/share/tty7/bin/tty7-server-0.9.1".into(), size_bytes: 9_437_184,