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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,16 @@ permissions:
contents: read

jobs:
shellcheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: ShellCheck install.sh
run: |
sudo apt-get update -qq
sudo apt-get install -y shellcheck
shellcheck scripts/install.sh

lint:
runs-on: ubuntu-latest
steps:
Expand Down Expand Up @@ -44,7 +54,7 @@ jobs:

uat:
runs-on: ubuntu-latest
needs: [lint, test]
needs: [shellcheck, lint, test]
steps:
- uses: actions/checkout@v4

Expand Down
5 changes: 5 additions & 0 deletions .goreleaser.stable.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,11 @@ release:
go install github.com/git-rain/git-rain@{{ .Tag }}
```

**curl (Linux / macOS)**
```sh
curl -fsSL https://raw.githubusercontent.com/git-fire/git-rain/{{ .Tag }}/scripts/install.sh | VERSION={{ .Tag }} bash
```
Comment thread
coderabbitai[bot] marked this conversation as resolved.

**Homebrew (macOS/Linuxbrew)**
```sh
brew install git-fire/tap/git-rain
Expand Down
5 changes: 5 additions & 0 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,11 @@ release:
go install github.com/git-rain/git-rain@{{ .Tag }}
```

**curl (Linux / macOS)**
```sh
curl -fsSL https://raw.githubusercontent.com/git-fire/git-rain/{{ .Tag }}/scripts/install.sh | VERSION={{ .Tag }} bash
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prerelease footer installs wrong version silently

Medium Severity

The prerelease .goreleaser.yaml footer pipes the install script from main into bare bash without setting VERSION, while the stable .goreleaser.stable.yaml correctly uses {{ .Tag }} in both the URL and VERSION={{ .Tag }}. Without VERSION, the installer calls GitHub's /releases/latest endpoint, which only returns non-prerelease releases. Users copying the curl snippet from a prerelease's release notes would silently install the latest stable release instead of the prerelease they intended.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 7dea654. Configure here.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bugbot Autofix determined this is a false positive.

.goreleaser.yaml already uses {{ .Tag }} for the script URL and VERSION={{ .Tag }} on the curl line, matching stable behavior.

You can send follow-ups to the cloud agent here.

```
Comment thread
coderabbitai[bot] marked this conversation as resolved.

**Binaries**
Download platform archives from this release's assets.
Package-manager channels are published for stable tags (`vX.Y.Z`).
Expand Down
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ Invocation note: `git-rain` and `git rain` are equivalent when `git-rain` is on
- [Install](#install)
- [Homebrew (macOS/Linuxbrew)](#homebrew-macoslinuxbrew)
- [WinGet (Windows)](#winget-windows)
- [curl installer (Linux / macOS)](#curl-installer-linux--macos)
- [Linux native packages (.deb / .rpm)](#linux-native-packages-deb--rpm)
- [Go install](#go-install)
- [Binary archive (manual)](#binary-archive-manual)
Expand Down Expand Up @@ -65,6 +66,7 @@ git-rain --select
|---|---|---|
| Homebrew | `brew install git-fire/tap/git-rain` | macOS, Linuxbrew |
| WinGet | `winget install git-rain.git-rain` | Windows |
| curl script | [curl installer](#curl-installer-linux--macos) | Linux, macOS |
| Linux package | Download `.deb` or `.rpm` from [GitHub Releases](https://github.com/git-fire/git-rain/releases) | Linux |
| Go | `go install github.com/git-rain/git-rain@latest` | All (Go 1.24.2+) |
| Binary archive | [GitHub Releases](https://github.com/git-fire/git-rain/releases) | All |
Expand All @@ -82,6 +84,35 @@ brew install git-rain
winget install git-rain.git-rain
```

### curl installer (Linux / macOS)

First-party install script (same idea as [`git-fire/scripts/install.sh`](https://github.com/git-fire/git-fire/blob/main/scripts/install.sh)): downloads the matching `.tar.gz` from [Releases](https://github.com/git-fire/git-rain/releases), verifies `checksums.txt`, and installs to `$INSTALL_DIR` (default `~/.local/bin`).

The `main` URL below always runs the installer script from the latest commit on that branch, while the binary itself comes from the latest GitHub release (or from `VERSION` if you set it). That is convenient for copy-paste installs, but it means the script can drift ahead of any given release. For a fully pinned install, use the release tag in the URL (as in each release’s notes) and set `VERSION` to the same tag.

For repeated automation against the GitHub API (resolving `latest`), set **`GITHUB_TOKEN`** or **`GH_TOKEN`** so authenticated rate limits apply. `VERSION` may be a bare semver (`0.9.1`); the installer tries the `v`-prefixed release tag first, then the exact string you passed.

```bash
curl -fsSL https://raw.githubusercontent.com/git-fire/git-rain/main/scripts/install.sh | bash
```

Pin a version or install directory (environment variables must apply to `bash`, not `curl`):

```bash
curl -fsSL https://raw.githubusercontent.com/git-fire/git-rain/main/scripts/install.sh | VERSION=v0.9.1 INSTALL_DIR=/usr/local/bin bash
```

If your shell does not already include `~/.local/bin` on `PATH`, add it (the installer prints a reminder). Example for bash (skips the line if `.local/bin` is already mentioned in `~/.bashrc`):

```bash
if ! grep -qF '.local/bin' ~/.bashrc 2>/dev/null; then
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
fi
source ~/.bashrc
```

Windows is not supported by this script — use **WinGet** or download a `.zip` from Releases.

### Linux native packages (`.deb` / `.rpm`)

Download from [GitHub Releases](https://github.com/git-fire/git-rain/releases), then:
Expand Down
287 changes: 287 additions & 0 deletions scripts/install.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,287 @@
#!/usr/bin/env bash
set -euo pipefail

# Install git-rain from GitHub release assets with checksum verification.
# Usage:
# curl -fsSL https://raw.githubusercontent.com/git-fire/git-rain/main/scripts/install.sh | bash
# Optional env vars:
# VERSION=v0.9.1 (must match a GitHub release tag; bare semver like 0.9.1 tries v0.9.1 first)
# INSTALL_DIR=$HOME/.local/bin
# REPO=git-fire/git-rain
# GITHUB_TOKEN or GH_TOKEN (optional; increases api.github.com rate limits for "latest" resolution)

REPO="${REPO:-git-fire/git-rain}"
INSTALL_DIR="${INSTALL_DIR:-$HOME/.local/bin}"
VERSION="${VERSION:-}"
BINARY_NAME="git-rain"

log() {
printf '[git-rain install] %s\n' "$1"
}

fail() {
printf '[git-rain install] ERROR: %s\n' "$1" >&2
exit 1
}

need_cmd() {
command -v "$1" >/dev/null 2>&1 || fail "required command not found: $1"
}

github_token() {
printf '%s' "${GITHUB_TOKEN:-${GH_TOKEN:-}}"
}

download_to() {
local src="$1"
local dst="$2"
if command -v curl >/dev/null 2>&1; then
curl -fsSL "$src" -o "$dst"
elif command -v wget >/dev/null 2>&1; then
wget -qO "$dst" "$src"
else
fail "curl or wget is required"
fi
}

fetch_github_api() {
local url="$1"
if command -v curl >/dev/null 2>&1; then
local token
token="$(github_token)"
if [ -n "$token" ]; then
curl -fsSL \
-H "Authorization: Bearer ${token}" \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"$url"
else
curl -fsSL "$url"
fi
elif command -v wget >/dev/null 2>&1; then
local token
token="$(github_token)"
if [ -n "$token" ]; then
wget -qO- \
--header="Authorization: Bearer ${token}" \
--header="Accept: application/vnd.github+json" \
--header="X-GitHub-Api-Version: 2022-11-28" \
"$url"
Comment thread
cursor[bot] marked this conversation as resolved.
else
wget -qO- "$url"
fi
else
fail "curl or wget is required"
fi
}

sha256_file() {
local target="$1"
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$target" | awk '{print $1}'
elif command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$target" | awk '{print $1}'
else
fail "sha256sum or shasum is required"
fi
}

# First field is digest; remainder is filename (GNU text " " or binary " *").
checksum_for_file() {
local sums="$1"
local want="$2"
awk -v want="$want" '
{
hash = $1
name = $0
sub(/^[^[:space:]]+[[:space:]]+/, "", name)
sub(/^\*/, "", name)
if (name == want) {
print hash
exit
}
}' "$sums"
}

normalize_os() {
local raw_os
raw_os="$(uname -s | tr '[:upper:]' '[:lower:]')"
case "$raw_os" in
linux) echo "linux" ;;
darwin) echo "darwin" ;;
*)
fail "unsupported OS: $raw_os (expected linux or darwin). On Windows use WinGet or a release .zip from GitHub."
;;
esac
}

normalize_arch() {
local raw_arch
raw_arch="$(uname -m)"
case "$raw_arch" in
x86_64 | amd64) echo "amd64" ;;
aarch64 | arm64) echo "arm64" ;;
# linux/arm is published as armv6 only; armv7l is ABI-compatible.
armv6l | armv7l) echo "armv6" ;;
i386 | i686) echo "386" ;;
*)
fail "unsupported architecture: $raw_arch"
;;
esac
}

resolve_raw_version_tag() {
if [ -n "$VERSION" ]; then
printf '%s\n' "$VERSION"
return
fi

local api_url response tag
api_url="https://api.github.com/repos/$REPO/releases/latest"
response="$(fetch_github_api "$api_url")"
tag="$(printf '%s\n' "$response" | awk -F '"' '/"tag_name"[[:space:]]*:/ {print $4; exit}')"
[ -n "$tag" ] || fail "could not resolve latest release tag from GitHub API"
printf '%s\n' "$tag"
}

release_archive_url() {
local tag="$1"
local archive_name="$2"
printf 'https://github.com/%s/releases/download/%s/%s\n' "$REPO" "$tag" "$archive_name"
}

# Return 0 if a release asset URL responds with success (follows redirects).
release_asset_head_ok() {
local url="$1"
local code
if command -v curl >/dev/null 2>&1; then
code="$(curl -gfsSIL -L -o /dev/null -w "%{http_code}" "$url" 2>/dev/null || printf '%s' "000")"
[ "$code" = "200" ]
elif command -v wget >/dev/null 2>&1; then
wget --spider -q -L "$url"
else
false
fi
}

# Map user input or "latest" API tag to the GitHub release tag that owns the archive.
pick_release_tag() {
local raw="$1"
local -a candidates=()
case "$raw" in
v*)
candidates=("$raw" "${raw#v}")
;;
*)
candidates=("v${raw}" "${raw}")
;;
esac

local tag archive_version archive_name url
for tag in "${candidates[@]}"; do
archive_version="${tag#v}"
archive_name="${BINARY_NAME}_${archive_version}_${os}_${arch}.tar.gz"
url="$(release_archive_url "$tag" "$archive_name")"
if release_asset_head_ok "$url"; then
printf '%s\n' "$tag"
return
fi
done

fail "no GitHub release matched VERSION=${raw} for ${os}/${arch} (check tag spelling and that this platform is published)"
}

normalize_path_dir() {
local d="$1"
while [ "${#d}" -gt 1 ] && [ "${d%/}" != "$d" ]; do
d="${d%/}"
done
printf '%s\n' "$d"
}

install_binary() {
local src_bin="$1"
local target_dir="$2"
local target_bin="$target_dir/$BINARY_NAME"

if [ -e "$target_dir" ] && [ ! -d "$target_dir" ]; then
fail "install path exists but is not a directory: $target_dir"
fi

if [ ! -d "$target_dir" ]; then
if mkdir -p "$target_dir" 2>/dev/null; then
:
elif command -v sudo >/dev/null 2>&1; then
sudo mkdir -p "$target_dir"
else
fail "could not create install directory: $target_dir"
fi
fi

if [ -w "$target_dir" ]; then
install -m 0755 "$src_bin" "$target_bin"
return
fi
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if command -v sudo >/dev/null 2>&1; then
sudo install -m 0755 "$src_bin" "$target_bin"
return
fi

fail "install directory is not writable and sudo is unavailable: $target_dir"
}

path_has_dir() {
local dir
dir="$(normalize_path_dir "$1")"
case ":${PATH:-}:" in
*":${dir}:"*) return 0 ;;
*) return 1 ;;
esac
}

# Preflight: fail fast before downloads (curl/wget checked in download_to).
need_cmd tar
need_cmd install
os="$(normalize_os)"
arch="$(normalize_arch)"
version_tag="$(pick_release_tag "$(resolve_raw_version_tag)")"
version="${version_tag#v}"

archive_name="${BINARY_NAME}_${version}_${os}_${arch}.tar.gz"
archive_url="$(release_archive_url "$version_tag" "$archive_name")"
checksums_url="$(release_archive_url "$version_tag" "checksums.txt")"

log "installing ${BINARY_NAME} ${version_tag} for ${os}/${arch}"

tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' EXIT

archive_path="$tmp_dir/$archive_name"
checksums_path="$tmp_dir/checksums.txt"

log "downloading release archive"
download_to "$archive_url" "$archive_path"

log "downloading checksum file"
download_to "$checksums_url" "$checksums_path"

expected_sum="$(checksum_for_file "$checksums_path" "$archive_name")"
[ -n "$expected_sum" ] || fail "could not find checksum entry for $archive_name (no asset for this OS/arch on this release?)"

actual_sum="$(sha256_file "$archive_path")"
if [ "$expected_sum" != "$actual_sum" ]; then
fail "checksum mismatch for $archive_name"
fi

log "checksum verified"
tar -xzf "$archive_path" -C "$tmp_dir"
[ -f "$tmp_dir/$BINARY_NAME" ] || fail "archive did not contain $BINARY_NAME"

install_binary "$tmp_dir/$BINARY_NAME" "$INSTALL_DIR"

log "installed to $INSTALL_DIR/$BINARY_NAME"
if ! path_has_dir "$INSTALL_DIR"; then
log "warning: $INSTALL_DIR is not on PATH; add it to your shell profile, e.g. export PATH=\"$INSTALL_DIR:\$PATH\""
fi
log "verify with: $BINARY_NAME --version"
Loading