diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0faaa4c..68bad61 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,27 +8,32 @@ permissions: contents: read jobs: - test: + check: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 + with: + fetch-depth: 0 - - name: Set up Go - uses: actions/setup-go@v6 + - name: Setup Go + uses: actions/setup-go@v7 with: go-version-file: go.mod cache: true - - name: Check formatting - run: test -z "$(gofmt -l .)" - - - name: Vet - run: go vet ./... + - name: Setup Bats + uses: bats-core/bats-action@3.0.1 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} - - name: Test - run: go test -race ./... + - name: Validate release configuration + uses: goreleaser/goreleaser-action@v7 + with: + distribution: goreleaser + version: "~> v2" + args: check - - name: Build - run: go build ./cmd/termcourse + - name: Run checks + run: make check diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..9f80e68 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,108 @@ +name: Release + +on: + push: + tags: + - "v*" + workflow_dispatch: + inputs: + tag: + description: Existing semantic-version tag to publish + required: true + type: string + +permissions: {} + +concurrency: + group: release-${{ inputs.tag || github.ref_name }} + cancel-in-progress: false + +jobs: + validate: + name: Validate and test + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + outputs: + commit: ${{ steps.release_commit.outputs.sha }} + + steps: + - name: Checkout release tag + uses: actions/checkout@v7 + with: + fetch-depth: 0 + ref: ${{ inputs.tag || github.ref }} + + - name: Validate release tag + id: release_commit + env: + RELEASE_TAG: ${{ inputs.tag || github.ref_name }} + run: | + if [[ ! "$RELEASE_TAG" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$ ]]; then + echo "Release tag must be semantic and start with v: $RELEASE_TAG" >&2 + exit 1 + fi + git show-ref --verify --quiet "refs/tags/$RELEASE_TAG" + git fetch --no-tags origin master + if ! git merge-base --is-ancestor "$RELEASE_TAG^{commit}" FETCH_HEAD; then + echo "Release tag is not reachable from origin/master: $RELEASE_TAG" >&2 + exit 1 + fi + printf 'sha=%s\n' "$(git rev-parse "$RELEASE_TAG^{commit}")" >> "$GITHUB_OUTPUT" + + - name: Setup Go + uses: actions/setup-go@v7 + with: + go-version-file: go.mod + cache: true + + - name: Setup Bats + uses: bats-core/bats-action@3.0.1 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Run checks + run: make check + + release: + name: Build and publish + needs: validate + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: write + + steps: + - name: Checkout release tag + uses: actions/checkout@v7 + with: + fetch-depth: 0 + persist-credentials: false + ref: ${{ inputs.tag || github.ref }} + + - name: Verify validated commit + env: + EXPECTED_COMMIT: ${{ needs.validate.outputs.commit }} + RELEASE_TAG: ${{ inputs.tag || github.ref_name }} + run: | + actual_commit=$(git rev-parse "$RELEASE_TAG^{commit}") + if [[ "$actual_commit" != "$EXPECTED_COMMIT" ]]; then + echo "Release tag changed after validation: $RELEASE_TAG" >&2 + exit 1 + fi + + - name: Setup Go + uses: actions/setup-go@v7 + with: + go-version-file: go.mod + cache: true + + - name: Build and publish GitHub release + uses: goreleaser/goreleaser-action@v7 + with: + distribution: goreleaser + version: "~> v2" + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..293026e --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,35 @@ +version: 2 + +project_name: termcourse + +builds: + - id: termcourse + main: ./cmd/termcourse + binary: termcourse + env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + - windows + goarch: + - amd64 + - arm64 + flags: + - -buildvcs=false + - -trimpath + ldflags: + - -s -w -X github.com/merefield/termcourse.buildVersion={{ .Version }} + +archives: + - formats: [tar.gz] + format_overrides: + - goos: windows + formats: [zip] + name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" + +checksum: + name_template: checksums.txt + +changelog: + sort: asc diff --git a/Makefile b/Makefile index d806230..8a9b032 100644 --- a/Makefile +++ b/Makefile @@ -1,16 +1,40 @@ -.PHONY: build test fmt vet +SHELL := /bin/bash +BINARY := termcourse +OUTPUT ?= $(BINARY) +PACKAGE := ./cmd/termcourse -VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo v0.2.0) +VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo v0.2.1) LDFLAGS := -X github.com/merefield/termcourse.buildVersion=$(VERSION) +.PHONY: build test race-test fmt fmt-check vet integration-test check install clean + build: - go build -ldflags "$(LDFLAGS)" ./cmd/termcourse + go build -buildvcs=false -trimpath -ldflags "$(LDFLAGS)" -o $(OUTPUT) $(PACKAGE) test: go test ./... +race-test: + go test -race ./... + fmt: gofmt -w . +fmt-check: + test -z "$$(gofmt -l .)" + vet: go vet ./... + +integration-test: + bats test + +check: fmt-check vet race-test integration-test build + +install: build + mkdir -p $${DESTDIR}$${PREFIX:-/usr/local}/bin + install -m 0755 $(OUTPUT) $${DESTDIR}$${PREFIX:-/usr/local}/bin/$(BINARY) + +clean: + go clean + rm -f $(OUTPUT) diff --git a/README.md b/README.md index 4970e5b..536ac7e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # termcourse -[![Version](https://img.shields.io/github/v/tag/merefield/termcourse?sort=semver&label=version)](https://github.com/merefield/termcourse/tags) +[![Latest release](https://img.shields.io/github/v/release/merefield/termcourse?display_name=tag&sort=semver&label=release)](https://github.com/merefield/termcourse/releases/latest) [![CI](https://github.com/merefield/termcourse/actions/workflows/ci.yml/badge.svg)](https://github.com/merefield/termcourse/actions/workflows/ci.yml) [![Go version](https://img.shields.io/github/go-mod/go-version/merefield/termcourse)](go.mod) [![License](https://img.shields.io/github/license/merefield/termcourse)](LICENSE) @@ -34,19 +34,55 @@ Termcourse is a Go 1.26.6 terminal UI for browsing and posting to Discourse foru ## Install and run -Go 1.26.6 or newer is required when installing from source. The shortest installation path is: +### Prebuilt release (recommended) + +On Linux or macOS, the release installer selects the archive for the current operating system and architecture, verifies its SHA-256 checksum, checks the binary's reported version, and installs it as `/usr/local/bin/termcourse`: + +```sh +curl -fsSL https://raw.githubusercontent.com/merefield/termcourse/master/install-release.sh | sh +``` + +The installer requires `curl` or `wget`, `tar`, and either `sha256sum` (Linux) or `shasum` (macOS). It uses `sudo` only when the destination is not writable. For a user-local installation that does not require `sudo`: + +```sh +curl -fsSL https://raw.githubusercontent.com/merefield/termcourse/master/install-release.sh | + TERMCOURSE_BIN_DIR="$HOME/.local/bin" sh +``` + +Ensure `$HOME/.local/bin` is on `PATH` when using that location. To install a particular release reproducibly: + +```sh +curl -fsSL https://raw.githubusercontent.com/merefield/termcourse/master/install-release.sh | + sh -s -- --version v0.2.1 +``` + +You can download and inspect [install-release.sh](install-release.sh) before running it. The installer supports `--help`, `--version TAG`, and `--bin-dir DIR`; the equivalent environment variables are `TERMCOURSE_VERSION` and `TERMCOURSE_BIN_DIR`. + +Prebuilt releases do not require Go. Each [GitHub Release](https://github.com/merefield/termcourse/releases) contains these assets: + +| Operating system | Architectures | Archive | Installation | +| --- | --- | --- | --- | +| Linux | AMD64, ARM64 | `.tar.gz` | Installer or manual | +| macOS | Intel (AMD64), Apple Silicon (ARM64) | `.tar.gz` | Installer or manual | +| Windows | AMD64, ARM64 | `.zip` | Manual | + +For a manual installation, verify the selected archive against the release's `checksums.txt`, extract `termcourse` (or `termcourse.exe` on Windows), and place it on `PATH`. + +Run Termcourse with the hostname or URL of any Discourse site: ```sh -go install github.com/merefield/termcourse/cmd/termcourse@latest termcourse meta.discourse.org ``` -Replace `meta.discourse.org` with the hostname or URL of any Discourse site. If no credentials are configured, Termcourse prompts for the missing username and password. Password input is hidden. +If no credentials are configured, Termcourse prompts for the missing username and password. Password input is hidden. Confirm the installed release at any time with `termcourse --version`. -Confirm the installed release at any time with: +### Install with Go + +Go 1.26.6 or newer is required when installing from source. `go install` compiles Termcourse locally: ```sh -termcourse --version +go install github.com/merefield/termcourse/cmd/termcourse@latest +termcourse meta.discourse.org ``` If the shell cannot find `termcourse`, add the Go binary directory to `PATH`. `go install` uses `GOBIN` when configured and otherwise uses `$(go env GOPATH)/bin`: @@ -55,7 +91,7 @@ If the shell cannot find `termcourse`, add the Go binary directory to `PATH`. `g export PATH="$(go env GOPATH)/bin:$PATH" ``` -To build a local executable instead: +### Build from a checkout ```sh git clone https://github.com/merefield/termcourse.git @@ -70,6 +106,12 @@ make build go run ./cmd/termcourse meta.discourse.org ``` +`make install` is also available and honours `DESTDIR` and `PREFIX`: + +```sh +make install PREFIX="$HOME/.local" +``` + For repeat use, credentials can be supplied in `.env` or the host-mapped credentials file described under [Configuration](#configuration). The examples below use an installed `termcourse`; replace it with `./termcourse` when running a binary built in the repository. Username/password login enables realtime MessageBus updates: ```sh @@ -95,11 +137,28 @@ termcourse themes hacker A local `.env` is loaded automatically. CLI credentials override host credentials from YAML, which override generic environment variables. If both login and API pairs exist, login is tried first unless the host entry selects `auth: api`. -For contributors, run the local checks with `make test` and `make vet`. +For contributors, `make check` runs formatting validation, vet, race-enabled Go tests, installer integration tests, and a local build. + +## Releases and versioning + +Termcourse uses semantic Git tags such as `v0.2.1` as the release-version source of truth. Go embeds that module version in binaries installed with `go install`; GoReleaser injects it into release binaries; and `make build` injects the current `git describe` value. `termcourse --version` and the wide masthead subtitle use the same resolved build version. Untagged direct development builds append their embedded commit and dirty state to the development version declared in [termcourse.go](termcourse.go). -## Versioning +[GoReleaser](.goreleaser.yaml) builds static Linux, macOS, and Windows archives for AMD64 and ARM64, plus `checksums.txt`. Test the configuration locally without publishing: + +```sh +goreleaser release --snapshot --clean --skip=publish +``` + +Pushing a semantic-version tag runs [the release workflow](.github/workflows/release.yml). It validates the tag syntax and confirms the tagged commit is reachable from `master`, runs the complete check suite, verifies that the tag did not move between validation and publication, and then creates the GitHub Release. No package manager, container registry, or announcement publisher is configured. + +After this release workflow reaches `master`, create `v0.2.1` from the intended release commit. The existing `v0.2.0` tag remains immutable and has no generated binary release: + +```sh +git tag -a v0.2.1 -m "termcourse v0.2.1" +git push origin v0.2.1 +``` -Termcourse uses semantic Git tags such as `v0.2.0` as the release-version source of truth. Go embeds that module version in binaries installed with `go install`, while `make build` injects the current `git describe` value. `termcourse --version` and the wide masthead subtitle both use the same resolved build version. Untagged direct development builds append their embedded commit and dirty state to the development version declared in [termcourse.go](termcourse.go). +An existing unpublished tag containing the release configuration can also be published explicitly with `gh workflow run release.yml --ref master -f tag=TAG`. ## Migrating from the Ruby version diff --git a/install-release.sh b/install-release.sh new file mode 100755 index 0000000..a921a5f --- /dev/null +++ b/install-release.sh @@ -0,0 +1,178 @@ +#!/bin/sh + +set -eu + +repository=${TERMCOURSE_REPOSITORY:-merefield/termcourse} +release_tag=${TERMCOURSE_VERSION:-latest} +bin_dir=${TERMCOURSE_BIN_DIR:-/usr/local/bin} +binary_name=${TERMCOURSE_BIN_NAME:-termcourse} +github_url=${TERMCOURSE_GITHUB_URL:-https://github.com} +github_api_url=${TERMCOURSE_GITHUB_API_URL:-https://api.github.com} + +usage() { + cat <<'EOF' +Install a pre-built Termcourse release from GitHub. + +Usage: install-release.sh [--version TAG] [--bin-dir DIR] [--help] + +Options: + --version TAG Install a specific release tag instead of the latest release. + --bin-dir DIR Install into DIR instead of /usr/local/bin. + --help Show this help. + +Environment: + TERMCOURSE_VERSION Release tag to install (default: latest). + TERMCOURSE_BIN_DIR Installation directory (default: /usr/local/bin). + TERMCOURSE_BIN_NAME Installed binary name (default: termcourse). + TERMCOURSE_REPOSITORY GitHub owner/repository (default: merefield/termcourse). + TERMCOURSE_GITHUB_URL GitHub web base URL (primarily for testing/mirrors). + TERMCOURSE_GITHUB_API_URL GitHub API base URL (primarily for testing/mirrors). +EOF +} + +fail() { + printf 'termcourse installer: %s\n' "$*" >&2 + exit 1 +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --version) + [ "$#" -ge 2 ] || fail "--version requires a release tag" + release_tag=$2 + shift 2 + ;; + --bin-dir) + [ "$#" -ge 2 ] || fail "--bin-dir requires a directory" + bin_dir=$2 + shift 2 + ;; + --help|-h) + usage + exit 0 + ;; + *) + fail "unknown option: $1" + ;; + esac +done + +case "$repository" in + /*|*/|*//*|*[!A-Za-z0-9._/-]*) fail "invalid TERMCOURSE_REPOSITORY: $repository" ;; +esac +repository_owner=${repository%%/*} +repository_name=${repository#*/} +if [ -z "$repository_owner" ] || [ -z "$repository_name" ] || [ "$repository_name" = "$repository" ] || [ "$repository_name" != "${repository_name#*/}" ]; then + fail "TERMCOURSE_REPOSITORY must have the form owner/repository" +fi + +[ -n "$bin_dir" ] || fail "TERMCOURSE_BIN_DIR must not be empty" +if { [ -e "$bin_dir" ] || [ -L "$bin_dir" ]; } && [ ! -d "$bin_dir" ]; then + fail "TERMCOURSE_BIN_DIR exists and is not a directory: $bin_dir" +fi +case "$binary_name" in + ''|*/*) fail "TERMCOURSE_BIN_NAME must be a single file name" ;; +esac + +for command_name in tar awk sed tr install mktemp; do + command -v "$command_name" >/dev/null 2>&1 || fail "required command not found: $command_name" +done + +if command -v curl >/dev/null 2>&1; then + download() { + curl --fail --silent --show-error --location --retry 3 --output "$2" "$1" + } +elif command -v wget >/dev/null 2>&1; then + download() { + wget --quiet --output-document="$2" "$1" + } +else + fail "curl or wget is required to download a release" +fi + +case "$(uname -s)" in + Linux) release_os=linux ;; + Darwin) release_os=darwin ;; + *) fail "unsupported operating system: $(uname -s)" ;; +esac + +case "$(uname -m)" in + x86_64|amd64) release_arch=amd64 ;; + arm64|aarch64) release_arch=arm64 ;; + *) fail "unsupported architecture: $(uname -m)" ;; +esac + +tmp_dir=$(mktemp -d "${TMPDIR:-/tmp}/termcourse-release-install.XXXXXX") +trap 'rm -rf "$tmp_dir"' EXIT HUP INT TERM + +if [ "$release_tag" = latest ]; then + printf 'Resolving the latest Termcourse release...\n' + release_json=${tmp_dir}/release.json + download "${github_api_url}/repos/${repository}/releases/latest" "$release_json" + release_tag=$(sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"/]*\)".*/\1/p' "$release_json" | sed -n '1p') + [ -n "$release_tag" ] || fail "could not determine the latest release tag" +fi + +case "$release_tag" in + ''|*[!A-Za-z0-9._+-]*) fail "invalid release tag: $release_tag" ;; +esac + +release_version=${release_tag#v} +[ -n "$release_version" ] || fail "invalid release tag: $release_tag" +archive_name=termcourse_${release_version}_${release_os}_${release_arch}.tar.gz +release_url=${github_url}/${repository}/releases/download/${release_tag} +archive_path=${tmp_dir}/${archive_name} +checksums_path=${tmp_dir}/checksums.txt + +printf 'Downloading Termcourse %s for %s/%s...\n' "$release_tag" "$release_os" "$release_arch" +download "${release_url}/${archive_name}" "$archive_path" +download "${release_url}/checksums.txt" "$checksums_path" + +checksum_count=$(awk -v name="$archive_name" '$2 == name || $2 == "*" name { count++ } END { print count + 0 }' "$checksums_path") +[ "$checksum_count" -eq 1 ] || fail "checksums.txt does not contain exactly one checksum for $archive_name" +expected_hash=$(awk -v name="$archive_name" '$2 == name || $2 == "*" name { print $1 }' "$checksums_path") +case "$expected_hash" in + *[!0-9A-Fa-f]*|'') fail "invalid SHA-256 checksum for $archive_name" ;; +esac +[ "${#expected_hash}" -eq 64 ] || fail "invalid SHA-256 checksum for $archive_name" + +if command -v sha256sum >/dev/null 2>&1; then + actual_hash=$(sha256sum "$archive_path" | awk '{ print $1 }') +elif command -v shasum >/dev/null 2>&1; then + actual_hash=$(shasum -a 256 "$archive_path" | awk '{ print $1 }') +else + fail "sha256sum or shasum is required to verify the release" +fi + +expected_hash=$(printf '%s' "$expected_hash" | tr 'A-F' 'a-f') +actual_hash=$(printf '%s' "$actual_hash" | tr 'A-F' 'a-f') +[ "$actual_hash" = "$expected_hash" ] || fail "SHA-256 checksum verification failed for $archive_name" +printf 'Verified the release checksum.\n' + +member_count=$(tar -tzf "$archive_path" | awk '$0 == "termcourse" { count++ } END { print count + 0 }') +[ "$member_count" -eq 1 ] || fail "release archive does not contain exactly one root-level termcourse binary" +extract_dir=${tmp_dir}/extract +mkdir "$extract_dir" +tar -xzf "$archive_path" -C "$extract_dir" termcourse +candidate=${extract_dir}/termcourse +[ -x "$candidate" ] || fail "the downloaded termcourse binary is not executable" + +version_output=$("$candidate" --version 2>&1) || fail "the downloaded termcourse binary failed its version check" +[ "$version_output" = "termcourse $release_version" ] || + fail "the downloaded binary reported an unexpected version: $version_output" + +target=${bin_dir}/${binary_name} +if [ -w "$bin_dir" ] || { [ ! -e "$bin_dir" ] && [ -w "$(dirname "$bin_dir")" ]; }; then + mkdir -p "$bin_dir" + install -m 0755 "$candidate" "$target" +else + command -v sudo >/dev/null 2>&1 || fail "$bin_dir is not writable and sudo is unavailable; set TERMCOURSE_BIN_DIR to a writable directory" + sudo mkdir -p "$bin_dir" + sudo install -m 0755 "$candidate" "$target" +fi + +printf 'Installed %s to %s (%s).\n' "$binary_name" "$target" "$version_output" +case ":${PATH}:" in + *:"$bin_dir":*) ;; + *) printf 'Add %s to PATH before invoking %s.\n' "$bin_dir" "$binary_name" ;; +esac diff --git a/internal/ui/ui_test.go b/internal/ui/ui_test.go index cd63313..f12fc55 100644 --- a/internal/ui/ui_test.go +++ b/internal/ui/ui_test.go @@ -693,7 +693,7 @@ func TestSemanticHeaderStylesUseHeaderBackgroundAndSeparators(t *testing.T) { func TestPanelsUseRoundedIntegratedTitlesAndResponsiveBranding(t *testing.T) { t.Setenv("TERMCOURSE_COLOR_MODE", "truecolor") style := NewStyle(testTheme(), &bytes.Buffer{}) - compact := style.AppHeader("Latest Topics", "community.example", "0.2.0", []string{"arrows: move | q: quit", "Logged in: member"}, 52, 24) + compact := style.AppHeader("Latest Topics", "community.example", "0.2.1", []string{"arrows: move | q: quit", "Logged in: member"}, 52, 24) plain := stripANSI(strings.Join(compact, "\n")) if !strings.HasPrefix(plain, "▰ TERMCOURSE ▰") || !strings.Contains(plain, "╭─ LATEST TOPICS ") || !strings.HasSuffix(plain, "╰"+strings.Repeat("─", 50)+"╯") { t.Fatalf("compact app header =\n%s", plain) @@ -707,8 +707,8 @@ func TestPanelsUseRoundedIntegratedTitlesAndResponsiveBranding(t *testing.T) { } } - wide := stripANSI(strings.Join(style.AppHeader("Latest", "community.example", "0.2.0", []string{"controls", "status"}, 90, 32), "\n")) - if !strings.Contains(wide, "▀█▀ █▀▀ █▀█ █▀▄▀█") || !strings.Contains(wide, "◉ DISCOURSE TERMINAL · VERSION 0.2.0") || strings.Contains(wide, "· TEST") || !strings.Contains(wide, "● ONLINE") { + wide := stripANSI(strings.Join(style.AppHeader("Latest", "community.example", "0.2.1", []string{"controls", "status"}, 90, 32), "\n")) + if !strings.Contains(wide, "▀█▀ █▀▀ █▀█ █▀▄▀█") || !strings.Contains(wide, "◉ DISCOURSE TERMINAL · VERSION 0.2.1") || strings.Contains(wide, "· TEST") || !strings.Contains(wide, "● ONLINE") { t.Fatalf("wide app header =\n%s", wide) } } diff --git a/meta-topic.md b/meta-topic.md index 837b2fb..b57f5f2 100644 --- a/meta-topic.md +++ b/meta-topic.md @@ -41,10 +41,10 @@ The interface uses the current Charm stack and works with both keyboard and mous ## Install and run -Go 1.26.6 or newer is required when installing from source. The shortest route is: +On Linux or macOS, the recommended installer downloads the prebuilt release for the current operating system and architecture, verifies its SHA-256 checksum and reported version, then installs it: ```sh -go install github.com/merefield/termcourse/cmd/termcourse@latest +curl -fsSL https://raw.githubusercontent.com/merefield/termcourse/master/install-release.sh | sh termcourse your.discourse.host ``` @@ -52,6 +52,17 @@ Termcourse prompts for a username and password when credentials have not already Use `termcourse --version` to show the installed semantic version; the same version appears in the wide terminal masthead. +For a user-local installation that does not require `sudo`: + +```sh +curl -fsSL https://raw.githubusercontent.com/merefield/termcourse/master/install-release.sh | + TERMCOURSE_BIN_DIR="$HOME/.local/bin" sh +``` + +Each [GitHub Release](https://github.com/merefield/termcourse/releases) provides SHA-256 checksums and prebuilt archives for Linux, macOS and Windows on AMD64 and ARM64. Linux/macOS use `.tar.gz`; Windows uses `.zip`. Prebuilt releases do not require Go. + +The installer can also pin a release, for example `sh -s -- --version v0.2.1`, after the download pipe. Go 1.26.6 or newer is only required when installing from source. + To build a local executable from a checkout instead: ```sh diff --git a/termcourse.go b/termcourse.go index 5afbdc7..79d3a4a 100644 --- a/termcourse.go +++ b/termcourse.go @@ -8,7 +8,7 @@ import ( // Version is the development fallback. Tagged module installs and builds made // through the Makefile use their embedded semantic version instead. -const Version = "0.2.0" +const Version = "0.2.1" // buildVersion is populated by the Makefile from the nearest Git tag. var buildVersion string diff --git a/termcourse_test.go b/termcourse_test.go index 27dea25..033f759 100644 --- a/termcourse_test.go +++ b/termcourse_test.go @@ -7,11 +7,11 @@ import ( func TestCurrentVersionUsesBuildOverride(t *testing.T) { previous := buildVersion - buildVersion = "v0.2.0" + buildVersion = "v0.2.1" t.Cleanup(func() { buildVersion = previous }) - if actual := CurrentVersion(); actual != "0.2.0" { - t.Fatalf("CurrentVersion() = %q, want 0.2.0", actual) + if actual := CurrentVersion(); actual != "0.2.1" { + t.Fatalf("CurrentVersion() = %q, want 0.2.1", actual) } } @@ -26,7 +26,7 @@ func TestDevelopmentVersionIncludesRevisionAndDirtyState(t *testing.T) { {Key: "vcs.revision", Value: "0123456789abcdef"}, {Key: "vcs.modified", Value: "true"}, }) - if actual != "0.2.0-dev+0123456789ab.dirty" { + if actual != "0.2.1-dev+0123456789ab.dirty" { t.Fatalf("developmentVersion() = %q", actual) } } diff --git a/test/install-release.bats b/test/install-release.bats new file mode 100644 index 0000000..bd0fba6 --- /dev/null +++ b/test/install-release.bats @@ -0,0 +1,192 @@ +#!/usr/bin/env bats + +setup() { + export TEST_ROOT + TEST_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/termcourse-release-install-test.XXXXXX")" + export FIXTURE_DIR="$TEST_ROOT/fixture" + export FIXTURE_TAG=v0.2.1 + export FIXTURE_ASSET=termcourse_0.2.1_linux_amd64.tar.gz + export FIXTURE_VERSION_OUTPUT="termcourse 0.2.1" + export CURL_LOG="$TEST_ROOT/curl.log" + export FAKE_UNAME_S=Linux + export FAKE_UNAME_M=x86_64 + mkdir -p "$TEST_ROOT/fakebin" "$TEST_ROOT/bin" "$FIXTURE_DIR/package" + + cat > "$FIXTURE_DIR/package/termcourse" <<'EOF' +#!/bin/sh +printf '%s\n' "$FIXTURE_VERSION_OUTPUT" +EOF + chmod +x "$FIXTURE_DIR/package/termcourse" + tar -czf "$FIXTURE_DIR/archive.tar.gz" -C "$FIXTURE_DIR/package" termcourse + export FIXTURE_HASH + FIXTURE_HASH=$(sha256sum "$FIXTURE_DIR/archive.tar.gz" | awk '{ print $1 }') + + cat > "$TEST_ROOT/fakebin/uname" <<'EOF' +#!/bin/sh +case "$1" in + -s) printf '%s\n' "$FAKE_UNAME_S" ;; + -m) printf '%s\n' "$FAKE_UNAME_M" ;; + *) exit 2 ;; +esac +EOF + chmod +x "$TEST_ROOT/fakebin/uname" + + cat > "$TEST_ROOT/fakebin/curl" <<'EOF' +#!/bin/sh +output= +url= +while [ "$#" -gt 0 ]; do + case "$1" in + --output) + output=$2 + shift 2 + ;; + --retry) + shift 2 + ;; + --*) + shift + ;; + *) + url=$1 + shift + ;; + esac +done +[ -n "$output" ] && [ -n "$url" ] || exit 2 +printf '%s\n' "$url" >> "$CURL_LOG" +case "$url" in + */repos/*/releases/latest) + printf '{"tag_name":"%s"}\n' "$FIXTURE_TAG" > "$output" + ;; + */checksums.txt) + printf '%s %s\n' "$FIXTURE_HASH" "$FIXTURE_ASSET" > "$output" + ;; + */"$FIXTURE_ASSET") + cp "$FIXTURE_DIR/archive.tar.gz" "$output" + ;; + *) + printf 'unexpected URL: %s\n' "$url" >&2 + exit 3 + ;; +esac +EOF + chmod +x "$TEST_ROOT/fakebin/curl" +} + +teardown() { + rm -rf "$TEST_ROOT" +} + +@test "release installer resolves, verifies, and installs the latest release" { + run env \ + PATH="$TEST_ROOT/fakebin:$PATH" \ + TERMCOURSE_BIN_DIR="$TEST_ROOT/bin" \ + sh ./install-release.sh + + [ "$status" -eq 0 ] + [ -x "$TEST_ROOT/bin/termcourse" ] + [[ "$output" == *"Verified the release checksum."* ]] + [[ "$output" == *"Installed termcourse to $TEST_ROOT/bin/termcourse (termcourse 0.2.1)."* ]] + grep -q '/repos/merefield/termcourse/releases/latest$' "$CURL_LOG" + grep -q '/releases/download/v0.2.1/termcourse_0.2.1_linux_amd64.tar.gz$' "$CURL_LOG" + + run "$TEST_ROOT/bin/termcourse" --version + [ "$status" -eq 0 ] + [ "$output" = "termcourse 0.2.1" ] +} + +@test "release installer supports an explicit version and Darwin ARM64" { + export FAKE_UNAME_S=Darwin + export FAKE_UNAME_M=arm64 + export FIXTURE_TAG=v0.2.1+build.1 + export FIXTURE_ASSET=termcourse_0.2.1+build.1_darwin_arm64.tar.gz + export FIXTURE_VERSION_OUTPUT="termcourse 0.2.1+build.1" + + run env \ + PATH="$TEST_ROOT/fakebin:$PATH" \ + TERMCOURSE_BIN_DIR="$TEST_ROOT/bin" \ + sh ./install-release.sh --version v0.2.1+build.1 + + [ "$status" -eq 0 ] + [ -x "$TEST_ROOT/bin/termcourse" ] + ! grep -q '/releases/latest$' "$CURL_LOG" + grep -Fq '/releases/download/v0.2.1+build.1/termcourse_0.2.1+build.1_darwin_arm64.tar.gz' "$CURL_LOG" +} + +@test "release installer refuses a checksum mismatch" { + export FIXTURE_HASH=0000000000000000000000000000000000000000000000000000000000000000 + + run env \ + PATH="$TEST_ROOT/fakebin:$PATH" \ + TERMCOURSE_BIN_DIR="$TEST_ROOT/bin" \ + sh ./install-release.sh + + [ "$status" -eq 1 ] + [[ "$output" == *"SHA-256 checksum verification failed"* ]] + [ ! -e "$TEST_ROOT/bin/termcourse" ] +} + +@test "release installer refuses a binary with the wrong version" { + export FIXTURE_VERSION_OUTPUT="termcourse 9.9.9" + + run env \ + PATH="$TEST_ROOT/fakebin:$PATH" \ + TERMCOURSE_BIN_DIR="$TEST_ROOT/bin" \ + sh ./install-release.sh + + [ "$status" -eq 1 ] + [[ "$output" == *"downloaded binary reported an unexpected version: termcourse 9.9.9"* ]] + [ ! -e "$TEST_ROOT/bin/termcourse" ] +} + +@test "release installer rejects unsupported platforms before downloading" { + export FAKE_UNAME_S=FreeBSD + + run env \ + PATH="$TEST_ROOT/fakebin:$PATH" \ + TERMCOURSE_BIN_DIR="$TEST_ROOT/bin" \ + sh ./install-release.sh + + [ "$status" -eq 1 ] + [[ "$output" == *"unsupported operating system: FreeBSD"* ]] + [ ! -e "$CURL_LOG" ] +} + +@test "release installer rejects a bin path that is not a directory" { + occupied_path="$TEST_ROOT/not-a-directory" + printf 'occupied\n' > "$occupied_path" + + run env \ + PATH="$TEST_ROOT/fakebin:$PATH" \ + TERMCOURSE_BIN_DIR="$occupied_path" \ + sh ./install-release.sh + + [ "$status" -eq 1 ] + [[ "$output" == *"TERMCOURSE_BIN_DIR exists and is not a directory: $occupied_path"* ]] + [ ! -e "$CURL_LOG" ] + + symlink_path="$TEST_ROOT/file-symlink" + ln -s "$occupied_path" "$symlink_path" + + run env \ + PATH="$TEST_ROOT/fakebin:$PATH" \ + TERMCOURSE_BIN_DIR="$symlink_path" \ + sh ./install-release.sh + + [ "$status" -eq 1 ] + [[ "$output" == *"TERMCOURSE_BIN_DIR exists and is not a directory: $symlink_path"* ]] + [ ! -e "$CURL_LOG" ] +} + +@test "release installer requires an owner and repository" { + run env \ + PATH="$TEST_ROOT/fakebin:$PATH" \ + TERMCOURSE_REPOSITORY=termcourse \ + TERMCOURSE_BIN_DIR="$TEST_ROOT/bin" \ + sh ./install-release.sh + + [ "$status" -eq 1 ] + [[ "$output" == *"TERMCOURSE_REPOSITORY must have the form owner/repository"* ]] + [ ! -e "$CURL_LOG" ] +}