diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..288cdae --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,73 @@ +name: release + +# CLI-MCP-13R2 — release pipeline for the `instant` CLI. +# +# Fires on a semver tag push (`v*.*.*`). Cross-compiles via GoReleaser, +# generates SBOMs, signs the checksum file with sigstore cosign (keyless +# OIDC), and publishes everything to the GitHub Release page. +# +# Why tag-driven instead of branch-driven: the other backend services in +# instanode.dev auto-deploy on every push to `master` (CLAUDE.md rule 15), +# but a CLI binary has a different shape — users install once and pin to +# the latest published release. Tagging is the canonical "this is a real +# release, not a transient build" signal. + +on: + push: + tags: + - "v*.*.*" + +# Default permissions are read-only. Each job grants the minimum scope it +# needs. `id-token: write` is required for sigstore keyless signing via +# the GitHub OIDC issuer. +permissions: + contents: read + +jobs: + goreleaser: + name: build, sign, publish + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: write # publish artifacts to the release page + id-token: write # sigstore OIDC for keyless signing + attestations: write # SBOM attestation + steps: + # Full history + tags are required so GoReleaser can read the tag + # message and infer changelog scope. + - name: Checkout (full history + tags) + uses: actions/checkout@v6 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Setup Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + + # Third-party actions are PINNED to a commit SHA per CSO supply-chain + # policy. Renovate / Dependabot manages bumps; never use a floating + # tag in this workflow. + - name: Install cosign (sigstore) + # pinned: tag v3.7.0 + uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac + with: + cosign-release: 'v2.4.1' + + - name: Install syft (SBOM) + # pinned: tag v0.20.0 + uses: anchore/sbom-action/download-syft@e22c389904149dbc22b58101806040fa8d37a610 + + - name: Run GoReleaser + # pinned: tag v6.4.0 + uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a + with: + distribution: goreleaser + version: "~> v2" + args: release --clean + env: + # GITHUB_TOKEN is the per-job, repo-scoped, short-lived token — + # NOT a long-lived PAT. GoReleaser uses it to upload the + # release artifacts to the same repo. + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 9597203..1d6903f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ bin/ # Internal Claude Code skills .claude/ + +# GoReleaser snapshot/release artifacts +dist/ diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..4ee3fba --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,164 @@ +# .goreleaser.yml — release pipeline for the `instant` CLI. +# +# CLI-MCP-13R2 (BugBash QA round 2 strategic gap): the CLI had no release +# workflow. Every other backend service in instanode.dev auto-builds on push +# (CLAUDE.md rule 15); the CLI's only install path was `go install`, which +# requires a Go toolchain and pins the user to whatever HEAD happens to be. +# +# This config + .github/workflows/release.yml + install.sh together close +# that gap: +# +# * Tagging `vX.Y.Z` on master triggers cross-compiled builds for +# darwin / linux / windows × amd64 / arm64. +# * Binaries are stamped with the tag's version, the commit SHA, and the +# UTC build time (matches the Makefile's ldflag scheme — CLAUDE.md rule +# 14 build-SHA gate still applies via `instant --version`). +# * `instant_v0.2.0_darwin_arm64.tar.gz` etc. land on the GitHub release +# page; `checksums.txt` is signed by sigstore cosign (keyless OIDC). +# * `install.sh` curl-pipe-sh fetches the right archive for the user's +# platform. +# +# We intentionally do NOT publish to Homebrew / Scoop / apt yet — that's a +# follow-up PR. This release pipeline keeps the dependency footprint to: +# - goreleaser (pinned action SHA in release.yml) +# - syft (SBOM, pinned) +# - cosign (signing, pinned) +# Everything else is removed. + +version: 2 + +project_name: instant + +before: + hooks: + - go mod tidy + +builds: + - id: instant + binary: instant + main: ./ + env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + - windows + goarch: + - amd64 + - arm64 + # No 32-bit / mips targets — the agent surface is `INSTANT_API_URL` over + # HTTPS, which is hard to use from constrained devices anyway. + ignore: + # Windows on ARM64 ships, but it's a niche; revisit if anyone files + # an issue. Linux/arm64 + Darwin/arm64 cover the GH Actions runner + # matrix and Apple Silicon developer laptops. + - goos: windows + goarch: arm64 + # ldflags mirror the Makefile's `make build` target so the release + # binary's `--version` line matches the source of truth. CLAUDE.md + # rule 14 (build-SHA gate) reads from these via `instant --version`. + ldflags: + - -s -w + - -X main.Version={{.Version}} + - -X main.Commit={{.ShortCommit}} + - -X main.BuildTime={{.Date}} + +archives: + - id: instant-archive + # GoReleaser v2 renamed `name_template` keys — the format below is the + # v2 canonical layout: `___`. install.sh + # depends on this exact pattern. + name_template: >- + {{ .ProjectName }}_{{ .Version }}_ + {{- if eq .Os "darwin" }}darwin + {{- else if eq .Os "linux" }}linux + {{- else if eq .Os "windows" }}windows{{ end }}_ + {{- .Arch }} + format_overrides: + - goos: windows + formats: + - zip + formats: + - tar.gz + files: + - LICENSE + - README.md + +checksum: + name_template: "checksums.txt" + algorithm: sha256 + +# Sigstore keyless signing via GitHub OIDC. The release workflow grants +# `id-token: write` so cosign can mint a short-lived cert from Fulcio. No +# private keys to manage. Verification: +# +# cosign verify-blob \ +# --certificate-identity-regexp 'https://github.com/InstaNode-dev/cli/.github/workflows/release.yml@.*' \ +# --certificate-oidc-issuer https://token.actions.githubusercontent.com \ +# --signature checksums.txt.sig \ +# --certificate checksums.txt.pem \ +# checksums.txt +signs: + - id: cosign-checksums + cmd: cosign + artifacts: checksums + signature: "${artifact}.sig" + certificate: "${artifact}.pem" + args: + - sign-blob + - "--output-signature=${signature}" + - "--output-certificate=${certificate}" + - "${artifact}" + - --yes + output: true + +# SBOM generation (CycloneDX via syft). Lands alongside binaries on the +# release page. cyclonedx is the CSO/CISA-preferred format. +sboms: + - id: instant-sbom + artifacts: archive + +release: + github: + owner: InstaNode-dev + name: cli + draft: false + prerelease: auto + name_template: "v{{.Version}}" + header: | + `instant` CLI release {{.Version}}. + + ## Install (one-liner) + + ```bash + curl -sSfL https://instanode.dev/install.sh | sh + ``` + + Or download the archive for your platform from the assets below and + drop the binary on `$PATH`. + + ## Verify the release + + Checksums are signed with sigstore cosign (keyless OIDC). To verify: + + ```bash + cosign verify-blob \ + --certificate-identity-regexp 'https://github.com/InstaNode-dev/cli/.github/workflows/release.yml@.*' \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + --signature checksums.txt.sig \ + --certificate checksums.txt.pem \ + checksums.txt + ``` + +snapshot: + version_template: "{{ incpatch .Version }}-next" + +changelog: + sort: asc + filters: + exclude: + - '^docs:' + - '^chore:' + - '^test:' + - '^ci:' + - Merge pull request diff --git a/README.md b/README.md index da9b1c8..56fe9ce 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,29 @@ Zero-friction infrastructure CLI for [instanode.dev](https://instanode.dev). ## Install +Pre-built binaries for darwin / linux × amd64 / arm64 (the curl-pipe-sh +script auto-detects your platform): + +```bash +curl -sSfL https://instanode.dev/install.sh | sh +``` + +The installer downloads the latest release archive from +[GitHub Releases](https://github.com/InstaNode-dev/cli/releases), verifies +its SHA-256 against the signed `checksums.txt`, and drops the binary at +`/usr/local/bin/instant`. Set `INSTANT_INSTALL_DIR=$HOME/.local/bin` to +avoid sudo; set `INSTANT_VERSION=v0.2.0` to pin a specific release. + +Or, with a Go toolchain already installed: + ```bash go install github.com/InstaNode-dev/cli@latest ``` +Windows users: download the `.zip` from the +[releases page](https://github.com/InstaNode-dev/cli/releases) and add +`instant.exe` to your `PATH`. + ## Usage Every provisioning command requires a `--name` flag. The name must be 1–64 diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..d506646 --- /dev/null +++ b/install.sh @@ -0,0 +1,205 @@ +#!/usr/bin/env sh +# +# install.sh — one-shot installer for the `instant` CLI. +# +# Usage: +# curl -sSfL https://instanode.dev/install.sh | sh +# +# What it does: +# 1. Detect the host OS (darwin | linux | windows) and arch (amd64 | arm64). +# 2. Resolve the latest release tag from the GitHub API (or honor an +# explicit INSTANT_VERSION env var, e.g. v0.2.0). +# 3. Download the matching tar.gz archive from the release page. +# 4. Verify its SHA-256 against the release's checksums.txt. +# 5. Drop `instant` into INSTANT_INSTALL_DIR (default /usr/local/bin). +# +# Why POSIX sh (not bash): the curl-pipe-sh path runs in whatever /bin/sh +# the user has — that's POSIX dash on Debian/Ubuntu, bash on macOS, +# busybox sh on Alpine. Sticking to POSIX keeps the install path +# friction-free everywhere. No arrays, no [[ ]], no `local`. +# +# Why not `go install`: a Go toolchain is a > 200 MB dependency for what +# should be a 30-second install. `go install` is still documented in the +# README as a fallback for users who already have Go. +# +# CLI-MCP-13R2 — closes the BugBash QA round 2 strategic gap: the CLI had +# no release path at all. + +set -eu + +REPO="InstaNode-dev/cli" +BINARY_NAME="instant" +DEFAULT_INSTALL_DIR="/usr/local/bin" +INSTALL_DIR="${INSTANT_INSTALL_DIR:-$DEFAULT_INSTALL_DIR}" +VERSION="${INSTANT_VERSION:-}" + +# ── helpers ───────────────────────────────────────────────────────────────── + +# Coloured logging — gracefully degrades on terminals without ANSI support. +if [ -t 1 ] && command -v tput >/dev/null 2>&1 && [ "$(tput colors 2>/dev/null || echo 0)" -ge 8 ]; then + BOLD="$(tput bold)" + DIM="$(tput dim)" + RED="$(tput setaf 1)" + GREEN="$(tput setaf 2)" + YELLOW="$(tput setaf 3)" + RESET="$(tput sgr0)" +else + BOLD=""; DIM=""; RED=""; GREEN=""; YELLOW=""; RESET="" +fi + +info() { printf "%s==>%s %s\n" "$GREEN$BOLD" "$RESET" "$1"; } +warn() { printf "%s==>%s %s\n" "$YELLOW$BOLD" "$RESET" "$1" >&2; } +fail() { printf "%serror:%s %s\n" "$RED$BOLD" "$RESET" "$1" >&2; exit 1; } + +# detect_os normalises uname's output into goreleaser's archive naming +# (darwin | linux | windows). MINGW / MSYS / CYGWIN all collapse to +# `windows`; the windows path emits an explicit "use the zip from the +# release page" message because curl-pipe-sh under windows is not +# something we want to surprise users with. +detect_os() { + uname_out=$(uname -s 2>/dev/null || echo unknown) + case "$uname_out" in + Darwin) printf 'darwin' ;; + Linux) printf 'linux' ;; + MINGW*|MSYS*|CYGWIN*|Windows_NT) printf 'windows' ;; + *) fail "unsupported OS: $uname_out (expected Darwin, Linux, or Windows)" ;; + esac +} + +# detect_arch normalises uname -m into goreleaser's arch names. The +# common Apple Silicon (arm64), Intel (amd64 / x86_64), and Linux/arm64 +# variants are covered; 32-bit and esoteric ISAs are explicitly +# rejected (the platform isn't shipped for them). +detect_arch() { + arch_out=$(uname -m 2>/dev/null || echo unknown) + case "$arch_out" in + x86_64|amd64) printf 'amd64' ;; + arm64|aarch64) printf 'arm64' ;; + *) fail "unsupported architecture: $arch_out (expected amd64 or arm64)" ;; + esac +} + +# need_cmd checks that a required CLI exists on PATH, with a clear error +# pointing at the missing dependency. +need_cmd() { + command -v "$1" >/dev/null 2>&1 || fail "missing required command: $1. Please install it and re-run." +} + +# resolve_version queries GitHub's release API for the latest tag when +# INSTANT_VERSION is unset. The endpoint returns 200 with `tag_name` in +# JSON; we grep it out with sed (no jq dependency). +resolve_version() { + if [ -n "$VERSION" ]; then + printf '%s' "$VERSION" + return + fi + info "Resolving latest release for $REPO..." >&2 + api_url="https://api.github.com/repos/$REPO/releases/latest" + latest=$(curl -fsSL "$api_url" 2>/dev/null \ + | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \ + | head -n1) || true + if [ -z "$latest" ]; then + fail "could not resolve latest release for $REPO. Set INSTANT_VERSION=vX.Y.Z and retry." + fi + printf '%s' "$latest" +} + +# verify_checksum downloads checksums.txt for the release, matches the +# archive name, and re-computes the SHA-256 locally. shasum (BSD/macOS) +# and sha256sum (GNU/Linux) are both supported transparently. +verify_checksum() { + archive="$1" + checksum_url="$2" + archive_base=$(basename "$archive") + info "Verifying checksum..." + if ! curl -fsSL "$checksum_url" -o "$archive.checksums"; then + fail "could not download checksums.txt from $checksum_url" + fi + expected=$(awk -v n="$archive_base" '$2 == n {print $1}' "$archive.checksums") + if [ -z "$expected" ]; then + fail "no checksum entry for $archive_base in checksums.txt" + fi + if command -v sha256sum >/dev/null 2>&1; then + actual=$(sha256sum "$archive" | awk '{print $1}') + elif command -v shasum >/dev/null 2>&1; then + actual=$(shasum -a 256 "$archive" | awk '{print $1}') + else + warn "neither sha256sum nor shasum found; skipping checksum verification" + return + fi + if [ "$expected" != "$actual" ]; then + fail "checksum mismatch: expected $expected, got $actual" + fi + info "Checksum OK ($expected)" +} + +# install_binary copies the extracted binary into INSTALL_DIR, using +# sudo if the target dir isn't writable by the current user. Permission +# requests are explicit so a curl-pipe-sh user understands the prompt. +install_binary() { + src="$1" + dest="$INSTALL_DIR/$BINARY_NAME" + if [ -w "$INSTALL_DIR" ]; then + install -m 0755 "$src" "$dest" + else + info "Installing to $dest requires sudo..." + sudo install -m 0755 "$src" "$dest" + fi +} + +# ── main ──────────────────────────────────────────────────────────────────── + +need_cmd curl +need_cmd tar +need_cmd uname + +os=$(detect_os) +if [ "$os" = "windows" ]; then + fail "Windows is not supported by this script. Download the .zip from https://github.com/$REPO/releases and add instant.exe to your PATH." +fi +arch=$(detect_arch) +version=$(resolve_version) + +# Strip the leading "v" — goreleaser archives use the bare semver in +# their filename (e.g. instant_0.2.0_darwin_arm64.tar.gz). +version_no_v="${version#v}" + +archive_name="${BINARY_NAME}_${version_no_v}_${os}_${arch}.tar.gz" +release_base="https://github.com/$REPO/releases/download/$version" +archive_url="$release_base/$archive_name" +checksum_url="$release_base/checksums.txt" + +info "Detected: $os/$arch" +info "Installing $BINARY_NAME $version from $archive_url" + +tmpdir=$(mktemp -d 2>/dev/null || mktemp -d -t instant-install) +trap 'rm -rf "$tmpdir"' EXIT INT TERM + +archive_path="$tmpdir/$archive_name" +if ! curl -fsSL "$archive_url" -o "$archive_path"; then + fail "could not download $archive_url. Check that the release exists at https://github.com/$REPO/releases/tag/$version" +fi + +verify_checksum "$archive_path" "$checksum_url" + +info "Extracting archive..." +tar -xzf "$archive_path" -C "$tmpdir" + +if [ ! -f "$tmpdir/$BINARY_NAME" ]; then + fail "extracted archive does not contain $BINARY_NAME" +fi + +install_binary "$tmpdir/$BINARY_NAME" + +info "Installed $BINARY_NAME $version to $INSTALL_DIR/$BINARY_NAME" +printf "%sRun%s '%s --version' to verify.\n" "$DIM" "$RESET" "$BINARY_NAME" + +# Sanity-check that INSTALL_DIR is on PATH; warn if not (silent install +# pipelines are no fun to debug). +case ":$PATH:" in + *":$INSTALL_DIR:"*) ;; + *) + warn "$INSTALL_DIR is not on your PATH. Add it to your shell profile, e.g.:" + printf "\n export PATH=\"%s:\$PATH\"\n\n" "$INSTALL_DIR" >&2 + ;; +esac