diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml new file mode 100644 index 0000000..640706a --- /dev/null +++ b/.github/workflows/nightly.yml @@ -0,0 +1,231 @@ +name: Nightly + +on: + schedule: + - cron: '17 2 * * *' + workflow_dispatch: + +permissions: + contents: read + +# A manual run queued behind the scheduled run must still execute: it is the +# recovery path when a runner or GitHub Releases had a transient failure. +concurrency: + group: nightly + cancel-in-progress: false + +env: + CARGO_TERM_COLOR: always + +jobs: + source: + name: Resolve develop + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + sha: ${{ steps.commit.outputs.sha }} + steps: + # workflow_dispatch has a branch selector, but a Nightly run always means + # develop. Resolve it once so every target builds the identical commit + # even if develop moves while the matrix is starting. + - uses: actions/checkout@v4 + with: + ref: develop + persist-credentials: false + + - name: Record source commit + id: commit + shell: bash + run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + + binaries: + name: ${{ matrix.target }} + needs: source + runs-on: ${{ matrix.os }} + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + include: + - target: x86_64-unknown-linux-musl + os: ubuntu-latest + - target: aarch64-unknown-linux-musl + os: ubuntu-24.04-arm + - target: x86_64-apple-darwin + os: macos-latest + - target: aarch64-apple-darwin + os: macos-latest + - target: x86_64-pc-windows-msvc + os: windows-latest + - target: aarch64-pc-windows-msvc + os: windows-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.source.outputs.sha }} + persist-credentials: false + + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - uses: Swatinem/rust-cache@v2 + with: + key: nightly-${{ matrix.target }} + + - name: Install musl toolchain + if: endsWith(matrix.target, '-musl') + run: sudo apt-get update && sudo apt-get install -y musl-tools + + # Dry-run produces the same archives and checksums as the stable release + # workflow without publishing them one matrix job at a time. The publish + # job exposes the complete, verified set together. + - name: Build archive and checksum + id: package + uses: taiki-e/upload-rust-binary-action@v1 + with: + bin: soulseek-rs + target: ${{ matrix.target }} + build-tool: cargo + locked: true + archive: soulseek-rs-nightly-$target + include: README.md,LICENSE + checksum: sha256 + ref: refs/tags/nightly + dry-run: true + dry-run-intended: true + + - name: Store packaged binary + uses: actions/upload-artifact@v4 + with: + name: nightly-${{ matrix.target }} + path: | + ${{ steps.package.outputs.tar || steps.package.outputs.zip }} + ${{ steps.package.outputs.sha256 }} + if-no-files-found: error + retention-days: 1 + + publish: + name: Publish prerelease + needs: [source, binaries] + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + steps: + - name: Collect packaged binaries + uses: actions/download-artifact@v4 + with: + pattern: nightly-* + path: dist + merge-multiple: true + + - name: Verify the complete asset set + shell: bash + run: | + set -euo pipefail + expected=( + soulseek-rs-nightly-x86_64-unknown-linux-musl.tar.gz + soulseek-rs-nightly-aarch64-unknown-linux-musl.tar.gz + soulseek-rs-nightly-x86_64-apple-darwin.tar.gz + soulseek-rs-nightly-aarch64-apple-darwin.tar.gz + soulseek-rs-nightly-x86_64-pc-windows-msvc.zip + soulseek-rs-nightly-aarch64-pc-windows-msvc.zip + soulseek-rs-nightly-x86_64-unknown-linux-musl.sha256 + soulseek-rs-nightly-aarch64-unknown-linux-musl.sha256 + soulseek-rs-nightly-x86_64-apple-darwin.sha256 + soulseek-rs-nightly-aarch64-apple-darwin.sha256 + soulseek-rs-nightly-x86_64-pc-windows-msvc.sha256 + soulseek-rs-nightly-aarch64-pc-windows-msvc.sha256 + ) + printf '%s\n' "${expected[@]}" | sort > "$RUNNER_TEMP/expected-assets" + find dist -maxdepth 1 -type f -exec basename {} \; | sort > "$RUNNER_TEMP/actual-assets" + diff -u "$RUNNER_TEMP/expected-assets" "$RUNNER_TEMP/actual-assets" + (cd dist && sha256sum --check ./*.sha256) + + - name: Publish the nightly release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + SOURCE_SHA: ${{ needs.source.outputs.sha }} + shell: bash + run: | + set -euo pipefail + notes="$RUNNER_TEMP/nightly-notes.md" + short_sha=${SOURCE_SHA:0:12} + { + printf 'Automated development build from the develop branch.\n\n' + printf 'Source: [%s](https://github.com/%s/commit/%s)\n\n' "$short_sha" "$GITHUB_REPOSITORY" "$SOURCE_SHA" + printf 'This prerelease and its nightly tag are replaced by each successful scheduled or manual run. It may be less stable than a numbered release.\n' + } > "$notes" + + release_exists=false + if gh release view nightly >/dev/null 2>&1; then + release_exists=true + # Hide the old set while assets and the mutable tag are replaced; + # installers either see a complete release or fail before changing + # their existing binary. + gh release edit nightly --draft --prerelease --latest=false + fi + + current_tag=$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/nightly" --jq .object.sha 2>/dev/null || true) + if [ -n "$current_tag" ]; then + gh api --silent --method PATCH "repos/$GITHUB_REPOSITORY/git/refs/tags/nightly" \ + -f sha="$SOURCE_SHA" -F force=true + else + gh api --silent --method POST "repos/$GITHUB_REPOSITORY/git/refs" \ + -f ref=refs/tags/nightly -f sha="$SOURCE_SHA" + fi + + if [ "$release_exists" = false ]; then + gh release create nightly --verify-tag --draft --prerelease --latest=false \ + --title 'Nightly (develop)' --notes-file "$notes" + else + gh release edit nightly --draft --prerelease --latest=false \ + --target "$SOURCE_SHA" --title 'Nightly (develop)' --notes-file "$notes" + fi + + gh release view nightly --json assets --jq '.assets[].name' > "$RUNNER_TEMP/old-assets" + while IFS= read -r asset; do + [ -z "$asset" ] || gh release delete-asset nightly "$asset" --yes + done < "$RUNNER_TEMP/old-assets" + gh release upload nightly dist/* + + gh release view nightly --json assets --jq '.assets[].name' | sort > "$RUNNER_TEMP/published-assets" + diff -u "$RUNNER_TEMP/expected-assets" "$RUNNER_TEMP/published-assets" + empty_assets=$(gh release view nightly --json assets --jq '[.assets[] | select(.size == 0)] | length') + [ "$empty_assets" -eq 0 ] + + gh release edit nightly --draft=false --prerelease --latest=false \ + --target "$SOURCE_SHA" --title 'Nightly (develop)' --notes-file "$notes" + + smoke-test: + name: Install published nightly + needs: publish + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + with: + ref: nightly + persist-credentials: false + + - name: Install with curl + env: + SOULSEEK_RS_INSTALL_DIR: ${{ runner.temp }}/curl-install + run: | + sh website/public/install.sh --nightly + "$SOULSEEK_RS_INSTALL_DIR/soulseek-rs" --version + + - name: Install with wget and no curl on PATH + env: + SOULSEEK_RS_INSTALL_DIR: ${{ runner.temp }}/wget-install + shell: bash + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/wget-path" + for tool in basename cat chmod cp gzip mkdir mktemp mv rm sha256sum tar uname wget; do + ln -s "$(command -v "$tool")" "$RUNNER_TEMP/wget-path/$tool" + done + PATH="$RUNNER_TEMP/wget-path" /bin/sh website/public/install.sh --nightly + "$SOULSEEK_RS_INSTALL_DIR/soulseek-rs" --version diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index d18518b..b583dab 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -30,6 +30,9 @@ jobs: - name: Install run: bun install --frozen-lockfile + - name: Test installer + run: bun run test:install + - name: Typecheck run: bun run typecheck diff --git a/README.md b/README.md index c8d031d..82893fa 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,27 @@ project is identifiable on the network. ## Installation -Homebrew ships a prebuilt binary, so no Rust toolchain: +The installer picks the right macOS or Linux binary, verifies its sha256, and +installs the latest stable release (through Homebrew when it is available): + +```bash +curl -fsSL https://re-invention.nl/soulseek-rs/install.sh | sh +``` + +To install the current successful build from `develop` instead, pass the +installer's `--nightly` option through to `sh`: + +```bash +curl -fsSL https://re-invention.nl/soulseek-rs/install.sh | sh -s -- --nightly +``` + +Nightly is a mutable prerelease rebuilt every day and whenever the **Nightly** +workflow is run by hand. It bypasses Homebrew, and may be less stable than a +numbered release. A failed download, checksum, or startup check leaves an +existing binary untouched. Windows archives for both channels are on the +[releases page](https://github.com/michel/soulseek-rs/releases). + +The other stable routes need no installer script: ```bash brew install michel/tap/soulseek-rs # macOS and Linux diff --git a/RELEASE.md b/RELEASE.md index b7a5593..e4839e5 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -33,6 +33,25 @@ Releases are automated by [release-plz](https://release-plz.dev). You do not run Both crates always share one version, one tag, and one changelog — see `release-plz.toml`. +## Nightly builds + +`.github/workflows/nightly.yml` builds the tip of `develop` at 02:17 UTC every +day. It resolves the commit once, builds the same six targets as a numbered +release, verifies all twelve archives and checksum files as one set, then +replaces the mutable `nightly` prerelease and tag. The published release is +smoke-tested through both the curl and wget installer paths. + +Use **Actions → Nightly → Run workflow** to force a build. The workflow always +checks out `develop`, regardless of the ref selected in the dispatch form. The +equivalent command is: + +```bash +gh workflow run nightly.yml --ref develop +``` + +Nightly does not publish crates or update Homebrew. If a target fails, the +previous nightly release stays available; use a manual run to retry. + ## One-time repository setup - **Settings → Actions → General → Workflow permissions**: enable *Allow GitHub Actions to diff --git a/website/package.json b/website/package.json index 7bd0f89..2030bb0 100644 --- a/website/package.json +++ b/website/package.json @@ -9,6 +9,7 @@ "build:ssr": "vite build --ssr src/entry-server.tsx --outDir dist-ssr", "prerender": "bun scripts/prerender.ts", "preview": "vite preview", + "test:install": "sh scripts/test-install.sh", "typecheck": "tsc -b", "lint": "eslint .", "lint:fix": "eslint . --fix" diff --git a/website/public/install.sh b/website/public/install.sh index 1a5561a..15326f0 100644 --- a/website/public/install.sh +++ b/website/public/install.sh @@ -1,11 +1,15 @@ #!/bin/sh -# Installs the latest soulseek-rs release. +# Installs soulseek-rs from GitHub releases. # +# Latest stable release: # curl -fsSL https://re-invention.nl/soulseek-rs/install.sh | sh # -# Uses Homebrew when it is on the PATH (macOS and Linux), otherwise downloads -# the prebuilt binary for this platform from GitHub releases, verifies its -# sha256, and puts it in /usr/local/bin or ~/.local/bin. +# Latest successful build from develop: +# curl -fsSL https://re-invention.nl/soulseek-rs/install.sh | sh -s -- --nightly +# +# Stable installs use Homebrew when it is on PATH (macOS and Linux). Nightly +# installs always download the prebuilt binary for this platform. Direct +# downloads are sha256-verified and replace an existing binary atomically. # # SOULSEEK_RS_INSTALL_DIR overrides where the binary lands. set -eu @@ -17,23 +21,66 @@ die() { printf 'install.sh: %s\n' "$*" >&2 exit 1 } +usage() { + cat <<'EOF' +Usage: install.sh [--nightly] -case "$(uname -s)" in +Install the latest stable soulseek-rs release. Options: + --nightly Install the latest successful build from the develop branch. + -h, --help Show this help. + +When piping the script, pass the option after `sh -s --`: + curl -fsSL https://re-invention.nl/soulseek-rs/install.sh | sh -s -- --nightly + +SOULSEEK_RS_INSTALL_DIR overrides the destination directory. +EOF +} + +channel="stable" +while [ "$#" -gt 0 ]; do + case "$1" in + --nightly) channel="nightly" ;; + -h | --help) + usage + exit 0 + ;; + --) + shift + [ "$#" -eq 0 ] || die "unexpected argument '$1' (try --help)" + break + ;; + *) die "unknown option '$1' (try --help)" ;; + esac + shift +done + +if [ "$channel" = "nightly" ]; then + tag="nightly" + release_page="https://github.com/$REPO/releases/tag/nightly" +else + tag="" + release_page="https://github.com/$REPO/releases/latest" +fi + +system=$(uname -s) +case "$system" in Darwin) os="apple-darwin" ;; Linux) os="unknown-linux-musl" ;; MINGW* | MSYS* | CYGWIN* | Windows_NT) - die "no install script for Windows: take the pc-windows-msvc zip from https://github.com/$REPO/releases/latest, or run 'cargo install soulseek-rs'" ;; - *) die "unsupported OS '$(uname -s)': see https://github.com/$REPO/releases/latest" ;; + die "no install script for Windows: take the pc-windows-msvc zip from $release_page, or run 'cargo install soulseek-rs'" ;; + *) die "unsupported OS '$system': see $release_page" ;; esac -case "$(uname -m)" in +machine=$(uname -m) +case "$machine" in x86_64 | amd64) arch="x86_64" ;; arm64 | aarch64) arch="aarch64" ;; - *) die "unsupported architecture '$(uname -m)': releases cover x86_64 and aarch64" ;; + *) die "unsupported architecture '$machine': builds cover x86_64 and aarch64" ;; esac target="$arch-$os" -if command -v brew >/dev/null 2>&1; then +if [ "$channel" = "stable" ] && [ -z "${SOULSEEK_RS_INSTALL_DIR:-}" ] && + command -v brew >/dev/null 2>&1; then say "Homebrew found: installing michel/tap/soulseek-rs (brew also handles upgrades)" exec brew install michel/tap/soulseek-rs fi @@ -46,47 +93,84 @@ else die "need curl or wget" fi -tag=$(fetch "https://api.github.com/repos/$REPO/releases/latest" | - sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p' | head -n1) -[ -n "$tag" ] || die "could not resolve the latest release; downloads are at https://github.com/$REPO/releases/latest" +command -v tar >/dev/null 2>&1 || die "need tar to unpack the download" +if command -v sha256sum >/dev/null 2>&1; then + sha256() { sha256sum "$1"; } +elif command -v shasum >/dev/null 2>&1; then + sha256() { shasum -a 256 "$1"; } +else + die "need sha256sum or shasum to verify the download" +fi + +if [ "$channel" = "stable" ]; then + tag=$(fetch "https://api.github.com/repos/$REPO/releases/latest" | + sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p' | head -n1) + [ -n "$tag" ] || die "could not resolve the latest release; downloads are at $release_page" + case "$tag" in + v*.*.*) ;; + *) die "latest release returned an unexpected tag '$tag'" ;; + esac + case "$tag" in + *[!0-9A-Za-z._+-]*) die "latest release returned an unsafe tag '$tag'" ;; + esac +fi archive="soulseek-rs-$tag-$target.tar.gz" base_url="https://github.com/$REPO/releases/download/$tag" -tmp=$(mktemp -d) -trap 'rm -rf "$tmp"' EXIT +tmp="" +staged="" +cleanup() { + [ -z "$staged" ] || rm -f "$staged" >/dev/null 2>&1 || : + [ -z "$tmp" ] || rm -rf "$tmp" >/dev/null 2>&1 || : +} +trap cleanup EXIT HUP INT TERM +tmp=$(mktemp -d) || die "could not create a temporary directory" say "Downloading $archive" -fetch "$base_url/$archive" >"$tmp/$archive" -fetch "$base_url/soulseek-rs-$tag-$target.sha256" >"$tmp/expected.sha256" +fetch "$base_url/$archive" >"$tmp/$archive" || die "could not download $archive from $release_page" +fetch "$base_url/soulseek-rs-$tag-$target.sha256" >"$tmp/expected.sha256" || + die "could not download the sha256 checksum from $release_page" -if command -v sha256sum >/dev/null 2>&1; then - got=$(sha256sum "$tmp/$archive") -else - got=$(shasum -a 256 "$tmp/$archive") -fi +got=$(sha256 "$tmp/$archive") || die "could not calculate the sha256 for $archive" got=${got%% *} -want=$(cut -d' ' -f1 <"$tmp/expected.sha256") +want="" +IFS=' ' read -r want _ <"$tmp/expected.sha256" || : +[ -n "$want" ] || die "the sha256 checksum from $release_page was empty" [ "$got" = "$want" ] || die "sha256 mismatch for $archive: expected $want, got $got" -tar -xzf "$tmp/$archive" -C "$tmp" +tar -xzf "$tmp/$archive" -C "$tmp" || die "could not unpack $archive" [ -f "$tmp/soulseek-rs" ] || die "archive $archive did not contain a soulseek-rs binary" dir="${SOULSEEK_RS_INSTALL_DIR:-}" if [ -z "$dir" ]; then dir="/usr/local/bin" - { [ -d "$dir" ] && [ -w "$dir" ]; } || dir="$HOME/.local/bin" + if ! { [ -d "$dir" ] && [ -w "$dir" ]; }; then + [ -n "${HOME:-}" ] || + die "HOME is not set and /usr/local/bin is not writable; set SOULSEEK_RS_INSTALL_DIR" + dir="$HOME/.local/bin" + fi fi -mkdir -p "$dir" +mkdir -p "$dir" || die "could not create install directory '$dir'" +[ -d "$dir" ] || die "install destination '$dir' is not a directory" +[ ! -d "$dir/soulseek-rs" ] || die "install destination '$dir/soulseek-rs' is a directory" -# mv over a copy, not cp over the target: replacing a running binary in place -# fails with "text file busy" on Linux. -cp "$tmp/soulseek-rs" "$dir/.soulseek-rs.new" -chmod 755 "$dir/.soulseek-rs.new" -mv -f "$dir/.soulseek-rs.new" "$dir/soulseek-rs" +staged=$(mktemp "$dir/.soulseek-rs.XXXXXX") || + die "could not create a temporary file in '$dir'" +cp "$tmp/soulseek-rs" "$staged" || die "could not stage the downloaded binary in '$dir'" +chmod 755 "$staged" || die "could not make the downloaded binary executable" +version=$("$staged" --version) || + die "the downloaded binary could not run on this machine; the existing install was not changed" +case "$version" in + soulseek-rs*) ;; + *) die "the downloaded binary returned unexpected version output '$version'; the existing install was not changed" ;; +esac -say "Installed $("$dir/soulseek-rs" --version) to $dir/soulseek-rs" -case ":$PATH:" in +mv -f "$staged" "$dir/soulseek-rs" || die "could not replace '$dir/soulseek-rs'" +staged="" + +say "Installed $version ($channel) to $dir/soulseek-rs" +case ":${PATH:-}:" in *:"$dir":*) ;; *) say "note: $dir is not on your PATH; add it with: export PATH=\"$dir:\$PATH\"" ;; esac diff --git a/website/scripts/test-install.sh b/website/scripts/test-install.sh index 91cae23..4465f20 100644 --- a/website/scripts/test-install.sh +++ b/website/scripts/test-install.sh @@ -1,17 +1,17 @@ #!/bin/sh -# Exercises public/install.sh: target detection, the brew fast path, checksum -# verification, and a real install into a throwaway prefix. Network tests hit -# GitHub; run from a machine that can reach it. +# Hermetic coverage for public/install.sh. Set INSTALL_TEST_NETWORK=stable to +# add a live stable-release install, or =all after a nightly release exists to +# exercise both public channels. set -eu here=$(cd "$(dirname "$0")" && pwd) script="$here/../public/install.sh" tmp=$(mktemp -d) -trap 'rm -rf "$tmp"' EXIT +trap 'rm -rf "$tmp"' EXIT HUP INT TERM failures=0 report() { - if [ "$1" = 0 ]; then + if [ "$1" -eq 0 ]; then printf 'ok %s\n' "$2" else printf 'FAIL %s\n' "$2" @@ -20,33 +20,133 @@ report() { } contains() { case "$1" in *"$2"*) true ;; *) false ;; esac; } -# Stub uname reads STUB_OS / STUB_ARCH; stub curl logs every URL it is asked -# for, answers the release-API call with a canned tag, and refuses the rest so -# a detection test stops before downloading anything. +if command -v sha256sum >/dev/null 2>&1; then + fixture_sha256() { sha256sum "$1"; } +else + fixture_sha256() { shasum -a 256 "$1"; } +fi + +mkdir -p "$tmp/fixtures/good" "$tmp/fixtures/missing" "$tmp/fixtures/bad-version" \ + "$tmp/fixtures/cannot-run" +printf '#!/bin/sh\nprintf "soulseek-rs 99.0.0-test\\n"\n' >"$tmp/fixtures/good/soulseek-rs" +printf 'no binary here\n' >"$tmp/fixtures/missing/README.md" +printf '#!/bin/sh\nprintf "not-the-client 99.0.0-test\\n"\n' >"$tmp/fixtures/bad-version/soulseek-rs" +printf '#!/bin/sh\nexit 7\n' >"$tmp/fixtures/cannot-run/soulseek-rs" +chmod +x "$tmp/fixtures/good/soulseek-rs" "$tmp/fixtures/bad-version/soulseek-rs" \ + "$tmp/fixtures/cannot-run/soulseek-rs" +tar -czf "$tmp/fixtures/good.tar.gz" -C "$tmp/fixtures/good" soulseek-rs +tar -czf "$tmp/fixtures/missing.tar.gz" -C "$tmp/fixtures/missing" README.md +tar -czf "$tmp/fixtures/bad-version.tar.gz" -C "$tmp/fixtures/bad-version" soulseek-rs +tar -czf "$tmp/fixtures/cannot-run.tar.gz" -C "$tmp/fixtures/cannot-run" soulseek-rs +printf 'this is not a tar archive\n' >"$tmp/fixtures/corrupt.tar.gz" +fixture_sum=$(fixture_sha256 "$tmp/fixtures/good.tar.gz") +fixture_sum=${fixture_sum%% *} +printf '%s fixture.tar.gz\n' "$fixture_sum" >"$tmp/fixtures/good.sha256" +missing_sum=$(fixture_sha256 "$tmp/fixtures/missing.tar.gz") +missing_sum=${missing_sum%% *} +printf '%s fixture.tar.gz\n' "$missing_sum" >"$tmp/fixtures/missing.sha256" +bad_version_sum=$(fixture_sha256 "$tmp/fixtures/bad-version.tar.gz") +bad_version_sum=${bad_version_sum%% *} +printf '%s fixture.tar.gz\n' "$bad_version_sum" >"$tmp/fixtures/bad-version.sha256" +cannot_run_sum=$(fixture_sha256 "$tmp/fixtures/cannot-run.tar.gz") +cannot_run_sum=${cannot_run_sum%% *} +printf '%s fixture.tar.gz\n' "$cannot_run_sum" >"$tmp/fixtures/cannot-run.sha256" +corrupt_sum=$(fixture_sha256 "$tmp/fixtures/corrupt.tar.gz") +corrupt_sum=${corrupt_sum%% *} +printf '%s fixture.tar.gz\n' "$corrupt_sum" >"$tmp/fixtures/corrupt.sha256" + +# Every fetch is local. The same stub accepts curl's and wget's argument forms, +# records the final URL, and can inject each failure mode the installer handles. mkdir -p "$tmp/stub" cat >"$tmp/stub/uname" <<'EOF' #!/bin/sh +[ -z "${UNAME_LOG:-}" ] || printf '%s\n' "${1:-system}" >>"$UNAME_LOG" case "${1:-}" in - -m) echo "$STUB_ARCH" ;; - *) echo "$STUB_OS" ;; + -m) printf '%s\n' "$STUB_ARCH" ;; + *) printf '%s\n' "$STUB_OS" ;; esac EOF -cat >"$tmp/stub/curl" <"$tmp/stub/fetch" <<'EOF' #!/bin/sh -for a; do url=\$a; done -echo "\$url" >>"$tmp/curl.log" -case "\$url" in - *api.github.com*) echo '{"tag_name": "v12.0.0"}' ;; +url="" +for argument do url=$argument; done +[ -z "${FETCH_LOG:-}" ] || printf '%s\n' "$url" >>"$FETCH_LOG" +case "$url" in + *api.github.com*) + [ "${API_MODE:-ok}" = "ok" ] || exit 22 + printf '{"tag_name": "%s"}\n' "${API_TAG:-v12.0.0}" + ;; + *.sha256) + case "${CHECKSUM_MODE:-good}" in + good) cat "$FIXTURE_DIR/${ARCHIVE_MODE:-good}.sha256" ;; + bad) printf '%064d fixture.tar.gz\n' 0 ;; + empty) : ;; + fail) exit 22 ;; + *) exit 64 ;; + esac + ;; + *.tar.gz) + case "${ARCHIVE_MODE:-good}" in + good) cat "$FIXTURE_DIR/good.tar.gz" ;; + missing) cat "$FIXTURE_DIR/missing.tar.gz" ;; + bad-version) cat "$FIXTURE_DIR/bad-version.tar.gz" ;; + cannot-run) cat "$FIXTURE_DIR/cannot-run.tar.gz" ;; + corrupt) cat "$FIXTURE_DIR/corrupt.tar.gz" ;; + fail) exit 22 ;; + *) exit 64 ;; + esac + ;; *) exit 22 ;; esac EOF -chmod +x "$tmp/stub/uname" "$tmp/stub/curl" -base_path="/usr/bin:/bin" +cat >"$tmp/brew-stub" <<'EOF' +#!/bin/sh +printf '%s\n' "$*" >"$BREW_LOG" +[ "${BREW_MODE:-ok}" = ok ] || exit 23 +EOF +chmod +x "$tmp/stub/uname" "$tmp/stub/fetch" "$tmp/brew-stub" +ln -s "$tmp/stub/fetch" "$tmp/stub/curl" + +base_path="$tmp/stub:/usr/bin:/bin" +fetch_log="$tmp/fetch.log" +uname_log="$tmp/uname.log" +brew_log="$tmp/brew.log" + +fixture_install() { + destination=$1 + shift + : >"$fetch_log" + STUB_OS="${STUB_OS:-Darwin}" STUB_ARCH="${STUB_ARCH:-arm64}" \ + FETCH_LOG="$fetch_log" FIXTURE_DIR="$tmp/fixtures" \ + SOULSEEK_RS_INSTALL_DIR="$destination" PATH="$base_path" \ + /bin/sh "$script" "$@" +} + +# Argument parsing happens before platform detection, including when piped via +# `sh -s --`. Help must therefore work even on an unsupported machine. +: >"$uname_log" +out=$(STUB_OS=Plan9 STUB_ARCH=mips UNAME_LOG="$uname_log" PATH="$base_path" \ + /bin/sh "$script" --help 2>&1) && rc=0 || rc=$? +res=1 +if [ "$rc" -eq 0 ] && contains "$out" "sh -s -- --nightly" && [ ! -s "$uname_log" ]; then res=0; fi +report "$res" "--help works without probing the machine" + +out=$(PATH="$base_path" /bin/sh "$script" --unknown 2>&1) && rc=0 || rc=$? +res=1 +if [ "$rc" -ne 0 ] && contains "$out" "unknown option '--unknown'"; then res=0; fi +report "$res" "unknown options fail with guidance" + +out=$(PATH="$base_path" /bin/sh "$script" -- nightly 2>&1) && rc=0 || rc=$? +res=1 +if [ "$rc" -ne 0 ] && contains "$out" "unexpected argument 'nightly'"; then res=0; fi +report "$res" "positional arguments are rejected" resolved_target() { - : >"$tmp/curl.log" - STUB_OS="$1" STUB_ARCH="$2" PATH="$tmp/stub:$base_path" sh "$script" >/dev/null 2>&1 || true - grep -o 'soulseek-rs-v12\.0\.0-[a-z0-9_-]*\.tar\.gz' "$tmp/curl.log" | head -n1 + : >"$fetch_log" + STUB_OS="$1" STUB_ARCH="$2" ARCHIVE_MODE=fail FETCH_LOG="$fetch_log" \ + FIXTURE_DIR="$tmp/fixtures" SOULSEEK_RS_INSTALL_DIR="$tmp/detection" \ + PATH="$base_path" /bin/sh "$script" >/dev/null 2>&1 || true + sed -n 's#^.*/\(soulseek-rs-v12\.0\.0-[a-z0-9_-]*\.tar\.gz\)$#\1#p' "$fetch_log" | head -n1 } for spec in \ @@ -54,102 +154,246 @@ for spec in \ "Darwin x86_64 x86_64-apple-darwin" \ "Linux aarch64 aarch64-unknown-linux-musl" \ "Linux amd64 x86_64-unknown-linux-musl"; do - # spec is three space-separated fields, so the splitting is the point + # The record deliberately relies on POSIX field splitting. # shellcheck disable=SC2086 set -- $spec res=1 if [ "$(resolved_target "$1" "$2")" = "soulseek-rs-v12.0.0-$3.tar.gz" ]; then res=0; fi - report $res "$1/$2 resolves to $3" + report "$res" "$1/$2 resolves to $3" done -out=$(STUB_OS="MINGW64_NT-10.0" STUB_ARCH=x86_64 PATH="$tmp/stub:$base_path" sh "$script" 2>&1) && rc=0 || rc=$? +out=$(STUB_OS=MINGW64_NT-10.0 STUB_ARCH=x86_64 PATH="$base_path" \ + /bin/sh "$script" --nightly 2>&1) && rc=0 || rc=$? res=1 -if [ "$rc" != 0 ] && contains "$out" "pc-windows-msvc"; then res=0; fi -report $res "Windows refuses and points at the msvc zip" +if [ "$rc" -ne 0 ] && contains "$out" "pc-windows-msvc" && contains "$out" "releases/tag/nightly"; then res=0; fi +report "$res" "Windows refuses and links to the selected channel" -out=$(STUB_OS=Linux STUB_ARCH=armv7l PATH="$tmp/stub:$base_path" sh "$script" 2>&1) && rc=0 || rc=$? +out=$(STUB_OS=Linux STUB_ARCH=armv7l PATH="$base_path" /bin/sh "$script" 2>&1) && rc=0 || rc=$? res=1 -if [ "$rc" != 0 ] && contains "$out" "unsupported architecture"; then res=0; fi -report $res "armv7l refuses as unsupported" +if [ "$rc" -ne 0 ] && contains "$out" "unsupported architecture 'armv7l'"; then res=0; fi +report "$res" "unsupported architectures fail before downloading" -# Brew fast path: a stub brew must be exec'd with the tap formula, and no URL -# may be fetched. -cat >"$tmp/stub/brew" <"$tmp/brew.log" -EOF -chmod +x "$tmp/stub/brew" -: >"$tmp/curl.log" -STUB_OS=Darwin STUB_ARCH=arm64 PATH="$tmp/stub:$base_path" sh "$script" >/dev/null 2>&1 +out=$(STUB_OS=Plan9 STUB_ARCH=x86_64 PATH="$base_path" /bin/sh "$script" 2>&1) && rc=0 || rc=$? res=1 -if [ "$(cat "$tmp/brew.log")" = "install michel/tap/soulseek-rs" ] && [ ! -s "$tmp/curl.log" ]; then res=0; fi -report $res "brew on PATH wins and skips the download" -rm -f "$tmp/stub/brew" +if [ "$rc" -ne 0 ] && contains "$out" "unsupported OS 'Plan9'"; then res=0; fi +report "$res" "unsupported operating systems fail before downloading" -# Offline happy path: stub curl serves a locally built archive whose sha256 -# matches, and the installed fake binary must answer --version. -mkdir -p "$tmp/payload" "$tmp/bin" -printf '#!/bin/sh\necho "soulseek-rs 99.0.0-test"\n' >"$tmp/payload/soulseek-rs" -chmod +x "$tmp/payload/soulseek-rs" -tar -czf "$tmp/payload.tar.gz" -C "$tmp/payload" soulseek-rs -sum=$(shasum -a 256 "$tmp/payload.tar.gz" | cut -d' ' -f1) -cat >"$tmp/stub/curl" <>"$tmp/curl.log" -case "\$url" in - *api.github.com*) echo '{"tag_name": "v12.0.0"}' ;; - *.tar.gz) cat "$tmp/payload.tar.gz" ;; - *.sha256) echo "$sum *payload" ;; - *) exit 22 ;; +# Stable uses Homebrew when available. Nightly must bypass it because the tap +# intentionally tracks stable releases only. +mkdir -p "$tmp/with-brew" +ln -s "$tmp/stub/uname" "$tmp/with-brew/uname" +ln -s "$tmp/brew-stub" "$tmp/with-brew/brew" +ln -s "$tmp/stub/fetch" "$tmp/with-brew/curl" +: >"$fetch_log" +BREW_LOG="$brew_log" STUB_OS=Darwin STUB_ARCH=arm64 FETCH_LOG="$fetch_log" \ + PATH="$tmp/with-brew:/usr/bin:/bin" /bin/sh "$script" >/dev/null 2>&1 +res=1 +if [ "$(cat "$brew_log")" = "install michel/tap/soulseek-rs" ] && [ ! -s "$fetch_log" ]; then res=0; fi +report "$res" "stable uses Homebrew without touching GitHub" + +out=$(BREW_MODE=fail BREW_LOG="$brew_log" STUB_OS=Darwin STUB_ARCH=arm64 \ + PATH="$tmp/with-brew:/usr/bin:/bin" /bin/sh "$script" 2>&1) && rc=0 || rc=$? +res=1 +if [ "$rc" -eq 23 ]; then res=0; fi +report "$res" "Homebrew failures propagate to the caller" + +nightly_dir="$tmp/nightly install/bin" +: >"$brew_log" +out=$(BREW_LOG="$brew_log" STUB_OS=Darwin STUB_ARCH=arm64 \ + FETCH_LOG="$fetch_log" FIXTURE_DIR="$tmp/fixtures" \ + SOULSEEK_RS_INSTALL_DIR="$nightly_dir" PATH="$tmp/with-brew:/usr/bin:/bin" \ + /bin/sh "$script" --nightly 2>&1) && rc=0 || rc=$? +res=1 +if [ "$rc" -eq 0 ] && [ -x "$nightly_dir/soulseek-rs" ] && + contains "$out" "(nightly)" && [ ! -s "$brew_log" ] && + ! grep -q 'api.github.com' "$fetch_log" && + grep -q '/nightly/soulseek-rs-nightly-aarch64-apple-darwin.tar.gz$' "$fetch_log"; then res=0; fi +report "$res" "--nightly bypasses Homebrew and the stable-release API" + +custom_stable_dir="$tmp/custom stable/bin" +: >"$brew_log" +out=$(BREW_LOG="$brew_log" STUB_OS=Darwin STUB_ARCH=arm64 \ + FETCH_LOG="$fetch_log" FIXTURE_DIR="$tmp/fixtures" \ + SOULSEEK_RS_INSTALL_DIR="$custom_stable_dir" PATH="$tmp/with-brew:/usr/bin:/bin" \ + /bin/sh "$script" 2>&1) && rc=0 || rc=$? +res=1 +if [ "$rc" -eq 0 ] && [ -x "$custom_stable_dir/soulseek-rs" ] && + [ ! -s "$brew_log" ] && grep -q 'api.github.com' "$fetch_log"; then res=0; fi +report "$res" "custom install directory overrides Homebrew" + +stable_dir="$tmp/stable/bin" +out=$(fixture_install "$stable_dir" 2>&1) && rc=0 || rc=$? +res=1 +if [ "$rc" -eq 0 ] && [ -x "$stable_dir/soulseek-rs" ] && + [ "$("$stable_dir/soulseek-rs" --version)" = "soulseek-rs 99.0.0-test" ] && + contains "$out" "(stable)" && + grep -q '/v12.0.0/soulseek-rs-v12.0.0-aarch64-apple-darwin.tar.gz$' "$fetch_log"; then res=0; fi +report "$res" "verified stable archive installs and runs" + +res=1 +if contains "$out" "$stable_dir is not on your PATH"; then res=0; fi +report "$res" "a destination off PATH gets a usable PATH hint" + +# A minimal PATH with wget but no curl proves the fallback instead of merely +# exercising a wget-shaped stub while curl still wins command discovery. +mkdir -p "$tmp/wget-path" +for tool in cat chmod cp gzip head mkdir mktemp mv rm sed shasum sha256sum tar; do + tool_path=$(command -v "$tool" 2>/dev/null || true) + [ -z "$tool_path" ] || ln -s "$tool_path" "$tmp/wget-path/$tool" +done +ln -s "$tmp/stub/uname" "$tmp/wget-path/uname" +ln -s "$tmp/stub/fetch" "$tmp/wget-path/wget" +wget_dir="$tmp/wget/bin" +: >"$fetch_log" +out=$(STUB_OS=Darwin STUB_ARCH=arm64 FETCH_LOG="$fetch_log" \ + FIXTURE_DIR="$tmp/fixtures" SOULSEEK_RS_INSTALL_DIR="$wget_dir" \ + PATH="$tmp/wget-path" /bin/sh "$script" --nightly 2>&1) && rc=0 || rc=$? +res=1 +if [ "$rc" -eq 0 ] && [ -x "$wget_dir/soulseek-rs" ]; then res=0; fi +report "$res" "wget-only installation works" + +# Failures before replacement must preserve an existing install and leave no +# fixed-name staging file behind. +preserve_dir="$tmp/preserve/bin" +mkdir -p "$preserve_dir" +printf '#!/bin/sh\nprintf "soulseek-rs old\\n"\n' >"$preserve_dir/soulseek-rs" +chmod +x "$preserve_dir/soulseek-rs" +out=$(CHECKSUM_MODE=bad fixture_install "$preserve_dir" --nightly 2>&1) && rc=0 || rc=$? +res=1 +if [ "$rc" -ne 0 ] && contains "$out" "sha256 mismatch" && + [ "$("$preserve_dir/soulseek-rs")" = "soulseek-rs old" ]; then res=0; fi +report "$res" "checksum mismatch preserves the installed binary" + +out=$(CHECKSUM_MODE=empty fixture_install "$preserve_dir" --nightly 2>&1) && rc=0 || rc=$? +res=1 +if [ "$rc" -ne 0 ] && contains "$out" "checksum" && contains "$out" "empty"; then res=0; fi +report "$res" "empty checksum is rejected" + +out=$(ARCHIVE_MODE=missing fixture_install "$preserve_dir" --nightly 2>&1) && rc=0 || rc=$? +res=1 +if [ "$rc" -ne 0 ] && contains "$out" "did not contain a soulseek-rs binary" && + [ "$("$preserve_dir/soulseek-rs")" = "soulseek-rs old" ]; then res=0; fi +report "$res" "archive without the binary preserves the installed binary" + +out=$(ARCHIVE_MODE=bad-version fixture_install "$preserve_dir" --nightly 2>&1) && rc=0 || rc=$? +res=1 +if [ "$rc" -ne 0 ] && contains "$out" "unexpected version output" && + [ "$("$preserve_dir/soulseek-rs")" = "soulseek-rs old" ]; then res=0; fi +report "$res" "wrong executable preserves the installed binary" + +out=$(ARCHIVE_MODE=cannot-run fixture_install "$preserve_dir" --nightly 2>&1) && rc=0 || rc=$? +res=1 +if [ "$rc" -ne 0 ] && contains "$out" "could not run on this machine" && + [ "$("$preserve_dir/soulseek-rs")" = "soulseek-rs old" ]; then res=0; fi +report "$res" "non-running executable preserves the installed binary" + +out=$(ARCHIVE_MODE=corrupt fixture_install "$preserve_dir" --nightly 2>&1) && rc=0 || rc=$? +res=1 +if [ "$rc" -ne 0 ] && contains "$out" "could not unpack" && + [ "$("$preserve_dir/soulseek-rs")" = "soulseek-rs old" ]; then res=0; fi +report "$res" "corrupt archive preserves the installed binary" + +out=$(ARCHIVE_MODE=fail fixture_install "$preserve_dir" --nightly 2>&1) && rc=0 || rc=$? +res=1 +if [ "$rc" -ne 0 ] && contains "$out" "could not download soulseek-rs-nightly" && + [ "$("$preserve_dir/soulseek-rs")" = "soulseek-rs old" ]; then res=0; fi +report "$res" "archive download failure preserves the installed binary" + +out=$(CHECKSUM_MODE=fail fixture_install "$preserve_dir" --nightly 2>&1) && rc=0 || rc=$? +res=1 +if [ "$rc" -ne 0 ] && contains "$out" "could not download the sha256 checksum" && + [ "$("$preserve_dir/soulseek-rs")" = "soulseek-rs old" ]; then res=0; fi +report "$res" "checksum download failure preserves the installed binary" + +directory_target="$tmp/directory-target/bin" +mkdir -p "$directory_target/soulseek-rs" +out=$(fixture_install "$directory_target" --nightly 2>&1) && rc=0 || rc=$? +res=1 +if [ "$rc" -ne 0 ] && contains "$out" "is a directory" && + [ ! -e "$directory_target/soulseek-rs/.soulseek-rs.new" ]; then res=0; fi +report "$res" "a directory at the binary path is never written into" + +out=$(API_MODE=fail fixture_install "$tmp/api-failure" 2>&1) && rc=0 || rc=$? +res=1 +if [ "$rc" -ne 0 ] && contains "$out" "could not resolve the latest release"; then res=0; fi +report "$res" "stable API failure is explained" + +: >"$fetch_log" +out=$(API_TAG='../nightly' fixture_install "$tmp/unsafe-tag" 2>&1) && rc=0 || rc=$? +res=1 +if [ "$rc" -ne 0 ] && contains "$out" "unexpected tag" && + [ "$(wc -l <"$fetch_log" | tr -d ' ')" = 1 ]; then res=0; fi +report "$res" "unexpected stable tag is rejected before asset download" + +mkdir -p "$tmp/no-fetch" +ln -s "$tmp/stub/uname" "$tmp/no-fetch/uname" +out=$(STUB_OS=Darwin STUB_ARCH=arm64 PATH="$tmp/no-fetch" /bin/sh "$script" --nightly 2>&1) && rc=0 || rc=$? +res=1 +if [ "$rc" -ne 0 ] && contains "$out" "need curl or wget"; then res=0; fi +report "$res" "missing downloader is reported" + +mkdir -p "$tmp/no-tar" +ln -s "$tmp/stub/uname" "$tmp/no-tar/uname" +ln -s "$tmp/stub/fetch" "$tmp/no-tar/curl" +out=$(STUB_OS=Darwin STUB_ARCH=arm64 PATH="$tmp/no-tar" /bin/sh "$script" --nightly 2>&1) && rc=0 || rc=$? +res=1 +if [ "$rc" -ne 0 ] && contains "$out" "need tar"; then res=0; fi +report "$res" "missing tar is reported before downloading" + +mkdir -p "$tmp/no-sha" +ln -s "$tmp/stub/uname" "$tmp/no-sha/uname" +ln -s "$tmp/stub/fetch" "$tmp/no-sha/curl" +tar_path=$(command -v tar) +ln -s "$tar_path" "$tmp/no-sha/tar" +out=$(STUB_OS=Darwin STUB_ARCH=arm64 PATH="$tmp/no-sha" /bin/sh "$script" --nightly 2>&1) && rc=0 || rc=$? +res=1 +if [ "$rc" -ne 0 ] && contains "$out" "need sha256sum or shasum"; then res=0; fi +report "$res" "missing checksum tool is reported before downloading" + +# Two callers can update the same path without sharing a predictable staging +# name. Either can win; the result must run and no staging file may remain. +concurrent_dir="$tmp/concurrent/bin" +fixture_install "$concurrent_dir" --nightly >/dev/null 2>&1 & first=$! +fixture_install "$concurrent_dir" --nightly >/dev/null 2>&1 & second=$! +first_rc=0 +second_rc=0 +wait "$first" || first_rc=$? +wait "$second" || second_rc=$? +set -- "$concurrent_dir"/.soulseek-rs.* +res=1 +if [ "$first_rc" -eq 0 ] && [ "$second_rc" -eq 0 ] && + [ "$("$concurrent_dir/soulseek-rs" --version)" = "soulseek-rs 99.0.0-test" ] && + [ ! -e "$1" ]; then res=0; fi +report "$res" "concurrent installers leave one complete binary" + +case "${INSTALL_TEST_NETWORK:-}" in + "") ;; + stable | all) + network_path="/usr/bin:/bin" + live_stable="$tmp/live-stable" + out=$(SOULSEEK_RS_INSTALL_DIR="$live_stable" PATH="$network_path" \ + /bin/sh "$script" 2>&1) && rc=0 || rc=$? + res=1 + if [ "$rc" -eq 0 ] && "$live_stable/soulseek-rs" --version >/dev/null 2>&1; then res=0; fi + report "$res" "live stable release installs and runs" + + if [ "${INSTALL_TEST_NETWORK:-}" = all ]; then + live_nightly="$tmp/live-nightly" + out=$(SOULSEEK_RS_INSTALL_DIR="$live_nightly" PATH="$network_path" \ + /bin/sh "$script" --nightly 2>&1) && rc=0 || rc=$? + res=1 + if [ "$rc" -eq 0 ] && "$live_nightly/soulseek-rs" --version >/dev/null 2>&1; then res=0; fi + report "$res" "live nightly release installs and runs" + fi + ;; + *) + printf 'FAIL INSTALL_TEST_NETWORK must be empty, stable, or all\n' + failures=$((failures + 1)) + ;; esac -EOF -out=$(STUB_OS=Darwin STUB_ARCH=arm64 SOULSEEK_RS_INSTALL_DIR="$tmp/bin" \ - PATH="$tmp/stub:$base_path" sh "$script" 2>&1) && rc=0 || rc=$? -res=1 -if [ "$rc" = 0 ] && [ -x "$tmp/bin/soulseek-rs" ] && - contains "$out" "Installed soulseek-rs 99.0.0-test"; then res=0; fi -report $res "verified archive installs and reports the version" -res=1 -if contains "$out" "not on your PATH"; then res=0; fi -report $res "install dir off the PATH gets the PATH note" -# Tampered archive: same stub but a wrong checksum must abort before install. -rm -f "$tmp/bin/soulseek-rs" -sed "s/$sum/0000000000000000000000000000000000000000000000000000000000000000/" \ - "$tmp/stub/curl" >"$tmp/stub/curl.bad" && mv "$tmp/stub/curl.bad" "$tmp/stub/curl" -chmod +x "$tmp/stub/curl" -out=$(STUB_OS=Darwin STUB_ARCH=arm64 SOULSEEK_RS_INSTALL_DIR="$tmp/bin" \ - PATH="$tmp/stub:$base_path" sh "$script" 2>&1) && rc=0 || rc=$? -res=1 -if [ "$rc" != 0 ] && [ ! -e "$tmp/bin/soulseek-rs" ] && - contains "$out" "sha256 mismatch"; then res=0; fi -report $res "sha256 mismatch aborts without installing" - -# Real network installs: once over curl, once over wget (a symlink farm of -# /usr/bin and /bin minus curl and brew forces the wget code path). -out=$(SOULSEEK_RS_INSTALL_DIR="$tmp/real-curl" PATH="$base_path" sh "$script" 2>&1) && rc=0 || rc=$? -res=1 -if [ "$rc" = 0 ] && "$tmp/real-curl/soulseek-rs" --version >/dev/null 2>&1; then res=0; fi -report $res "real release installs via curl and runs" - -if wget=$(command -v wget); then - mkdir -p "$tmp/farm" - for d in /usr/bin /bin; do - for f in "$d"/*; do ln -sf "$f" "$tmp/farm/${f##*/}"; done - done - rm -f "$tmp/farm/curl" "$tmp/farm/brew" - ln -sf "$wget" "$tmp/farm/wget" - out=$(SOULSEEK_RS_INSTALL_DIR="$tmp/real-wget" PATH="$tmp/farm" sh "$script" 2>&1) && rc=0 || rc=$? - res=1 - if [ "$rc" = 0 ] && "$tmp/real-wget/soulseek-rs" --version >/dev/null 2>&1; then res=0; fi - report $res "real release installs via wget and runs" -else - echo "skip real release installs via wget (no wget on this machine)" -fi - -[ "$failures" = 0 ] || { - echo "$failures test(s) failed" +[ "$failures" -eq 0 ] || { + printf '%s test(s) failed\n' "$failures" exit 1 } -echo "all tests passed" +printf 'all tests passed\n' diff --git a/website/src/lib/links.ts b/website/src/lib/links.ts index ca09fe0..ff75651 100644 --- a/website/src/lib/links.ts +++ b/website/src/lib/links.ts @@ -2,6 +2,7 @@ export const LINKS = { gh: 'https://github.com/michel/soulseek-rs', issues: 'https://github.com/michel/soulseek-rs/issues', releases: 'https://github.com/michel/soulseek-rs/releases', + nightlyRelease: 'https://github.com/michel/soulseek-rs/releases/tag/nightly', changelog: 'https://github.com/michel/soulseek-rs/blob/master/CHANGELOG.md', contributing: 'https://github.com/michel/soulseek-rs/blob/master/CONTRIBUTING.md', license: 'https://github.com/michel/soulseek-rs/blob/master/LICENSE', @@ -25,3 +26,5 @@ export const VERSION = __APP_VERSION__ export const SITE_URL = 'https://re-invention.nl/soulseek-rs/' export const INSTALL_CMD = `curl -fsSL ${SITE_URL}install.sh | sh` + +export const NIGHTLY_INSTALL_CMD = `${INSTALL_CMD} -s -- --nightly` diff --git a/website/src/pages/install.tsx b/website/src/pages/install.tsx index b36d7c0..e14f800 100644 --- a/website/src/pages/install.tsx +++ b/website/src/pages/install.tsx @@ -6,7 +6,14 @@ import { Cols, PageHead, Prose, Section, SectionHead } from '@/components/ui/lay import { OsIcon, type OsName } from '@/components/ui/os-icon' import { Callout } from '@/components/ui/panel' import { Terminal, type TermLine } from '@/components/ui/terminal' -import { INSTALL_CMD, LINKS, SITE_URL } from '@/lib/links' +import { INSTALL_CMD, LINKS, NIGHTLY_INSTALL_CMD, SITE_URL } from '@/lib/links' + +const INSTALL_SCRIPT_LINES: readonly TermLine[] = [ + { t: 'cm', text: '# latest stable release' }, + { t: 'cmd', text: INSTALL_CMD }, + { t: 'cm', text: '# latest successful build from develop' }, + { t: 'cmd', text: NIGHTLY_INSTALL_CMD }, +] const LIB_SRC = `use soulseek_rs::Client; use std::time::Duration; @@ -266,16 +273,23 @@ const Steps = () => (

- One command on macOS and Linux. The script installs through Homebrew when you - have it; otherwise it downloads the latest release binary for your platform, - checks its sha256, and puts it on your PATH. + One command on macOS and Linux. The script installs the latest stable release + through Homebrew when you have it, or downloads the prebuilt binary, checks its + sha256, and puts it on your PATH. Pass --nightly for the current + successful build from develop; nightly always downloads directly.

- +

No curl? {`wget -qO- ${SITE_URL}install.sh | sh`} does the same. Piping to a shell asks for trust; read the script first at{' '} {`${SITE_URL}install.sh`}.

+

+ Nightly is rebuilt every day and can be replaced by a manual build. It may be + less stable than a numbered release. The installer stages and runs the verified + download before replacing an existing copy, so a bad build leaves that copy in + place. +

Or pick a route per platform. Cargo needs a Rust toolchain from{' '} rustup.rs; the other routes don’t. @@ -316,6 +330,7 @@ const Steps = () => (

+