diff --git a/README.ko.md b/README.ko.md index 1db2070..00d0362 100644 --- a/README.ko.md +++ b/README.ko.md @@ -38,6 +38,17 @@ GitHub CLI 확장 프로그램 - LLM을 위한 GitHub 도구 gh extension install pleaseai/gh-please ``` +**CI / Docker (인증 불필요):** `gh extension install`은 GitHub API를 사용하는데, +비인증 요청의 속도 제한(IP당 시간당 60회)은 공유 CI 러너에서 쉽게 소진되어 gh가 +`HTTP 403`을 인증 오류처럼 표시합니다. 공개 릴리스 CDN에서 토큰 없이 바이너리를 +내려받는 독립 설치 스크립트를 사용하세요: + +```bash +curl -fsSL https://raw.githubusercontent.com/pleaseai/gh-please/main/scripts/install.sh | bash +``` + +> CI에서 `gh extension install`을 그대로 쓰려면 `GH_TOKEN`(GitHub Actions의 경우 `${{ github.token }}`)을 설정해 속도 제한을 해제하세요. + ### 5분 안에 시작하기 ```bash diff --git a/README.md b/README.md index f137c43..6134cd4 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,18 @@ English | [한국어](./README.ko.md) gh extension install pleaseai/gh-please ``` +**CI / Docker (no authentication needed):** `gh extension install` uses the GitHub +API, whose unauthenticated rate limit (60 req/hour per IP) is easily exhausted on +shared CI runners — gh then reports the `HTTP 403` as an auth error. Use the +standalone installer, which downloads the binary from the public release CDN with +no token: + +```bash +curl -fsSL https://raw.githubusercontent.com/pleaseai/gh-please/main/scripts/install.sh | bash +``` + +> Prefer `gh extension install` in CI? Set `GH_TOKEN` (e.g. `${{ github.token }}` in GitHub Actions) to lift the rate limit. + ### Get Started in 5 Minutes ```bash diff --git a/docs/content/en/1.guide/1.getting-started.md b/docs/content/en/1.guide/1.getting-started.md index 3e893b0..1dbc4c3 100644 --- a/docs/content/en/1.guide/1.getting-started.md +++ b/docs/content/en/1.guide/1.getting-started.md @@ -20,6 +20,30 @@ Before installing `gh-please`, make sure you have: gh extension install pleaseai/gh-please ``` +### Install in CI / Docker (no authentication) + +`gh extension install` works fine on a personal machine, but in CI or during a +Docker build it talks to the GitHub API, whose **unauthenticated** rate limit +(60 requests/hour per IP) is shared across runners and is easily exhausted — +gh then reports the resulting `HTTP 403` as an authentication error. + +Use the standalone installer instead. It downloads the precompiled binary from +the public release CDN, so it needs **no token** and bakes no secret into your +image: + +```bash +curl -fsSL https://raw.githubusercontent.com/pleaseai/gh-please/main/scripts/install.sh | bash + +# Pin a specific version (reproducible builds) — pin the script and the binary to the same tag: +TAG=github-v0.32.0 +curl -fsSL "https://raw.githubusercontent.com/pleaseai/gh-please/${TAG}/scripts/install.sh" | bash -s -- --version "${TAG}" +``` + +::callout{type="info"} +Prefer `gh extension install` in CI? Provide a token to lift the rate limit — in +GitHub Actions set `env: { GH_TOKEN: ${{ github.token }} }`. +:: + ### Install for Development ```bash diff --git a/docs/content/ko/1.guide/1.getting-started.md b/docs/content/ko/1.guide/1.getting-started.md index c2fb905..12f9ed8 100644 --- a/docs/content/ko/1.guide/1.getting-started.md +++ b/docs/content/ko/1.guide/1.getting-started.md @@ -20,6 +20,31 @@ description: gh-please 확장 프로그램 설치 및 설정 gh extension install pleaseai/gh-please ``` +### CI / Docker에서 설치 (인증 불필요) + +`gh extension install`은 개인 PC에서는 잘 동작하지만, CI나 Docker 빌드에서는 +GitHub API를 호출합니다. **비인증** 요청의 속도 제한(IP당 시간당 60회)은 러너 +간에 공유되어 쉽게 소진되며, 이때 발생하는 `HTTP 403`을 gh가 인증 오류처럼 +표시합니다. + +이 경우 독립 설치 스크립트를 사용하세요. 공개 릴리스 CDN에서 사전 컴파일된 +바이너리를 내려받으므로 **토큰이 필요 없고** 이미지에 비밀값을 넣지 않아도 +됩니다: + +```bash +curl -fsSL https://raw.githubusercontent.com/pleaseai/gh-please/main/scripts/install.sh | bash + +# 특정 버전 고정 (재현 가능한 빌드) — 스크립트와 바이너리를 같은 태그로 고정: +TAG=github-v0.32.0 +curl -fsSL "https://raw.githubusercontent.com/pleaseai/gh-please/${TAG}/scripts/install.sh" | bash -s -- --version "${TAG}" +``` + +::callout{type="info"} +CI에서 `gh extension install`을 그대로 쓰고 싶다면 토큰을 제공해 속도 제한을 +해제하세요. GitHub Actions에서는 `env: { GH_TOKEN: ${{ github.token }} }`로 +설정합니다. +:: + ### 개발용 설치 ```bash diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..8c1007d --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,205 @@ +#!/usr/bin/env bash +# +# Auth-free installer for the gh-please GitHub CLI extension. +# +# Why this exists: +# `gh extension install pleaseai/gh-please` works without authentication for +# a single machine, but in CI/Docker the *unauthenticated* GitHub API rate +# limit (60 req/hour per IP) is quickly exhausted on shared runner IPs. gh +# then surfaces the resulting HTTP 403 as an authentication-looking error. +# +# This script bypasses api.github.com entirely. It resolves the release tag +# via the public `releases/latest` web redirect and downloads the precompiled +# binary from GitHub's release-download CDN. Neither endpoint is rate-limited +# like the API nor requires a token, so it works at Docker build time with no +# credentials baked into the image. +# +# Usage: +# curl -fsSL https://raw.githubusercontent.com/pleaseai/gh-please/main/scripts/install.sh | bash +# curl -fsSL .../install.sh | bash -s -- --version github-v0.32.0 # pin a version +# +# Environment overrides: +# GH_PLEASE_VERSION Pin a release tag (same as --version). + +set -euo pipefail + +# --- Constants --------------------------------------------------------------- +readonly OWNER="pleaseai" +readonly REPO="gh-please" +readonly EXT_NAME="gh-please" # gh extension name -> `gh please` +readonly BIN_NAME="gh-please" # executable + release asset prefix +readonly HOST="github.com" +readonly RELEASES_URL="https://github.com/${OWNER}/${REPO}/releases" + +# --- Output helpers ---------------------------------------------------------- +# Only emit ANSI colors when stderr is a terminal. In CI/Docker (this script's +# primary target) output is redirected to log files, where escape sequences would +# show up as literal garbage. +if [[ -t 2 ]]; then + RED='\033[0;31m' + GREEN='\033[0;32m' + YELLOW='\033[1;33m' + NC='\033[0m' +else + RED='' + GREEN='' + YELLOW='' + NC='' +fi + +# Temp paths cleaned up by the EXIT trap. Declared at file scope so the trap can +# reference them after main()'s locals go out of scope. WORKDIR holds downloads; +# STAGING holds the new extension copy until it is atomically moved into place. +WORKDIR="" +STAGING="" + +info() { printf '%b\n' "${YELLOW}==>${NC} $*"; } +ok() { printf '%b\n' "${GREEN}✓${NC} $*"; } +err() { printf '%b\n' "${RED}✗${NC} $*" >&2; } + +die() { + err "$*" + exit 1 +} + +# --- Platform detection ------------------------------------------------------ +# Maps `uname` output to the `{os}-{arch}` suffix used in release asset names. +detect_platform() { + local os arch + case "$(uname -s)" in + Linux) os="linux" ;; + Darwin) os="darwin" ;; + *) die "Unsupported OS: $(uname -s). For Windows use 'gh extension install ${OWNER}/${REPO}'." ;; + esac + + case "$(uname -m)" in + x86_64 | amd64) arch="amd64" ;; + arm64 | aarch64) arch="arm64" ;; + *) die "Unsupported architecture: $(uname -m)." ;; + esac + + printf '%s-%s' "$os" "$arch" +} + +# --- Tag resolution ---------------------------------------------------------- +# Resolves the latest tag from the `releases/latest` redirect (no API call). +resolve_latest_tag() { + local redirect + redirect="$(curl -fsS -o /dev/null -w '%{redirect_url}' "${RELEASES_URL}/latest" || true)" + [[ -n "$redirect" ]] || die "Could not resolve the latest release tag from ${RELEASES_URL}/latest" + printf '%s' "${redirect##*/tag/}" +} + +# --- Checksum verification --------------------------------------------------- +sha256_of() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | awk '{print $1}' + else + die "Neither sha256sum nor shasum is available for checksum verification." + fi +} + +verify_checksum() { + local binary="$1" checksums="$2" asset="$3" + local expected actual + expected="$(awk -v f="$asset" '$2 == f {print $1}' "$checksums")" + # Fail closed: a missing entry must abort, never silently install an unverified + # binary. The release workflow generates checksums for every gh-please_* asset, + # so an absent entry means asset-name drift or a corrupt/partial checksums file. + if [[ -z "$expected" ]]; then + die "No checksum entry found for ${asset} in checksums.txt — cannot verify binary integrity." + fi + actual="$(sha256_of "$binary")" + [[ "$expected" == "$actual" ]] || die "Checksum mismatch for ${asset} (expected ${expected}, got ${actual})." + ok "Checksum verified." +} + +# --- gh data directory ------------------------------------------------------- +# Resolves the directory gh uses to store extensions (XDG data, not config). +gh_extensions_dir() { + printf '%s/gh/extensions' "${XDG_DATA_HOME:-$HOME/.local/share}" +} + +# Writes manifest.yml. The file location and the recorded `path` are separate so +# the manifest can be staged in a temp dir while still pointing at the final path. +write_manifest() { + local manifest_file="$1" install_path="$2" tag="$3" pinned="$4" + cat >"$manifest_file" <]\n' + exit 0 + ;; + *) die "Unknown argument: $1" ;; + esac + done + + command -v curl >/dev/null 2>&1 || die "curl is required but not installed." + + local platform tag asset base binary checksums + platform="$(detect_platform)" + + if [[ -n "$version" ]]; then + tag="$version" + pinned="true" + else + info "Resolving latest release..." + tag="$(resolve_latest_tag)" + fi + + asset="${BIN_NAME}_${tag}_${platform}" + base="${RELEASES_URL}/download/${tag}" + info "Installing ${EXT_NAME} ${tag} (${platform})..." + + WORKDIR="$(mktemp -d)" + trap 'rm -rf "${WORKDIR:-}" "${STAGING:-}"' EXIT + + binary="${WORKDIR}/${BIN_NAME}" + checksums="${WORKDIR}/checksums.txt" + + curl -fsSL -o "$binary" "${base}/${asset}" \ + || die "Failed to download ${base}/${asset} (does the release have a ${platform} binary?)." + curl -fsSL -o "$checksums" "${base}/checksums.txt" \ + || die "Failed to download checksums from ${base}/checksums.txt" + + verify_checksum "$binary" "$checksums" "$asset" + + # Stage the new copy fully, then swap it into place. This keeps any existing + # install intact if writing the binary or manifest fails partway through. + local dir + dir="$(gh_extensions_dir)/${EXT_NAME}" + STAGING="$(dirname "$dir")/.${EXT_NAME}.tmp.$$" + rm -rf "$STAGING" + mkdir -p "$STAGING" + install -m 0755 "$binary" "${STAGING}/${BIN_NAME}" + write_manifest "${STAGING}/manifest.yml" "${dir}/${BIN_NAME}" "$tag" "$pinned" + rm -rf "$dir" + mv "$STAGING" "$dir" + + ok "Installed to ${dir}/${BIN_NAME}" + info "Run: gh please --version" +} + +main "$@"