diff --git a/.github/workflows/packages.yml b/.github/workflows/packages.yml new file mode 100644 index 0000000..32f2ba0 --- /dev/null +++ b/.github/workflows/packages.yml @@ -0,0 +1,67 @@ +name: Linux packages + +# Builds the .deb, .rpm and AppImage as the release does, unsigned, and +# checks the localhost command in each (scripts/check-linux-packages.sh). +# Run by hand on the release commit before tagging (RELEASING.md), so a +# packaging problem shows up before a tag does: +# gh workflow run packages.yml -R marcellosso/localhost --ref +on: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: packages-${{ github.ref }} + cancel-in-progress: true + +jobs: + linux: + name: Build and check the Linux packages + runs-on: ubuntu-22.04 + timeout-minutes: 45 + defaults: + run: + working-directory: apps/desktop + env: + # Any version: the check compares the command's to it. + VERSION: 0.0.0 + steps: + - uses: actions/checkout@v4 + - name: Install the webview and tray libraries, and the package tools + run: > + sudo apt-get update && sudo apt-get install -y + libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev libssl-dev libarchive-tools + - uses: dtolnay/rust-toolchain@1.90 + - uses: Swatinem/rust-cache@v2 + with: + workspaces: | + core + apps/desktop/src-tauri + - uses: pnpm/action-setup@v4 + with: + package_json_file: apps/desktop/package.json + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + cache-dependency-path: apps/desktop/pnpm-lock.yaml + - run: pnpm install --frozen-lockfile + + # As in release.yml. + - name: Build the localhost command + env: + LOCALHOST_VERSION: ${{ env.VERSION }} + run: | + cargo build --manifest-path ../../core/Cargo.toml -p localhost-cli --bin localhost --release + target=$(rustc -vV | sed -n 's/^host: //p') + mkdir -p src-tauri/binaries + cp ../../core/target/release/localhost "src-tauri/binaries/localhost-$target" + + - name: Build the .deb, .rpm and AppImage (unsigned) + run: | + config=$(jq -nc --arg version "$VERSION" '{version: $version, bundle: {externalBin: ["binaries/localhost"]}}') + pnpm tauri build --bundles deb,rpm,appimage --config "$config" + + - name: Check the localhost command in every package + run: ../../scripts/check-linux-packages.sh src-tauri/target/release/bundle "$VERSION" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e916589..b0dbdbf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -232,7 +232,7 @@ jobs: - name: Install the webview and tray libraries run: > sudo apt-get update && sudo apt-get install -y - libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev libssl-dev rpm + libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev libssl-dev libarchive-tools - uses: dtolnay/rust-toolchain@1.90 - uses: pnpm/action-setup@v4 with: @@ -274,9 +274,10 @@ jobs: {version: $version, bundle: {createUpdaterArtifacts: true, externalBin: ["binaries/localhost"]}, plugins: {updater: {pubkey: $key, endpoints: [$feed]}}}') pnpm tauri build --bundles deb,rpm,appimage --config "$config" - # The command is in every package. - dpkg-deb --contents src-tauri/target/release/bundle/deb/*.deb | grep -E ' \./usr/bin/localhost$' - rpm -qlp src-tauri/target/release/bundle/rpm/*.rpm | grep -x /usr/bin/localhost + + # Each package unpacked: usr/bin/localhost is there, runs, and says this version. + - name: Check the localhost command in every package + run: ../../scripts/check-linux-packages.sh src-tauri/target/release/bundle "${{ needs.version.outputs.version }}" - name: Collect and checksum env: diff --git a/RELEASING.md b/RELEASING.md index 1461e7c..001c52e 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,6 +1,6 @@ # Releasing localhost -Releases are on GitHub Releases: a DMG for macOS; for Linux, a `.deb`, an `.rpm` and an AppImage; and an installer for Windows. Pushing a `v*` tag runs the [Release workflow](.github/workflows/release.yml). It builds and signs the macOS app into `localhost-.dmg`, and builds the Linux app (`apps/desktop/`) on Ubuntu 22.04 into `localhost-app__amd64.deb`, `localhost-app--1.x86_64.rpm` and `localhost-app__amd64.AppImage`, unsigned. For Windows it builds `localhostd` static for Linux (`x86_64-unknown-linux-musl`), then the same app on Windows into the per-user NSIS installer `localhost__x64-setup.exe`, with `localhostd` installed beside `localhost-app.exe` for the WSL distros. The installer is signed when a certificate exists (below). Every app bundles the `localhost` command of the same commit, which coding agents run as their MCP server: the macOS app in `Contents/Helpers/localhost`, signed like the app; the Linux packages as `/usr/bin/localhost`; the Windows installer as `localhost.exe` beside `localhost-app.exe`. Each job checks that it reports the release's version. Then it drafts one GitHub Release with all of them, a SHA-256 for each, and notes generated from the merged PRs. Nothing is public until a maintainer publishes the draft. +Releases are on GitHub Releases: a DMG for macOS; for Linux, a `.deb`, an `.rpm` and an AppImage; and an installer for Windows. Pushing a `v*` tag runs the [Release workflow](.github/workflows/release.yml). It builds and signs the macOS app into `localhost-.dmg`, and builds the Linux app (`apps/desktop/`) on Ubuntu 22.04 into `localhost-app__amd64.deb`, `localhost-app--1.x86_64.rpm` and `localhost-app__amd64.AppImage`, unsigned. For Windows it builds `localhostd` static for Linux (`x86_64-unknown-linux-musl`), then the same app on Windows into the per-user NSIS installer `localhost__x64-setup.exe`, with `localhostd` installed beside `localhost-app.exe` for the WSL distros. The installer is signed when a certificate exists (below). Every app bundles the `localhost` command of the same commit, which coding agents run as their MCP server: the macOS app in `Contents/Helpers/localhost`, signed like the app; the Linux packages as `/usr/bin/localhost`; the Windows installer as `localhost.exe` beside `localhost-app.exe`. Each job checks that it reports the release's version; for Linux, `scripts/check-linux-packages.sh` unpacks the `.deb`, the `.rpm` and the AppImage and runs the command in each. The Linux packages workflow does the same, unsigned, when run by hand. Then it drafts one GitHub Release with all of them, a SHA-256 for each, and notes generated from the merged PRs. Nothing is public until a maintainer publishes the draft. The release also carries the update feeds that installed apps check: `latest.json` for the Linux and Windows app and `appcast.xml` for the macOS app, both naming this release's packages, each signed with the updater keys (see [Updates](#updates)). Apps read them from `releases/latest/download/`, so an update reaches users only once its release is published, and a draft or pre-release never. @@ -13,7 +13,14 @@ Tags are `vMAJOR.MINOR.PATCH`, nothing else; the workflow rejects any other `v*` ## Cutting a release 1. Make sure `main` has everything and CI is green. -2. Tag the commit and push the tag: +2. Run **Linux packages** by hand on the release commit, and wait for it to pass. Pull-request CI doesn't build the packages; this builds the `.deb`, the `.rpm` and the AppImage as the release does, unsigned, and checks the bundled `localhost` command in each (`scripts/check-linux-packages.sh`): + + ```sh + gh workflow run packages.yml -R marcellosso/localhost --ref + gh run watch -R marcellosso/localhost "$(gh run list -R marcellosso/localhost --workflow packages.yml --limit 1 --json databaseId -q '.[0].databaseId')" + ``` + +3. Tag the commit and push the tag: ```sh git switch main && git pull @@ -21,9 +28,9 @@ Tags are `vMAJOR.MINOR.PATCH`, nothing else; the workflow rejects any other `v*` git push origin v0.3.0 ``` -3. Wait for the **Release** workflow (Actions tab). It ends with a draft release named "localhost 0.3.0". -4. Open the draft. Edit the notes if needed (the first line says how the build is signed and what the first launch needs). Download the DMG and check that it opens, installs and launches, following the README's first-launch steps. Install the `.deb` on Ubuntu and the `.rpm` on Fedora (or run the AppImage) and check that the app launches and its tray icon appears. On Windows 11 with Ubuntu in WSL 2, run the installer and check that a Project in the distro starts, opens in the browser, and stops when localhost quits. -5. Click **Publish release**. Installed apps see the update at their next check (at launch, or within six hours). Check that one does: open the previous version, use **Check for Updates…**, and install the update with a Service running, which should be listed and stopped first. +4. Wait for the **Release** workflow (Actions tab). It ends with a draft release named "localhost 0.3.0". +5. Open the draft. Edit the notes if needed (the first line says how the build is signed and what the first launch needs). Download the DMG and check that it opens, installs and launches, following the README's first-launch steps. Install the `.deb` on Ubuntu and the `.rpm` on Fedora (or run the AppImage) and check that the app launches and its tray icon appears. On Windows 11 with Ubuntu in WSL 2, run the installer and check that a Project in the distro starts, opens in the browser, and stops when localhost quits. +6. Click **Publish release**. Installed apps see the update at their next check (at launch, or within six hours). Check that one does: open the previous version, use **Check for Updates…**, and install the update with a Service running, which should be listed and stopped first. A fix is a normal change: branch → PR → merge → a patch tag (`v0.3.1`). diff --git a/core/localhost-core/tests/common/mod.rs b/core/localhost-core/tests/common/mod.rs index eff1913..b007760 100644 --- a/core/localhost-core/tests/common/mod.rs +++ b/core/localhost-core/tests/common/mod.rs @@ -253,14 +253,15 @@ pub async fn wait_until(mut condition: impl FnMut() -> bool) { /// A port nothing listens on right now (bound once by the kernel's choice, /// then released), at least 100 away from any port another test of this /// binary was given: tests run in parallel, the kernel picks nearby ports, -/// and a Service whose Port is taken moves up to 100 ports above it. +/// and a Service whose Port is taken moves up to 100 ports above it, so +/// those 100 ports must exist too (Windows hands out ports up to 65535). pub fn free_port() -> u16 { static HANDED_OUT: Mutex>> = Mutex::new(None); loop { let port = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port(); let mut handed_out = HANDED_OUT.lock().unwrap(); let ports = handed_out.get_or_insert_with(HashSet::new); - if ports.iter().all(|other| other.abs_diff(port) > 100) { + if port <= u16::MAX - 100 && ports.iter().all(|other| other.abs_diff(port) > 100) { ports.insert(port); return port; } diff --git a/scripts/check-linux-packages.sh b/scripts/check-linux-packages.sh new file mode 100755 index 0000000..d9b05cb --- /dev/null +++ b/scripts/check-linux-packages.sh @@ -0,0 +1,85 @@ +#!/bin/bash +# Checks that every Linux package carries the `localhost` command coding +# agents run: usr/bin/localhost (on the PATH as /usr/bin/localhost once the +# .deb or .rpm is installed; inside the AppImage, which the app copies out +# for agents), executable, reporting the expected version. Each package is +# unpacked, so this doesn't depend on how a tool lists its paths. Every +# package is checked, and every failure says what failed. Needs dpkg-deb and +# bsdtar (libarchive-tools), which reads an .rpm whatever its compression. +# +# scripts/check-linux-packages.sh +# +# is Tauri's output (src-tauri/target/release/bundle). +set -uo pipefail + +usage="usage: scripts/check-linux-packages.sh " +bundle=$(cd "${1:?$usage}" && pwd) || exit 1 +version=${2:?$usage} +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +# Anything else failing still says so. +trap 'echo "error: line $LINENO: \`$BASH_COMMAND\` exited $?" >&2' ERR + +for tool in dpkg-deb bsdtar; do + command -v "$tool" > /dev/null || { echo "error: $tool isn't installed" >&2; exit 1; } +done + +# The one file matching a pattern under the bundle folder. +one() { + local files + # shellcheck disable=SC2206 # $1 is a pattern, expanded here on purpose + files=("$bundle"/$1) + if [[ ! -f ${files[0]} ]]; then + echo "error: no $1 in $bundle" >&2 + return 1 + fi + echo "${files[0]}" +} + +# Unpacks a package into a folder, saying why if it can't. +unpack() { + local kind=$1 file=$2 into=$3 output + mkdir -p "$into" + case $kind in + .deb) output=$(dpkg-deb -x "$file" "$into" 2>&1) ;; + .rpm) output=$(bsdtar -xf "$file" -C "$into" 2>&1) ;; + AppImage) output=$(cd "$into" && "$file" --appimage-extract 2>&1 > /dev/null) ;; + esac + local status=$? + if (( status != 0 )); then + echo "error: couldn't unpack the $kind ($(basename "$file")), exit $status: $output" >&2 + return 1 + fi +} + +check() { + local package=$1 root=$2 command=$2/usr/bin/localhost said + if [[ ! -x $command ]]; then + echo "error: $package has no executable usr/bin/localhost. Its usr/bin holds:" >&2 + ls -l "$root/usr/bin" >&2 2>&1 || echo "(no usr/bin; it holds: $(ls "$root"))" >&2 + return 1 + fi + if ! said=$("$command" --version 2>&1); then + echo "error: $package's usr/bin/localhost --version failed: $said" >&2 + return 1 + fi + if [[ $said != "localhost $version" ]]; then + echo "error: $package's usr/bin/localhost says '$said', not 'localhost $version'" >&2 + return 1 + fi + echo "$package: usr/bin/localhost, $said" +} + +failed=0 +for package in ".deb:deb/*.deb" ".rpm:rpm/*.rpm" "AppImage:appimage/*.AppImage"; do + kind=${package%%:*} pattern=${package#*:} + root=$work/${kind#.} + if file=$(one "$pattern") && unpack "$kind" "$file" "$root"; then + [[ $kind == AppImage ]] && root=$root/squashfs-root + check "$kind" "$root" || failed=1 + else + failed=1 + fi +done +(( failed == 0 )) && echo "Every package carries localhost $version." || echo "error: see above" >&2 +exit $failed