diff --git a/.github/workflows/build-binaries.yml b/.github/workflows/build-binaries.yml deleted file mode 100644 index 16c141dae..000000000 --- a/.github/workflows/build-binaries.yml +++ /dev/null @@ -1,230 +0,0 @@ -name: Build Binaries - -# Build multi-platform binaries on release or manual trigger -# Produces distributable packages for Linux, macOS, and Windows -on: - release: - types: [published] - workflow_dispatch: - -# Environment variables for build configuration -env: - GO_VERSION: "1.26.1" - NODE_VERSION: "24" - PNPM_VERSION: "10" - ARTIFACT_RETENTION_DAYS: 60 - # Artifact naming: {ARTIFACT_PREFIX}_{version}_{os}_{arch}.tar.gz|zip - ARTIFACT_PREFIX: memos - -jobs: - # Job 1: Extract version information - # - For git tags: use tag version (e.g., v0.28.1 -> 0.28.1) - # - For branches: use branch-name-shortSHA format - prepare: - name: Extract Version - runs-on: ubuntu-latest - outputs: - version: ${{ steps.version.outputs.version }} - steps: - - name: Checkout code - uses: actions/checkout@v6 - with: - fetch-depth: 0 # Full history for git describe - - - name: Extract version - id: version - run: | - # Try to get version from git tag - TAG=$(git describe --tags --exact-match 2>/dev/null || echo "") - if [ -n "$TAG" ]; then - echo "version=${TAG#v}" >> $GITHUB_OUTPUT - echo "Version from tag: ${TAG#v}" - else - # Use branch name + short SHA - BRANCH="${GITHUB_REF_NAME//\//-}" - SHORT_SHA="${GITHUB_SHA:0:7}" - echo "version=${BRANCH}-${SHORT_SHA}" >> $GITHUB_OUTPUT - echo "Version from branch: ${BRANCH}-${SHORT_SHA}" - fi - - # Job 2: Build frontend assets - # - Builds React frontend with Vite - # - Produces static files that will be embedded in Go binary - # - Shared across all platform builds - build-frontend: - name: Build Frontend - needs: prepare - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v6 - - - name: Setup pnpm - uses: pnpm/action-setup@v4.2.0 - with: - version: ${{ env.PNPM_VERSION }} - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: ${{ env.NODE_VERSION }} - cache: pnpm - cache-dependency-path: web/pnpm-lock.yaml - - - name: Get pnpm store directory - id: pnpm-cache - shell: bash - run: echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT - - - name: Setup pnpm cache - uses: actions/cache@v5 - with: - path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} - key: ${{ runner.os }}-pnpm-store-${{ hashFiles('web/pnpm-lock.yaml') }} - restore-keys: ${{ runner.os }}-pnpm-store- - - - name: Install dependencies - working-directory: web - run: pnpm install --frozen-lockfile - - - name: Build frontend - working-directory: web - run: pnpm release - - - name: Upload frontend artifacts - uses: actions/upload-artifact@v6 - with: - name: frontend-dist - path: server/router/frontend/dist - retention-days: ${{ env.ARTIFACT_RETENTION_DAYS }} - - # Job 3: Build Go binaries for multiple platforms - # - Cross-compiles using native Go toolchain - # - Embeds frontend assets built in previous job - # - Produces static binaries with no external dependencies - # - Packages as tar.gz (Unix) or zip (Windows) - build-binaries: - name: Build ${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.goarm && format('v{0}', matrix.goarm) || '' }} - needs: [prepare, build-frontend] - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - include: - # Linux targets - - goos: linux - goarch: amd64 - - goos: linux - goarch: arm64 - - goos: linux - goarch: arm - goarm: "7" - # macOS targets - - goos: darwin - goarch: amd64 # Intel Macs - - goos: darwin - goarch: arm64 # Apple Silicon - # Windows targets - - goos: windows - goarch: amd64 - - steps: - - name: Checkout code - uses: actions/checkout@v6 - - - name: Setup Go - uses: actions/setup-go@v6 - with: - go-version: ${{ env.GO_VERSION }} - cache: true - - - name: Download frontend artifacts - uses: actions/download-artifact@v7 - with: - name: frontend-dist - path: server/router/frontend/dist - - - name: Build binary - env: - GOOS: ${{ matrix.goos }} - GOARCH: ${{ matrix.goarch }} - GOARM: ${{ matrix.goarm }} - CGO_ENABLED: "0" - run: | - # Determine output binary name - OUTPUT_NAME="memos" - if [ "$GOOS" = "windows" ]; then - OUTPUT_NAME="memos.exe" - fi - - mkdir -p build - - # Build static binary with optimizations - go build \ - -trimpath \ - -ldflags="-s -w -extldflags '-static'" \ - -tags netgo,osusergo \ - -o "build/${OUTPUT_NAME}" \ - ./cmd/memos - - echo "✓ Built: build/${OUTPUT_NAME}" - ls -lh build/ - - - name: Package binary - id: package - env: - VERSION: ${{ needs.prepare.outputs.version }} - GOOS: ${{ matrix.goos }} - GOARCH: ${{ matrix.goarch }} - GOARM: ${{ matrix.goarm }} - run: | - cd build - - # Construct package name: {prefix}_{version}_{os}_{arch}[v{arm_version}] - PACKAGE_NAME="${ARTIFACT_PREFIX}_${VERSION}_${GOOS}_${GOARCH}" - if [ -n "$GOARM" ]; then - PACKAGE_NAME="${PACKAGE_NAME}v${GOARM}" - fi - - # Package based on platform - if [ "$GOOS" = "windows" ]; then - ARTIFACT_NAME="${PACKAGE_NAME}.zip" - zip -q "${ARTIFACT_NAME}" memos.exe - else - ARTIFACT_NAME="${PACKAGE_NAME}.tar.gz" - tar czf "${ARTIFACT_NAME}" memos - fi - - # Output for next step - echo "ARTIFACT_NAME=${ARTIFACT_NAME}" >> $GITHUB_ENV - echo "✓ Package created: ${ARTIFACT_NAME} ($(du -h "${ARTIFACT_NAME}" | cut -f1))" - - - name: Upload binary artifact - uses: actions/upload-artifact@v6 - with: - name: ${{ env.ARTIFACT_NAME }} - path: build/${{ env.ARTIFACT_NAME }} - retention-days: ${{ env.ARTIFACT_RETENTION_DAYS }} - - # Job 4: Upload artifacts to GitHub Release - # - Only runs when triggered by a release publish event - # - Downloads all built artifacts and attaches them to the release - release: - name: Upload Release Assets - needs: build-binaries - if: github.event_name == 'release' - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - name: Download all artifacts - uses: actions/download-artifact@v7 - with: - path: artifacts - pattern: ${{ env.ARTIFACT_PREFIX }}_* - merge-multiple: true - - - name: Upload to GitHub Release - uses: softprops/action-gh-release@v2 - with: - files: artifacts/* diff --git a/.github/workflows/build-stable-image.yml b/.github/workflows/build-stable-image.yml deleted file mode 100644 index acb4f5e9a..000000000 --- a/.github/workflows/build-stable-image.yml +++ /dev/null @@ -1,184 +0,0 @@ -name: Build Stable Image - -on: - push: - branches: - - "release/**" - tags: - - "v*.*.*" - -jobs: - prepare: - runs-on: ubuntu-latest - outputs: - version: ${{ steps.version.outputs.version }} - steps: - - name: Extract version - id: version - run: | - if [[ "$GITHUB_REF_TYPE" == "tag" ]]; then - echo "version=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT - else - echo "version=${GITHUB_REF_NAME#release/}" >> $GITHUB_OUTPUT - fi - - build-frontend: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - - uses: pnpm/action-setup@v4.2.0 - with: - version: 10 - - uses: actions/setup-node@v6 - with: - node-version: "24" - cache: pnpm - cache-dependency-path: "web/pnpm-lock.yaml" - - name: Get pnpm store directory - id: pnpm-cache - shell: bash - run: echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT - - name: Setup pnpm cache - uses: actions/cache@v5 - with: - path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} - key: ${{ runner.os }}-pnpm-store-${{ hashFiles('web/pnpm-lock.yaml') }} - restore-keys: ${{ runner.os }}-pnpm-store- - - run: pnpm install --frozen-lockfile - working-directory: web - - name: Run frontend build - run: pnpm release - working-directory: web - - - name: Upload frontend artifacts - uses: actions/upload-artifact@v6 - with: - name: frontend-dist - path: server/router/frontend/dist - retention-days: 1 - - build-push: - needs: [prepare, build-frontend] - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - strategy: - fail-fast: false - matrix: - platform: - - linux/amd64 - - linux/arm/v7 - - linux/arm64 - steps: - - uses: actions/checkout@v6 - - - name: Download frontend artifacts - uses: actions/download-artifact@v7 - with: - name: frontend-dist - path: server/router/frontend/dist - - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Login to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKER_HUB_USERNAME }} - password: ${{ secrets.DOCKER_HUB_TOKEN }} - - - name: Login to GitHub Container Registry - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ github.token }} - - - name: Build and push by digest - id: build - uses: docker/build-push-action@v6 - with: - context: . - file: ./scripts/Dockerfile - platforms: ${{ matrix.platform }} - cache-from: type=gha,scope=build-${{ matrix.platform }} - cache-to: type=gha,mode=max,scope=build-${{ matrix.platform }} - outputs: type=image,name=neosmemo/memos,push-by-digest=true,name-canonical=true,push=true - - - name: Export digest - run: | - mkdir -p /tmp/digests - digest="${{ steps.build.outputs.digest }}" - touch "/tmp/digests/${digest#sha256:}" - - - name: Upload digest - uses: actions/upload-artifact@v6 - with: - name: digests-${{ strategy.job-index }} - path: /tmp/digests/* - if-no-files-found: error - retention-days: 1 - - merge: - needs: [prepare, build-push] - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - steps: - - name: Download digests - uses: actions/download-artifact@v7 - with: - pattern: digests-* - merge-multiple: true - path: /tmp/digests - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Docker meta - id: meta - uses: docker/metadata-action@v5 - with: - images: | - neosmemo/memos - ghcr.io/usememos/memos - tags: | - type=semver,pattern={{version}},value=${{ needs.prepare.outputs.version }} - type=semver,pattern={{major}}.{{minor}},value=${{ needs.prepare.outputs.version }} - type=raw,value=stable - flavor: | - latest=false - labels: | - org.opencontainers.image.version=${{ needs.prepare.outputs.version }} - - - name: Login to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKER_HUB_USERNAME }} - password: ${{ secrets.DOCKER_HUB_TOKEN }} - - - name: Login to GitHub Container Registry - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ github.token }} - - - name: Create manifest list and push - working-directory: /tmp/digests - run: | - docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ - $(printf 'neosmemo/memos@sha256:%s ' *) - env: - DOCKER_METADATA_OUTPUT_JSON: ${{ steps.meta.outputs.json }} - - - name: Inspect images - run: | - docker buildx imagetools inspect neosmemo/memos:stable - docker buildx imagetools inspect ghcr.io/usememos/memos:stable diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..7e946a7b9 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,352 @@ +name: Release + +on: + push: + tags: + - "v*.*.*" + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + GO_VERSION: "1.26.1" + NODE_VERSION: "24" + PNPM_VERSION: "10" + ARTIFACT_RETENTION_DAYS: 60 + ARTIFACT_PREFIX: memos + +jobs: + prepare: + name: Extract Version + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + tag: ${{ steps.version.outputs.tag }} + steps: + - name: Extract version + id: version + env: + REF_NAME: ${{ github.ref_name }} + EVENT_NAME: ${{ github.event_name }} + run: | + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then + echo "tag=" >> "$GITHUB_OUTPUT" + echo "version=manual-${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "tag=${REF_NAME}" >> "$GITHUB_OUTPUT" + echo "version=${REF_NAME#v}" >> "$GITHUB_OUTPUT" + + build-frontend: + name: Build Frontend + needs: prepare + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Setup pnpm + uses: pnpm/action-setup@v4.2.0 + with: + version: ${{ env.PNPM_VERSION }} + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm + cache-dependency-path: web/pnpm-lock.yaml + + - name: Get pnpm store directory + id: pnpm-cache + shell: bash + run: echo "STORE_PATH=$(pnpm store path)" >> "$GITHUB_OUTPUT" + + - name: Setup pnpm cache + uses: actions/cache@v4 + with: + path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('web/pnpm-lock.yaml') }} + restore-keys: ${{ runner.os }}-pnpm-store- + + - name: Install dependencies + working-directory: web + run: pnpm install --frozen-lockfile + + - name: Build frontend release assets + working-directory: web + run: pnpm release + + - name: Upload frontend artifacts + uses: actions/upload-artifact@v4 + with: + name: frontend-dist + path: server/router/frontend/dist + retention-days: 1 + + build-binaries: + name: Build ${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.goarm && format('v{0}', matrix.goarm) || '' }} + needs: [prepare, build-frontend] + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - goos: linux + goarch: amd64 + - goos: linux + goarch: arm64 + - goos: linux + goarch: arm + goarm: "7" + - goos: darwin + goarch: amd64 + - goos: darwin + goarch: arm64 + - goos: windows + goarch: amd64 + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Setup Go + uses: actions/setup-go@v6 + with: + go-version: ${{ env.GO_VERSION }} + cache: true + + - name: Download frontend artifacts + uses: actions/download-artifact@v4 + with: + name: frontend-dist + path: server/router/frontend/dist + + - name: Build binary + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + GOARM: ${{ matrix.goarm }} + CGO_ENABLED: "0" + run: | + output_name="memos" + if [ "$GOOS" = "windows" ]; then + output_name="memos.exe" + fi + + mkdir -p build + + go build \ + -trimpath \ + -ldflags="-s -w -X github.com/usememos/memos/internal/version.Version=${{ needs.prepare.outputs.version }} -extldflags '-static'" \ + -tags netgo,osusergo \ + -o "build/${output_name}" \ + ./cmd/memos + + - name: Package binary + env: + VERSION: ${{ needs.prepare.outputs.version }} + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + GOARM: ${{ matrix.goarm }} + run: | + cd build + + package_name="${ARTIFACT_PREFIX}_${VERSION}_${GOOS}_${GOARCH}" + if [ -n "$GOARM" ]; then + package_name="${package_name}v${GOARM}" + fi + + if [ "$GOOS" = "windows" ]; then + artifact_name="${package_name}.zip" + zip -q "${artifact_name}" memos.exe + else + artifact_name="${package_name}.tar.gz" + tar czf "${artifact_name}" memos + fi + + echo "artifact_name=${artifact_name}" >> "$GITHUB_ENV" + + - name: Upload binary artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ env.artifact_name }} + path: build/${{ env.artifact_name }} + retention-days: ${{ env.ARTIFACT_RETENTION_DAYS }} + + checksums: + name: Generate Checksums + needs: [prepare, build-binaries] + runs-on: ubuntu-latest + steps: + - name: Download binary artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + pattern: ${{ env.ARTIFACT_PREFIX }}_* + merge-multiple: true + + - name: Generate checksums + working-directory: artifacts + run: sha256sum * > checksums.txt + + - name: Upload checksum artifact + uses: actions/upload-artifact@v4 + with: + name: checksums + path: artifacts/checksums.txt + retention-days: ${{ env.ARTIFACT_RETENTION_DAYS }} + + release: + name: Publish GitHub Release + needs: [prepare, build-binaries, checksums] + if: github.event_name != 'workflow_dispatch' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Download binary artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + pattern: ${{ env.ARTIFACT_PREFIX }}_* + merge-multiple: true + + - name: Download checksum artifact + uses: actions/download-artifact@v4 + with: + name: checksums + path: artifacts + + - name: Publish release assets + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ needs.prepare.outputs.tag }} + name: ${{ needs.prepare.outputs.tag }} + generate_release_notes: true + files: artifacts/* + + build-push: + name: Build Image ${{ matrix.platform }} + needs: [prepare, build-frontend] + if: github.event_name != 'workflow_dispatch' + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + strategy: + fail-fast: false + matrix: + platform: + - linux/amd64 + - linux/arm/v7 + - linux/arm64 + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Download frontend artifacts + uses: actions/download-artifact@v4 + with: + name: frontend-dist + path: server/router/frontend/dist + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_TOKEN }} + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ github.token }} + + - name: Build and push by digest + id: build + uses: docker/build-push-action@v6 + with: + context: . + file: ./scripts/Dockerfile + platforms: ${{ matrix.platform }} + build-args: | + VERSION=${{ needs.prepare.outputs.version }} + COMMIT=${{ github.sha }} + cache-from: type=gha,scope=release-${{ matrix.platform }} + cache-to: type=gha,mode=max,scope=release-${{ matrix.platform }} + outputs: type=image,name=neosmemo/memos,push-by-digest=true,name-canonical=true,push=true + + - name: Export digest + run: | + mkdir -p /tmp/digests + digest="${{ steps.build.outputs.digest }}" + touch "/tmp/digests/${digest#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@v4 + with: + name: digests-${{ strategy.job-index }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + merge-images: + name: Publish Stable Image Tags + needs: [prepare, build-push] + if: github.event_name != 'workflow_dispatch' + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Download digests + uses: actions/download-artifact@v4 + with: + pattern: digests-* + merge-multiple: true + path: /tmp/digests + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_TOKEN }} + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ github.token }} + + - name: Create manifest list and push + working-directory: /tmp/digests + run: | + version="${{ needs.prepare.outputs.version }}" + major_minor=$(echo "$version" | cut -d. -f1,2) + docker buildx imagetools create \ + -t "neosmemo/memos:${version}" \ + -t "neosmemo/memos:${major_minor}" \ + -t "neosmemo/memos:stable" \ + -t "ghcr.io/usememos/memos:${version}" \ + -t "ghcr.io/usememos/memos:${major_minor}" \ + -t "ghcr.io/usememos/memos:stable" \ + $(printf 'neosmemo/memos@sha256:%s ' *) + + - name: Inspect images + run: | + docker buildx imagetools inspect neosmemo/memos:${{ needs.prepare.outputs.version }} + docker buildx imagetools inspect neosmemo/memos:stable diff --git a/README.md b/README.md index 3ece57e10..8d60acda2 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,18 @@ docker run -d \ Open `http://localhost:5230` and start writing! +### Native Binary + +```bash +curl -fsSL https://raw.githubusercontent.com/usememos/memos/main/scripts/install.sh | sh +``` + +To install a specific version: + +```bash +curl -fsSL https://raw.githubusercontent.com/usememos/memos/main/scripts/install.sh | sh -s -- --version +``` + ### Try the Live Demo Don't want to install yet? Try our [live demo](https://demo.usememos.com/) first! diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 000000000..611535b4b --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,248 @@ +#!/bin/sh + +set -eu + +REPO="${REPO:-usememos/memos}" +BIN_NAME="memos" +VERSION="${MEMOS_VERSION:-}" +INSTALL_DIR="${MEMOS_INSTALL_DIR:-}" +SKIP_CHECKSUM="${MEMOS_SKIP_CHECKSUM:-0}" + +usage() { + cat <<'EOF' +Install Memos from GitHub Releases. + +Usage: + install.sh [--version ] [--install-dir ] [--skip-checksum] + +Environment: + MEMOS_VERSION Version to install without the leading "v". Defaults to latest release. + MEMOS_INSTALL_DIR Directory to install the binary into. + MEMOS_SKIP_CHECKSUM Set to 1 to skip checksum verification. + REPO GitHub repository in owner/name form. Defaults to usememos/memos. + +Examples: + curl -fsSL https://raw.githubusercontent.com/usememos/memos/main/scripts/install.sh | sh + curl -fsSL https://raw.githubusercontent.com/usememos/memos/main/scripts/install.sh | sh -s -- --version 0.28.1 +EOF +} + +log() { + printf '%s\n' "$*" +} + +fail() { + printf 'Error: %s\n' "$*" >&2 + exit 1 +} + +need_cmd() { + command -v "$1" >/dev/null 2>&1 || fail "required command not found: $1" +} + +resolve_latest_version() { + latest_url="$(curl -fsSL -o /dev/null -w '%{url_effective}' "https://github.com/${REPO}/releases/latest")" + latest_tag="${latest_url##*/}" + [ -n "$latest_tag" ] || fail "failed to resolve latest release tag" + printf '%s\n' "${latest_tag#v}" +} + +detect_os() { + os="$(uname -s | tr '[:upper:]' '[:lower:]')" + case "$os" in + linux) + printf 'linux\n' + ;; + darwin) + printf 'darwin\n' + ;; + *) + fail "unsupported operating system: $os" + ;; + esac +} + +detect_arch() { + arch="$(uname -m)" + case "$arch" in + x86_64|amd64) + printf 'amd64\n' + ;; + arm64|aarch64) + printf 'arm64\n' + ;; + armv7l|armv7) + printf 'armv7\n' + ;; + *) + fail "unsupported architecture: $arch" + ;; + esac +} + +resolve_install_dir() { + if [ -n "$INSTALL_DIR" ]; then + printf '%s\n' "$INSTALL_DIR" + return + fi + + if [ -w "/usr/local/bin" ]; then + printf '/usr/local/bin\n' + return + fi + + if command -v sudo >/dev/null 2>&1; then + printf '/usr/local/bin\n' + return + fi + + printf '%s/.local/bin\n' "$HOME" +} + +download() { + src="$1" + dest="$2" + curl -fsSL "$src" -o "$dest" +} + +verify_checksum() { + archive_path="$1" + checksum_path="$2" + + if [ "$SKIP_CHECKSUM" = "1" ]; then + log "Skipping checksum verification" + return + fi + + archive_name="$(basename "$archive_path")" + expected_line="$(grep " ${archive_name}\$" "$checksum_path" || true)" + [ -n "$expected_line" ] || fail "checksum entry not found for ${archive_name}" + + if command -v sha256sum >/dev/null 2>&1; then + ( + cd "$(dirname "$archive_path")" + printf '%s\n' "$expected_line" | sha256sum -c - + ) + return + fi + + if command -v shasum >/dev/null 2>&1; then + expected_sum="$(printf '%s' "$expected_line" | awk '{print $1}')" + actual_sum="$(shasum -a 256 "$archive_path" | awk '{print $1}')" + [ "$expected_sum" = "$actual_sum" ] || fail "checksum verification failed for ${archive_name}" + return + fi + + log "Warning: sha256sum/shasum not found; skipping checksum verification" +} + +extract_archive() { + archive_path="$1" + dest_dir="$2" + + tar -xzf "$archive_path" -C "$dest_dir" +} + +install_binary() { + src="$1" + dest_dir="$2" + + mkdir -p "$dest_dir" + + if [ -w "$dest_dir" ]; then + install -m 755 "$src" "${dest_dir}/${BIN_NAME}" + return + fi + + if command -v sudo >/dev/null 2>&1; then + sudo mkdir -p "$dest_dir" + sudo install -m 755 "$src" "${dest_dir}/${BIN_NAME}" + return + fi + + fail "install directory is not writable: $dest_dir" +} + +parse_args() { + while [ "$#" -gt 0 ]; do + case "$1" in + --version) + [ "$#" -ge 2 ] || fail "missing value for --version" + VERSION="$2" + shift 2 + ;; + --install-dir) + [ "$#" -ge 2 ] || fail "missing value for --install-dir" + INSTALL_DIR="$2" + shift 2 + ;; + --skip-checksum) + SKIP_CHECKSUM="1" + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + fail "unknown argument: $1" + ;; + esac + done +} + +main() { + parse_args "$@" + + need_cmd curl + need_cmd tar + need_cmd install + need_cmd uname + need_cmd grep + need_cmd awk + + os="$(detect_os)" + arch="$(detect_arch)" + + if [ -z "$VERSION" ]; then + VERSION="$(resolve_latest_version)" + fi + + install_dir="$(resolve_install_dir)" + tag="v${VERSION}" + + asset_suffix="${arch}" + if [ "$arch" = "armv7" ]; then + asset_suffix="armv7" + fi + + asset_name="${BIN_NAME}_${VERSION}_${os}_${asset_suffix}.tar.gz" + checksums_name="checksums.txt" + base_url="https://github.com/${REPO}/releases/download/${tag}" + + tmpdir="$(mktemp -d)" + trap 'rm -rf "$tmpdir"' EXIT INT TERM + + archive_path="${tmpdir}/${asset_name}" + checksums_path="${tmpdir}/${checksums_name}" + extract_dir="${tmpdir}/extract" + + mkdir -p "$extract_dir" + + log "Installing ${BIN_NAME} ${VERSION} for ${os}/${arch}" + download "${base_url}/${asset_name}" "$archive_path" + download "${base_url}/${checksums_name}" "$checksums_path" + + verify_checksum "$archive_path" "$checksums_path" + extract_archive "$archive_path" "$extract_dir" + + [ -f "${extract_dir}/${BIN_NAME}" ] || fail "archive did not contain ${BIN_NAME}" + install_binary "${extract_dir}/${BIN_NAME}" "$install_dir" + + log "Installed ${BIN_NAME} to ${install_dir}/${BIN_NAME}" + if ! command -v "${install_dir}/${BIN_NAME}" >/dev/null 2>&1 && ! printf '%s' ":$PATH:" | grep -q ":${install_dir}:"; then + log "Add ${install_dir} to your PATH to run ${BIN_NAME} directly" + fi +} + +main "$@"