Skip to content

Commit 0a7269b

Browse files
committed
feat: mirror releases + install scripts to S3 for GitHub-restricted hosts
Add an opt-in CDN mirror path so the CLI can be installed where github.com is unreachable, without exposing the mirror host in the repo: - release.yml: mirror release binaries + checksums.txt + a releases/latest pointer to an S3-compatible bucket (driven by MIRROR_S3_* secrets; skips when unset). - install-scripts.yml: lint install.sh and mirror install.sh + install.ps1 to the same bucket on push to main. - install.sh / install.ps1: honor MIRROR_URL to resolve the version pointer and download assets from the mirror instead of GitHub, with checksums.txt verification (warn-and-skip for pre-mirror releases) and release-tag validation on the network-resolved version. - .goreleaser.yml: pin the checksum file name to checksums.txt so the verification path has a stable asset name. The mirror host is supplied at call time via MIRROR_URL / the S3 secrets, so the billable CDN address is never published in the repo.
1 parent c43f31e commit 0a7269b

5 files changed

Lines changed: 274 additions & 17 deletions

File tree

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
name: install scripts
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
paths:
8+
- install.sh
9+
- install.ps1
10+
- .github/workflows/install-scripts.yml
11+
pull_request:
12+
paths:
13+
- install.sh
14+
- install.ps1
15+
- .github/workflows/install-scripts.yml
16+
17+
permissions:
18+
contents: read
19+
20+
jobs:
21+
lint:
22+
name: shellcheck + parse
23+
runs-on: ubuntu-latest
24+
steps:
25+
- uses: actions/checkout@v4
26+
- name: shellcheck
27+
run: shellcheck -s sh install.sh
28+
- name: sh parse
29+
run: sh -n install.sh
30+
- name: bash parse
31+
run: bash -n install.sh
32+
33+
mirror:
34+
name: mirror install scripts
35+
runs-on: ubuntu-latest
36+
needs: lint
37+
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
38+
steps:
39+
- uses: actions/checkout@v4
40+
- name: Upload install scripts to S3-compatible storage
41+
env:
42+
AWS_ACCESS_KEY_ID: ${{ secrets.MIRROR_S3_ACCESS_KEY_ID }}
43+
AWS_SECRET_ACCESS_KEY: ${{ secrets.MIRROR_S3_SECRET_ACCESS_KEY }}
44+
AWS_DEFAULT_REGION: ${{ secrets.MIRROR_S3_REGION }}
45+
BUCKET: ${{ secrets.MIRROR_S3_BUCKET }}
46+
ENDPOINT: ${{ secrets.MIRROR_S3_ENDPOINT }}
47+
PREFIX: ${{ secrets.MIRROR_S3_PATH_PREFIX }}
48+
run: |
49+
set -eu
50+
if [ -z "${BUCKET:-}" ] || [ -z "${ENDPOINT:-}" ]; then
51+
echo "Mirror not configured (need MIRROR_S3_BUCKET + MIRROR_S3_ENDPOINT). Skipping."
52+
exit 0
53+
fi
54+
# Aliyun OSS rejects path-style requests; force virtual-hosted style.
55+
aws configure set default.s3.addressing_style virtual
56+
# AWS CLI v2.23+ default integrity protections add `aws-chunked`
57+
# encoding which OSS rejects (InvalidArgument). Restore old behavior.
58+
aws configure set default.request_checksum_calculation when_required
59+
aws configure set default.response_checksum_validation when_required
60+
PREFIX="${PREFIX#/}"; PREFIX="${PREFIX%/}"
61+
62+
sh_key="${PREFIX:+${PREFIX}/}install.sh"
63+
aws --endpoint-url="$ENDPOINT" s3 cp install.sh "s3://${BUCKET}/${sh_key}" \
64+
--cache-control "public, max-age=300" \
65+
--content-type "text/x-shellscript; charset=utf-8"
66+
67+
ps1_key="${PREFIX:+${PREFIX}/}install.ps1"
68+
aws --endpoint-url="$ENDPOINT" s3 cp install.ps1 "s3://${BUCKET}/${ps1_key}" \
69+
--cache-control "public, max-age=300" \
70+
--content-type "text/plain; charset=utf-8"

.github/workflows/release.yml

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,56 @@ jobs:
4444
dist/*.tar.gz
4545
dist/*.zip
4646
dist/*.txt
47+
48+
- name: Mirror release assets to S3-compatible storage
49+
env:
50+
AWS_ACCESS_KEY_ID: ${{ secrets.MIRROR_S3_ACCESS_KEY_ID }}
51+
AWS_SECRET_ACCESS_KEY: ${{ secrets.MIRROR_S3_SECRET_ACCESS_KEY }}
52+
AWS_DEFAULT_REGION: ${{ secrets.MIRROR_S3_REGION }}
53+
BUCKET: ${{ secrets.MIRROR_S3_BUCKET }}
54+
ENDPOINT: ${{ secrets.MIRROR_S3_ENDPOINT }}
55+
PREFIX: ${{ secrets.MIRROR_S3_PATH_PREFIX }}
56+
VERSION: ${{ github.ref_name }}
57+
run: |
58+
set -eu
59+
if [ -z "${BUCKET:-}" ] || [ -z "${ENDPOINT:-}" ]; then
60+
echo "Mirror not configured (need MIRROR_S3_BUCKET + MIRROR_S3_ENDPOINT). Skipping."
61+
exit 0
62+
fi
63+
64+
# Aliyun OSS rejects path-style requests (SecondLevelDomainForbidden);
65+
# AWS CLI defaults to path-style for custom endpoints, so force
66+
# virtual-hosted style. Harmless for endpoints that accept either.
67+
aws configure set default.s3.addressing_style virtual
68+
# AWS CLI v2.23+ enabled default integrity protections that add
69+
# `aws-chunked` request encoding, which OSS rejects with
70+
# InvalidArgument. Restore the pre-2.23 behavior.
71+
aws configure set default.request_checksum_calculation when_required
72+
aws configure set default.response_checksum_validation when_required
73+
74+
# Normalize PREFIX: strip both leading and trailing slashes so a
75+
# value of "/" or "/foo/" doesn't produce a doubled or leading slash
76+
# in the resulting key.
77+
PREFIX="${PREFIX#/}"; PREFIX="${PREFIX%/}"
78+
base="${PREFIX:+${PREFIX}/}releases/download/${VERSION}"
79+
uploaded=0
80+
for f in dist/*.tar.gz dist/*.zip dist/checksums.txt; do
81+
[ -f "$f" ] || continue
82+
name=$(basename "$f")
83+
echo "Uploading $f -> s3://${BUCKET}/${base}/${name}"
84+
aws --endpoint-url="$ENDPOINT" s3 cp "$f" "s3://${BUCKET}/${base}/${name}" \
85+
--cache-control "public, max-age=31536000, immutable"
86+
uploaded=$((uploaded + 1))
87+
done
88+
if [ "$uploaded" -eq 0 ]; then
89+
echo "No release artifacts found in dist/ — refusing to update latest pointer."
90+
exit 1
91+
fi
92+
93+
# Latest pointer used by install.sh resolve_version when MIRROR_URL is set.
94+
# Updated last so a partial upload doesn't make the mirror advertise a broken version.
95+
latest_key="${PREFIX:+${PREFIX}/}releases/latest"
96+
printf '%s\n' "$VERSION" > /tmp/latest
97+
aws --endpoint-url="$ENDPOINT" s3 cp /tmp/latest "s3://${BUCKET}/${latest_key}" \
98+
--cache-control "public, max-age=60" \
99+
--content-type "text/plain; charset=utf-8"

.goreleaser.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ archives:
3232
- goos: windows
3333
formats: zip
3434

35+
checksum:
36+
name_template: "checksums.txt"
37+
3538
changelog:
3639
sort: asc
3740
filters:

install.ps1

Lines changed: 72 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,30 @@
44
# Environment variables:
55
# FLASHDUTY_VERSION - specific version to install (e.g. "v0.1.2")
66
# FLASHDUTY_INSTALL_DIR - install directory (default: $HOME\.flashduty\bin)
7+
# MIRROR_URL - fetch release assets from this https mirror prefix
8+
# instead of github.com. The mirror must replicate
9+
# GitHub's release layout
10+
# (<MIRROR_URL>/releases/download/<tag>/<asset>) and
11+
# expose a plain-text <MIRROR_URL>/releases/latest file
12+
# containing the latest tag.
713

814
$ErrorActionPreference = "Stop"
15+
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
916

1017
$Repo = "flashcatcloud/flashduty-cli"
1118
$Binary = "flashduty-cli.exe"
1219
$InstalledName = "flashduty.exe"
1320

21+
# When set, all release downloads are fetched from this prefix instead of github.com.
22+
$MirrorUrl = $env:MIRROR_URL
23+
if ($MirrorUrl) {
24+
$MirrorUrl = $MirrorUrl.TrimEnd('/')
25+
if ($MirrorUrl -notlike "https://*") {
26+
Write-Error "[flashduty] MIRROR_URL must use https:// scheme, got: $MirrorUrl"
27+
exit 1
28+
}
29+
}
30+
1431
function Write-Info($msg) {
1532
Write-Host "[flashduty] $msg"
1633
}
@@ -36,12 +53,27 @@ function Get-Version {
3653
if ($env:FLASHDUTY_VERSION) {
3754
return $env:FLASHDUTY_VERSION
3855
}
39-
try {
40-
$release = Invoke-RestMethod -Uri "https://api.github.com/repos/$Repo/releases/latest" -UseBasicParsing
41-
return $release.tag_name
42-
} catch {
43-
Fail "could not determine latest version. Set FLASHDUTY_VERSION to install a specific version."
56+
if ($MirrorUrl) {
57+
try {
58+
$raw = Invoke-RestMethod -Uri "$MirrorUrl/releases/latest" -UseBasicParsing
59+
$version = ([string]$raw).Trim()
60+
} catch {
61+
Fail "could not fetch $MirrorUrl/releases/latest. Set FLASHDUTY_VERSION to install a specific version."
62+
}
63+
} else {
64+
try {
65+
$release = Invoke-RestMethod -Uri "https://api.github.com/repos/$Repo/releases/latest" -UseBasicParsing
66+
$version = $release.tag_name
67+
} catch {
68+
Fail "could not determine latest version. Set FLASHDUTY_VERSION to install a specific version."
69+
}
70+
}
71+
# The resolved value comes from a network response and is interpolated into
72+
# the download URL — reject anything that isn't a plain release tag.
73+
if ($version -notmatch '^v[0-9][A-Za-z0-9.+-]*$') {
74+
Fail "resolved version is not a valid release tag: '$version'"
4475
}
76+
return $version
4577
}
4678

4779
# --- main ---
@@ -56,7 +88,12 @@ $InstallDir = if ($env:FLASHDUTY_INSTALL_DIR) {
5688
}
5789

5890
$Archive = "flashduty-cli_Windows_${Arch}.zip"
59-
$Url = "https://github.com/$Repo/releases/download/$Version/$Archive"
91+
$Base = if ($MirrorUrl) {
92+
"$MirrorUrl/releases/download/$Version"
93+
} else {
94+
"https://github.com/$Repo/releases/download/$Version"
95+
}
96+
$Url = "$Base/$Archive"
6097

6198
Write-Info "Installing Flashduty CLI $Version (Windows/$Arch)"
6299
Write-Info "Downloading $Url"
@@ -66,9 +103,37 @@ New-Item -ItemType Directory -Path $TmpDir -Force | Out-Null
66103

67104
try {
68105
$ArchivePath = Join-Path $TmpDir $Archive
69-
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
70106
Invoke-WebRequest -Uri $Url -OutFile $ArchivePath -UseBasicParsing
71107

108+
# Verify against the published checksums.txt when present. Releases cut
109+
# before the mirror existed don't ship one, so a missing file only warns.
110+
$ChecksumPath = Join-Path $TmpDir "checksums.txt"
111+
try {
112+
Invoke-WebRequest -Uri "$Base/checksums.txt" -OutFile $ChecksumPath -UseBasicParsing
113+
} catch {
114+
$ChecksumPath = $null
115+
}
116+
if ($ChecksumPath -and (Test-Path $ChecksumPath)) {
117+
$expected = $null
118+
foreach ($line in Get-Content $ChecksumPath) {
119+
$parts = $line -split '\s+', 2
120+
if ($parts.Count -eq 2 -and $parts[1].Trim() -eq $Archive) {
121+
$expected = $parts[0].Trim().ToLower()
122+
break
123+
}
124+
}
125+
if (-not $expected) {
126+
Fail "archive $Archive not listed in checksums.txt (wrong release or renamed asset)"
127+
}
128+
$actual = (Get-FileHash -Path $ArchivePath -Algorithm SHA256).Hash.ToLower()
129+
if ($actual -ne $expected) {
130+
Fail "checksum mismatch for ${Archive}: expected $expected, got $actual"
131+
}
132+
Write-Info "Checksum OK"
133+
} else {
134+
Write-Info "WARNING: checksums.txt not available -- skipping integrity check"
135+
}
136+
72137
Expand-Archive -Path $ArchivePath -DestinationPath $TmpDir -Force
73138

74139
$BinaryPath = Join-Path $TmpDir $Binary

install.sh

Lines changed: 76 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,33 @@
11
#!/bin/sh
22
# Flashduty CLI installer
33
# Usage: curl -sSL https://raw.githubusercontent.com/flashcatcloud/flashduty-cli/main/install.sh | sh
4+
#
5+
# Environment:
6+
# FLASHDUTY_VERSION Install a specific version (e.g. v0.1.2). Default: latest.
7+
# FLASHDUTY_INSTALL_DIR Install directory. Default: /usr/local/bin.
8+
# MIRROR_URL Fetch release assets from this https mirror prefix
9+
# instead of github.com. The mirror must replicate
10+
# GitHub's release layout
11+
# (<MIRROR_URL>/releases/download/<tag>/<asset>) and expose
12+
# a plain-text <MIRROR_URL>/releases/latest file containing
13+
# the latest tag.
414
set -e
515

616
REPO="flashcatcloud/flashduty-cli"
717
BINARY="flashduty-cli"
818
INSTALLED_NAME="flashduty"
919
INSTALL_DIR="${FLASHDUTY_INSTALL_DIR:-/usr/local/bin}"
1020

21+
# When set, all release downloads are fetched from this prefix instead of github.com.
22+
MIRROR_URL="${MIRROR_URL:-}"
23+
MIRROR_URL="${MIRROR_URL%/}"
24+
if [ -n "${MIRROR_URL}" ]; then
25+
case "${MIRROR_URL}" in
26+
https://*) : ;;
27+
*) printf "Error: MIRROR_URL must use https:// scheme, got: %s\n" "${MIRROR_URL}" >&2; exit 1 ;;
28+
esac
29+
fi
30+
1131
# --- helper functions ---
1232

1333
fail() {
@@ -25,6 +45,17 @@ need_cmd() {
2545
fi
2646
}
2747

48+
sha256_of() {
49+
file="$1"
50+
if command -v sha256sum > /dev/null 2>&1; then
51+
sha256sum "${file}" | awk '{print $1}'
52+
elif command -v shasum > /dev/null 2>&1; then
53+
shasum -a 256 "${file}" | awk '{print $1}'
54+
else
55+
fail "need 'sha256sum' or 'shasum' to verify the download (install coreutils)"
56+
fi
57+
}
58+
2859
# --- detect platform ---
2960

3061
detect_os() {
@@ -51,14 +82,30 @@ resolve_version() {
5182
echo "${FLASHDUTY_VERSION}"
5283
return
5384
fi
54-
need_cmd curl
55-
# Use the GitHub API to get the latest release tag
56-
version=$(curl -sL "https://api.github.com/repos/${REPO}/releases/latest" \
57-
| grep '"tag_name"' \
58-
| sed -E 's/.*"tag_name": *"([^"]+)".*/\1/')
85+
if [ -n "${MIRROR_URL}" ]; then
86+
# The mirror publishes a plain-text pointer with the latest tag.
87+
version=$(curl --proto '=https' --tlsv1.2 -fsSL "${MIRROR_URL}/releases/latest" 2>/dev/null \
88+
| awk 'NR==1 {gsub(/^[[:space:]]+|[[:space:]]+$/, ""); print; exit}')
89+
else
90+
# Follow the github.com/<repo>/releases/latest redirect to read the tag
91+
# from the resolved URL — avoids the unauthenticated api.github.com rate limit.
92+
effective=$(curl --proto '=https' --tlsv1.2 -sIL -o /dev/null -w '%{url_effective}' \
93+
"https://github.com/${REPO}/releases/latest" || true)
94+
version="${effective##*/}"
95+
[ "${version}" = "latest" ] && version=""
96+
fi
5997
if [ -z "${version}" ]; then
6098
fail "could not determine latest version. Set FLASHDUTY_VERSION to install a specific version."
6199
fi
100+
# Reject anything that doesn't look like a release tag — the resolved value
101+
# comes from a network response and is interpolated into the download URL.
102+
case "${version}" in
103+
*[!A-Za-z0-9.+-]*) fail "resolved version contains illegal characters: '${version}'" ;;
104+
esac
105+
case "${version}" in
106+
v[0-9]*) : ;;
107+
*) fail "resolved version is not a valid release tag: '${version}'" ;;
108+
esac
62109
echo "${version}"
63110
}
64111

@@ -81,17 +128,36 @@ main() {
81128
fi
82129

83130
ARCHIVE="flashduty-cli_${OS}_${ARCH}.${EXT}"
84-
URL="https://github.com/${REPO}/releases/download/${VERSION}/${ARCHIVE}"
131+
if [ -n "${MIRROR_URL}" ]; then
132+
BASE="${MIRROR_URL}/releases/download/${VERSION}"
133+
else
134+
BASE="https://github.com/${REPO}/releases/download/${VERSION}"
135+
fi
85136

86137
info "Installing Flashduty CLI ${VERSION} (${OS}/${ARCH})"
87-
info "Downloading ${URL}"
138+
info "Downloading ${BASE}/${ARCHIVE}"
88139

89140
TMP_DIR=$(mktemp -d)
90141
trap 'rm -rf "${TMP_DIR}"' EXIT
91142

92-
HTTP_CODE=$(curl -sL -H "Accept: application/octet-stream" -o "${TMP_DIR}/${ARCHIVE}" -w "%{http_code}" "${URL}")
93-
if [ "${HTTP_CODE}" != "200" ]; then
94-
fail "download failed (HTTP ${HTTP_CODE}). Check that ${VERSION} exists at https://github.com/${REPO}/releases"
143+
if ! curl --proto '=https' --tlsv1.2 -fsSL "${BASE}/${ARCHIVE}" -o "${TMP_DIR}/${ARCHIVE}"; then
144+
fail "download failed for ${BASE}/${ARCHIVE}. Check that ${VERSION} exists."
145+
fi
146+
147+
# Verify against the published checksums.txt when present. Releases cut
148+
# before the mirror existed don't ship one, so a missing file only warns.
149+
if curl --proto '=https' --tlsv1.2 -fsSL "${BASE}/checksums.txt" -o "${TMP_DIR}/checksums.txt" 2>/dev/null; then
150+
expected=$(awk -v a="${ARCHIVE}" '$2 == a {print $1; exit}' "${TMP_DIR}/checksums.txt")
151+
if [ -z "${expected}" ]; then
152+
fail "archive ${ARCHIVE} not listed in checksums.txt (wrong release or renamed asset)"
153+
fi
154+
actual=$(sha256_of "${TMP_DIR}/${ARCHIVE}")
155+
if [ "${actual}" != "${expected}" ]; then
156+
fail "checksum mismatch for ${ARCHIVE}: expected ${expected}, got ${actual}"
157+
fi
158+
info "Checksum OK"
159+
else
160+
info "WARNING: checksums.txt not available — skipping integrity check"
95161
fi
96162

97163
if [ "${EXT}" = "zip" ]; then

0 commit comments

Comments
 (0)