From 0a7269bc1aaab3cf2e501899e3101ed3ca57a033 Mon Sep 17 00:00:00 2001 From: ysyneu Date: Thu, 28 May 2026 19:52:25 +0800 Subject: [PATCH] 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. --- .github/workflows/install-scripts.yml | 70 ++++++++++++++++++++++ .github/workflows/release.yml | 53 +++++++++++++++++ .goreleaser.yml | 3 + install.ps1 | 79 +++++++++++++++++++++--- install.sh | 86 +++++++++++++++++++++++---- 5 files changed, 274 insertions(+), 17 deletions(-) create mode 100644 .github/workflows/install-scripts.yml diff --git a/.github/workflows/install-scripts.yml b/.github/workflows/install-scripts.yml new file mode 100644 index 0000000..dc3375d --- /dev/null +++ b/.github/workflows/install-scripts.yml @@ -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" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 67e8859..0e0021f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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" diff --git a/.goreleaser.yml b/.goreleaser.yml index 631cdf2..de6c0f6 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -32,6 +32,9 @@ archives: - goos: windows formats: zip +checksum: + name_template: "checksums.txt" + changelog: sort: asc filters: diff --git a/install.ps1 b/install.ps1 index ca1acf8..d0fd128 100644 --- a/install.ps1 +++ b/install.ps1 @@ -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 +# (/releases/download//) and +# expose a plain-text /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" } @@ -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 --- @@ -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" @@ -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 diff --git a/install.sh b/install.sh index d2daa4f..5476216 100755 --- a/install.sh +++ b/install.sh @@ -1,6 +1,16 @@ #!/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 +# (/releases/download//) and expose +# a plain-text /releases/latest file containing +# the latest tag. set -e REPO="flashcatcloud/flashduty-cli" @@ -8,6 +18,16 @@ 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() { @@ -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() { @@ -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//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}" } @@ -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