feat(release): add install.sh for standalone binaries

Standalone installs previously required downloading a release tarball,
checking SHA256SUMS, extracting and installing by hand. Add a POSIX shell
installer, published as a release asset so `releases/latest/download/`
always serves the current one:

    curl -fsSL https://github.com/Routstr/routstrd/releases/latest/download/install.sh | sh

The installer detects platform and architecture, resolves the latest
version from the GitHub API (or takes --version), verifies the archive
against the release SHA256SUMS, and only replaces the installed executable
after the checksum matches. It needs only sh, tar, curl/wget and a SHA256
tool -- no Bun, Node.js or npm.

The release job now checks out the repo, smoke tests install.sh against the
artifacts it just built by serving them locally, and attaches install.sh to
the GitHub Release.

Tests cover asset-name parity with releaseArchiveName, version/platform
validation, and end-to-end installs against a fake release server including
checksum mismatch, missing asset and missing SHA256SUMS failure paths.
This commit is contained in:
redshift
2026-09-10 17:04:02 +02:00
parent dae175892b
commit f5c257674f
4 changed files with 589 additions and 6 deletions
+23 -1
View File
@@ -92,18 +92,40 @@ jobs:
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/download-artifact@v4
with:
pattern: routstrd-*
merge-multiple: true
- name: Create checksums
run: shasum -a 256 routstrd-*.tar.gz > SHA256SUMS
- name: Serve artifacts for installer smoke test
run: |
version="${GITHUB_REF_NAME#v}"
mkdir -p "serve/v${version}"
cp "routstrd-v${version}-linux-x64.tar.gz" SHA256SUMS "serve/v${version}/"
- name: Smoke test install.sh against the built artifacts
run: |
version="${GITHUB_REF_NAME#v}"
python3 -m http.server 8137 --bind 127.0.0.1 --directory serve &
server_pid=$!
trap 'kill "${server_pid}"' EXIT
sleep 1
sh install.sh \
--version "${version}" \
--platform linux \
--arch x64 \
--dir /tmp/routstrd-install-smoke \
--download-base-url http://127.0.0.1:8137
test "$(/tmp/routstrd-install-smoke/routstrd --version)" = "${version}"
- name: Publish GitHub Release
env:
GH_TOKEN: ${{ github.token }}
run: >-
gh release create "${GITHUB_REF_NAME}"
routstrd-*.tar.gz SHA256SUMS
routstrd-*.tar.gz SHA256SUMS install.sh
--repo "${GITHUB_REPOSITORY}"
--verify-tag
--generate-notes
+33 -5
View File
@@ -26,7 +26,28 @@ npm or running from source requires the [Bun](https://bun.sh) runtime.
### Step 1: Install
**Standalone binary:**
**Standalone binary (recommended):**
Installs the standalone executable for Linux or macOS (x64 or arm64) into
`$HOME/.local/bin`. No Bun, Node.js, or npm required.
```sh
curl -fsSL https://github.com/Routstr/routstrd/releases/latest/download/install.sh | sh
```
Pin a version, change the install directory, or print the resolved asset without
installing anything:
```sh
curl -fsSL https://github.com/Routstr/routstrd/releases/latest/download/install.sh \
| sh -s -- --version 0.4.9 --dir /usr/local/bin
```
The installer downloads the release archive, verifies it against the release
`SHA256SUMS`, and only replaces `routstrd` after the checksum matches.
<details>
<summary>Manual install</summary>
Download the archive for your operating system and architecture from the
[latest GitHub Release](https://github.com/Routstr/routstrd/releases/latest).
@@ -42,6 +63,12 @@ install -m 755 routstrd "$HOME/.local/bin/routstrd"
Substitute the version, platform, and architecture for the archive you
downloaded, and ensure `$HOME/.local/bin` is on `PATH`.
</details>
Installing the standalone binary is preferred over the npm package: the npm
package runs through the Bun runtime, while the standalone executable has no
runtime dependency.
**Global with bun:**
```sh
bun i -g routstrd
@@ -310,10 +337,11 @@ not part of `bun test`.
1. Set a new `package.json` version and commit it. The release tag must be the
same version prefixed with `v`, and the tag must not already exist.
2. Push the tag. The release workflow runs lint and tests, builds Linux and
macOS executables for x64 and arm64, smoke-tests them, and publishes the
archives with `SHA256SUMS`.
3. Verify all four archives appear in the GitHub Release and validate each
checksum before announcing it.
macOS executables for x64 and arm64, smoke-tests them, verifies the archives
through `install.sh` itself, and publishes the archives with `SHA256SUMS` and
`install.sh`.
3. Verify all four archives and `install.sh` appear in the GitHub Release and
validate each checksum before announcing it.
4. In disposable environments for each platform, test `--version`, `--help`,
foreground startup failure, and background `start`, `status`, and `stop`
without Bun on `PATH`.
Executable
+241
View File
@@ -0,0 +1,241 @@
#!/bin/sh
# routstrd installer for standalone releases.
#
# curl -fsSL https://github.com/Routstr/routstrd/releases/latest/download/install.sh | sh
#
# Downloads the standalone archive for this platform from the GitHub Release,
# verifies it against the release SHA256SUMS, and installs the `routstrd`
# executable. Needs only `sh`, `tar`, `curl` (or `wget`) and a SHA256 tool --
# not Bun, Node.js, or npm.
#
# Options (also available as ROUTSTRD_* environment variables): run this file
# with --help, or see the usage text below, for the full list.
set -eu
REPO="${ROUTSTRD_REPO:-Routstr/routstrd}"
API_BASE_URL="${ROUTSTRD_API_BASE_URL:-https://api.github.com}"
EXPLICIT_DOWNLOAD_BASE_URL="${ROUTSTRD_DOWNLOAD_BASE_URL:-}"
VERSION="${ROUTSTRD_VERSION:-}"
PLATFORM="${ROUTSTRD_PLATFORM:-}"
ARCH="${ROUTSTRD_ARCH:-}"
INSTALL_DIR="${ROUTSTRD_INSTALL_DIR:-}"
PRINT_ASSET=0
MAX_ARCHIVE_BYTES=262144000
say() { printf '%s\n' "$*" >&2; }
die() { printf 'error: %s\n' "$*" >&2; exit 1; }
usage() {
cat <<'EOF'
Install routstrd from a standalone GitHub Release.
Usage: sh install.sh [options]
Options:
--version <version> Install a specific version (default: latest)
--dir <path> Install directory (default: $HOME/.local/bin)
--platform <platform> Override platform detection (linux|darwin)
--arch <arch> Override architecture detection (x64|arm64)
--repo <owner/name> GitHub repository (default: Routstr/routstrd)
--api-base-url <url> GitHub API base URL (testing/mirrors)
--download-base-url <url> Release download base URL (testing/mirrors)
--print-asset Print the resolved asset name and exit
--help Show this help
Environment: ROUTSTRD_VERSION, ROUTSTRD_INSTALL_DIR, ROUTSTRD_PLATFORM,
ROUTSTRD_ARCH, ROUTSTRD_REPO, ROUTSTRD_API_BASE_URL,
ROUTSTRD_DOWNLOAD_BASE_URL, GITHUB_TOKEN (for API rate limits)
EOF
}
while [ $# -gt 0 ]; do
case "$1" in
--version) [ $# -ge 2 ] || die "--version requires a value"; VERSION="$2"; shift 2 ;;
--dir|--install-dir) [ $# -ge 2 ] || die "$1 requires a value"; INSTALL_DIR="$2"; shift 2 ;;
--platform) [ $# -ge 2 ] || die "--platform requires a value"; PLATFORM="$2"; shift 2 ;;
--arch) [ $# -ge 2 ] || die "--arch requires a value"; ARCH="$2"; shift 2 ;;
--repo) [ $# -ge 2 ] || die "--repo requires a value"; REPO="$2"; shift 2 ;;
--api-base-url) [ $# -ge 2 ] || die "--api-base-url requires a value"; API_BASE_URL="$2"; shift 2 ;;
--download-base-url) [ $# -ge 2 ] || die "--download-base-url requires a value"; EXPLICIT_DOWNLOAD_BASE_URL="$2"; shift 2 ;;
--print-asset) PRINT_ASSET=1; shift ;;
--help|-h) usage; exit 0 ;;
*) die "unknown option '$1' (try --help)" ;;
esac
done
if [ -n "$EXPLICIT_DOWNLOAD_BASE_URL" ]; then
DOWNLOAD_BASE_URL="$EXPLICIT_DOWNLOAD_BASE_URL"
else
DOWNLOAD_BASE_URL="https://github.com/${REPO}/releases/download"
fi
if [ -z "$INSTALL_DIR" ]; then
[ -n "${HOME:-}" ] || die "HOME is not set; pass --dir or set ROUTSTRD_INSTALL_DIR."
INSTALL_DIR="$HOME/.local/bin"
fi
if [ -z "$PLATFORM" ]; then
case "$(uname -s)" in
Linux) PLATFORM=linux ;;
Darwin) PLATFORM=darwin ;;
*) die "unsupported operating system '$(uname -s)'; releases cover Linux and macOS." ;;
esac
fi
if [ -z "$ARCH" ]; then
case "$(uname -m)" in
x86_64|amd64) ARCH=x64 ;;
arm64|aarch64) ARCH=arm64 ;;
*) die "unsupported architecture '$(uname -m)'; releases cover x64 and arm64." ;;
esac
fi
case "$PLATFORM" in
linux|darwin) ;;
*) die "unsupported platform '$PLATFORM'; expected linux or darwin." ;;
esac
case "$ARCH" in
x64|arm64) ;;
*) die "unsupported architecture '$ARCH'; expected x64 or arm64." ;;
esac
if command -v curl >/dev/null 2>&1; then
HTTP_CLIENT=curl
elif command -v wget >/dev/null 2>&1; then
HTTP_CLIENT=wget
else
die "curl or wget is required to download routstrd."
fi
fetch_stdout() {
if [ "$HTTP_CLIENT" = curl ]; then
curl -fsSL -H "Accept: application/vnd.github+json" \
${GITHUB_TOKEN:+-H "Authorization: Bearer ${GITHUB_TOKEN}"} "$1"
else
wget -qO- --header="Accept: application/vnd.github+json" \
${GITHUB_TOKEN:+--header="Authorization: Bearer ${GITHUB_TOKEN}"} "$1"
fi
}
fetch_file() {
if [ "$HTTP_CLIENT" = curl ]; then
curl -fsSL -o "$2" "$1"
else
wget -qO "$2" "$1"
fi
}
if [ -z "$VERSION" ]; then
if ! release_json="$(fetch_stdout "${API_BASE_URL}/repos/${REPO}/releases/latest")"; then
die "could not query ${API_BASE_URL}/repos/${REPO}/releases/latest."
fi
tag="$(printf '%s\n' "$release_json" \
| sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \
| head -n 1)"
[ -n "$tag" ] || die "could not read the latest release tag from ${REPO}."
VERSION="${tag#v}"
else
VERSION="${VERSION#v}"
fi
case "$VERSION" in
[0-9]*) ;;
*) die "invalid version '$VERSION'." ;;
esac
case "$VERSION" in
*[!0-9A-Za-z.+-]*) die "invalid version '$VERSION'." ;;
esac
ASSET="routstrd-v${VERSION}-${PLATFORM}-${ARCH}.tar.gz"
if [ "$PRINT_ASSET" = 1 ]; then
printf '%s\n' "$ASSET"
exit 0
fi
if command -v sha256sum >/dev/null 2>&1; then
SHA256_CMD="sha256sum"
elif command -v shasum >/dev/null 2>&1; then
SHA256_CMD="shasum -a 256"
elif command -v openssl >/dev/null 2>&1; then
SHA256_CMD="openssl_sha256"
else
die "no SHA256 tool found (need sha256sum, shasum, or openssl)."
fi
hash_file() {
if [ "$SHA256_CMD" = openssl_sha256 ]; then
openssl dgst -sha256 -r "$1" | awk '{print $1}' | tr 'A-Z' 'a-z'
else
# shellcheck disable=SC2086
$SHA256_CMD "$1" | awk '{print $1}' | tr 'A-Z' 'a-z'
fi
}
WORKDIR="$(mktemp -d "${TMPDIR:-/tmp}/routstrd-install.XXXXXX")" \
|| die "could not create a temporary directory."
STAGED=""
cleanup() {
if [ -n "$STAGED" ]; then rm -f "$STAGED" 2>/dev/null || true; fi
if [ -n "$WORKDIR" ]; then rm -rf "$WORKDIR" 2>/dev/null || true; fi
}
trap cleanup EXIT INT TERM
RELEASE_BASE_URL="${DOWNLOAD_BASE_URL}/v${VERSION}"
ARCHIVE="${WORKDIR}/${ASSET}"
CHECKSUMS="${WORKDIR}/SHA256SUMS"
say "Installing routstrd v${VERSION} (${PLATFORM}-${ARCH})."
if ! fetch_file "${RELEASE_BASE_URL}/${ASSET}" "$ARCHIVE"; then
die "could not download ${ASSET} from ${RELEASE_BASE_URL}. Release v${VERSION} may not include a build for ${PLATFORM}-${ARCH}."
fi
if ! fetch_file "${RELEASE_BASE_URL}/SHA256SUMS" "$CHECKSUMS"; then
die "could not download SHA256SUMS from ${RELEASE_BASE_URL}."
fi
archive_bytes="$(wc -c < "$ARCHIVE" | tr -d '[:space:]')"
[ "$archive_bytes" -le "$MAX_ARCHIVE_BYTES" ] \
|| die "${ASSET} is unexpectedly large (${archive_bytes} bytes)."
expected="$(grep -F "$ASSET" "$CHECKSUMS" 2>/dev/null \
| awk -v name="$ASSET" '$2 == name || $2 == "*" name { print $1 }' \
| head -n 1 \
| tr 'A-Z' 'a-z')"
[ -n "$expected" ] || die "SHA256SUMS does not contain ${ASSET}."
actual="$(hash_file "$ARCHIVE")"
if [ "$actual" != "$expected" ]; then
die "checksum mismatch for ${ASSET}: expected ${expected}, got ${actual}."
fi
tar -xzf "$ARCHIVE" -C "$WORKDIR" || die "could not extract ${ASSET}."
[ -f "${WORKDIR}/routstrd" ] || die "${ASSET} does not contain a routstrd executable."
mkdir -p "$INSTALL_DIR" || die "could not create ${INSTALL_DIR}."
TARGET="${INSTALL_DIR}/routstrd"
STAGED="${INSTALL_DIR}/.routstrd.tmp.$$"
cp "${WORKDIR}/routstrd" "$STAGED" || die "could not write to ${INSTALL_DIR}."
chmod 755 "$STAGED"
# Rename over the target so a running daemon keeps its old inode.
mv -f "$STAGED" "$TARGET" || die "could not install to ${TARGET}."
STAGED=""
installed_version="$("$TARGET" --version 2>/dev/null || true)"
if [ "$installed_version" != "$VERSION" ]; then
say "warning: ${TARGET} reported version '${installed_version:-unknown}' instead of '${VERSION}'."
fi
say "Installed routstrd v${VERSION} to ${TARGET}"
case ":${PATH:-}:" in
*":${INSTALL_DIR}:"*) ;;
*)
say ""
say "${INSTALL_DIR} is not in PATH. Add it to your shell profile:"
say " export PATH=\"${INSTALL_DIR}:\$PATH\""
;;
esac
+292
View File
@@ -0,0 +1,292 @@
import { afterEach, describe, expect, test } from "bun:test";
import { createHash } from "crypto";
import { chmodSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
import { releaseArchiveName } from "../src/utils/standalone-update";
const INSTALL_SCRIPT = join(import.meta.dir, "..", "install.sh");
const REPO = "Routstr/routstrd";
const VERSION = "9.9.9";
const PLATFORMS = ["linux", "darwin"] as const;
const ARCHS = ["x64", "arm64"] as const;
const tempDirs: string[] = [];
const servers: ReturnType<typeof Bun.serve>[] = [];
function tempDir(prefix: string): string {
const dir = mkdtempSync(join(tmpdir(), prefix));
tempDirs.push(dir);
return dir;
}
afterEach(() => {
for (const server of servers.splice(0)) server.stop(true);
for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true });
});
function sha256Hex(bytes: Uint8Array): string {
return createHash("sha256").update(bytes).digest("hex");
}
/** Builds a tar.gz that mimics a real release archive: a single `routstrd` entry. */
function buildArchive(dir: string, version: string): Uint8Array {
const stage = join(dir, "stage");
const archivePath = join(dir, "archive.tar.gz");
writeFileSync(
join(dir, "routstrd"),
`#!/bin/sh\necho ${version}\n`,
);
Bun.spawnSync([
"sh",
"-c",
`mkdir -p ${JSON.stringify(stage)} && cp ${JSON.stringify(join(dir, "routstrd"))} ${JSON.stringify(join(stage, "routstrd"))} && tar -czf ${JSON.stringify(archivePath)} -C ${JSON.stringify(stage)} routstrd`,
]);
return new Uint8Array(readFileSync(archivePath));
}
type FakeReleaseOptions = {
version?: string;
asset?: string;
archive?: Uint8Array;
checksums?: string;
omitAsset?: boolean;
};
/**
* Serves the subset of the GitHub API and release download URLs that install.sh
* consumes, so the installer can be exercised end to end without network access.
*/
function serveFakeRelease(options: FakeReleaseOptions = {}): string {
const version = options.version ?? VERSION;
const asset = options.asset ?? releaseArchiveName(version, "linux", "x64");
const server = Bun.serve({
port: 0,
fetch(request) {
const { pathname } = new URL(request.url);
if (pathname === `/repos/${REPO}/releases/latest`) {
return Response.json({
tag_name: `v${version}`,
assets: [{ name: asset }, { name: "SHA256SUMS" }],
});
}
if (pathname === `/dl/v${version}/SHA256SUMS`) {
return new Response(options.checksums ?? "");
}
if (pathname === `/dl/v${version}/${asset}`) {
if (options.omitAsset) return new Response("not found", { status: 404 });
return new Response(options.archive ?? new Uint8Array());
}
return new Response("not found", { status: 404 });
},
});
servers.push(server);
const origin = `http://127.0.0.1:${server.port}`;
return origin;
}
function runInstaller(args: string[], env: Record<string, string> = {}) {
return Bun.spawnSync(["sh", INSTALL_SCRIPT, ...args], {
env: { ...process.env, ...env },
stdout: "pipe",
stderr: "pipe",
});
}
describe("install.sh", () => {
test("is valid POSIX shell", () => {
const result = Bun.spawnSync(["sh", "-n", INSTALL_SCRIPT], { stderr: "pipe" });
expect(result.stderr.toString()).toBe("");
expect(result.exitCode).toBe(0);
});
test("resolves the same asset names as releaseArchiveName", () => {
for (const platform of PLATFORMS) {
for (const arch of ARCHS) {
const result = runInstaller([
"--print-asset",
"--version",
VERSION,
"--platform",
platform,
"--arch",
arch,
]);
expect(result.exitCode).toBe(0);
expect(result.stdout.toString().trim()).toBe(
releaseArchiveName(VERSION, platform, arch),
);
}
}
});
test("accepts a v-prefixed version", () => {
const result = runInstaller([
"--print-asset",
"--version",
`v${VERSION}`,
"--platform",
"linux",
"--arch",
"x64",
]);
expect(result.stdout.toString().trim()).toBe(releaseArchiveName(VERSION, "linux", "x64"));
});
test("rejects unsupported platforms and architectures", () => {
for (const args of [
["--print-asset", "--version", VERSION, "--platform", "windows", "--arch", "x64"],
["--print-asset", "--version", VERSION, "--platform", "linux", "--arch", "riscv64"],
["--print-asset", "--version", "../../etc/passwd", "--platform", "linux", "--arch", "x64"],
]) {
const result = runInstaller(args);
expect(result.exitCode).not.toBe(0);
}
});
test("installs the executable from a release archive", () => {
const dir = tempDir("routstrd-install-e2e-");
const installDir = join(dir, "bin");
const asset = releaseArchiveName(VERSION, process.platform, process.arch);
const archive = buildArchive(dir, VERSION);
const origin = serveFakeRelease({
asset,
archive,
checksums: `${sha256Hex(archive)} ${asset}\n`,
});
const result = runInstaller([
"--dir",
installDir,
"--api-base-url",
origin,
"--download-base-url",
`${origin}/dl`,
]);
expect(result.stderr.toString()).toContain(`Installed routstrd v${VERSION}`);
expect(result.exitCode).toBe(0);
const target = join(installDir, "routstrd");
expect(statSync(target).mode & 0o111).not.toBe(0);
const version = Bun.spawnSync([target, "--version"]);
expect(version.stdout.toString().trim()).toBe(VERSION);
});
test("installs a specific version without querying the API", () => {
const dir = tempDir("routstrd-install-pinned-");
const installDir = join(dir, "bin");
const asset = releaseArchiveName(VERSION, process.platform, process.arch);
const archive = buildArchive(dir, VERSION);
const origin = serveFakeRelease({
asset,
archive,
checksums: `${sha256Hex(archive)} ${asset}\n`,
});
const result = runInstaller([
"--version",
VERSION,
"--dir",
installDir,
"--api-base-url",
`${origin}/broken`,
"--download-base-url",
`${origin}/dl`,
]);
expect(result.exitCode).toBe(0);
expect(statSync(join(installDir, "routstrd")).isFile()).toBe(true);
});
test("refuses to install when the checksum does not match", () => {
const dir = tempDir("routstrd-install-tampered-");
const installDir = join(dir, "bin");
const asset = releaseArchiveName(VERSION, process.platform, process.arch);
const archive = buildArchive(dir, VERSION);
const origin = serveFakeRelease({
asset,
archive,
checksums: `${sha256Hex(new TextEncoder().encode("tampered"))} ${asset}\n`,
});
const result = runInstaller([
"--dir",
installDir,
"--api-base-url",
origin,
"--download-base-url",
`${origin}/dl`,
]);
expect(result.exitCode).not.toBe(0);
expect(result.stderr.toString()).toContain("checksum mismatch");
expect(() => statSync(join(installDir, "routstrd"))).toThrow();
});
test("reports a missing asset for the current platform", () => {
const dir = tempDir("routstrd-install-missing-");
const origin = serveFakeRelease({
asset: releaseArchiveName(VERSION, process.platform, process.arch),
omitAsset: true,
});
const result = runInstaller([
"--dir",
join(dir, "bin"),
"--api-base-url",
origin,
"--download-base-url",
`${origin}/dl`,
]);
expect(result.exitCode).not.toBe(0);
expect(result.stderr.toString()).toContain("may not include a build");
});
test("reports a release without SHA256SUMS", () => {
const dir = tempDir("routstrd-install-nosums-");
const asset = releaseArchiveName(VERSION, process.platform, process.arch);
const archive = buildArchive(dir, VERSION);
const origin = serveFakeRelease({ asset, archive, checksums: "" });
const result = runInstaller([
"--dir",
join(dir, "bin"),
"--api-base-url",
origin,
"--download-base-url",
`${origin}/dl`,
]);
expect(result.exitCode).not.toBe(0);
expect(result.stderr.toString()).toContain("does not contain");
});
test("honours ROUTSTRD_INSTALL_DIR when --dir is absent", () => {
const dir = tempDir("routstrd-install-env-");
const installDir = join(dir, "bin");
const asset = releaseArchiveName(VERSION, process.platform, process.arch);
const archive = buildArchive(dir, VERSION);
const origin = serveFakeRelease({
asset,
archive,
checksums: `${sha256Hex(archive)} ${asset}\n`,
});
const result = runInstaller(
[
"--api-base-url",
origin,
"--download-base-url",
`${origin}/dl`,
],
{ ROUTSTRD_INSTALL_DIR: installDir },
);
expect(result.exitCode).toBe(0);
const installed = join(installDir, "routstrd");
chmodSync(installed, 0o755);
expect(statSync(installed).isFile()).toBe(true);
});
});