Skip to content

Commit 7dea654

Browse files
fix(install): harden installer and CI
Resolve VERSION against real release tags (try v-prefix and bare), optional GITHUB_TOKEN/GH_TOKEN for the releases/latest API, defensive checksum parsing, install/tar preflight, PATH dir normalization, and README notes. Add a shellcheck job for scripts/install.sh. Made-with: Cursor
1 parent cdb6a18 commit 7dea654

3 files changed

Lines changed: 137 additions & 19 deletions

File tree

.github/workflows/ci.yml

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,16 @@ permissions:
1010
contents: read
1111

1212
jobs:
13+
shellcheck:
14+
runs-on: ubuntu-latest
15+
steps:
16+
- uses: actions/checkout@v4
17+
- name: ShellCheck install.sh
18+
run: |
19+
sudo apt-get update -qq
20+
sudo apt-get install -y shellcheck
21+
shellcheck scripts/install.sh
22+
1323
lint:
1424
runs-on: ubuntu-latest
1525
steps:
@@ -44,7 +54,7 @@ jobs:
4454

4555
uat:
4656
runs-on: ubuntu-latest
47-
needs: [lint, test]
57+
needs: [shellcheck, lint, test]
4858
steps:
4959
- uses: actions/checkout@v4
5060

README.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,8 @@ First-party install script (same idea as [`git-fire/scripts/install.sh`](https:/
9090

9191
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.
9292

93+
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.
94+
9395
```bash
9496
curl -fsSL https://raw.githubusercontent.com/git-fire/git-rain/main/scripts/install.sh | bash
9597
```
@@ -100,10 +102,13 @@ Pin a version or install directory (environment variables must apply to `bash`,
100102
curl -fsSL https://raw.githubusercontent.com/git-fire/git-rain/main/scripts/install.sh | VERSION=v0.9.1 INSTALL_DIR=/usr/local/bin bash
101103
```
102104

103-
If your shell does not already include `~/.local/bin` on `PATH`, add it (the installer prints a reminder). Example for bash:
105+
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`):
104106

105107
```bash
106-
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc && source ~/.bashrc
108+
if ! grep -qF '.local/bin' ~/.bashrc 2>/dev/null; then
109+
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
110+
fi
111+
source ~/.bashrc
107112
```
108113

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

scripts/install.sh

Lines changed: 119 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@ set -euo pipefail
55
# Usage:
66
# curl -fsSL https://raw.githubusercontent.com/git-fire/git-rain/main/scripts/install.sh | bash
77
# Optional env vars:
8-
# VERSION=v0.9.1
8+
# VERSION=v0.9.1 (must match a GitHub release tag; bare semver like 0.9.1 tries v0.9.1 first)
99
# INSTALL_DIR=$HOME/.local/bin
1010
# REPO=git-fire/git-rain
11+
# GITHUB_TOKEN or GH_TOKEN (optional; increases api.github.com rate limits for "latest" resolution)
1112

1213
REPO="${REPO:-git-fire/git-rain}"
1314
INSTALL_DIR="${INSTALL_DIR:-$HOME/.local/bin}"
@@ -27,9 +28,13 @@ need_cmd() {
2728
command -v "$1" >/dev/null 2>&1 || fail "required command not found: $1"
2829
}
2930

31+
github_token() {
32+
printf '%s' "${GITHUB_TOKEN:-${GH_TOKEN:-}}"
33+
}
34+
3035
download_to() {
31-
src="$1"
32-
dst="$2"
36+
local src="$1"
37+
local dst="$2"
3338
if command -v curl >/dev/null 2>&1; then
3439
curl -fsSL "$src" -o "$dst"
3540
elif command -v wget >/dev/null 2>&1; then
@@ -39,8 +44,38 @@ download_to() {
3944
fi
4045
}
4146

47+
fetch_github_api() {
48+
local url="$1"
49+
if command -v curl >/dev/null 2>&1; then
50+
local token
51+
token="$(github_token)"
52+
if [ -n "$token" ]; then
53+
curl -fsSL \
54+
-H "Authorization: Bearer ${token}" \
55+
-H "Accept: application/vnd.github+json" \
56+
-H "X-GitHub-Api-Version: 2022-11-28" \
57+
"$url"
58+
else
59+
curl -fsSL "$url"
60+
fi
61+
elif command -v wget >/dev/null 2>&1; then
62+
local token
63+
token="$(github_token)"
64+
if [ -n "$token" ]; then
65+
wget -qO- \
66+
--header="Authorization: Bearer ${token}" \
67+
--header="Accept: application/vnd.github+json" \
68+
"$url"
69+
else
70+
wget -qO- "$url"
71+
fi
72+
else
73+
fail "curl or wget is required"
74+
fi
75+
}
76+
4277
sha256_file() {
43-
target="$1"
78+
local target="$1"
4479
if command -v sha256sum >/dev/null 2>&1; then
4580
sha256sum "$target" | awk '{print $1}'
4681
elif command -v shasum >/dev/null 2>&1; then
@@ -50,6 +85,23 @@ sha256_file() {
5085
fi
5186
}
5287

88+
# First field is digest; remainder is filename (GNU text " " or binary " *").
89+
checksum_for_file() {
90+
local sums="$1"
91+
local want="$2"
92+
awk -v want="$want" '
93+
{
94+
hash = $1
95+
name = $0
96+
sub(/^[^[:space:]]+[[:space:]]+/, "", name)
97+
sub(/^\*/, "", name)
98+
if (name == want) {
99+
print hash
100+
exit
101+
}
102+
}' "$sums"
103+
}
104+
53105
normalize_os() {
54106
local raw_os
55107
raw_os="$(uname -s | tr '[:upper:]' '[:lower:]')"
@@ -77,24 +129,73 @@ normalize_arch() {
77129
esac
78130
}
79131

80-
resolve_version() {
132+
resolve_raw_version_tag() {
81133
if [ -n "$VERSION" ]; then
82134
printf '%s\n' "$VERSION"
83135
return
84136
fi
85137

138+
local api_url response tag
86139
api_url="https://api.github.com/repos/$REPO/releases/latest"
140+
response="$(fetch_github_api "$api_url")"
141+
tag="$(printf '%s\n' "$response" | awk -F '"' '/"tag_name"[[:space:]]*:/ {print $4; exit}')"
142+
[ -n "$tag" ] || fail "could not resolve latest release tag from GitHub API"
143+
printf '%s\n' "$tag"
144+
}
145+
146+
release_archive_url() {
147+
local tag="$1"
148+
local archive_name="$2"
149+
printf 'https://github.com/%s/releases/download/%s/%s\n' "$REPO" "$tag" "$archive_name"
150+
}
151+
152+
# Return 0 if a release asset URL responds with success (follows redirects).
153+
release_asset_head_ok() {
154+
local url="$1"
155+
local code
87156
if command -v curl >/dev/null 2>&1; then
88-
response="$(curl -fsSL "$api_url")"
157+
code="$(curl -gfsSIL -L -o /dev/null -w "%{http_code}" "$url" 2>/dev/null || printf '%s' "000")"
158+
[ "$code" = "200" ]
89159
elif command -v wget >/dev/null 2>&1; then
90-
response="$(wget -qO- "$api_url")"
160+
wget --spider -q -L "$url"
91161
else
92-
fail "curl or wget is required"
162+
false
93163
fi
164+
}
94165

95-
tag="$(printf '%s\n' "$response" | awk -F '"' '/"tag_name"[[:space:]]*:/ {print $4; exit}')"
96-
[ -n "$tag" ] || fail "could not resolve latest release tag from GitHub API"
97-
printf '%s\n' "$tag"
166+
# Map user input or "latest" API tag to the GitHub release tag that owns the archive.
167+
pick_release_tag() {
168+
local raw="$1"
169+
local -a candidates=()
170+
case "$raw" in
171+
v*)
172+
candidates=("$raw" "${raw#v}")
173+
;;
174+
*)
175+
candidates=("v${raw}" "${raw}")
176+
;;
177+
esac
178+
179+
local tag archive_version archive_name url
180+
for tag in "${candidates[@]}"; do
181+
archive_version="${tag#v}"
182+
archive_name="${BINARY_NAME}_${archive_version}_${os}_${arch}.tar.gz"
183+
url="$(release_archive_url "$tag" "$archive_name")"
184+
if release_asset_head_ok "$url"; then
185+
printf '%s\n' "$tag"
186+
return
187+
fi
188+
done
189+
190+
fail "no GitHub release matched VERSION=${raw} for ${os}/${arch} (check tag spelling and that this platform is published)"
191+
}
192+
193+
normalize_path_dir() {
194+
local d="$1"
195+
while [ "${#d}" -gt 1 ] && [ "${d%/}" != "$d" ]; do
196+
d="${d%/}"
197+
done
198+
printf '%s\n' "$d"
98199
}
99200

100201
install_binary() {
@@ -130,7 +231,8 @@ install_binary() {
130231
}
131232

132233
path_has_dir() {
133-
local dir="$1"
234+
local dir
235+
dir="$(normalize_path_dir "$1")"
134236
case ":${PATH:-}:" in
135237
*":${dir}:"*) return 0 ;;
136238
*) return 1 ;;
@@ -139,14 +241,15 @@ path_has_dir() {
139241

140242
# Preflight: fail fast before downloads (curl/wget checked in download_to).
141243
need_cmd tar
244+
need_cmd install
142245
os="$(normalize_os)"
143246
arch="$(normalize_arch)"
144-
version_tag="$(resolve_version)"
247+
version_tag="$(pick_release_tag "$(resolve_raw_version_tag)")"
145248
version="${version_tag#v}"
146249

147250
archive_name="${BINARY_NAME}_${version}_${os}_${arch}.tar.gz"
148-
archive_url="https://github.com/$REPO/releases/download/${version_tag}/${archive_name}"
149-
checksums_url="https://github.com/$REPO/releases/download/${version_tag}/checksums.txt"
251+
archive_url="$(release_archive_url "$version_tag" "$archive_name")"
252+
checksums_url="$(release_archive_url "$version_tag" "checksums.txt")"
150253

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

@@ -162,7 +265,7 @@ download_to "$archive_url" "$archive_path"
162265
log "downloading checksum file"
163266
download_to "$checksums_url" "$checksums_path"
164267

165-
expected_sum="$(awk -v file="$archive_name" '$2 == file {print $1; exit}' "$checksums_path")"
268+
expected_sum="$(checksum_for_file "$checksums_path" "$archive_name")"
166269
[ -n "$expected_sum" ] || fail "could not find checksum entry for $archive_name (no asset for this OS/arch on this release?)"
167270

168271
actual_sum="$(sha256_file "$archive_path")"

0 commit comments

Comments
 (0)