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
70 changes: 70 additions & 0 deletions .github/workflows/install-scripts.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
name: install scripts

on:
push:
branches:
- main
paths:
- install.sh
- install.ps1
- .github/workflows/install-scripts.yml
pull_request:
paths:
- install.sh
- install.ps1
- .github/workflows/install-scripts.yml

permissions:
contents: read

jobs:
lint:
name: shellcheck + parse
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: shellcheck
run: shellcheck -s sh install.sh
- name: sh parse
run: sh -n install.sh
- name: bash parse
run: bash -n install.sh

mirror:
name: mirror install scripts
runs-on: ubuntu-latest
needs: lint
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Upload install scripts to S3-compatible storage
env:
AWS_ACCESS_KEY_ID: ${{ secrets.MIRROR_S3_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.MIRROR_S3_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: ${{ secrets.MIRROR_S3_REGION }}
BUCKET: ${{ secrets.MIRROR_S3_BUCKET }}
ENDPOINT: ${{ secrets.MIRROR_S3_ENDPOINT }}
PREFIX: ${{ secrets.MIRROR_S3_PATH_PREFIX }}
run: |
set -eu
if [ -z "${BUCKET:-}" ] || [ -z "${ENDPOINT:-}" ]; then
echo "Mirror not configured (need MIRROR_S3_BUCKET + MIRROR_S3_ENDPOINT). Skipping."
exit 0
fi
# Aliyun OSS rejects path-style requests; force virtual-hosted style.
aws configure set default.s3.addressing_style virtual
# AWS CLI v2.23+ default integrity protections add `aws-chunked`
# encoding which OSS rejects (InvalidArgument). Restore old behavior.
aws configure set default.request_checksum_calculation when_required
aws configure set default.response_checksum_validation when_required
PREFIX="${PREFIX#/}"; PREFIX="${PREFIX%/}"

sh_key="${PREFIX:+${PREFIX}/}install.sh"
aws --endpoint-url="$ENDPOINT" s3 cp install.sh "s3://${BUCKET}/${sh_key}" \
--cache-control "public, max-age=300" \
--content-type "text/x-shellscript; charset=utf-8"

ps1_key="${PREFIX:+${PREFIX}/}install.ps1"
aws --endpoint-url="$ENDPOINT" s3 cp install.ps1 "s3://${BUCKET}/${ps1_key}" \
--cache-control "public, max-age=300" \
--content-type "text/plain; charset=utf-8"
53 changes: 53 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,56 @@ jobs:
dist/*.tar.gz
dist/*.zip
dist/*.txt

- name: Mirror release assets to S3-compatible storage
env:
AWS_ACCESS_KEY_ID: ${{ secrets.MIRROR_S3_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.MIRROR_S3_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: ${{ secrets.MIRROR_S3_REGION }}
BUCKET: ${{ secrets.MIRROR_S3_BUCKET }}
ENDPOINT: ${{ secrets.MIRROR_S3_ENDPOINT }}
PREFIX: ${{ secrets.MIRROR_S3_PATH_PREFIX }}
VERSION: ${{ github.ref_name }}
run: |
set -eu
if [ -z "${BUCKET:-}" ] || [ -z "${ENDPOINT:-}" ]; then
echo "Mirror not configured (need MIRROR_S3_BUCKET + MIRROR_S3_ENDPOINT). Skipping."
exit 0
fi

# Aliyun OSS rejects path-style requests (SecondLevelDomainForbidden);
# AWS CLI defaults to path-style for custom endpoints, so force
# virtual-hosted style. Harmless for endpoints that accept either.
aws configure set default.s3.addressing_style virtual
# AWS CLI v2.23+ enabled default integrity protections that add
# `aws-chunked` request encoding, which OSS rejects with
# InvalidArgument. Restore the pre-2.23 behavior.
aws configure set default.request_checksum_calculation when_required
aws configure set default.response_checksum_validation when_required

# Normalize PREFIX: strip both leading and trailing slashes so a
# value of "/" or "/foo/" doesn't produce a doubled or leading slash
# in the resulting key.
PREFIX="${PREFIX#/}"; PREFIX="${PREFIX%/}"
base="${PREFIX:+${PREFIX}/}releases/download/${VERSION}"
uploaded=0
for f in dist/*.tar.gz dist/*.zip dist/checksums.txt; do
[ -f "$f" ] || continue
name=$(basename "$f")
echo "Uploading $f -> s3://${BUCKET}/${base}/${name}"
aws --endpoint-url="$ENDPOINT" s3 cp "$f" "s3://${BUCKET}/${base}/${name}" \
--cache-control "public, max-age=31536000, immutable"
uploaded=$((uploaded + 1))
done
if [ "$uploaded" -eq 0 ]; then
echo "No release artifacts found in dist/ — refusing to update latest pointer."
exit 1
fi

# Latest pointer used by install.sh resolve_version when MIRROR_URL is set.
# Updated last so a partial upload doesn't make the mirror advertise a broken version.
latest_key="${PREFIX:+${PREFIX}/}releases/latest"
printf '%s\n' "$VERSION" > /tmp/latest
aws --endpoint-url="$ENDPOINT" s3 cp /tmp/latest "s3://${BUCKET}/${latest_key}" \
--cache-control "public, max-age=60" \
--content-type "text/plain; charset=utf-8"
3 changes: 3 additions & 0 deletions .goreleaser.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ archives:
- goos: windows
formats: zip

checksum:
name_template: "checksums.txt"

changelog:
sort: asc
filters:
Expand Down
79 changes: 72 additions & 7 deletions install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,30 @@
# Environment variables:
# FLASHDUTY_VERSION - specific version to install (e.g. "v0.1.2")
# FLASHDUTY_INSTALL_DIR - install directory (default: $HOME\.flashduty\bin)
# MIRROR_URL - fetch release assets from this https mirror prefix
# instead of github.com. The mirror must replicate
# GitHub's release layout
# (<MIRROR_URL>/releases/download/<tag>/<asset>) and
# expose a plain-text <MIRROR_URL>/releases/latest file
# containing the latest tag.

$ErrorActionPreference = "Stop"
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12

$Repo = "flashcatcloud/flashduty-cli"
$Binary = "flashduty-cli.exe"
$InstalledName = "flashduty.exe"

# When set, all release downloads are fetched from this prefix instead of github.com.
$MirrorUrl = $env:MIRROR_URL
if ($MirrorUrl) {
$MirrorUrl = $MirrorUrl.TrimEnd('/')
if ($MirrorUrl -notlike "https://*") {
Write-Error "[flashduty] MIRROR_URL must use https:// scheme, got: $MirrorUrl"
exit 1
}
}

function Write-Info($msg) {
Write-Host "[flashduty] $msg"
}
Expand All @@ -36,12 +53,27 @@ function Get-Version {
if ($env:FLASHDUTY_VERSION) {
return $env:FLASHDUTY_VERSION
}
try {
$release = Invoke-RestMethod -Uri "https://api.github.com/repos/$Repo/releases/latest" -UseBasicParsing
return $release.tag_name
} catch {
Fail "could not determine latest version. Set FLASHDUTY_VERSION to install a specific version."
if ($MirrorUrl) {
try {
$raw = Invoke-RestMethod -Uri "$MirrorUrl/releases/latest" -UseBasicParsing
$version = ([string]$raw).Trim()
} catch {
Fail "could not fetch $MirrorUrl/releases/latest. Set FLASHDUTY_VERSION to install a specific version."
}
} else {
try {
$release = Invoke-RestMethod -Uri "https://api.github.com/repos/$Repo/releases/latest" -UseBasicParsing
$version = $release.tag_name
} catch {
Fail "could not determine latest version. Set FLASHDUTY_VERSION to install a specific version."
}
}
# The resolved value comes from a network response and is interpolated into
# the download URL — reject anything that isn't a plain release tag.
if ($version -notmatch '^v[0-9][A-Za-z0-9.+-]*$') {
Fail "resolved version is not a valid release tag: '$version'"
}
return $version
}

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

$Archive = "flashduty-cli_Windows_${Arch}.zip"
$Url = "https://github.com/$Repo/releases/download/$Version/$Archive"
$Base = if ($MirrorUrl) {
"$MirrorUrl/releases/download/$Version"
} else {
"https://github.com/$Repo/releases/download/$Version"
}
$Url = "$Base/$Archive"

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

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

# Verify against the published checksums.txt when present. Releases cut
# before the mirror existed don't ship one, so a missing file only warns.
$ChecksumPath = Join-Path $TmpDir "checksums.txt"
try {
Invoke-WebRequest -Uri "$Base/checksums.txt" -OutFile $ChecksumPath -UseBasicParsing
} catch {
$ChecksumPath = $null
}
if ($ChecksumPath -and (Test-Path $ChecksumPath)) {
$expected = $null
foreach ($line in Get-Content $ChecksumPath) {
$parts = $line -split '\s+', 2
if ($parts.Count -eq 2 -and $parts[1].Trim() -eq $Archive) {
$expected = $parts[0].Trim().ToLower()
break
}
}
if (-not $expected) {
Fail "archive $Archive not listed in checksums.txt (wrong release or renamed asset)"
}
$actual = (Get-FileHash -Path $ArchivePath -Algorithm SHA256).Hash.ToLower()
if ($actual -ne $expected) {
Fail "checksum mismatch for ${Archive}: expected $expected, got $actual"
}
Write-Info "Checksum OK"
} else {
Write-Info "WARNING: checksums.txt not available -- skipping integrity check"
}

Expand-Archive -Path $ArchivePath -DestinationPath $TmpDir -Force

$BinaryPath = Join-Path $TmpDir $Binary
Expand Down
86 changes: 76 additions & 10 deletions install.sh
Original file line number Diff line number Diff line change
@@ -1,13 +1,33 @@
#!/bin/sh
# Flashduty CLI installer
# Usage: curl -sSL https://raw.githubusercontent.com/flashcatcloud/flashduty-cli/main/install.sh | sh
#
# Environment:
# FLASHDUTY_VERSION Install a specific version (e.g. v0.1.2). Default: latest.
# FLASHDUTY_INSTALL_DIR Install directory. Default: /usr/local/bin.
# MIRROR_URL Fetch release assets from this https mirror prefix
# instead of github.com. The mirror must replicate
# GitHub's release layout
# (<MIRROR_URL>/releases/download/<tag>/<asset>) and expose
# a plain-text <MIRROR_URL>/releases/latest file containing
# the latest tag.
set -e

REPO="flashcatcloud/flashduty-cli"
BINARY="flashduty-cli"
INSTALLED_NAME="flashduty"
INSTALL_DIR="${FLASHDUTY_INSTALL_DIR:-/usr/local/bin}"

# When set, all release downloads are fetched from this prefix instead of github.com.
MIRROR_URL="${MIRROR_URL:-}"
MIRROR_URL="${MIRROR_URL%/}"
if [ -n "${MIRROR_URL}" ]; then
case "${MIRROR_URL}" in
https://*) : ;;
*) printf "Error: MIRROR_URL must use https:// scheme, got: %s\n" "${MIRROR_URL}" >&2; exit 1 ;;
esac
fi

# --- helper functions ---

fail() {
Expand All @@ -25,6 +45,17 @@ need_cmd() {
fi
}

sha256_of() {
file="$1"
if command -v sha256sum > /dev/null 2>&1; then
sha256sum "${file}" | awk '{print $1}'
elif command -v shasum > /dev/null 2>&1; then
shasum -a 256 "${file}" | awk '{print $1}'
else
fail "need 'sha256sum' or 'shasum' to verify the download (install coreutils)"
fi
}

# --- detect platform ---

detect_os() {
Expand All @@ -51,14 +82,30 @@ resolve_version() {
echo "${FLASHDUTY_VERSION}"
return
fi
need_cmd curl
# Use the GitHub API to get the latest release tag
version=$(curl -sL "https://api.github.com/repos/${REPO}/releases/latest" \
| grep '"tag_name"' \
| sed -E 's/.*"tag_name": *"([^"]+)".*/\1/')
if [ -n "${MIRROR_URL}" ]; then
# The mirror publishes a plain-text pointer with the latest tag.
version=$(curl --proto '=https' --tlsv1.2 -fsSL "${MIRROR_URL}/releases/latest" 2>/dev/null \
| awk 'NR==1 {gsub(/^[[:space:]]+|[[:space:]]+$/, ""); print; exit}')
else
# Follow the github.com/<repo>/releases/latest redirect to read the tag
# from the resolved URL — avoids the unauthenticated api.github.com rate limit.
effective=$(curl --proto '=https' --tlsv1.2 -sIL -o /dev/null -w '%{url_effective}' \
"https://github.com/${REPO}/releases/latest" || true)
version="${effective##*/}"
[ "${version}" = "latest" ] && version=""
fi
if [ -z "${version}" ]; then
fail "could not determine latest version. Set FLASHDUTY_VERSION to install a specific version."
fi
# Reject anything that doesn't look like a release tag — the resolved value
# comes from a network response and is interpolated into the download URL.
case "${version}" in
*[!A-Za-z0-9.+-]*) fail "resolved version contains illegal characters: '${version}'" ;;
esac
case "${version}" in
v[0-9]*) : ;;
*) fail "resolved version is not a valid release tag: '${version}'" ;;
esac
echo "${version}"
}

Expand All @@ -81,17 +128,36 @@ main() {
fi

ARCHIVE="flashduty-cli_${OS}_${ARCH}.${EXT}"
URL="https://github.com/${REPO}/releases/download/${VERSION}/${ARCHIVE}"
if [ -n "${MIRROR_URL}" ]; then
BASE="${MIRROR_URL}/releases/download/${VERSION}"
else
BASE="https://github.com/${REPO}/releases/download/${VERSION}"
fi

info "Installing Flashduty CLI ${VERSION} (${OS}/${ARCH})"
info "Downloading ${URL}"
info "Downloading ${BASE}/${ARCHIVE}"

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

HTTP_CODE=$(curl -sL -H "Accept: application/octet-stream" -o "${TMP_DIR}/${ARCHIVE}" -w "%{http_code}" "${URL}")
if [ "${HTTP_CODE}" != "200" ]; then
fail "download failed (HTTP ${HTTP_CODE}). Check that ${VERSION} exists at https://github.com/${REPO}/releases"
if ! curl --proto '=https' --tlsv1.2 -fsSL "${BASE}/${ARCHIVE}" -o "${TMP_DIR}/${ARCHIVE}"; then
fail "download failed for ${BASE}/${ARCHIVE}. Check that ${VERSION} exists."
fi

# Verify against the published checksums.txt when present. Releases cut
# before the mirror existed don't ship one, so a missing file only warns.
if curl --proto '=https' --tlsv1.2 -fsSL "${BASE}/checksums.txt" -o "${TMP_DIR}/checksums.txt" 2>/dev/null; then
expected=$(awk -v a="${ARCHIVE}" '$2 == a {print $1; exit}' "${TMP_DIR}/checksums.txt")
if [ -z "${expected}" ]; then
fail "archive ${ARCHIVE} not listed in checksums.txt (wrong release or renamed asset)"
fi
actual=$(sha256_of "${TMP_DIR}/${ARCHIVE}")
if [ "${actual}" != "${expected}" ]; then
fail "checksum mismatch for ${ARCHIVE}: expected ${expected}, got ${actual}"
fi
info "Checksum OK"
else
info "WARNING: checksums.txt not available — skipping integrity check"
fi

if [ "${EXT}" = "zip" ]; then
Expand Down
Loading