mirror of
https://github.com/Routstr/routstrd.git
synced 2026-10-05 12:28:23 +00:00
feat(release): add install.sh for standalone binaries (#101)
* feat(release): add install.sh for standalone binaries * fix(tests): stop blocking the event loop in installer tests * fix(install): harden install.sh after review
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -26,7 +26,29 @@ 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 an existing `routstrd` once the checksum matches
|
||||
and the extracted binary reports the expected version.
|
||||
|
||||
<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 +64,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 +338,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
+284
@@ -0,0 +1,284 @@
|
||||
#!/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
|
||||
PLATFORM_DETECTED=0
|
||||
ARCH_DETECTED=0
|
||||
MAX_ARCHIVE_BYTES=262144000
|
||||
MAX_CHECKSUMS_BYTES=1048576
|
||||
CONNECT_TIMEOUT_SECS=10
|
||||
API_TIMEOUT_SECS=60
|
||||
DOWNLOAD_TIMEOUT_SECS=900
|
||||
|
||||
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)
|
||||
--install-dir <path> Alias for --dir
|
||||
--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 "$PLATFORM" ]; then
|
||||
case "$(uname -s)" in
|
||||
Linux) PLATFORM=linux ;;
|
||||
Darwin) PLATFORM=darwin ;;
|
||||
*) die "unsupported operating system '$(uname -s)'; releases cover Linux and macOS." ;;
|
||||
esac
|
||||
PLATFORM_DETECTED=1
|
||||
fi
|
||||
|
||||
if [ -z "$ARCH" ]; then
|
||||
case "$(uname -m)" in
|
||||
x86_64|amd64)
|
||||
ARCH=x64
|
||||
# Under Rosetta, uname reports x86_64 on arm64 Macs; prefer the native build.
|
||||
if [ "$PLATFORM" = darwin ] \
|
||||
&& [ "$(sysctl -n hw.optional.arm64 2>/dev/null)" = "1" ]; then
|
||||
ARCH=arm64
|
||||
fi
|
||||
;;
|
||||
arm64|aarch64) ARCH=arm64 ;;
|
||||
*) die "unsupported architecture '$(uname -m)'; releases cover x64 and arm64." ;;
|
||||
esac
|
||||
ARCH_DETECTED=1
|
||||
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
|
||||
|
||||
# Resolved lazily by require_http_client, so --print-asset does not need either.
|
||||
HTTP_CLIENT=""
|
||||
require_http_client() {
|
||||
if [ -n "$HTTP_CLIENT" ]; then
|
||||
return 0
|
||||
fi
|
||||
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() {
|
||||
require_http_client
|
||||
if [ "$HTTP_CLIENT" = curl ]; then
|
||||
curl -fsSL --connect-timeout "$CONNECT_TIMEOUT_SECS" \
|
||||
--max-time "$API_TIMEOUT_SECS" --retry 3 \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
${GITHUB_TOKEN:+-H "Authorization: Bearer ${GITHUB_TOKEN}"} "$1"
|
||||
else
|
||||
wget -qO- --timeout="$API_TIMEOUT_SECS" --tries=3 \
|
||||
--header="Accept: application/vnd.github+json" \
|
||||
${GITHUB_TOKEN:+--header="Authorization: Bearer ${GITHUB_TOKEN}"} "$1"
|
||||
fi
|
||||
}
|
||||
|
||||
fetch_file() {
|
||||
require_http_client
|
||||
if [ "$HTTP_CLIENT" = curl ]; then
|
||||
curl -fsSL --connect-timeout "$CONNECT_TIMEOUT_SECS" \
|
||||
--max-time "$DOWNLOAD_TIMEOUT_SECS" --retry 3 \
|
||||
-o "$2" "$1"
|
||||
else
|
||||
wget -qO "$2" --timeout="$DOWNLOAD_TIMEOUT_SECS" --tries=3 "$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 [ -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 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 '[:upper:]' '[:lower:]'
|
||||
else
|
||||
# shellcheck disable=SC2086
|
||||
$SHA256_CMD "$1" | awk '{print $1}' | tr '[:upper:]' '[:lower:]'
|
||||
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
|
||||
trap 'say "Interrupted."; exit 1' 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)."
|
||||
|
||||
checksums_bytes="$(wc -c < "$CHECKSUMS" | tr -d '[:space:]')"
|
||||
[ "$checksums_bytes" -le "$MAX_CHECKSUMS_BYTES" ] \
|
||||
|| die "SHA256SUMS is unexpectedly large (${checksums_bytes} bytes)."
|
||||
|
||||
expected="$(grep -F "$ASSET" "$CHECKSUMS" 2>/dev/null \
|
||||
| awk -v name="$ASSET" '$2 == name || $2 == "*" name { print $1 }' \
|
||||
| head -n 1 \
|
||||
| tr '[:upper:]' '[:lower:]')"
|
||||
[ -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"
|
||||
|
||||
# Validate the staged binary before replacing the target, mirroring
|
||||
# installStandaloneRelease: refuse a release whose binary reports the wrong
|
||||
# version rather than replacing a working install and warning about it.
|
||||
# Skipped for cross-installs (--platform/--arch overrides), where the binary may
|
||||
# not be able to run on this machine at all.
|
||||
if [ "$PLATFORM_DETECTED" = 1 ] && [ "$ARCH_DETECTED" = 1 ]; then
|
||||
staged_version="$("$STAGED" --version 2>/dev/null || true)"
|
||||
if [ "$staged_version" != "$VERSION" ] && [ "$staged_version" != "v$VERSION" ]; then
|
||||
die "staged routstrd reported version '${staged_version:-unknown}' instead of '${VERSION}'."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Rename over the target so a running daemon keeps its old inode.
|
||||
mv -f "$STAGED" "$TARGET" || die "could not install to ${TARGET}."
|
||||
STAGED=""
|
||||
|
||||
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
|
||||
@@ -0,0 +1,460 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { createHash } from "crypto";
|
||||
import { mkdirSync, mkdtempSync, readdirSync, 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;
|
||||
/** Delays the archive response so a test can signal while the download is in flight. */
|
||||
assetDelayMs?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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,
|
||||
async 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 });
|
||||
if (options.assetDelayMs) await Bun.sleep(options.assetDelayMs);
|
||||
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",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The fake release server lives in this process, so tests that download from it
|
||||
* must not block the event loop: Bun.spawnSync would deadlock on the request the
|
||||
* child makes back into this process. Always await the child instead.
|
||||
*/
|
||||
async function runInstallerAsync(args: string[], env: Record<string, string> = {}) {
|
||||
const proc = Bun.spawn(["sh", INSTALL_SCRIPT, ...args], {
|
||||
env: { ...process.env, ...env },
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
const [exitCode, stdout, stderr] = await Promise.all([
|
||||
proc.exited,
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
]);
|
||||
return { exitCode, stdout, stderr };
|
||||
}
|
||||
|
||||
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", async () => {
|
||||
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 = await runInstallerAsync([
|
||||
"--dir",
|
||||
installDir,
|
||||
"--api-base-url",
|
||||
origin,
|
||||
"--download-base-url",
|
||||
`${origin}/dl`,
|
||||
]);
|
||||
|
||||
expect(result.stderr).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", async () => {
|
||||
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 = await runInstallerAsync([
|
||||
"--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", async () => {
|
||||
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 = await runInstallerAsync([
|
||||
"--dir",
|
||||
installDir,
|
||||
"--api-base-url",
|
||||
origin,
|
||||
"--download-base-url",
|
||||
`${origin}/dl`,
|
||||
]);
|
||||
|
||||
expect(result.exitCode).not.toBe(0);
|
||||
expect(result.stderr).toContain("checksum mismatch");
|
||||
expect(() => statSync(join(installDir, "routstrd"))).toThrow();
|
||||
});
|
||||
|
||||
test("reports a missing asset for the current platform", async () => {
|
||||
const dir = tempDir("routstrd-install-missing-");
|
||||
const origin = serveFakeRelease({
|
||||
asset: releaseArchiveName(VERSION, process.platform, process.arch),
|
||||
omitAsset: true,
|
||||
});
|
||||
|
||||
const result = await runInstallerAsync([
|
||||
"--dir",
|
||||
join(dir, "bin"),
|
||||
"--api-base-url",
|
||||
origin,
|
||||
"--download-base-url",
|
||||
`${origin}/dl`,
|
||||
]);
|
||||
|
||||
expect(result.exitCode).not.toBe(0);
|
||||
expect(result.stderr).toContain("may not include a build");
|
||||
});
|
||||
|
||||
test("reports a release without SHA256SUMS", async () => {
|
||||
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 = await runInstallerAsync([
|
||||
"--dir",
|
||||
join(dir, "bin"),
|
||||
"--api-base-url",
|
||||
origin,
|
||||
"--download-base-url",
|
||||
`${origin}/dl`,
|
||||
]);
|
||||
|
||||
expect(result.exitCode).not.toBe(0);
|
||||
expect(result.stderr).toContain("does not contain");
|
||||
});
|
||||
|
||||
test("honours ROUTSTRD_INSTALL_DIR when --dir is absent", async () => {
|
||||
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 = await runInstallerAsync(
|
||||
[
|
||||
"--api-base-url",
|
||||
origin,
|
||||
"--download-base-url",
|
||||
`${origin}/dl`,
|
||||
],
|
||||
{ ROUTSTRD_INSTALL_DIR: installDir },
|
||||
);
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
const installed = join(installDir, "routstrd");
|
||||
expect(statSync(installed).isFile()).toBe(true);
|
||||
expect(statSync(installed).mode & 0o111).not.toBe(0);
|
||||
});
|
||||
|
||||
test("prints the asset without curl, wget or HOME", () => {
|
||||
// --print-asset performs no install and, with --version, no network access,
|
||||
// so it must not require an HTTP client or an install directory. /bin/sh is
|
||||
// addressed absolutely because PATH is deliberately stripped.
|
||||
const result = Bun.spawnSync(
|
||||
[
|
||||
"/bin/sh",
|
||||
INSTALL_SCRIPT,
|
||||
"--print-asset",
|
||||
"--version",
|
||||
VERSION,
|
||||
"--platform",
|
||||
"linux",
|
||||
"--arch",
|
||||
"x64",
|
||||
],
|
||||
{ env: { PATH: "/nonexistent" }, stdout: "pipe", stderr: "pipe" },
|
||||
);
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout.toString().trim()).toBe(releaseArchiveName(VERSION, "linux", "x64"));
|
||||
});
|
||||
|
||||
test("refuses a release whose binary reports the wrong version", async () => {
|
||||
const dir = tempDir("routstrd-install-wrong-version-");
|
||||
const installDir = join(dir, "bin");
|
||||
mkdirSync(installDir, { recursive: true });
|
||||
writeFileSync(join(installDir, "routstrd"), "#!/bin/sh\necho old\n");
|
||||
const asset = releaseArchiveName(VERSION, process.platform, process.arch);
|
||||
const archive = buildArchive(dir, "0.0.1");
|
||||
const origin = serveFakeRelease({
|
||||
asset,
|
||||
archive,
|
||||
checksums: `${sha256Hex(archive)} ${asset}\n`,
|
||||
});
|
||||
|
||||
const result = await runInstallerAsync([
|
||||
"--dir",
|
||||
installDir,
|
||||
"--api-base-url",
|
||||
origin,
|
||||
"--download-base-url",
|
||||
`${origin}/dl`,
|
||||
]);
|
||||
|
||||
expect(result.exitCode).not.toBe(0);
|
||||
expect(result.stderr).toContain("reported version");
|
||||
// The previous install must survive a rejected update.
|
||||
expect(readFileSync(join(installDir, "routstrd"), "utf8")).toContain("old");
|
||||
});
|
||||
|
||||
test("accepts a staged binary that reports a v-prefixed version", async () => {
|
||||
const dir = tempDir("routstrd-install-vprefixed-binary-");
|
||||
const installDir = join(dir, "bin");
|
||||
const asset = releaseArchiveName(VERSION, process.platform, process.arch);
|
||||
const archive = buildArchive(dir, `v${VERSION}`);
|
||||
const origin = serveFakeRelease({
|
||||
asset,
|
||||
archive,
|
||||
checksums: `${sha256Hex(archive)} ${asset}\n`,
|
||||
});
|
||||
|
||||
const result = await runInstallerAsync([
|
||||
"--dir",
|
||||
installDir,
|
||||
"--api-base-url",
|
||||
origin,
|
||||
"--download-base-url",
|
||||
`${origin}/dl`,
|
||||
]);
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(statSync(join(installDir, "routstrd")).isFile()).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects an oversized SHA256SUMS", async () => {
|
||||
const dir = tempDir("routstrd-install-big-sums-");
|
||||
const asset = releaseArchiveName(VERSION, process.platform, process.arch);
|
||||
const archive = buildArchive(dir, VERSION);
|
||||
const origin = serveFakeRelease({
|
||||
asset,
|
||||
archive,
|
||||
checksums: `${sha256Hex(archive)} ${asset}\n${"a".repeat(2 * 1024 * 1024)}\n`,
|
||||
});
|
||||
|
||||
const result = await runInstallerAsync([
|
||||
"--dir",
|
||||
join(dir, "bin"),
|
||||
"--api-base-url",
|
||||
origin,
|
||||
"--download-base-url",
|
||||
`${origin}/dl`,
|
||||
]);
|
||||
|
||||
expect(result.exitCode).not.toBe(0);
|
||||
expect(result.stderr).toContain("SHA256SUMS is unexpectedly large");
|
||||
});
|
||||
|
||||
test(
|
||||
"exits without installing when interrupted mid-download",
|
||||
async () => {
|
||||
const dir = tempDir("routstrd-install-interrupt-");
|
||||
const installDir = join(dir, "bin");
|
||||
const tmpParent = join(dir, "tmp");
|
||||
mkdirSync(tmpParent, { recursive: true });
|
||||
const asset = releaseArchiveName(VERSION, process.platform, process.arch);
|
||||
const archive = buildArchive(dir, VERSION);
|
||||
const origin = serveFakeRelease({
|
||||
asset,
|
||||
archive,
|
||||
checksums: `${sha256Hex(archive)} ${asset}\n`,
|
||||
assetDelayMs: 2000,
|
||||
});
|
||||
|
||||
const proc = Bun.spawn(
|
||||
[
|
||||
"sh",
|
||||
INSTALL_SCRIPT,
|
||||
"--dir",
|
||||
installDir,
|
||||
"--api-base-url",
|
||||
origin,
|
||||
"--download-base-url",
|
||||
`${origin}/dl`,
|
||||
],
|
||||
{ env: { ...process.env, TMPDIR: tmpParent }, stdout: "pipe", stderr: "pipe" },
|
||||
);
|
||||
|
||||
// Signal while the archive download is still in flight. The installer must
|
||||
// report the interrupt, exit non-zero and clean up after itself, rather
|
||||
// than resuming into a misleading follow-on failure (or an install).
|
||||
await Bun.sleep(300);
|
||||
proc.kill();
|
||||
|
||||
const [exitCode, stderr] = await Promise.all([
|
||||
Promise.race([proc.exited, Bun.sleep(8000).then(() => -1)]),
|
||||
new Response(proc.stderr).text(),
|
||||
]);
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stderr).toContain("Interrupted.");
|
||||
expect(readdirSync(tmpParent)).toEqual([]);
|
||||
expect(() => statSync(join(installDir, "routstrd"))).toThrow();
|
||||
},
|
||||
15000,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user