diff --git a/.github/workflows/oss-staging.yml b/.github/workflows/oss-staging.yml new file mode 100644 index 0000000..c5acd4e --- /dev/null +++ b/.github/workflows/oss-staging.yml @@ -0,0 +1,60 @@ +name: Test Alibaba Cloud OSS publishing + +on: + workflow_dispatch: + +jobs: + staging: + name: Verify GitHub OIDC staging access + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + environment: + name: oss-staging + steps: + - uses: actions/checkout@v5 + + - name: Validate OSS environment configuration + env: + ALIYUN_OIDC_PROVIDER_ARN: ${{ vars.ALIYUN_OIDC_PROVIDER_ARN }} + ALIYUN_ROLE_ARN: ${{ vars.ALIYUN_ROLE_ARN }} + OSS_BUCKET: ${{ vars.OSS_BUCKET }} + OSS_REGION: ${{ vars.OSS_REGION }} + OSS_ENDPOINT: ${{ vars.OSS_ENDPOINT }} + OSS_PUBLIC_BASE_URL: ${{ vars.OSS_PUBLIC_BASE_URL }} + run: | + set -eu + for name in ALIYUN_OIDC_PROVIDER_ARN ALIYUN_ROLE_ARN OSS_BUCKET OSS_REGION OSS_ENDPOINT OSS_PUBLIC_BASE_URL; do + eval "value=\${$name:-}" + if [ -z "$value" ]; then + echo "error: $name is not configured in the oss-staging environment" >&2 + exit 1 + fi + done + + - name: Download and verify ossutil + run: | + sh scripts/install-ossutil.sh "$RUNNER_TEMP/ossutil-bin" + echo "$RUNNER_TEMP/ossutil-bin" >> "$GITHUB_PATH" + + - name: Exchange GitHub OIDC token for Alibaba Cloud credentials + id: aliyun + uses: aliyun/configure-aliyun-credentials-action@1e5248c8d5d93a8781ac344a68e19a43341e79e6 + with: + oidc-provider-arn: ${{ vars.ALIYUN_OIDC_PROVIDER_ARN }} + role-to-assume: ${{ vars.ALIYUN_ROLE_ARN }} + role-session-name: penguin-oss-staging-${{ github.run_id }} + role-session-expiration: 1800 + audience: github-actions + + - name: Verify staging access boundaries + env: + OSS_ACCESS_KEY_ID: ${{ steps.aliyun.outputs['aliyun-access-key-id'] }} + OSS_ACCESS_KEY_SECRET: ${{ steps.aliyun.outputs['aliyun-access-key-secret'] }} + OSS_SESSION_TOKEN: ${{ steps.aliyun.outputs['aliyun-security-token'] }} + OSS_BUCKET: ${{ vars.OSS_BUCKET }} + OSS_REGION: ${{ vars.OSS_REGION }} + OSS_ENDPOINT: ${{ vars.OSS_ENDPOINT }} + OSS_PUBLIC_BASE_URL: ${{ vars.OSS_PUBLIC_BASE_URL }} + run: sh scripts/test-oss-staging.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3c0b3e0..f9d73b5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,8 +1,8 @@ # Release: tag v* -> build the one-line install artifacts and publish a GitHub Release. # Releases are immutable: once published, assets can never be replaced. A tiny check-release # job therefore gates the release job on the tag's Release not existing yet — dispatching an -# already-released tag skips the build/upload entirely and only re-runs npm publishing. -# Two parallel jobs (the release job is gated on the existence check): +# already-released tag skips the GitHub build/upload while still retrying the OSS mirror and npm publishing. +# Release jobs (the release job is gated on the existence check): # - release: build the monorepo -> pnpm deploy a production CLI dir -> assemble penguin/ (bin + lib + web) # -> one program payload per target (four platform payloads bundling the official Node runtime, # a win-x64 payload with runtime + MinGit, and a runtime-less universal payload) -> wrap each @@ -31,6 +31,10 @@ # that failed mid-chain can be re-run as-is after fixing the config. # npm publishing lives here rather than a separate `on: release: published` workflow: the Release is created # by this workflow's GITHUB_TOKEN, and GitHub won't trigger other workflows' release events from that. +# - mirror-oss: download the exact GitHub Release assets, verify their checksums, then mirror the same bytes +# to immutable releases// keys in Alibaba Cloud OSS through GitHub OIDC. The GitHub Environment +# `oss-production` supplies the provider/role ARNs and OSS settings. latest.json is uploaded last and only +# when the tag is still GitHub's current latest Release. Manual retries also work after a Release exists. name: Release on: @@ -54,7 +58,7 @@ env: jobs: # Skip the build/upload when the tag's Release already exists (immutable releases forbid - # replacing assets, so re-uploading can only fail; npm publishing is idempotent on its own). + # replacing assets, so re-uploading can only fail; OSS mirroring and npm publishing are idempotent). check-release: runs-on: ubuntu-latest permissions: @@ -278,7 +282,7 @@ jobs: - name: Generate SHA256SUMS run: | cd dist-artifacts - sha256sum *.tar.gz *.zip > SHA256SUMS + sha256sum -- *.tar.gz *.zip > SHA256SUMS # Release notes come from changelog//RELEASE.md, written during release preparation and # committed BEFORE the tag (the release job runs on the tag's checkout, so a file added afterwards @@ -319,6 +323,98 @@ jobs: install.sh install.ps1 + mirror-oss: + name: Mirror GitHub Release to Alibaba Cloud OSS + needs: [check-release, release] + if: >- + ${{ always() && needs.check-release.result == 'success' && + (needs.release.result == 'success' || needs.release.result == 'skipped') }} + runs-on: ubuntu-latest + concurrency: + group: penguin-oss-production + cancel-in-progress: false + permissions: + contents: read + id-token: write + environment: + name: oss-production + url: ${{ vars.OSS_PUBLIC_BASE_URL }} + steps: + # On workflow_dispatch, use the selected branch's current mirror scripts while downloading + # the requested tag's immutable Release assets. A tag push naturally checks out that tag. + - uses: actions/checkout@v5 + + - name: Validate OSS environment configuration + env: + ALIYUN_OIDC_PROVIDER_ARN: ${{ vars.ALIYUN_OIDC_PROVIDER_ARN }} + ALIYUN_ROLE_ARN: ${{ vars.ALIYUN_ROLE_ARN }} + OSS_BUCKET: ${{ vars.OSS_BUCKET }} + OSS_REGION: ${{ vars.OSS_REGION }} + OSS_ENDPOINT: ${{ vars.OSS_ENDPOINT }} + OSS_PUBLIC_BASE_URL: ${{ vars.OSS_PUBLIC_BASE_URL }} + run: | + set -eu + for name in ALIYUN_OIDC_PROVIDER_ARN ALIYUN_ROLE_ARN OSS_BUCKET OSS_REGION OSS_ENDPOINT OSS_PUBLIC_BASE_URL; do + eval "value=\${$name:-}" + if [ -z "$value" ]; then + echo "error: $name is not configured in the oss-production environment" >&2 + exit 1 + fi + done + + - name: Download and verify ossutil + run: | + sh scripts/install-ossutil.sh "$RUNNER_TEMP/ossutil-bin" + echo "$RUNNER_TEMP/ossutil-bin" >> "$GITHUB_PATH" + + # Pinned v1 commit. audience must match the client ID configured on the Alibaba Cloud + # OIDC provider; this project deliberately uses `github-actions`. + - name: Exchange GitHub OIDC token for Alibaba Cloud credentials + id: aliyun + uses: aliyun/configure-aliyun-credentials-action@1e5248c8d5d93a8781ac344a68e19a43341e79e6 + with: + oidc-provider-arn: ${{ vars.ALIYUN_OIDC_PROVIDER_ARN }} + role-to-assume: ${{ vars.ALIYUN_ROLE_ARN }} + role-session-name: penguin-oss-${{ github.run_id }} + role-session-expiration: 1800 + audience: github-actions + + - name: Download exact GitHub Release assets + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }} + run: | + mkdir -p release-assets + gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null + gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --dir release-assets + + - name: Determine whether the tag is the latest Release + id: latest + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }} + run: | + LATEST_TAG="$(gh release view --repo "$GITHUB_REPOSITORY" --json tagName --jq .tagName)" + if [ "$TAG" = "$LATEST_TAG" ]; then + echo "update=true" >> "$GITHUB_OUTPUT" + else + echo "update=false" >> "$GITHUB_OUTPUT" + echo "$TAG will be mirrored without replacing latest.json (current latest: $LATEST_TAG)." + fi + + - name: Mirror and verify OSS objects + env: + TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }} + GH_TOKEN: ${{ github.token }} + OSS_ACCESS_KEY_ID: ${{ steps.aliyun.outputs['aliyun-access-key-id'] }} + OSS_ACCESS_KEY_SECRET: ${{ steps.aliyun.outputs['aliyun-access-key-secret'] }} + OSS_SESSION_TOKEN: ${{ steps.aliyun.outputs['aliyun-security-token'] }} + OSS_BUCKET: ${{ vars.OSS_BUCKET }} + OSS_REGION: ${{ vars.OSS_REGION }} + OSS_ENDPOINT: ${{ vars.OSS_ENDPOINT }} + OSS_PUBLIC_BASE_URL: ${{ vars.OSS_PUBLIC_BASE_URL }} + run: sh scripts/publish-release-to-oss.sh release-assets "$TAG" "${{ steps.latest.outputs.update }}" + publish-npm: name: Publish npm packages runs-on: ubuntu-latest diff --git a/install.ps1 b/install.ps1 index a59e4a2..d6b993d 100644 --- a/install.ps1 +++ b/install.ps1 @@ -6,6 +6,8 @@ # $env:PENGUIN_VERSION = "vX.Y.Z" pin a version (same as -Version vX.Y.Z); default is the latest Release # $env:PENGUIN_INSTALL_DIR = "" install dir; default $env:USERPROFILE\.penguin # $env:PENGUIN_ARCHIVE = "" install a local Release zip without network access (same as -ArchivePath) +# $env:PENGUIN_DOWNLOAD_BASE_URL = "https://..." exact online asset directory selected by the stable forwarder +# $env:PENGUIN_DOWNLOAD_FALLBACK_BASE_URL = "https://..." same-version fallback asset directory # # Each Release attaches exactly one Windows artifact: penguin-win32-x64.zip, a shallow installer # bundle holding install.cmd, this script, the program payload (payload.zip) and the payload's @@ -67,6 +69,38 @@ function Assert-Sha256([string]$FilePath, [string]$ShaPath, [string]$Label) { Write-Host "$Label checksum OK." } +function Assert-HttpsUrl([string]$Name, [string]$Value) { + try { $Uri = [Uri]$Value } catch { Fail "$Name is not a valid URL" } + if (-not $Uri.IsAbsoluteUri -or $Uri.Scheme -ne "https") { + Fail "$Name must be an absolute HTTPS URL" + } +} + +function Get-DownloadSourceLabel([string]$BaseUrl) { + try { $HostName = ([Uri]$BaseUrl).Host } catch { return "configured mirror" } + if ($HostName -like "*.aliyuncs.com") { return "OSS mirror" } + if ($HostName -eq "github.com") { return "GitHub" } + return "configured mirror" +} + +function Get-ReleasePair( + [string]$BaseUrl, + [string]$ZipPath, + [string]$ShaPath +) { + $Label = Get-DownloadSourceLabel $BaseUrl + Write-Host "Downloading $Asset from $Label ..." + Remove-Item -LiteralPath $ZipPath, $ShaPath -Force -ErrorAction SilentlyContinue + try { + Invoke-WebRequest -Uri "$BaseUrl/$Asset" -OutFile $ZipPath -UseBasicParsing + Invoke-WebRequest -Uri "$BaseUrl/$Asset.sha256" -OutFile $ShaPath -UseBasicParsing + return $true + } catch { + Remove-Item -LiteralPath $ZipPath, $ShaPath -Force -ErrorAction SilentlyContinue + return $false + } +} + function Restore-PreviousInstall( [string]$InstallDir, [string]$OldDir, @@ -97,6 +131,16 @@ if (-not $InstallDir) { if (-not $ArchivePath) { $ArchivePath = if ($env:PENGUIN_ARCHIVE) { $env:PENGUIN_ARCHIVE } else { "" } } +$DownloadBaseUrl = if ($env:PENGUIN_DOWNLOAD_BASE_URL) { + $env:PENGUIN_DOWNLOAD_BASE_URL.TrimEnd('/') +} else { + "" +} +$DownloadFallbackBaseUrl = if ($env:PENGUIN_DOWNLOAD_FALLBACK_BASE_URL) { + $env:PENGUIN_DOWNLOAD_FALLBACK_BASE_URL.TrimEnd('/') +} else { + "" +} # An extracted installer bundle keeps install.cmd, this script, payload.zip and its checksum # together. `$PSScriptRoot` is empty for the documented `irm ... | iex` path, so online installs # do not accidentally pick up an unrelated archive from the caller's current directory. @@ -107,6 +151,15 @@ if (-not $ArchivePath -and $PSScriptRoot) { if ($ArchivePath -and $Version) { Fail "-ArchivePath/PENGUIN_ARCHIVE cannot be combined with -Version/PENGUIN_VERSION" } +if ($Version -and $Version -notmatch '^v[0-9A-Za-z][0-9A-Za-z._-]*$') { + Fail "invalid release version: $Version" +} +if ($DownloadBaseUrl) { + Assert-HttpsUrl "PENGUIN_DOWNLOAD_BASE_URL" $DownloadBaseUrl +} +if ($DownloadFallbackBaseUrl) { + Assert-HttpsUrl "PENGUIN_DOWNLOAD_FALLBACK_BASE_URL" $DownloadFallbackBaseUrl +} # --- Platform preconditions: 64-bit Windows; the only Windows package is x64 (ARM64 runs it emulated) --- if (-not [Environment]::Is64BitOperatingSystem) { @@ -123,8 +176,10 @@ try { # .NET builds where the enum is immutable already default to TLS 1.2+. } -# --- Download (latest Release by default; PENGUIN_VERSION pins a version) --- -if ($Version) { +# --- Download (latest GitHub Release by default; the stable forwarder may select exact mirrors) --- +if ($DownloadBaseUrl) { + $BaseUrl = $DownloadBaseUrl +} elseif ($Version) { $BaseUrl = "$Repo/releases/download/$Version" } else { $BaseUrl = "$Repo/releases/latest/download" @@ -149,19 +204,18 @@ try { Write-Host "Using local archive $ZipPath ..." } else { # Online: download the canonical bundle; the published checksum is mandatory. - Write-Host "Downloading $BaseUrl/$Asset ..." $ZipPath = Join-Path $Tmp $Asset - try { - Invoke-WebRequest -Uri "$BaseUrl/$Asset" -OutFile $ZipPath -UseBasicParsing - } catch { - Fail "download failed. Check the version tag and your network, then retry. ($($_.Exception.Message))" - } $ArchiveName = $Asset $ShaPath = Join-Path $Tmp "$Asset.sha256" - try { - Invoke-WebRequest -Uri "$BaseUrl/$Asset.sha256" -OutFile $ShaPath -UseBasicParsing - } catch { - Fail "checksum download failed. Check the version tag and your network, then retry. ($($_.Exception.Message))" + if (-not (Get-ReleasePair $BaseUrl $ZipPath $ShaPath)) { + if ($DownloadFallbackBaseUrl -and $DownloadFallbackBaseUrl -ne $BaseUrl) { + Write-Host "Primary download source unavailable; trying $(Get-DownloadSourceLabel $DownloadFallbackBaseUrl) ..." + if (-not (Get-ReleasePair $DownloadFallbackBaseUrl $ZipPath $ShaPath)) { + Fail "download failed from both the primary source and its fallback. Check your network, then retry." + } + } else { + Fail "download failed from $(Get-DownloadSourceLabel $BaseUrl). Check the version tag and your network, then retry." + } } Assert-Sha256 $ZipPath $ShaPath "Bundle" } diff --git a/install.sh b/install.sh index e6a45df..8f55698 100644 --- a/install.sh +++ b/install.sh @@ -7,6 +7,8 @@ # PENGUIN_VERSION=vX.Y.Z pin a version (same as --version vX.Y.Z); default is the latest Release # PENGUIN_INSTALL_DIR= install dir; default ~/.penguin # PENGUIN_ARCHIVE= install a local Release archive without network access (same as --archive ) +# PENGUIN_DOWNLOAD_BASE_URL= exact online asset directory selected by the stable forwarder +# PENGUIN_DOWNLOAD_FALLBACK_BASE_URL= same-version fallback asset directory # --universal install the universal package (no bundled Node runtime; needs system Node >= 24) # # Each Release attaches exactly one artifact per target: penguin-.tar.gz, a shallow @@ -29,6 +31,8 @@ INSTALL_DIR="${PENGUIN_INSTALL_DIR:-$HOME/.penguin}" BIN_DIR="$HOME/.local/bin" UNIVERSAL=0 ARCHIVE="${PENGUIN_ARCHIVE:-}" +DOWNLOAD_BASE_URL="${PENGUIN_DOWNLOAD_BASE_URL:-}" +DOWNLOAD_FALLBACK_BASE_URL="${PENGUIN_DOWNLOAD_FALLBACK_BASE_URL:-}" PAYLOAD_NAME="payload.tar.gz" fail() { @@ -36,6 +40,31 @@ fail() { exit 1 } +validate_https_url() { + case "$2" in + https://*) ;; + *) fail "$1 must be an absolute HTTPS URL" ;; + esac +} + +validate_release_tag() { + case "$1" in + v[0-9A-Za-z]* ) ;; + *) fail "invalid release version: $1" ;; + esac + case "$1" in + *[!0-9A-Za-z._-]*) fail "invalid release version: $1" ;; + esac +} + +download_source_label() { + case "$1" in + https://*.aliyuncs.com/*) printf '%s\n' "OSS mirror" ;; + https://github.com/*) printf '%s\n' "GitHub" ;; + *) printf '%s\n' "configured mirror" ;; + esac +} + # --- Parse args (also passable via curl | sh -s -- --universal) --- while [ $# -gt 0 ]; do case "$1" in @@ -79,6 +108,17 @@ ASSET="penguin-$TARGET.tar.gz" if [ -n "$ARCHIVE" ] && [ -n "$VERSION" ]; then fail "--archive/PENGUIN_ARCHIVE cannot be combined with --version/PENGUIN_VERSION" fi +if [ -n "$VERSION" ]; then + validate_release_tag "$VERSION" +fi +if [ -n "$DOWNLOAD_BASE_URL" ]; then + DOWNLOAD_BASE_URL="${DOWNLOAD_BASE_URL%/}" + validate_https_url PENGUIN_DOWNLOAD_BASE_URL "$DOWNLOAD_BASE_URL" +fi +if [ -n "$DOWNLOAD_FALLBACK_BASE_URL" ]; then + DOWNLOAD_FALLBACK_BASE_URL="${DOWNLOAD_FALLBACK_BASE_URL%/}" + validate_https_url PENGUIN_DOWNLOAD_FALLBACK_BASE_URL "$DOWNLOAD_FALLBACK_BASE_URL" +fi # --- Universal package precheck: system Node >= 24 (platform packages bundle the runtime, so exempt) --- if [ "$UNIVERSAL" -eq 1 ]; then @@ -184,6 +224,21 @@ verify_sha256() { echo "$3 checksum OK." } +# Downloads the bundle and its checksum as a pair. Transport failures may try a same-version +# fallback; checksum failures are handled afterwards and always abort rather than being hidden +# by a different source. +download_release_pair() { + drp_base="$1" + drp_label="$(download_source_label "$drp_base")" + echo "Downloading $ASSET from $drp_label ..." + rm -f "$ARCHIVE_PATH" "$TMP/$ASSET.sha256" + curl -fSL --progress-bar "$drp_base/$ASSET" -o "$ARCHIVE_PATH" \ + || return 1 + curl -fsSL "$drp_base/$ASSET.sha256" -o "$TMP/$ASSET.sha256" \ + || return 1 + return 0 +} + # --- Resolve the program payload. Three entries converge on PAYLOAD_PATH: # (a) bundled offline: this script sits next to payload.tar.gz in an extracted bundle; # (b) --archive : a local installer bundle, or a payload/legacy program archive; @@ -231,18 +286,24 @@ elif [ -n "$ARCHIVE" ]; then echo "Using local archive $ARCHIVE_PATH ..." else # (c) Online: download the canonical bundle; the published checksum is mandatory. - if [ -n "$VERSION" ]; then + if [ -n "$DOWNLOAD_BASE_URL" ]; then + BASE_URL="$DOWNLOAD_BASE_URL" + elif [ -n "$VERSION" ]; then BASE_URL="$REPO/releases/download/$VERSION" else BASE_URL="$REPO/releases/latest/download" fi ARCHIVE_PATH="$TMP/$ASSET" ARCHIVE_NAME="$ASSET" - echo "Downloading $BASE_URL/$ASSET ..." - curl -fSL --progress-bar "$BASE_URL/$ASSET" -o "$ARCHIVE_PATH" \ - || fail "download failed. Check the version tag and your network, then retry." - curl -fsSL "$BASE_URL/$ASSET.sha256" -o "$TMP/$ASSET.sha256" \ - || fail "checksum download failed. Check the version tag and your network, then retry." + if ! download_release_pair "$BASE_URL"; then + if [ -n "$DOWNLOAD_FALLBACK_BASE_URL" ] && [ "$DOWNLOAD_FALLBACK_BASE_URL" != "$BASE_URL" ]; then + echo "Primary download source unavailable; trying $(download_source_label "$DOWNLOAD_FALLBACK_BASE_URL") ..." + download_release_pair "$DOWNLOAD_FALLBACK_BASE_URL" \ + || fail "download failed from both the primary source and its fallback. Check your network, then retry." + else + fail "download failed from $(download_source_label "$BASE_URL"). Check the version tag and your network, then retry." + fi + fi verify_sha256 "$ARCHIVE_PATH" "$TMP/$ASSET.sha256" "Bundle" fi diff --git a/packages/docs/content/installation.en.md b/packages/docs/content/installation.en.md index fd409d4..77378f7 100644 --- a/packages/docs/content/installation.en.md +++ b/packages/docs/content/installation.en.md @@ -19,6 +19,8 @@ curl -fsSL https://penguin.ooo/install.sh | sh The script downloads the matching `penguin-{linux,darwin}-{x64,arm64}.tar.gz` — the canonical installer bundle, sealing the program payload (with an official Node.js runtime), the payload's SHA256 checksum and this same installer. The download is verified against its published `.sha256`, then the sealed payload checksum is verified again before anything is staged. Other POSIX platforms do **not** fall back automatically: the script exits and asks you to install Node.js >= 24 and re-run with `--universal`, which selects the runtime-less `penguin-universal.tar.gz` bundle (Windows is served by its own installer below, not by `--universal`). +The stable entry point defaults to `PENGUIN_DOWNLOAD_SOURCE=auto`: it prefers an immutable OSS release directory only after that release has been completely uploaded and verified, then falls back to the matching GitHub Release if the metadata or download is unavailable. Set the variable to `oss` or `github` to force either source. Normal installer output names the source without printing the mirror's full URL. + On Windows (PowerShell): ```powershell @@ -62,6 +64,7 @@ The extracted bundle keeps the installer, the program payload (`payload.tar.gz` | Install dir | `~/.penguin` by default; override with the `PENGUIN_INSTALL_DIR` env var | | Command entry | A symlink `~/.local/bin/penguin` is created (the script warns if `~/.local/bin` is not on PATH) | | Version pin | `PENGUIN_VERSION=vX.Y.Z` env var, or the `--version vX.Y.Z` script flag; defaults to the latest Release | +| Download source | `PENGUIN_DOWNLOAD_SOURCE=auto` (default), `oss`, or `github`; auto prefers OSS and falls back to the same GitHub version | | Local archive | `PENGUIN_ARCHIVE=` or `--archive `; accepts a Release bundle (self-verifying via its sealed payload checksum) or a payload/legacy program archive with an adjacent `.sha256` (renamed legacy files may use the platform asset's canonical `.sha256`) | | Integrity check | Always on: online downloads are verified against the published `.sha256`, and bundle payloads against the checksum sealed inside the bundle | | Upgrade | Re-run the install script; files are swapped atomically | diff --git a/packages/docs/content/installation.zh.md b/packages/docs/content/installation.zh.md index 618d1b5..ab15e95 100644 --- a/packages/docs/content/installation.zh.md +++ b/packages/docs/content/installation.zh.md @@ -19,6 +19,8 @@ curl -fsSL https://penguin.ooo/install.sh | sh 脚本按平台下载 `penguin-{linux,darwin}-{x64,arm64}.tar.gz`——即标准安装包:包内封入程序负载(捆绑官方 Node.js 运行时)、负载的 SHA256 校验文件与同一个安装器。下载后先对照 Release 发布的 `.sha256` 校验外层,再校验包内封入的负载 checksum,然后才进入暂存安装。其他 POSIX 平台**不会自动回退**:脚本会退出并提示先安装 Node.js >= 24、再携带 `--universal` 重新执行,改用不含运行时的 `penguin-universal.tar.gz` 安装包(Windows 使用下方专属安装器,而不是 `--universal`)。 +稳定入口默认使用 `PENGUIN_DOWNLOAD_SOURCE=auto`:优先选择已完整上传并验证的 OSS 不可变版本目录;元数据或下载不可用时,回退到同一版本的 GitHub Release。也可以将该变量设为 `oss` 或 `github` 来强制指定来源。安装器只显示来源名称,不在常规输出中打印镜像的完整 URL。 + 在 Windows(PowerShell)上执行: ```powershell @@ -62,6 +64,7 @@ Linux / macOS 上执行: | 安装目录 | 默认 `~/.penguin`,可用环境变量 `PENGUIN_INSTALL_DIR` 覆盖 | | 命令入口 | 创建符号链接 `~/.local/bin/penguin`(若 `~/.local/bin` 不在 PATH 上,脚本会给出提示) | | 版本固定 | 环境变量 `PENGUIN_VERSION=vX.Y.Z`,或脚本参数 `--version vX.Y.Z`;默认安装最新 Release | +| 下载来源 | `PENGUIN_DOWNLOAD_SOURCE=auto`(默认)、`oss` 或 `github`;自动模式优先 OSS,并按同一版本回退到 GitHub | | 本地压缩包 | `PENGUIN_ARCHIVE=` 或 `--archive `;接受 Release 安装包(凭包内封入的负载 checksum 自校验),或旁边带 `.sha256` 的负载 / 旧版程序压缩包(重命名的旧版文件可用平台标准名称的 `.sha256`) | | 完整性校验 | 始终进行:在线下载对照发布的 `.sha256` 校验,安装包负载对照包内封入的 checksum 校验 | | 升级 | 重新执行安装脚本即可,文件原子替换 | diff --git a/packages/landing/public/install.ps1 b/packages/landing/public/install.ps1 index d9eed1e..d81fe3d 100644 --- a/packages/landing/public/install.ps1 +++ b/packages/landing/public/install.ps1 @@ -1,33 +1,161 @@ # https://penguin.ooo/install.ps1 - PenguinHarness installer entry point for Windows. # # GitHub Pages cannot serve HTTP redirects, so this thin forwarder IS the -# stable install URL: it fetches the real installer attached to the latest -# GitHub release and runs it, forwarding every argument it was given. Usage: +# stable install URL. It selects an immutable OSS release when that mirror is +# available, otherwise it falls back to the matching GitHub Release, then runs +# the real installer while forwarding every argument it was given. Usage: # # irm https://penguin.ooo/install.ps1 | iex # & ([scriptblock]::Create((irm https://penguin.ooo/install.ps1))) -Version v0.2.0 # -$ErrorActionPreference = "Stop" -$ProgressPreference = "SilentlyContinue" -try { - [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 -} catch { - # .NET builds where the enum is immutable already default to TLS 1.2+. -} -# Download fully first, then run: executing a piped stream directly would run a -# truncated download line by line, and the real installer moves the old -# bin/lib/web/node aside before moving the new ones in - a cut connection -# mid-way must never leave a half-executed installer. The installer runs as an -# in-memory script block (not a script file): script files are subject to the -# execution policy, which is Restricted by default on client Windows - while the -# user has already consented to remote code by piping this forwarder into iex. -# Neither this forwarder nor the installer calls `exit`, which in iex/script-block -# context would terminate the user's whole PowerShell session. -$Tmp = Join-Path ([IO.Path]::GetTempPath()) "penguin-install-$PID.ps1" -try { - Invoke-WebRequest -Uri "https://github.com/Prism-Shadow/penguin-harness/releases/latest/download/install.ps1" -OutFile $Tmp -UseBasicParsing - $Installer = [scriptblock]::Create((Get-Content -Path $Tmp -Raw)) - & $Installer @args -} finally { - Remove-Item -Force $Tmp -ErrorAction SilentlyContinue -} +& { + $ForwardedArgs = @($args) + $ErrorActionPreference = "Stop" + $ProgressPreference = "SilentlyContinue" + $OssOrigin = "https://penguin-harness-releases.oss-cn-beijing.aliyuncs.com" + $OssReleaseRoot = "$OssOrigin/releases" + $GitHubReleaseRoot = "https://github.com/Prism-Shadow/penguin-harness/releases/download" + $GitHubLatestBase = "https://github.com/Prism-Shadow/penguin-harness/releases/latest/download" + + function Fail([string]$Message) { + throw "error: $Message" + } + + function Test-HttpsUrl([string]$Value) { + try { $Uri = [Uri]$Value } catch { return $false } + return $Uri.IsAbsoluteUri -and $Uri.Scheme -eq "https" + } + + function Test-ReleaseTag([string]$Value) { + return $Value -match '^v[0-9A-Za-z][0-9A-Za-z._-]*$' + } + + function Try-DownloadFile([string]$Uri, [string]$OutFile, [int]$TimeoutSec) { + try { + Invoke-WebRequest -Uri $Uri -OutFile $OutFile -UseBasicParsing -TimeoutSec $TimeoutSec + return $true + } catch { + Remove-Item -LiteralPath $OutFile -Force -ErrorAction SilentlyContinue + return $false + } + } + + try { + [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 + } catch { + # .NET builds where the enum is immutable already default to TLS 1.2+. + } + + $SourceMode = if ($env:PENGUIN_DOWNLOAD_SOURCE) { $env:PENGUIN_DOWNLOAD_SOURCE.ToLowerInvariant() } else { "auto" } + if ($SourceMode -notin @("auto", "oss", "github")) { + Fail "PENGUIN_DOWNLOAD_SOURCE must be auto, oss, or github" + } + + $RequestedVersion = if ($env:PENGUIN_VERSION) { $env:PENGUIN_VERSION } else { "" } + for ($i = 0; $i -lt $ForwardedArgs.Count; $i++) { + if ([string]$ForwardedArgs[$i] -ieq "-Version" -and $i + 1 -lt $ForwardedArgs.Count) { + $RequestedVersion = [string]$ForwardedArgs[$i + 1] + $i++ + } + } + if ($RequestedVersion -and -not (Test-ReleaseTag $RequestedVersion)) { + Fail "invalid release version: $RequestedVersion" + } + + # Download fully first, then run: executing a piped stream directly would run a + # truncated download line by line, and the real installer moves the old + # bin/lib/web/node aside before moving the new ones in - a cut connection + # mid-way must never leave a half-executed installer. The installer runs as an + # in-memory script block (not a script file): script files are subject to the + # execution policy, which is Restricted by default on client Windows - while the + # user has already consented to remote code by piping this forwarder into iex. + # Neither this forwarder nor the installer calls `exit`, which in iex/script-block + # context would terminate the user's whole PowerShell session. + $TmpDir = Join-Path ([IO.Path]::GetTempPath()) "penguin-forwarder-$PID" + $InstallerPath = Join-Path $TmpDir "install.ps1" + $ManifestPath = Join-Path $TmpDir "latest.json" + $SelectedBase = "" + $FallbackBase = "" + $OriginalBase = [Environment]::GetEnvironmentVariable("PENGUIN_DOWNLOAD_BASE_URL", "Process") + $OriginalFallback = [Environment]::GetEnvironmentVariable("PENGUIN_DOWNLOAD_FALLBACK_BASE_URL", "Process") + + try { + if (Test-Path -LiteralPath $TmpDir) { + Remove-Item -LiteralPath $TmpDir -Recurse -Force + } + New-Item -ItemType Directory -Path $TmpDir | Out-Null + + if ($OriginalBase) { + $SelectedBase = $OriginalBase.TrimEnd('/') + $FallbackBase = if ($OriginalFallback) { $OriginalFallback.TrimEnd('/') } else { "" } + if (-not (Test-HttpsUrl $SelectedBase)) { Fail "PENGUIN_DOWNLOAD_BASE_URL must be an absolute HTTPS URL" } + if ($FallbackBase -and -not (Test-HttpsUrl $FallbackBase)) { + Fail "PENGUIN_DOWNLOAD_FALLBACK_BASE_URL must be an absolute HTTPS URL" + } + if (-not (Try-DownloadFile "$SelectedBase/install.ps1" $InstallerPath 30)) { + Fail "could not download the installer from the configured mirror." + } + } elseif ($SourceMode -eq "github") { + $SelectedBase = if ($RequestedVersion) { "$GitHubReleaseRoot/$RequestedVersion" } else { $GitHubLatestBase } + if (-not (Try-DownloadFile "$SelectedBase/install.ps1" $InstallerPath 30)) { + Fail "could not download the installer from GitHub. Check your network, then retry." + } + } else { + $OssTag = $RequestedVersion + $OssBase = if ($OssTag) { "$OssReleaseRoot/$OssTag" } else { "" } + + if (-not $OssTag -and (Try-DownloadFile "$OssOrigin/latest.json" $ManifestPath 8)) { + try { + $Manifest = [IO.File]::ReadAllText($ManifestPath, [Text.UTF8Encoding]::new($false)) | ConvertFrom-Json + $CandidateTag = [string]$Manifest.tag + $CandidateBase = ([string]$Manifest.releaseBaseUrl).TrimEnd('/') + if ([int]$Manifest.schemaVersion -eq 1 -and + (Test-ReleaseTag $CandidateTag) -and + $CandidateBase -eq "$OssReleaseRoot/$CandidateTag") { + $OssTag = $CandidateTag + $OssBase = $CandidateBase + } + } catch { + $OssTag = "" + $OssBase = "" + } + } + + if ($OssBase -and (Try-DownloadFile "$OssBase/install.ps1" $InstallerPath 30)) { + $SelectedBase = $OssBase + if ($SourceMode -eq "auto") { + $FallbackBase = "$GitHubReleaseRoot/$OssTag" + } + } elseif ($SourceMode -eq "oss") { + Fail "the OSS mirror is unavailable or its release metadata is invalid." + } else { + $SelectedBase = if ($OssTag) { "$GitHubReleaseRoot/$OssTag" } else { $GitHubLatestBase } + if (-not (Try-DownloadFile "$SelectedBase/install.ps1" $InstallerPath 30)) { + Fail "could not download the installer from GitHub. Check your network, then retry." + } + } + } + + $env:PENGUIN_DOWNLOAD_BASE_URL = $SelectedBase + if ($FallbackBase) { + $env:PENGUIN_DOWNLOAD_FALLBACK_BASE_URL = $FallbackBase + } else { + Remove-Item Env:\PENGUIN_DOWNLOAD_FALLBACK_BASE_URL -ErrorAction SilentlyContinue + } + $InstallerText = [IO.File]::ReadAllText($InstallerPath, [Text.UTF8Encoding]::new($false)) + $Installer = [scriptblock]::Create($InstallerText) + & $Installer @ForwardedArgs + } finally { + if ($null -eq $OriginalBase) { + Remove-Item Env:\PENGUIN_DOWNLOAD_BASE_URL -ErrorAction SilentlyContinue + } else { + $env:PENGUIN_DOWNLOAD_BASE_URL = $OriginalBase + } + if ($null -eq $OriginalFallback) { + Remove-Item Env:\PENGUIN_DOWNLOAD_FALLBACK_BASE_URL -ErrorAction SilentlyContinue + } else { + $env:PENGUIN_DOWNLOAD_FALLBACK_BASE_URL = $OriginalFallback + } + Remove-Item -LiteralPath $TmpDir -Recurse -Force -ErrorAction SilentlyContinue + } +} @args diff --git a/packages/landing/public/install.sh b/packages/landing/public/install.sh index 94ca728..a310a28 100644 --- a/packages/landing/public/install.sh +++ b/packages/landing/public/install.sh @@ -2,13 +2,63 @@ # https://penguin.ooo/install.sh - PenguinHarness installer entry point. # # GitHub Pages cannot serve HTTP redirects, so this thin forwarder IS the -# stable install URL: it fetches the real installer attached to the latest -# GitHub release and runs it, forwarding every argument it was given. Usage: +# stable install URL. It selects an immutable OSS release when that mirror is +# available, otherwise it falls back to the matching GitHub Release, then runs +# the real installer while forwarding every argument it was given. Usage: # # curl -fsSL https://penguin.ooo/install.sh | sh # curl -fsSL https://penguin.ooo/install.sh | sh -s -- --universal # set -eu +OSS_ORIGIN="https://penguin-harness-releases.oss-cn-beijing.aliyuncs.com" +OSS_RELEASE_ROOT="$OSS_ORIGIN/releases" +GITHUB_RELEASE_ROOT="https://github.com/Prism-Shadow/penguin-harness/releases/download" +SOURCE_MODE="${PENGUIN_DOWNLOAD_SOURCE:-auto}" + +fail() { + echo "error: $1" >&2 + exit 1 +} + +validate_https_url() { + case "$2" in + https://*) ;; + *) fail "$1 must be an absolute HTTPS URL" ;; + esac +} + +is_release_tag() { + case "$1" in + v[0-9A-Za-z]*) ;; + *) return 1 ;; + esac + case "$1" in + *[!0-9A-Za-z._-]*) return 1 ;; + esac + return 0 +} + +validate_release_tag() { + is_release_tag "$1" || fail "invalid release version: $1" +} + +case "$SOURCE_MODE" in + auto | oss | github) ;; + *) fail "PENGUIN_DOWNLOAD_SOURCE must be auto, oss, or github" ;; +esac + +REQUESTED_VERSION="${PENGUIN_VERSION:-}" +expect_version=0 +for arg in "$@"; do + if [ "$expect_version" -eq 1 ]; then + REQUESTED_VERSION="$arg" + expect_version=0 + elif [ "$arg" = "--version" ]; then + expect_version=1 + fi +done +[ -z "$REQUESTED_VERSION" ] || validate_release_tag "$REQUESTED_VERSION" + # Download to a file first, then run it: piping straight into `sh` would execute # a truncated download line by line, and the real installer removes the old # bin/lib/web/node before moving the new ones in — a cut connection mid-way @@ -18,7 +68,74 @@ set -eu # never offer that seam to other local users. TMP_DIR="$(mktemp -d)" trap 'rm -rf "$TMP_DIR"' EXIT -curl -fsSL "https://github.com/Prism-Shadow/penguin-harness/releases/latest/download/install.sh" -o "$TMP_DIR/install.sh" +INSTALLER="$TMP_DIR/install.sh" +MANIFEST="$TMP_DIR/latest.json" +SELECTED_BASE="" +FALLBACK_BASE="" + +download_installer() { + curl -fsSL --connect-timeout 5 --max-time 30 "$1/install.sh" -o "$INSTALLER" +} + +use_github() { + if [ -n "$1" ]; then + SELECTED_BASE="$GITHUB_RELEASE_ROOT/$1" + else + SELECTED_BASE="https://github.com/Prism-Shadow/penguin-harness/releases/latest/download" + fi + FALLBACK_BASE="" + download_installer "$SELECTED_BASE" \ + || fail "could not download the installer from GitHub. Check your network, then retry." +} + +EXPLICIT_BASE="${PENGUIN_DOWNLOAD_BASE_URL:-}" +if [ -n "$EXPLICIT_BASE" ]; then + SELECTED_BASE="${EXPLICIT_BASE%/}" + FALLBACK_BASE="${PENGUIN_DOWNLOAD_FALLBACK_BASE_URL:-}" + FALLBACK_BASE="${FALLBACK_BASE%/}" + validate_https_url PENGUIN_DOWNLOAD_BASE_URL "$SELECTED_BASE" + [ -z "$FALLBACK_BASE" ] || validate_https_url PENGUIN_DOWNLOAD_FALLBACK_BASE_URL "$FALLBACK_BASE" + download_installer "$SELECTED_BASE" \ + || fail "could not download the installer from the configured mirror." +elif [ "$SOURCE_MODE" = "github" ]; then + use_github "$REQUESTED_VERSION" +else + OSS_TAG="$REQUESTED_VERSION" + OSS_BASE="" + if [ -n "$OSS_TAG" ]; then + OSS_BASE="$OSS_RELEASE_ROOT/$OSS_TAG" + elif curl -fsSL --connect-timeout 3 --max-time 8 "$OSS_ORIGIN/latest.json" -o "$MANIFEST" 2>/dev/null; then + schema_version="$(sed -n 's/.*"schemaVersion":[[:space:]]*\([0-9][0-9]*\).*/\1/p' "$MANIFEST" | head -n 1)" + candidate_tag="$(sed -n 's/.*"tag":[[:space:]]*"\([^"]*\)".*/\1/p' "$MANIFEST" | head -n 1)" + candidate_base="$(sed -n 's/.*"releaseBaseUrl":[[:space:]]*"\([^"]*\)".*/\1/p' "$MANIFEST" | head -n 1)" + if [ "$schema_version" = "1" ] && is_release_tag "$candidate_tag"; then + if [ "$candidate_base" = "$OSS_RELEASE_ROOT/$candidate_tag" ]; then + OSS_TAG="$candidate_tag" + OSS_BASE="$candidate_base" + fi + fi + fi + + if [ -n "$OSS_BASE" ] && download_installer "$OSS_BASE" 2>/dev/null; then + SELECTED_BASE="$OSS_BASE" + if [ "$SOURCE_MODE" = "auto" ]; then + FALLBACK_BASE="$GITHUB_RELEASE_ROOT/$OSS_TAG" + fi + elif [ "$SOURCE_MODE" = "oss" ]; then + fail "the OSS mirror is unavailable or its release metadata is invalid." + else + use_github "$OSS_TAG" + fi +fi + rc=0 -sh "$TMP_DIR/install.sh" "$@" || rc=$? +( + export PENGUIN_DOWNLOAD_BASE_URL="$SELECTED_BASE" + if [ -n "$FALLBACK_BASE" ]; then + export PENGUIN_DOWNLOAD_FALLBACK_BASE_URL="$FALLBACK_BASE" + else + unset PENGUIN_DOWNLOAD_FALLBACK_BASE_URL + fi + sh "$INSTALLER" "$@" +) || rc=$? exit "$rc" diff --git a/scripts/install-ossutil.sh b/scripts/install-ossutil.sh new file mode 100644 index 0000000..1e68a7e --- /dev/null +++ b/scripts/install-ossutil.sh @@ -0,0 +1,40 @@ +#!/bin/sh +# Install a checksum-pinned ossutil 2 binary into a caller-provided directory. +# Usage: install-ossutil.sh +set -eu + +BIN_DIR="${1:?usage: install-ossutil.sh }" +VERSION="2.3.0" +ARCHIVE="ossutil-$VERSION-linux-amd64.zip" +ARCHIVE_SHA256="3ae4d9fc85a7a6e9f5654d1599766f1a3a42a3692870887b5ae9338d582ef65a" +DOWNLOAD_URL="https://gosspublic.alicdn.com/ossutil/v2/$VERSION/$ARCHIVE" + +command -v curl >/dev/null 2>&1 || { + echo "error: curl is required" >&2 + exit 1 +} +command -v sha256sum >/dev/null 2>&1 || { + echo "error: sha256sum is required" >&2 + exit 1 +} +command -v unzip >/dev/null 2>&1 || { + echo "error: unzip is required" >&2 + exit 1 +} + +WORK_DIR="$(mktemp -d)" +trap 'rm -rf "$WORK_DIR"' EXIT + +curl --proto '=https' --tlsv1.2 -fsSL "$DOWNLOAD_URL" -o "$WORK_DIR/$ARCHIVE" +printf '%s %s\n' "$ARCHIVE_SHA256" "$WORK_DIR/$ARCHIVE" | sha256sum -c - +unzip -q "$WORK_DIR/$ARCHIVE" -d "$WORK_DIR/extracted" + +OSSUTIL_SOURCE="$(find "$WORK_DIR/extracted" -type f -name ossutil -print -quit)" +[ -n "$OSSUTIL_SOURCE" ] || { + echo "error: ossutil binary not found in $ARCHIVE" >&2 + exit 1 +} + +mkdir -p "$BIN_DIR" +install -m 0755 "$OSSUTIL_SOURCE" "$BIN_DIR/ossutil" +"$BIN_DIR/ossutil" version diff --git a/scripts/publish-release-to-oss.sh b/scripts/publish-release-to-oss.sh new file mode 100644 index 0000000..eb1030f --- /dev/null +++ b/scripts/publish-release-to-oss.sh @@ -0,0 +1,220 @@ +#!/bin/sh +# Mirror the exact assets downloaded from a GitHub Release into Alibaba Cloud OSS. +# +# Usage: publish-release-to-oss.sh [update-latest] +# update-latest: true only when is GitHub's current latest Release. +# +# Required environment: +# OSS_BUCKET, OSS_REGION, OSS_ENDPOINT, OSS_PUBLIC_BASE_URL and temporary +# OSS_* credentials. +set -eu + +RELEASE_DIR="${1:?usage: publish-release-to-oss.sh [update-latest]}" +TAG="${2:?usage: publish-release-to-oss.sh [update-latest]}" +UPDATE_LATEST="${3:-false}" +OSSUTIL_BIN="${OSSUTIL_BIN:-ossutil}" + +require_env() { + eval "value=\${$1:-}" + [ -n "$value" ] || { + echo "error: required environment variable $1 is empty" >&2 + exit 1 + } +} + +for name in OSS_BUCKET OSS_REGION OSS_ENDPOINT OSS_PUBLIC_BASE_URL; do + require_env "$name" +done + +[ -d "$RELEASE_DIR" ] || { + echo "error: release directory not found: $RELEASE_DIR" >&2 + exit 1 +} +case "$TAG" in + v[0-9]*) VERSION="${TAG#v}" ;; + *) + echo "error: release tag must start with v followed by a digit: $TAG" >&2 + exit 1 + ;; +esac +case "$TAG" in + *[!A-Za-z0-9._+-]*|*..*) + echo "error: release tag is not safe for an OSS object prefix: $TAG" >&2 + exit 1 + ;; +esac +case "$UPDATE_LATEST" in + true|false) ;; + *) + echo "error: update-latest must be true or false" >&2 + exit 1 + ;; +esac +command -v "$OSSUTIL_BIN" >/dev/null 2>&1 || { + echo "error: ossutil not found: $OSSUTIL_BIN" >&2 + exit 1 +} +command -v sha256sum >/dev/null 2>&1 || { + echo "error: sha256sum is required" >&2 + exit 1 +} +command -v jq >/dev/null 2>&1 || { + echo "error: jq is required" >&2 + exit 1 +} + +BUNDLES=" +penguin-linux-x64.tar.gz +penguin-linux-arm64.tar.gz +penguin-darwin-x64.tar.gz +penguin-darwin-arm64.tar.gz +penguin-universal.tar.gz +penguin-win32-x64.zip +" +FILES="$BUNDLES +penguin-linux-x64.tar.gz.sha256 +penguin-linux-arm64.tar.gz.sha256 +penguin-darwin-x64.tar.gz.sha256 +penguin-darwin-arm64.tar.gz.sha256 +penguin-universal.tar.gz.sha256 +penguin-win32-x64.zip.sha256 +SHA256SUMS +install.sh +install.ps1 +" + +for file in $FILES; do + [ -f "$RELEASE_DIR/$file" ] || { + echo "error: missing GitHub Release asset: $file" >&2 + exit 1 + } +done + +for bundle in $BUNDLES; do + (cd "$RELEASE_DIR" && sha256sum -c "$bundle.sha256") +done +(cd "$RELEASE_DIR" && sha256sum -c SHA256SUMS) + +WORK_DIR="$(mktemp -d)" +trap 'rm -rf "$WORK_DIR"' EXIT + +oss_cp() { + if [ -n "$3" ]; then + "$OSSUTIL_BIN" cp "$1" "$2" \ + --endpoint "$OSS_ENDPOINT" \ + --region "$OSS_REGION" \ + --force \ + --no-progress \ + --cache-control "$3" + else + "$OSSUTIL_BIN" cp "$1" "$2" \ + --endpoint "$OSS_ENDPOINT" \ + --region "$OSS_REGION" \ + --force \ + --no-progress + fi +} + +oss_put_if_absent() { + local_file="$1" + object_key="$2" + cache_control="$3" + + "$OSSUTIL_BIN" api put-object \ + --bucket "$OSS_BUCKET" \ + --key "$object_key" \ + --body "file://$local_file" \ + --forbid-overwrite \ + --cache-control "$cache_control" \ + --endpoint "$OSS_ENDPOINT" \ + --region "$OSS_REGION" +} + +file_sha256() { + sha256sum "$1" | awk '{print $1}' +} + +verify_remote_file() { + local_file="$1" + remote_uri="$2" + remote_file="$WORK_DIR/remote-$(basename "$local_file")" + rm -f "$remote_file" + oss_cp "$remote_uri" "$remote_file" "" + local_hash="$(file_sha256 "$local_file")" + remote_hash="$(file_sha256 "$remote_file")" + [ "$local_hash" = "$remote_hash" ] || { + echo "error: OSS object differs from the GitHub Release asset: $remote_uri" >&2 + exit 1 + } +} + +upload_immutable_file() { + local_file="$1" + object_key="$2" + remote_uri="oss://$OSS_BUCKET/$object_key" + existing_file="$WORK_DIR/existing-$(basename "$local_file")" + rm -f "$existing_file" + + # An exact-key download avoids needing ListObjects. Existing identical bytes make retries + # idempotent; different bytes fail before any upload is attempted. + if oss_cp "$remote_uri" "$existing_file" "" >/dev/null 2>&1; then + if [ "$(file_sha256 "$local_file")" = "$(file_sha256 "$existing_file")" ]; then + echo "Already mirrored: $remote_uri" + return + fi + echo "error: immutable OSS object already exists with different content: $remote_uri" >&2 + exit 1 + fi + + echo "Uploading: $remote_uri" + if ! oss_put_if_absent "$local_file" "$object_key" "public,max-age=31536000,immutable"; then + # A concurrent retry may have won the create race. It is safe only if the resulting bytes match. + echo "Upload did not create $remote_uri; checking whether an identical object now exists." + fi + verify_remote_file "$local_file" "$remote_uri" +} + +RELEASE_PREFIX="releases/$TAG" +for file in $FILES; do + upload_immutable_file "$RELEASE_DIR/$file" "$RELEASE_PREFIX/$file" +done + +if [ "$UPDATE_LATEST" = "true" ]; then + # Re-check at the last possible moment. Another Release can finish while this job is + # transferring large assets; an older retry must never roll latest.json backwards. + require_env GH_TOKEN + require_env GITHUB_REPOSITORY + command -v gh >/dev/null 2>&1 || { + echo "error: gh is required when updating latest.json" >&2 + exit 1 + } + CURRENT_LATEST_TAG="$(gh release view --repo "$GITHUB_REPOSITORY" --json tagName --jq .tagName)" + if [ "$TAG" != "$CURRENT_LATEST_TAG" ]; then + echo "Skipping latest.json because GitHub's latest Release changed to $CURRENT_LATEST_TAG." + UPDATE_LATEST=false + fi +fi + +if [ "$UPDATE_LATEST" = "true" ]; then + PUBLIC_BASE="${OSS_PUBLIC_BASE_URL%/}/$RELEASE_PREFIX" + + jq -n \ + --arg tag "$TAG" \ + --arg version "$VERSION" \ + --arg releaseBaseUrl "$PUBLIC_BASE" \ + '{ + schemaVersion: 1, + tag: $tag, + version: $version, + releaseBaseUrl: $releaseBaseUrl + }' > "$WORK_DIR/latest.json" + + LATEST_URI="oss://$OSS_BUCKET/latest.json" + echo "Updating latest release pointer: $LATEST_URI" + oss_cp "$WORK_DIR/latest.json" "$LATEST_URI" "no-cache" + verify_remote_file "$WORK_DIR/latest.json" "$LATEST_URI" +else + echo "Skipping latest.json because update-latest is false." +fi + +echo "OSS mirror verified for $TAG." diff --git a/scripts/test-installer.ps1 b/scripts/test-installer.ps1 index bc90365..055c8bc 100644 --- a/scripts/test-installer.ps1 +++ b/scripts/test-installer.ps1 @@ -11,12 +11,19 @@ $Installer = Join-Path $RepoRoot "install.ps1" $WorkDir = Join-Path ([IO.Path]::GetTempPath()) "penguin-installer-tests-$PID" $OriginalPath = $env:Path $OriginalOs = $env:OS +$OriginalDownloadBaseUrl = $env:PENGUIN_DOWNLOAD_BASE_URL +$OriginalDownloadFallbackBaseUrl = $env:PENGUIN_DOWNLOAD_FALLBACK_BASE_URL +$OriginalDownloadSource = $env:PENGUIN_DOWNLOAD_SOURCE +$OriginalArchive = $env:PENGUIN_ARCHIVE +$OriginalInstallDir = $env:PENGUIN_INSTALL_DIR +$OriginalVersion = $env:PENGUIN_VERSION $Fixture = @{ Requests = [Collections.Generic.List[string]]::new() Mode = "canonical" GoodBundle = $null BadInnerBundle = $null LegacyArchive = $null + Installer = $Installer } $global:PenguinInstallerFixture = $Fixture @@ -53,13 +60,39 @@ function global:Invoke-WebRequest { param( [Parameter(Mandatory = $true)][string]$Uri, [Parameter(Mandatory = $true)][string]$OutFile, - [switch]$UseBasicParsing + [switch]$UseBasicParsing, + [int]$TimeoutSec = 0 ) $f = $global:PenguinInstallerFixture $f.Requests.Add($Uri) if ($f.Mode -eq "404") { throw "fixture 404: $Uri" } if ($f.Mode -eq "network") { throw "fixture network failure: $Uri" } + if ($f.Mode -eq "primary-network" -and $Uri -like "https://penguin-harness-releases.oss-cn-beijing.aliyuncs.com/*") { + throw "fixture primary network failure" + } + if ($f.Mode -eq "forced-oss-payload" -and + $Uri -like "https://penguin-harness-releases.oss-cn-beijing.aliyuncs.com/*/penguin-*") { + throw "fixture forced OSS payload failure" + } + if ($f.Mode -eq "forwarder-auto-github" -and $Uri -like "*/latest.json") { + throw "fixture OSS metadata failure" + } switch -Wildcard ($Uri) { + "*/latest.json" { + if ($f.Mode -eq "forwarder-invalid-metadata") { + '{"schemaVersion":1,"tag":"../invalid","releaseBaseUrl":"https://example.invalid"}' | + Set-Content -LiteralPath $OutFile -Encoding ascii + } else { + @{ + schemaVersion = 1 + tag = "v0.0.0-test" + releaseBaseUrl = "https://penguin-harness-releases.oss-cn-beijing.aliyuncs.com/releases/v0.0.0-test" + } | ConvertTo-Json | Set-Content -LiteralPath $OutFile -Encoding ascii + } + } + "*/install.ps1" { + Copy-Item -LiteralPath $f.Installer -Destination $OutFile + } "*/penguin-win32-x64.zip.sha256" { switch ($f.Mode) { "outer-sha-mismatch" { @@ -94,17 +127,54 @@ function Invoke-OnlineCase( $Arguments = @{ InstallDir = $InstallDir } if ($Version) { $Arguments.Version = $Version } $Succeeded = $true - try { & $Installer @Arguments *>&1 | Out-Null } catch { $Succeeded = $false } + $Output = @() + try { $Output = @(& $Installer @Arguments *>&1) } catch { $Succeeded = $false } Assert-True ($Succeeded -eq $ShouldSucceed) "$Name returned an unexpected result" Assert-True ($Fixture.Requests.Count -eq $ExpectedRequests) ` "$Name made $($Fixture.Requests.Count) requests, expected $ExpectedRequests" - [PSCustomObject]@{ InstallDir = $InstallDir; Requests = @($Fixture.Requests) } + [PSCustomObject]@{ InstallDir = $InstallDir; Requests = @($Fixture.Requests); Output = @($Output) } +} + +function Invoke-ForwarderCase( + [string]$Name, + [string]$Mode, + [string]$Source, + [int]$ExpectedRequests, + [string]$Version = "", + [bool]$ShouldSucceed = $true +) { + $Fixture.Mode = $Mode + $Fixture.Requests.Clear() + $InstallDir = Join-Path $WorkDir "$Name-install" + if ($Version) { + Remove-Item Env:\PENGUIN_ARCHIVE -ErrorAction SilentlyContinue + $env:PENGUIN_VERSION = $Version + } else { + $env:PENGUIN_ARCHIVE = $Fixture.GoodBundle + Remove-Item Env:\PENGUIN_VERSION -ErrorAction SilentlyContinue + } + $env:PENGUIN_INSTALL_DIR = $InstallDir + $env:PENGUIN_DOWNLOAD_SOURCE = $Source + Remove-Item Env:\PENGUIN_DOWNLOAD_BASE_URL, Env:\PENGUIN_DOWNLOAD_FALLBACK_BASE_URL -ErrorAction SilentlyContinue + $Forwarder = Join-Path $RepoRoot "packages\landing\public\install.ps1" + $Output = @() + $Succeeded = $true + try { $Output = @(& $Forwarder *>&1) } catch { $Succeeded = $false } + Assert-True ($Succeeded -eq $ShouldSucceed) "$Name returned an unexpected result" + Assert-True ($Fixture.Requests.Count -eq $ExpectedRequests) ` + "$Name made $($Fixture.Requests.Count) requests, expected $ExpectedRequests" + Assert-True (-not (($Output | Out-String) -match 'aliyuncs\.com')) ` + "$Name exposed the OSS URL in normal output" + [PSCustomObject]@{ InstallDir = $InstallDir; Requests = @($Fixture.Requests); Output = @($Output) } } try { New-Item -ItemType Directory -Path $WorkDir -Force | Out-Null # Keep the fixture tests away from the runner's user registry Path. $env:OS = "PenguinInstallerFixtureTest" + Remove-Item Env:\PENGUIN_DOWNLOAD_BASE_URL, Env:\PENGUIN_DOWNLOAD_FALLBACK_BASE_URL, ` + Env:\PENGUIN_DOWNLOAD_SOURCE, Env:\PENGUIN_ARCHIVE, Env:\PENGUIN_INSTALL_DIR, ` + Env:\PENGUIN_VERSION -ErrorAction SilentlyContinue # --- Offline program archive: good install, then a failing upgrade must roll back. --- $InstallDir = Join-Path $WorkDir "offline-installed" @@ -171,6 +241,57 @@ try { "canonical did not request the canonical bundle" $Version = & (Join-Path $canonical.InstallDir "bin\penguin.cmd") --version Assert-True ($Version -eq "fixture-old") "canonical bundle was not installed" + + $env:PENGUIN_DOWNLOAD_BASE_URL = "https://penguin-harness-releases.oss-cn-beijing.aliyuncs.com/releases/v0.0.0-test" + $override = Invoke-OnlineCase "download-base-override" "canonical" "" $true 2 + Assert-True ($override.Requests[0] -eq "https://penguin-harness-releases.oss-cn-beijing.aliyuncs.com/releases/v0.0.0-test/penguin-win32-x64.zip") ` + "download base override did not request the configured asset directory" + Assert-True (($override.Output | Out-String) -match 'OSS mirror') ` + "download base override did not identify the OSS mirror" + Assert-True (-not (($override.Output | Out-String) -match 'aliyuncs\.com')) ` + "download base override exposed the OSS URL in normal output" + + $env:PENGUIN_DOWNLOAD_FALLBACK_BASE_URL = "https://github.com/Prism-Shadow/penguin-harness/releases/download/v0.0.0-test" + $fallback = Invoke-OnlineCase "download-fallback" "primary-network" "" $true 3 + Assert-True ($fallback.Requests[0] -like "https://penguin-harness-releases.oss-cn-beijing.aliyuncs.com/*") ` + "download fallback did not try the primary source first" + Assert-True ($fallback.Requests[1] -like "https://github.com/*/penguin-win32-x64.zip") ` + "download fallback did not use the same-version GitHub source" + Assert-True (-not (($fallback.Output | Out-String) -match 'aliyuncs\.com')) ` + "download fallback exposed the OSS URL in normal output" + Remove-Item Env:\PENGUIN_DOWNLOAD_FALLBACK_BASE_URL + Remove-Item Env:\PENGUIN_DOWNLOAD_BASE_URL + + $forwarderOss = Invoke-ForwarderCase "forwarder-oss" "forwarder-oss" "auto" 2 + Assert-True ($forwarderOss.Requests[0] -like "*/latest.json") ` + "OSS forwarder did not request release metadata first" + Assert-True ($forwarderOss.Requests[1] -like "*/releases/v0.0.0-test/install.ps1") ` + "OSS forwarder did not request the versioned installer" + + $forwarderGitHub = Invoke-ForwarderCase "forwarder-auto-github" "forwarder-auto-github" "auto" 2 + Assert-True ($forwarderGitHub.Requests[1] -like "https://github.com/*/releases/latest/download/install.ps1") ` + "forwarder did not fall back to the GitHub installer" + + $invalidMetadata = Invoke-ForwarderCase "forwarder-invalid-metadata" "forwarder-invalid-metadata" "auto" 2 + Assert-True ($invalidMetadata.Requests[1] -like "https://github.com/*/releases/latest/download/install.ps1") ` + "invalid OSS metadata did not fall back to the GitHub installer" + + $forcedGitHub = Invoke-ForwarderCase "forwarder-github" "canonical" "github" 1 + Assert-True ($forcedGitHub.Requests[0] -like "https://github.com/*/releases/latest/download/install.ps1") ` + "forced GitHub mode did not request the GitHub installer" + + $forcedOss = Invoke-ForwarderCase "forwarder-forced-oss-no-fallback" ` + "forced-oss-payload" "oss" 2 "v0.0.0-test" $false + Assert-True (-not (($forcedOss.Requests | Out-String) -match 'github\.com')) ` + "forced OSS mode unexpectedly fell back to GitHub" + + $pinnedForwarder = Invoke-ForwarderCase "forwarder-pinned" "canonical" "auto" 3 "v0.0.0-test" + Assert-True ($pinnedForwarder.Requests[0] -like "*/releases/v0.0.0-test/install.ps1") ` + "pinned forwarder did not request the versioned installer" + Assert-True ($pinnedForwarder.Requests[1] -like "*/releases/v0.0.0-test/penguin-win32-x64.zip") ` + "pinned installer did not keep the selected release version" + Remove-Item Env:\PENGUIN_ARCHIVE, Env:\PENGUIN_INSTALL_DIR, Env:\PENGUIN_DOWNLOAD_SOURCE, Env:\PENGUIN_VERSION -ErrorAction SilentlyContinue + Invoke-OnlineCase "outer-mismatch" "outer-sha-mismatch" "" $false 2 | Out-Null Invoke-OnlineCase "inner-mismatch" "inner-sha-mismatch" "" $false 2 | Out-Null Invoke-OnlineCase "latest-404" "404" "" $false 1 | Out-Null @@ -183,6 +304,36 @@ try { } finally { $env:Path = $OriginalPath $env:OS = $OriginalOs + if ($null -eq $OriginalDownloadBaseUrl) { + Remove-Item Env:\PENGUIN_DOWNLOAD_BASE_URL -ErrorAction SilentlyContinue + } else { + $env:PENGUIN_DOWNLOAD_BASE_URL = $OriginalDownloadBaseUrl + } + if ($null -eq $OriginalDownloadFallbackBaseUrl) { + Remove-Item Env:\PENGUIN_DOWNLOAD_FALLBACK_BASE_URL -ErrorAction SilentlyContinue + } else { + $env:PENGUIN_DOWNLOAD_FALLBACK_BASE_URL = $OriginalDownloadFallbackBaseUrl + } + if ($null -eq $OriginalDownloadSource) { + Remove-Item Env:\PENGUIN_DOWNLOAD_SOURCE -ErrorAction SilentlyContinue + } else { + $env:PENGUIN_DOWNLOAD_SOURCE = $OriginalDownloadSource + } + if ($null -eq $OriginalArchive) { + Remove-Item Env:\PENGUIN_ARCHIVE -ErrorAction SilentlyContinue + } else { + $env:PENGUIN_ARCHIVE = $OriginalArchive + } + if ($null -eq $OriginalInstallDir) { + Remove-Item Env:\PENGUIN_INSTALL_DIR -ErrorAction SilentlyContinue + } else { + $env:PENGUIN_INSTALL_DIR = $OriginalInstallDir + } + if ($null -eq $OriginalVersion) { + Remove-Item Env:\PENGUIN_VERSION -ErrorAction SilentlyContinue + } else { + $env:PENGUIN_VERSION = $OriginalVersion + } Remove-Item Function:\Invoke-WebRequest -ErrorAction SilentlyContinue Remove-Variable PenguinInstallerFixture -Scope Global -ErrorAction SilentlyContinue if (Test-Path -LiteralPath $WorkDir) { Remove-Item -LiteralPath $WorkDir -Recurse -Force } diff --git a/scripts/test-installer.sh b/scripts/test-installer.sh index 3e3e78c..9ef3c4d 100755 --- a/scripts/test-installer.sh +++ b/scripts/test-installer.sh @@ -242,19 +242,36 @@ url="" while [ $# -gt 0 ]; do case "$1" in -o) output="$2"; shift 2 ;; + --connect-timeout | --max-time) shift 2 ;; -*) shift ;; *) url="$1"; shift ;; esac done printf '%s\n' "$url" >> "$REQUEST_LOG" base="${url##*/}" +case "$MODE:$url" in + primary-network:https://penguin-harness-releases.oss-cn-beijing.aliyuncs.com/*) exit 7 ;; + forced-oss-payload:https://penguin-harness-releases.oss-cn-beijing.aliyuncs.com/*/penguin-*) exit 7 ;; +esac case "$MODE:$base" in + forwarder-auto-github:latest.json) exit 7 ;; + forwarder-invalid-metadata:latest.json) + printf '%s\n' '{"schemaVersion":1,"tag":"../invalid","releaseBaseUrl":"https://example.invalid"}' > "$output" + ;; + forwarder-oss:latest.json | forced-oss-payload:latest.json) + printf '%s\n' '{"schemaVersion":1,"tag":"v0.0.0-test","releaseBaseUrl":"https://penguin-harness-releases.oss-cn-beijing.aliyuncs.com/releases/v0.0.0-test"}' > "$output" + ;; + forwarder-oss:install.sh | forced-oss-payload:install.sh | forwarder-auto-github:install.sh | forwarder-invalid-metadata:install.sh | canonical:install.sh) cp "$ROOT_DIR/install.sh" "$output" ;; 404:penguin-*) exit 22 ;; network:penguin-*) exit 7 ;; outer-sha-mismatch:penguin-*.sha256) printf '%064d %s\n' 0 "${base%.sha256}" > "$output" ;; outer-sha-mismatch:penguin-*) cp "$ARTIFACT_DIR/$base" "$output" ;; inner-sha-mismatch:penguin-*.sha256) cp "$BAD_BUNDLE.sha256" "$output" ;; inner-sha-mismatch:penguin-*) cp "$BAD_BUNDLE" "$output" ;; + primary-network:penguin-*.sha256) cp "$ARTIFACT_DIR/$base" "$output" ;; + primary-network:penguin-*) cp "$ARTIFACT_DIR/$base" "$output" ;; + forced-oss-payload:penguin-*.sha256) cp "$ARTIFACT_DIR/$base" "$output" ;; + forced-oss-payload:penguin-*) cp "$ARTIFACT_DIR/$base" "$output" ;; legacy:penguin-*.sha256) cp "$LEGACY_ARCHIVE.sha256" "$output" ;; legacy:penguin-*) cp "$LEGACY_ARCHIVE" "$output" ;; canonical:penguin-*.sha256) cp "$ARTIFACT_DIR/$base" "$output" ;; @@ -263,7 +280,7 @@ case "$MODE:$base" in esac EOF chmod +x "$STUB_BIN/curl" -export ARTIFACT_DIR BAD_BUNDLE LEGACY_ARCHIVE +export ARTIFACT_DIR BAD_BUNDLE LEGACY_ARCHIVE ROOT_DIR run_online_case() { name="$1" @@ -271,13 +288,18 @@ run_online_case() { version="$3" expected="$4" expected_requests="$5" + download_base_url="${6:-}" + download_fallback_base_url="${7:-}" CASE_LOG="$WORK_DIR/$name.log" + CASE_OUTPUT="$WORK_DIR/$name.output" CASE_INSTALL="$WORK_DIR/$name-install" : > "$CASE_LOG" set +e REQUEST_LOG="$CASE_LOG" MODE="$mode" PATH="$STUB_BIN:$PATH" \ HOME="$WORK_DIR/$name-home" PENGUIN_INSTALL_DIR="$CASE_INSTALL" \ - PENGUIN_VERSION="$version" sh "$ROOT_DIR/install.sh" >/dev/null 2>&1 + PENGUIN_VERSION="$version" PENGUIN_DOWNLOAD_BASE_URL="$download_base_url" \ + PENGUIN_DOWNLOAD_FALLBACK_BASE_URL="$download_fallback_base_url" \ + sh "$ROOT_DIR/install.sh" >"$CASE_OUTPUT" 2>&1 status=$? set -e if [ "$expected" = "success" ]; then @@ -294,6 +316,22 @@ run_online_case canonical canonical "" success 2 || fail_test "canonical online install did not produce a working command" grep -q "/releases/latest/download/$HOST_ASSET\$" "$WORK_DIR/canonical.log" \ || fail_test "canonical did not request the canonical bundle" +run_online_case download-base-override canonical "" success 2 \ + "https://penguin-harness-releases.oss-cn-beijing.aliyuncs.com/releases/v0.0.0-test" "" +grep -q "OSS mirror" "$WORK_DIR/download-base-override.output" \ + || fail_test "download base override did not identify the OSS mirror" +! grep -q "aliyuncs.com" "$WORK_DIR/download-base-override.output" \ + || fail_test "download base override exposed the OSS URL in normal output" +run_online_case download-fallback primary-network "" success 3 \ + "https://penguin-harness-releases.oss-cn-beijing.aliyuncs.com/releases/v0.0.0-test" \ + "https://github.com/Prism-Shadow/penguin-harness/releases/download/v0.0.0-test" +[ "$(sed -n '1p' "$WORK_DIR/download-fallback.log")" = \ + "https://penguin-harness-releases.oss-cn-beijing.aliyuncs.com/releases/v0.0.0-test/$HOST_ASSET" ] \ + || fail_test "download fallback did not try the primary source first" +grep -q "github.com/.*/releases/download/v0.0.0-test/$HOST_ASSET\$" "$WORK_DIR/download-fallback.log" \ + || fail_test "download fallback did not use the same-version GitHub source" +! grep -q "aliyuncs.com" "$WORK_DIR/download-fallback.output" \ + || fail_test "download fallback exposed the OSS URL in normal output" run_online_case outer-mismatch outer-sha-mismatch "" failure 2 run_online_case inner-mismatch inner-sha-mismatch "" failure 2 run_online_case latest-404 404 "" failure 1 @@ -302,4 +340,73 @@ run_online_case pinned-legacy legacy v0.1.4 success 2 grep -q "/releases/download/v0.1.4/$HOST_ASSET\$" "$WORK_DIR/pinned-legacy.log" \ || fail_test "pinned legacy did not request the pinned asset" +# --- Stable penguin.ooo forwarder: prefer a validated immutable OSS release, but fall back to +# GitHub when the metadata probe fails. The real installer uses a local fixture here so the +# test isolates bootstrap routing from bundle download behavior above. --- +run_forwarder_case() { + name="$1" + mode="$2" + expected_requests="$3" + source="${4:-auto}" + version="${5:-}" + expected="${6:-success}" + CASE_LOG="$WORK_DIR/$name.log" + CASE_OUTPUT="$WORK_DIR/$name.output" + CASE_INSTALL="$WORK_DIR/$name-install" + : > "$CASE_LOG" + if [ -n "$version" ]; then + archive="" + else + archive="$ARTIFACT_DIR/$HOST_ASSET" + fi + set +e + REQUEST_LOG="$CASE_LOG" MODE="$mode" PATH="$STUB_BIN:$PATH" \ + HOME="$WORK_DIR/$name-home" PENGUIN_INSTALL_DIR="$CASE_INSTALL" \ + PENGUIN_ARCHIVE="$archive" PENGUIN_VERSION="$version" \ + PENGUIN_DOWNLOAD_SOURCE="$source" PENGUIN_DOWNLOAD_BASE_URL="" \ + PENGUIN_DOWNLOAD_FALLBACK_BASE_URL="" \ + sh "$ROOT_DIR/packages/landing/public/install.sh" >"$CASE_OUTPUT" 2>&1 + status=$? + set -e + if [ "$expected" = "success" ]; then + [ "$status" -eq 0 ] || fail_test "$name unexpectedly failed" + else + [ "$status" -ne 0 ] || fail_test "$name unexpectedly succeeded" + fi + [ "$(wc -l < "$CASE_LOG" | tr -d ' ')" -eq "$expected_requests" ] \ + || fail_test "$name made an unexpected number of requests" + ! grep -q "aliyuncs.com" "$CASE_OUTPUT" \ + || fail_test "$name exposed the OSS URL in normal output" +} + +run_forwarder_case forwarder-oss forwarder-oss 2 +grep -q "/latest.json\$" "$WORK_DIR/forwarder-oss.log" \ + || fail_test "OSS forwarder did not request release metadata first" +grep -q "/releases/v0.0.0-test/install.sh\$" "$WORK_DIR/forwarder-oss.log" \ + || fail_test "OSS forwarder did not request the versioned installer" + +run_forwarder_case forwarder-auto-github forwarder-auto-github 2 +grep -q "github.com/.*/releases/latest/download/install.sh\$" "$WORK_DIR/forwarder-auto-github.log" \ + || fail_test "forwarder did not fall back to the GitHub installer" + +run_forwarder_case forwarder-invalid-metadata forwarder-invalid-metadata 2 +grep -q "github.com/.*/releases/latest/download/install.sh\$" "$WORK_DIR/forwarder-invalid-metadata.log" \ + || fail_test "invalid OSS metadata did not fall back to the GitHub installer" + +run_forwarder_case forwarder-github canonical 1 github +grep -q "github.com/.*/releases/latest/download/install.sh\$" "$WORK_DIR/forwarder-github.log" \ + || fail_test "forced GitHub mode did not request the GitHub installer" + +run_forwarder_case forwarder-forced-oss-no-fallback forced-oss-payload 2 oss v0.0.0-test failure +! grep -q "github.com" "$WORK_DIR/forwarder-forced-oss-no-fallback.log" \ + || fail_test "forced OSS mode unexpectedly fell back to GitHub" + +run_forwarder_case forwarder-pinned canonical 3 auto v0.0.0-test +[ "$(sed -n '1p' "$WORK_DIR/forwarder-pinned.log")" = \ + "https://penguin-harness-releases.oss-cn-beijing.aliyuncs.com/releases/v0.0.0-test/install.sh" ] \ + || fail_test "pinned forwarder did not request the versioned installer" +[ "$(sed -n '2p' "$WORK_DIR/forwarder-pinned.log")" = \ + "https://penguin-harness-releases.oss-cn-beijing.aliyuncs.com/releases/v0.0.0-test/$HOST_ASSET" ] \ + || fail_test "pinned installer did not keep the selected release version" + echo "Installer bundle, offline, rollback and online tests passed." diff --git a/scripts/test-oss-staging.sh b/scripts/test-oss-staging.sh new file mode 100644 index 0000000..4d51f1b --- /dev/null +++ b/scripts/test-oss-staging.sh @@ -0,0 +1,102 @@ +#!/bin/sh +# Verify a GitHub Actions OIDC staging role can round-trip an object under staging/ +# and cannot write either releases/ or the production latest.json pointer. +set -eu + +OSSUTIL_BIN="${OSSUTIL_BIN:-ossutil}" +RUN_ID="${GITHUB_RUN_ID:-manual}" +RUN_ATTEMPT="${GITHUB_RUN_ATTEMPT:-1}" +PREFIX="${1:-staging/$RUN_ID-$RUN_ATTEMPT}" + +require_env() { + eval "value=\${$1:-}" + [ -n "$value" ] || { + echo "error: required environment variable $1 is empty" >&2 + exit 1 + } +} + +for name in OSS_BUCKET OSS_REGION OSS_ENDPOINT OSS_PUBLIC_BASE_URL; do + require_env "$name" +done +case "$PREFIX" in + staging/*) ;; + *) + echo "error: staging prefix must start with staging/: $PREFIX" >&2 + exit 1 + ;; +esac +command -v "$OSSUTIL_BIN" >/dev/null 2>&1 || { + echo "error: ossutil not found: $OSSUTIL_BIN" >&2 + exit 1 +} +command -v curl >/dev/null 2>&1 || { + echo "error: curl is required" >&2 + exit 1 +} + +WORK_DIR="$(mktemp -d)" +trap 'rm -rf "$WORK_DIR"' EXIT +PROBE="$WORK_DIR/oidc-probe.txt" +DOWNLOADED="$WORK_DIR/downloaded.txt" +printf 'repository=%s\nrun_id=%s\nrun_attempt=%s\ncommit=%s\n' \ + "${GITHUB_REPOSITORY:-unknown}" "$RUN_ID" "$RUN_ATTEMPT" "${GITHUB_SHA:-unknown}" > "$PROBE" + +oss_cp() { + "$OSSUTIL_BIN" cp "$1" "$2" \ + --endpoint "$OSS_ENDPOINT" \ + --region "$OSS_REGION" \ + --force \ + --no-progress +} + +STAGING_URI="oss://$OSS_BUCKET/$PREFIX/oidc-probe.txt" +oss_cp "$PROBE" "$STAGING_URI" +oss_cp "$STAGING_URI" "$DOWNLOADED" +cmp "$PROBE" "$DOWNLOADED" +echo "Staging upload/download verified: $STAGING_URI" + +DENIED_URI="oss://$OSS_BUCKET/releases/_staging-deny-probe/$RUN_ID-$RUN_ATTEMPT.txt" +if oss_cp "$PROBE" "$DENIED_URI" >"$WORK_DIR/denied.log" 2>&1; then + echo "error: staging role unexpectedly wrote to production: $DENIED_URI" >&2 + echo "Remove that probe manually and fix the RAM policy before continuing." >&2 + exit 1 +fi +if ! grep -Eiq 'AccessDenied|Forbidden|(^|[^0-9])403([^0-9]|$)' "$WORK_DIR/denied.log"; then + echo "error: production probe failed, but not with a recognizable access-denied response" >&2 + cat "$WORK_DIR/denied.log" >&2 + exit 1 +fi +echo "Production write correctly denied for the staging role." + +# Probe the exact latest.json permission without risking an overwrite. The existing public +# object is used as the body and x-oss-forbid-overwrite makes the request non-destructive: +# AccessDenied is expected; FileAlreadyExists means the role was incorrectly authorized. +LATEST_COPY="$WORK_DIR/latest.json" +LATEST_URL="${OSS_PUBLIC_BASE_URL%/}/latest.json" +curl --proto '=https' --tlsv1.2 -fsSL "$LATEST_URL" -o "$LATEST_COPY" +[ -s "$LATEST_COPY" ] || { + echo "error: downloaded latest.json is empty: $LATEST_URL" >&2 + exit 1 +} + +if "$OSSUTIL_BIN" api put-object \ + --bucket "$OSS_BUCKET" \ + --key latest.json \ + --body "file://$LATEST_COPY" \ + --forbid-overwrite \ + --endpoint "$OSS_ENDPOINT" \ + --region "$OSS_REGION" >"$WORK_DIR/latest-denied.log" 2>&1; then + echo "error: staging role unexpectedly wrote the production latest.json pointer" >&2 + exit 1 +fi +if grep -Eiq 'AccessDenied|Forbidden|(^|[^0-9])403([^0-9]|$)' "$WORK_DIR/latest-denied.log"; then + echo "Production latest.json write correctly denied for the staging role." +elif grep -Eiq 'FileAlreadyExists|(^|[^0-9])409([^0-9]|$)' "$WORK_DIR/latest-denied.log"; then + echo "error: staging role is authorized to write latest.json; overwrite was blocked by OSS" >&2 + exit 1 +else + echo "error: latest.json probe failed, but not with a recognizable access-denied response" >&2 + cat "$WORK_DIR/latest-denied.log" >&2 + exit 1 +fi