Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions .github/workflows/packages.yml
Original file line number Diff line number Diff line change
@@ -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 <branch>
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"
9 changes: 5 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
17 changes: 12 additions & 5 deletions RELEASING.md
Original file line number Diff line number Diff line change
@@ -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-<version>.dmg`, and builds the Linux app (`apps/desktop/`) on Ubuntu 22.04 into `localhost-app_<version>_amd64.deb`, `localhost-app-<version>-1.x86_64.rpm` and `localhost-app_<version>_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_<version>_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-<version>.dmg`, and builds the Linux app (`apps/desktop/`) on Ubuntu 22.04 into `localhost-app_<version>_amd64.deb`, `localhost-app-<version>-1.x86_64.rpm` and `localhost-app_<version>_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_<version>_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.

Expand All @@ -13,17 +13,24 @@ 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 <branch>
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
git tag -a v0.3.0 -m "localhost 0.3.0"
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`).

Expand Down
5 changes: 3 additions & 2 deletions core/localhost-core/tests/common/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Option<HashSet<u16>>> = 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;
}
Expand Down
85 changes: 85 additions & 0 deletions scripts/check-linux-packages.sh
Original file line number Diff line number Diff line change
@@ -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 <bundle folder> <version>
#
# <bundle folder> is Tauri's output (src-tauri/target/release/bundle).
set -uo pipefail

usage="usage: scripts/check-linux-packages.sh <bundle folder> <version>"
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
Loading