From 06e9d0897c69bfb0674aefd696e1b2205bbd61aa Mon Sep 17 00:00:00 2001 From: merefield Date: Sun, 23 Aug 2026 13:34:58 +0100 Subject: [PATCH 1/4] feat: add verified release installers --- .github/workflows/ci.yml | 15 +++ .github/workflows/release.yml | 162 ++++++++++++++++++++++++++ .goreleaser.yaml | 35 ++++++ Makefile | 10 +- README.md | 120 +++++++++----------- install-release.ps1 | 189 +++++++++++++++++++++++++++++++ install-release.sh | 178 +++++++++++++++++++++++++++++ internal/ui/view_test.go | 4 +- internal/version/version.go | 2 +- internal/version/version_test.go | 8 +- test/install-release.bats | 184 ++++++++++++++++++++++++++++++ test/install-release.ps1 | 127 +++++++++++++++++++++ 12 files changed, 957 insertions(+), 77 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 .goreleaser.yaml create mode 100644 install-release.ps1 create mode 100755 install-release.sh create mode 100755 test/install-release.bats create mode 100644 test/install-release.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2370727..42f83f0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,12 @@ jobs: go-version: "1.26.6" cache: true + - name: Setup Bats + if: runner.os == 'Linux' + uses: bats-core/bats-action@3.0.1 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + - name: Check formatting if: runner.os != 'Windows' run: test -z "$(gofmt -l .)" @@ -36,3 +42,12 @@ jobs: - name: Build run: go build -trimpath ./... + + - name: Test Unix release installer + if: runner.os == 'Linux' + run: bats test/install-release.bats + + - name: Test Windows release installer + if: runner.os == 'Windows' + shell: pwsh + run: ./test/install-release.ps1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..54fc0c0 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,162 @@ +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 release tag + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + outputs: + commit: ${{ steps.release_commit.outputs.sha }} + tag: ${{ steps.release_commit.outputs.tag }} + + steps: + - name: Checkout release tag + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ inputs.tag || github.ref }} + + - name: Validate release tag + id: release_commit + shell: bash + 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 main + if ! git merge-base --is-ancestor "$RELEASE_TAG^{commit}" FETCH_HEAD; then + echo "Release tag is not reachable from origin/main: $RELEASE_TAG" >&2 + exit 1 + fi + printf 'sha=%s\n' "$(git rev-parse "$RELEASE_TAG^{commit}")" >> "$GITHUB_OUTPUT" + printf 'tag=%s\n' "$RELEASE_TAG" >> "$GITHUB_OUTPUT" + + test: + name: Test ${{ matrix.os }} + needs: validate + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + permissions: + contents: read + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + + steps: + - name: Checkout validated release + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ needs.validate.outputs.tag }} + + - name: Verify validated commit + shell: bash + env: + EXPECTED_COMMIT: ${{ needs.validate.outputs.commit }} + run: | + actual_commit=$(git rev-parse HEAD) + if [[ "$actual_commit" != "$EXPECTED_COMMIT" ]]; then + echo "Release tag changed after validation." >&2 + exit 1 + fi + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: "1.26.6" + cache: true + + - name: Setup Bats + if: runner.os == 'Linux' + uses: bats-core/bats-action@3.0.1 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Check formatting + if: runner.os != 'Windows' + shell: bash + run: test -z "$(gofmt -l .)" + + - name: Vet + run: go vet ./... + + - name: Test + run: go test -race -cover ./... + + - name: Build + run: go build -trimpath ./... + + - name: Test Unix release installer + if: runner.os == 'Linux' + run: bats test/install-release.bats + + - name: Test Windows release installer + if: runner.os == 'Windows' + shell: pwsh + run: ./test/install-release.ps1 + + release: + name: Build and publish + needs: [validate, test] + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: write + + steps: + - name: Checkout validated release + uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + ref: ${{ needs.validate.outputs.tag }} + + - name: Verify validated commit + shell: bash + env: + EXPECTED_COMMIT: ${{ needs.validate.outputs.commit }} + run: | + actual_commit=$(git rev-parse HEAD) + if [[ "$actual_commit" != "$EXPECTED_COMMIT" ]]; then + echo "Release tag changed after validation." >&2 + exit 1 + fi + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: "1.26.6" + 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..28dbdcc --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,35 @@ +version: 2 + +project_name: codexometer + +builds: + - id: codexometer + main: . + binary: codexometer + env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + - windows + goarch: + - amd64 + - arm64 + flags: + - -buildvcs=false + - -trimpath + ldflags: + - -s -w -X github.com/merefield/codexometer/internal/version.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 6410d1c..6c5a925 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build test fmt vet +.PHONY: build test integration-test check fmt vet release-snapshot # A tagged checkout gets the nearest semantic Git description. Repositories # without a reachable tag leave this empty so Go's embedded VCS revision and @@ -12,8 +12,16 @@ build: test: go test ./... +integration-test: + bats test/install-release.bats + +check: vet test integration-test + fmt: go fmt ./... vet: go vet ./... + +release-snapshot: + goreleaser release --snapshot --clean --skip=publish diff --git a/README.md b/README.md index da41503..c185221 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ opening `/status` in your working Codex session. ```text █▀▀ █▀█ █▀▄ █▀▀ ▀▄▀ █▀█ █▀▄▀█ █▀▀ ▀█▀ █▀▀ █▀█ █▄▄ █▄█ █▄▀ ██▄ █ █ █▄█ █ ▀ █ ██▄ █ ██▄ █▀▄ -◉ QUOTA TELEMETRY CONSOLE · VERSION 0.7.8 +◉ QUOTA TELEMETRY CONSOLE · VERSION 0.10.0 ``` ![Codexometer Hacker theme showing quota and reset-cycle gauges](assets/codexometer.png) @@ -99,85 +99,71 @@ does not require a Go runtime. ## Install -Once a release has been published, Go users can install directly: +The release installer downloads the pre-built binary for the current operating +system and architecture, verifies its published SHA-256 checksum, confirms the +binary reports the requested version, and then installs it. Go is not required. -```sh -go install github.com/merefield/codexometer@latest -``` - -`go install` places the executable in `GOBIN` when that setting is non-empty; -otherwise it uses the `bin` directory under `GOPATH` (normally -`$HOME/go/bin`). That directory must be on `PATH` to run `codexometer` from any -working directory. - -On macOS or Linux, find the directory Go used: +On macOS or Linux: ```sh -go_bin="$(go env GOBIN)" -if [ -z "$go_bin" ]; then go_bin="$(go env GOPATH)/bin"; fi -printf '%s\n' "$go_bin" +curl -fsSL https://raw.githubusercontent.com/merefield/codexometer/main/install-release.sh | sh ``` -Add the printed directory to your shell configuration. For a default Go setup, -add this line to `~/.zshrc` on macOS with Zsh, or `~/.bashrc` on Linux with -Bash: +The default destination is `/usr/local/bin`; the installer uses `sudo` only +when that directory is not writable. To install without elevation: ```sh -export PATH="$PATH:$HOME/go/bin" +curl -fsSL https://raw.githubusercontent.com/merefield/codexometer/main/install-release.sh | \ + CODEXOMETER_BIN_DIR="$HOME/.local/bin" sh ``` -Restart the terminal, or reload the relevant file with `source ~/.zshrc` or -`source ~/.bashrc`. +To install a specific release, add `CODEXOMETER_VERSION=v0.10.0` beside the +bin-directory setting or download the script and pass `--version v0.10.0`. -On Windows, PowerShell can discover Go's install directory and add it to the -current user's persistent `Path` without requiring administrator access: +On Windows, download and run the PowerShell installer: ```powershell -$goBin = go env GOBIN -if (-not $goBin) { $goBin = Join-Path (go env GOPATH) "bin" } -$userPath = [Environment]::GetEnvironmentVariable("Path", "User") -if (($userPath -split ";") -notcontains $goBin) { - $newUserPath = if ($userPath) { "$userPath;$goBin" } else { $goBin } - [Environment]::SetEnvironmentVariable("Path", $newUserPath, "User") -} +$installer = Join-Path ([IO.Path]::GetTempPath()) "install-codexometer.ps1" +Invoke-WebRequest https://raw.githubusercontent.com/merefield/codexometer/main/install-release.ps1 -OutFile $installer +& $installer +Remove-Item $installer ``` -Open a new terminal after changing the Windows `Path`. Confirm the command is -available everywhere: +It installs into `%LOCALAPPDATA%\Programs\codexometer\bin` by default. Override +that with `CODEXOMETER_BIN_DIR` or `-BinDir`; use `-Version v0.10.0` to select a +release. Both installers print a reminder if the destination is not already on +`PATH`. Re-running the same command safely upgrades or reinstalls Codexometer. + +The installer scripts are ordinary text files in this repository and can be +downloaded and inspected before execution. + +### Install from source + +Developers with a current Go toolchain can build and install from source: ```sh -codexometer --version +go install github.com/merefield/codexometer@latest ``` -On macOS/Linux, `command -v codexometer` shows the resolved executable. In -PowerShell, use `Get-Command codexometer`. +`go install` builds locally and places the executable in `GOBIN`, or in the +`bin` directory under `GOPATH` when `GOBIN` is empty. That directory must be on +`PATH`. To build the current checkout: ```sh git clone https://github.com/merefield/codexometer.git cd codexometer -go build -trimpath -o codexometer . +make build ``` -On systems with Make, `make build` is an equivalent convenience target that -also injects the nearest reachable Git tag when one exists. - -On Windows, use `-o codexometer.exe` instead. The compiled executable is -standalone and does not require Go at runtime. Run it from the project directory -as `./codexometer` (or `.\codexometer.exe` in PowerShell), or copy it into any -directory already on `PATH`. A common per-user option on macOS/Linux is: +Use `go build -trimpath -o codexometer .` directly if Make is unavailable; on +Windows, use `-o codexometer.exe`. Confirm any installation with: ```sh -mkdir -p "$HOME/.local/bin" -install -m 0755 codexometer "$HOME/.local/bin/codexometer" +codexometer --version ``` -If you use that location, ensure `$HOME/.local/bin` is also included in `PATH` -using the same shell-configuration steps above. On Windows, a directory such as -`$HOME\bin` can be created, added to the user `Path`, and used for -`codexometer.exe`. - ## Quick start Start the dashboard: @@ -1279,31 +1265,27 @@ codexometer --inline codexometer --codex ~/bin/codex ``` -## Platform builds +## Release builds -Codexometer uses pure Go and cross-compiles without CGo. Each operating system -and CPU architecture needs its own executable. +GoReleaser builds Linux, macOS, and Windows archives for AMD64 and ARM64 with +CGo disabled. Unix releases are `.tar.gz`; Windows releases are `.zip`; every +release also includes `checksums.txt`. ```sh -mkdir -p dist - -GOOS=darwin GOARCH=arm64 go build -trimpath -o dist/codexometer-darwin-arm64 . -GOOS=darwin GOARCH=amd64 go build -trimpath -o dist/codexometer-darwin-amd64 . -GOOS=linux GOARCH=arm64 go build -trimpath -o dist/codexometer-linux-arm64 . -GOOS=linux GOARCH=amd64 go build -trimpath -o dist/codexometer-linux-amd64 . -GOOS=windows GOARCH=arm64 go build -trimpath -o dist/codexometer-windows-arm64.exe . -GOOS=windows GOARCH=amd64 go build -trimpath -o dist/codexometer-windows-amd64.exe . +make release-snapshot ``` -The destination machine needs Codex installed and logged in, but it does not -need Go or Codexometer's source dependencies. +That local snapshot requires GoReleaser. Publishing is deliberately confined to +the `Release` GitHub Actions workflow: a semantic `v*` tag must resolve to a +commit reachable from `main`, pass the full Linux/macOS/Windows test matrix, and +remain unchanged between validation and publication. ## Versioning Codexometer follows semantic versioning; the current source version is -`v0.7.8`. The Git tag is the release source of truth. Go automatically embeds +`v0.10.0`. The Git tag is the release source of truth. Go automatically embeds that tag in binaries built with -`go install github.com/merefield/codexometer@v0.7.8`. +`go install github.com/merefield/codexometer@v0.10.0`. The resolver uses the first version available in this order: @@ -1311,10 +1293,10 @@ The resolver uses the first version available in this order: `make build`; 2. Go's embedded module version—an exact tag for a release build or, when Go supplies one, a pseudo-version such as - `0.7.9-0.-[+dirty]`; + `0.10.1-0.-[+dirty]`; 3. for a local checkout whose module version is `(devel)`, a VCS fallback in - the explicit form `0.7.8-dev+[.dirty]`; -4. the maintained source version, `0.7.8`, when no build or VCS identity is + the explicit form `0.10.0-dev+[.dirty]`; +4. the maintained source version, `0.10.0`, when no build or VCS identity is available. The leading `v` used by Git tags and Go module versions is removed in every @@ -1333,7 +1315,7 @@ codexometer --version Release automation can override the source-build fallback without editing code: ```sh -go build -ldflags="-s -w -X github.com/merefield/codexometer/internal/version.buildVersion=v0.7.8" . +go build -ldflags="-s -w -X github.com/merefield/codexometer/internal/version.buildVersion=v0.10.0" . ``` ## How refresh works diff --git a/install-release.ps1 b/install-release.ps1 new file mode 100644 index 0000000..f19b691 --- /dev/null +++ b/install-release.ps1 @@ -0,0 +1,189 @@ +[CmdletBinding()] +param( + [string]$Version, + [string]$BinDir, + [string]$Repository, + [string]$GitHubUrl, + [string]$GitHubApiUrl +) + +$ErrorActionPreference = "Stop" +$ProgressPreference = "SilentlyContinue" +Set-StrictMode -Version 2.0 + +function Get-Setting { + param( + [string]$Value, + [string]$EnvironmentName, + [string]$DefaultValue + ) + + if (-not [string]::IsNullOrWhiteSpace($Value)) { + return $Value + } + $environmentValue = [Environment]::GetEnvironmentVariable($EnvironmentName) + if (-not [string]::IsNullOrWhiteSpace($environmentValue)) { + return $environmentValue + } + return $DefaultValue +} + +function Fail { + param([string]$Message) + throw "codexometer installer: $Message" +} + +$Version = Get-Setting $Version "CODEXOMETER_VERSION" "latest" +$Repository = Get-Setting $Repository "CODEXOMETER_REPOSITORY" "merefield/codexometer" +$GitHubUrl = (Get-Setting $GitHubUrl "CODEXOMETER_GITHUB_URL" "https://github.com").TrimEnd("/") +$GitHubApiUrl = (Get-Setting $GitHubApiUrl "CODEXOMETER_GITHUB_API_URL" "https://api.github.com").TrimEnd("/") + +if ([string]::IsNullOrWhiteSpace($BinDir)) { + $BinDir = [Environment]::GetEnvironmentVariable("CODEXOMETER_BIN_DIR") +} +if ([string]::IsNullOrWhiteSpace($BinDir)) { + if (-not [string]::IsNullOrWhiteSpace($env:LOCALAPPDATA)) { + $BinDir = Join-Path $env:LOCALAPPDATA "Programs\codexometer\bin" + } else { + $BinDir = Join-Path $HOME ".local\bin" + } +} + +if ($Repository -notmatch '^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$') { + Fail "CODEXOMETER_REPOSITORY must have the form owner/repository" +} +if ([string]::IsNullOrWhiteSpace($BinDir)) { + Fail "CODEXOMETER_BIN_DIR must not be empty" +} +if ((Test-Path -LiteralPath $BinDir) -and -not (Test-Path -LiteralPath $BinDir -PathType Container)) { + Fail "CODEXOMETER_BIN_DIR exists and is not a directory: $BinDir" +} +if ($Version -ne "latest" -and $Version -notmatch '^[A-Za-z0-9._-]+$') { + Fail "invalid release tag: $Version" +} + +$architecture = [Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() +switch ($architecture) { + "X64" { $releaseArch = "amd64" } + "Arm64" { $releaseArch = "arm64" } + default { Fail "unsupported architecture: $architecture" } +} + +[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 +$headers = @{ "User-Agent" = "codexometer-release-installer" } +$temporaryDirectory = Join-Path ([IO.Path]::GetTempPath()) ("codexometer-release-install-" + [Guid]::NewGuid().ToString("N")) +New-Item -ItemType Directory -Path $temporaryDirectory | Out-Null + +try { + $releaseTag = $Version + if ($releaseTag -eq "latest") { + Write-Host "Resolving the latest Codexometer release..." + $latestUrl = "$GitHubApiUrl/repos/$Repository/releases/latest" + try { + $release = Invoke-RestMethod -Uri $latestUrl -Headers $headers + } catch { + Fail "could not resolve the latest release: $($_.Exception.Message)" + } + if ($release -is [string]) { + try { + $release = $release | ConvertFrom-Json + } catch { + Fail "could not parse the latest release response: $($_.Exception.Message)" + } + } + $releaseTag = [string]$release.tag_name + if ([string]::IsNullOrWhiteSpace($releaseTag)) { + Fail "could not determine the latest release tag" + } + } + + if ($releaseTag -notmatch '^[A-Za-z0-9._-]+$') { + Fail "invalid release tag: $releaseTag" + } + $releaseVersion = $releaseTag -replace '^v', '' + if ([string]::IsNullOrWhiteSpace($releaseVersion)) { + Fail "invalid release tag: $releaseTag" + } + + $archiveName = "codexometer_${releaseVersion}_windows_${releaseArch}.zip" + $releaseUrl = "$GitHubUrl/$Repository/releases/download/$releaseTag" + $archivePath = Join-Path $temporaryDirectory $archiveName + $checksumsPath = Join-Path $temporaryDirectory "checksums.txt" + + Write-Host "Downloading Codexometer $releaseTag for windows/$releaseArch..." + try { + Invoke-WebRequest -Uri "$releaseUrl/$archiveName" -Headers $headers -OutFile $archivePath -UseBasicParsing + Invoke-WebRequest -Uri "$releaseUrl/checksums.txt" -Headers $headers -OutFile $checksumsPath -UseBasicParsing + } catch { + Fail "release download failed: $($_.Exception.Message)" + } + + $matchingChecksums = @( + Get-Content -LiteralPath $checksumsPath | ForEach-Object { + if ($_ -match '^([0-9A-Fa-f]{64})\s+\*?(.+)$' -and $Matches[2] -eq $archiveName) { + $Matches[1] + } + } + ) + if ($matchingChecksums.Count -ne 1) { + Fail "checksums.txt does not contain exactly one valid checksum for $archiveName" + } + + $expectedHash = $matchingChecksums[0].ToLowerInvariant() + $actualHash = (Get-FileHash -LiteralPath $archivePath -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actualHash -ne $expectedHash) { + Fail "SHA-256 checksum verification failed for $archiveName" + } + Write-Host "Verified the release checksum." + + Add-Type -AssemblyName System.IO.Compression.FileSystem + $archive = [IO.Compression.ZipFile]::OpenRead($archivePath) + try { + $binaryEntries = @($archive.Entries | Where-Object { $_.FullName -eq "codexometer.exe" }) + if ($binaryEntries.Count -ne 1) { + Fail "release archive does not contain exactly one root-level codexometer.exe binary" + } + $candidate = Join-Path $temporaryDirectory "codexometer.exe" + $source = $binaryEntries[0].Open() + try { + $destination = [IO.File]::Create($candidate) + try { + $source.CopyTo($destination) + } finally { + $destination.Dispose() + } + } finally { + $source.Dispose() + } + } finally { + $archive.Dispose() + } + + $versionOutput = (& $candidate --version 2>&1 | Out-String).Trim() + if ($LASTEXITCODE -ne 0) { + Fail "the downloaded codexometer binary failed its version check" + } + if ($versionOutput -ne "codexometer $releaseVersion") { + Fail "the downloaded binary reported an unexpected version: $versionOutput" + } + + try { + New-Item -ItemType Directory -Path $BinDir -Force | Out-Null + $target = Join-Path $BinDir "codexometer.exe" + $stagedTarget = Join-Path $BinDir (".codexometer-" + [Guid]::NewGuid().ToString("N") + ".exe") + Copy-Item -LiteralPath $candidate -Destination $stagedTarget + Move-Item -LiteralPath $stagedTarget -Destination $target -Force + } catch { + Fail "could not install into ${BinDir}: $($_.Exception.Message); set CODEXOMETER_BIN_DIR to a writable directory" + } + + Write-Host "Installed codexometer to $target ($versionOutput)." + $pathEntries = @($env:PATH -split ';') + if ($pathEntries -notcontains $BinDir) { + Write-Host "Add $BinDir to PATH before invoking codexometer." + } +} finally { + if (Test-Path -LiteralPath $temporaryDirectory) { + Remove-Item -LiteralPath $temporaryDirectory -Recurse -Force + } +} diff --git a/install-release.sh b/install-release.sh new file mode 100755 index 0000000..20130d0 --- /dev/null +++ b/install-release.sh @@ -0,0 +1,178 @@ +#!/bin/sh + +set -eu + +repository=${CODEXOMETER_REPOSITORY:-merefield/codexometer} +release_tag=${CODEXOMETER_VERSION:-latest} +bin_dir=${CODEXOMETER_BIN_DIR:-/usr/local/bin} +binary_name=${CODEXOMETER_BIN_NAME:-codexometer} +github_url=${CODEXOMETER_GITHUB_URL:-https://github.com} +github_api_url=${CODEXOMETER_GITHUB_API_URL:-https://api.github.com} + +usage() { + cat <<'EOF' +Install a pre-built Codexometer 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: + CODEXOMETER_VERSION Release tag to install (default: latest). + CODEXOMETER_BIN_DIR Installation directory (default: /usr/local/bin). + CODEXOMETER_BIN_NAME Installed binary name (default: codexometer). + CODEXOMETER_REPOSITORY GitHub owner/repository (default: merefield/codexometer). + CODEXOMETER_GITHUB_URL GitHub web base URL (primarily for testing/mirrors). + CODEXOMETER_GITHUB_API_URL GitHub API base URL (primarily for testing/mirrors). +EOF +} + +fail() { + printf 'codexometer 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 CODEXOMETER_REPOSITORY: $repository" ;; +esac +repository_owner=${repository%%/*} +repository_name=${repository#*/} +if [ -z "$repository_owner" ] || [ -z "$repository_name" ] || [ "$repository_name" != "${repository_name#*/}" ]; then + fail "CODEXOMETER_REPOSITORY must have the form owner/repository" +fi + +[ -n "$bin_dir" ] || fail "CODEXOMETER_BIN_DIR must not be empty" +if { [ -e "$bin_dir" ] || [ -L "$bin_dir" ]; } && [ ! -d "$bin_dir" ]; then + fail "CODEXOMETER_BIN_DIR exists and is not a directory: $bin_dir" +fi +case "$binary_name" in + ''|*/*) fail "CODEXOMETER_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}/codexometer-release-install.XXXXXX") +trap 'rm -rf "$tmp_dir"' EXIT HUP INT TERM + +if [ "$release_tag" = latest ]; then + printf 'Resolving the latest Codexometer 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=codexometer_${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 Codexometer %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 == "codexometer" { count++ } END { print count + 0 }') +[ "$member_count" -eq 1 ] || fail "release archive does not contain exactly one root-level codexometer binary" +extract_dir=${tmp_dir}/extract +mkdir "$extract_dir" +tar -xzf "$archive_path" -C "$extract_dir" codexometer +candidate=${extract_dir}/codexometer +[ -x "$candidate" ] || fail "the downloaded codexometer binary is not executable" + +version_output=$("$candidate" --version 2>&1) || fail "the downloaded codexometer binary failed its version check" +[ "$version_output" = "codexometer $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 CODEXOMETER_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/view_test.go b/internal/ui/view_test.go index 17009af..04e25f5 100644 --- a/internal/ui/view_test.go +++ b/internal/ui/view_test.go @@ -136,7 +136,7 @@ func TestStatusAndFooterKeepOnlyEssentialMetadata(t *testing.T) { snapshot: codex.DemoSnapshot(), nextRefresh: time.Now().Add(time.Minute), meterView: viewBars, - appVersion: "0.7.8-dev+abc123.dirty", + appVersion: "0.10.0-dev+abc123.dirty", } colors := paletteFor(themeHacker) account := ansi.Strip(model.renderAccount(colors)) @@ -153,7 +153,7 @@ func TestStatusAndFooterKeepOnlyEssentialMetadata(t *testing.T) { if !strings.HasSuffix(headerLines[len(headerLines)-1], "ACCOUNT // PLUS") { t.Fatalf("account is not aligned with the subtitle: %q", headerLines[len(headerLines)-1]) } - if !strings.Contains(headerLines[len(headerLines)-1], "VERSION 0.7.8-DEV+ABC123.DIRTY") { + if !strings.Contains(headerLines[len(headerLines)-1], "VERSION 0.10.0-DEV+ABC123.DIRTY") { t.Fatalf("resolved build version is absent from the masthead: %q", headerLines[len(headerLines)-1]) } footer := ansi.Strip(model.renderFooter(100, colors)) diff --git a/internal/version/version.go b/internal/version/version.go index e3047e7..6ebbd14 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -8,7 +8,7 @@ import ( // Version is the source fallback. Tagged module installs and builds made with // an injected buildVersion use their embedded version instead. -const Version = "0.7.8" +const Version = "0.10.0" // buildVersion may be populated at link time from the nearest Git tag. var buildVersion string diff --git a/internal/version/version_test.go b/internal/version/version_test.go index 2d8b817..c0f8d5f 100644 --- a/internal/version/version_test.go +++ b/internal/version/version_test.go @@ -7,11 +7,11 @@ import ( func TestCurrentUsesBuildOverride(t *testing.T) { previous := buildVersion - buildVersion = "v0.7.8" + buildVersion = "v0.10.0" t.Cleanup(func() { buildVersion = previous }) - if got := Current(); got != "0.7.8" { - t.Fatalf("Current() = %q, want 0.7.8", got) + if got := Current(); got != "0.10.0" { + t.Fatalf("Current() = %q, want 0.10.0", got) } } @@ -32,7 +32,7 @@ func TestVCSFallbackIncludesRevisionAndDirtyState(t *testing.T) { {Key: "vcs.revision", Value: "0123456789abcdef"}, {Key: "vcs.modified", Value: "true"}, }) - if got != "0.7.8-dev+0123456789ab.dirty" { + if got != "0.10.0-dev+0123456789ab.dirty" { t.Fatalf("vcsFallback() = %q", got) } } diff --git a/test/install-release.bats b/test/install-release.bats new file mode 100755 index 0000000..96a9f59 --- /dev/null +++ b/test/install-release.bats @@ -0,0 +1,184 @@ +#!/usr/bin/env bats + +setup() { + export TEST_ROOT + TEST_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/codexometer-release-install-test.XXXXXX")" + export FIXTURE_DIR="$TEST_ROOT/fixture" + export FIXTURE_TAG=v1.2.3 + export FIXTURE_ASSET=codexometer_1.2.3_linux_amd64.tar.gz + 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/codexometer" <<'EOF' +#!/bin/sh +echo "codexometer 1.2.3" +EOF + chmod +x "$FIXTURE_DIR/package/codexometer" + tar -czf "$FIXTURE_DIR/archive.tar.gz" -C "$FIXTURE_DIR/package" codexometer + 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" \ + CODEXOMETER_BIN_DIR="$TEST_ROOT/bin" \ + sh ./install-release.sh + + [ "$status" -eq 0 ] + [ -x "$TEST_ROOT/bin/codexometer" ] + [[ "$output" == *"Verified the release checksum."* ]] + [[ "$output" == *"Installed codexometer to $TEST_ROOT/bin/codexometer (codexometer 1.2.3)."* ]] + grep -q '/repos/merefield/codexometer/releases/latest$' "$CURL_LOG" + grep -q '/releases/download/v1.2.3/codexometer_1.2.3_linux_amd64.tar.gz$' "$CURL_LOG" + + run "$TEST_ROOT/bin/codexometer" --version + [ "$status" -eq 0 ] + [ "$output" = "codexometer 1.2.3" ] +} + +@test "release installer supports an explicit version and Darwin ARM64" { + export FAKE_UNAME_S=Darwin + export FAKE_UNAME_M=arm64 + export FIXTURE_ASSET=codexometer_1.2.3_darwin_arm64.tar.gz + + run env \ + PATH="$TEST_ROOT/fakebin:$PATH" \ + CODEXOMETER_BIN_DIR="$TEST_ROOT/bin" \ + sh ./install-release.sh --version v1.2.3 + + [ "$status" -eq 0 ] + [ -x "$TEST_ROOT/bin/codexometer" ] + ! grep -q '/releases/latest$' "$CURL_LOG" + grep -q '/releases/download/v1.2.3/codexometer_1.2.3_darwin_arm64.tar.gz$' "$CURL_LOG" +} + +@test "release installer refuses a checksum mismatch" { + export FIXTURE_HASH=0000000000000000000000000000000000000000000000000000000000000000 + + run env \ + PATH="$TEST_ROOT/fakebin:$PATH" \ + CODEXOMETER_BIN_DIR="$TEST_ROOT/bin" \ + sh ./install-release.sh + + [ "$status" -eq 1 ] + [[ "$output" == *"SHA-256 checksum verification failed"* ]] + [ ! -e "$TEST_ROOT/bin/codexometer" ] +} + +@test "release installer rejects unsupported platforms before downloading" { + export FAKE_UNAME_S=FreeBSD + + run env \ + PATH="$TEST_ROOT/fakebin:$PATH" \ + CODEXOMETER_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" \ + CODEXOMETER_BIN_DIR="$occupied_path" \ + sh ./install-release.sh + + [ "$status" -eq 1 ] + [[ "$output" == *"CODEXOMETER_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" \ + CODEXOMETER_BIN_DIR="$symlink_path" \ + sh ./install-release.sh + + [ "$status" -eq 1 ] + [[ "$output" == *"CODEXOMETER_BIN_DIR exists and is not a directory: $symlink_path"* ]] + [ ! -e "$CURL_LOG" ] +} + +@test "release installer rejects a binary with the wrong version" { + cat > "$FIXTURE_DIR/package/codexometer" <<'EOF' +#!/bin/sh +echo "codexometer 9.9.9" +EOF + chmod +x "$FIXTURE_DIR/package/codexometer" + tar -czf "$FIXTURE_DIR/archive.tar.gz" -C "$FIXTURE_DIR/package" codexometer + export FIXTURE_HASH + FIXTURE_HASH=$(sha256sum "$FIXTURE_DIR/archive.tar.gz" | awk '{ print $1 }') + + run env \ + PATH="$TEST_ROOT/fakebin:$PATH" \ + CODEXOMETER_BIN_DIR="$TEST_ROOT/bin" \ + sh ./install-release.sh + + [ "$status" -eq 1 ] + [[ "$output" == *"downloaded binary reported an unexpected version: codexometer 9.9.9"* ]] + [ ! -e "$TEST_ROOT/bin/codexometer" ] +} diff --git a/test/install-release.ps1 b/test/install-release.ps1 new file mode 100644 index 0000000..33ddad1 --- /dev/null +++ b/test/install-release.ps1 @@ -0,0 +1,127 @@ +$ErrorActionPreference = "Stop" +Set-StrictMode -Version 2.0 + +$repositoryRoot = Split-Path -Parent $PSScriptRoot +$installer = Join-Path $repositoryRoot "install-release.ps1" +$testRoot = Join-Path ([IO.Path]::GetTempPath()) ("codexometer-release-installer-test-" + [Guid]::NewGuid().ToString("N")) +$serverRoot = Join-Path $testRoot "server" +$packageDirectory = Join-Path $testRoot "package" +$installDirectory = Join-Path $testRoot "installed" +$badInstallDirectory = Join-Path $testRoot "bad-installed" +$releaseTag = "v1.2.3" +$releaseVersion = "1.2.3" +$assetName = "codexometer_1.2.3_windows_amd64.zip" +$releaseDirectory = Join-Path $serverRoot "merefield\codexometer\releases\download\$releaseTag" +$latestDirectory = Join-Path $serverRoot "repos\merefield\codexometer\releases" +$fixtureBinary = Join-Path $packageDirectory "codexometer.exe" +$archivePath = Join-Path $releaseDirectory $assetName +$checksumsPath = Join-Path $releaseDirectory "checksums.txt" +$serverProcess = $null +$savedEnvironment = @{} + +function Write-Utf8File { + param([string]$Path, [string]$Content) + [IO.File]::WriteAllText($Path, $Content, (New-Object Text.UTF8Encoding($false))) +} + +function Invoke-Installer { + param([string]$Destination) + $hostExecutable = (Get-Process -Id $PID).Path + $output = & $hostExecutable -NoProfile -ExecutionPolicy Bypass -File $installer -BinDir $Destination 2>&1 + return @{ + Status = $LASTEXITCODE + Output = ($output | Out-String).Trim() + } +} + +try { + New-Item -ItemType Directory -Path $packageDirectory, $releaseDirectory, $latestDirectory | Out-Null + + Push-Location $repositoryRoot + try { + & go build -trimpath -ldflags "-X github.com/merefield/codexometer/internal/version.buildVersion=$releaseTag" -o $fixtureBinary . + if ($LASTEXITCODE -ne 0) { + throw "failed to build the Windows installer fixture" + } + } finally { + Pop-Location + } + + Compress-Archive -LiteralPath $fixtureBinary -DestinationPath $archivePath + $archiveHash = (Get-FileHash -LiteralPath $archivePath -Algorithm SHA256).Hash.ToLowerInvariant() + Write-Utf8File $checksumsPath "$archiveHash $assetName`n" + Write-Utf8File (Join-Path $latestDirectory "latest") '{"tag_name":"v1.2.3"}' + + $listener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0) + $listener.Start() + $port = ([Net.IPEndPoint]$listener.LocalEndpoint).Port + $listener.Stop() + + $pythonCommand = Get-Command python -ErrorAction SilentlyContinue + if ($null -eq $pythonCommand) { + $pythonCommand = Get-Command python3 -ErrorAction Stop + } + $serverProcess = Start-Process -FilePath $pythonCommand.Source -ArgumentList @("-m", "http.server", "$port", "--bind", "127.0.0.1") -WorkingDirectory $serverRoot -PassThru + $baseUrl = "http://127.0.0.1:$port" + $ready = $false + for ($attempt = 0; $attempt -lt 50; $attempt++) { + try { + Invoke-WebRequest -Uri "$baseUrl/repos/merefield/codexometer/releases/latest" -UseBasicParsing | Out-Null + $ready = $true + break + } catch { + Start-Sleep -Milliseconds 100 + } + } + if (-not $ready) { + throw "fixture HTTP server did not start" + } + + foreach ($name in @("CODEXOMETER_VERSION", "CODEXOMETER_REPOSITORY", "CODEXOMETER_GITHUB_URL", "CODEXOMETER_GITHUB_API_URL")) { + $savedEnvironment[$name] = [Environment]::GetEnvironmentVariable($name) + } + $env:CODEXOMETER_VERSION = "latest" + $env:CODEXOMETER_REPOSITORY = "merefield/codexometer" + $env:CODEXOMETER_GITHUB_URL = $baseUrl + $env:CODEXOMETER_GITHUB_API_URL = $baseUrl + + $result = Invoke-Installer $installDirectory + if ($result.Status -ne 0) { + throw "release installer failed:`n$($result.Output)" + } + if ($result.Output -notmatch 'Verified the release checksum\.') { + throw "release installer did not report checksum verification" + } + $installedBinary = Join-Path $installDirectory "codexometer.exe" + if (-not (Test-Path -LiteralPath $installedBinary -PathType Leaf)) { + throw "release installer did not install codexometer.exe" + } + $versionOutput = (& $installedBinary --version 2>&1 | Out-String).Trim() + if ($versionOutput -ne "codexometer $releaseVersion") { + throw "installed binary reported an unexpected version: $versionOutput" + } + + Write-Utf8File $checksumsPath "$('0' * 64) $assetName`n" + $badResult = Invoke-Installer $badInstallDirectory + if ($badResult.Status -eq 0) { + throw "release installer accepted a checksum mismatch" + } + if ($badResult.Output -notmatch 'SHA-256 checksum verification failed') { + throw "release installer returned the wrong checksum failure: $($badResult.Output)" + } + if (Test-Path -LiteralPath (Join-Path $badInstallDirectory "codexometer.exe")) { + throw "release installer installed a checksum-mismatched binary" + } + + Write-Host "Windows release installer tests passed." +} finally { + foreach ($name in $savedEnvironment.Keys) { + [Environment]::SetEnvironmentVariable($name, $savedEnvironment[$name]) + } + if ($null -ne $serverProcess -and -not $serverProcess.HasExited) { + Stop-Process -Id $serverProcess.Id -Force + } + if (Test-Path -LiteralPath $testRoot) { + Remove-Item -LiteralPath $testRoot -Recurse -Force + } +} From 5947818157d873a0704e7c33174c8dad81dd03e5 Mon Sep 17 00:00:00 2001 From: merefield Date: Sun, 23 Aug 2026 13:38:27 +0100 Subject: [PATCH 2/4] test: report successful Windows installer checks --- test/install-release.ps1 | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/install-release.ps1 b/test/install-release.ps1 index 33ddad1..498f98a 100644 --- a/test/install-release.ps1 +++ b/test/install-release.ps1 @@ -125,3 +125,7 @@ try { Remove-Item -LiteralPath $testRoot -Recurse -Force } } + +# The expected checksum-failure child leaves LASTEXITCODE set to 1. Reaching +# this point means every assertion passed, so report success explicitly. +exit 0 From bf17dd75606d83f941d2c1b13783409048173859 Mon Sep 17 00:00:00 2001 From: merefield Date: Sun, 23 Aug 2026 13:43:38 +0100 Subject: [PATCH 3/4] docs: recommend verified release installers --- intro-post.md | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/intro-post.md b/intro-post.md index 63396eb..ac75a68 100644 --- a/intro-post.md +++ b/intro-post.md @@ -87,16 +87,26 @@ It is written in Go and builds as a standalone binary for macOS, Windows, and Li ## Install and upgrade -The easiest installation is also the easiest way to upgrade an existing Go-installed copy: +The release installer is also the easiest way to upgrade. It downloads the pre-built binary for your platform, verifies the published SHA-256 checksum, confirms the release version, and installs it without requiring Go. + +On macOS or Linux: ```sh -go install github.com/merefield/codexometer@latest -codexometer --version +curl -fsSL https://raw.githubusercontent.com/merefield/codexometer/main/install-release.sh | sh ``` -Go installs the executable into `GOBIN`, or normally `$HOME/go/bin`. Ensure that directory is on `PATH`. If the version command still finds an older installation, `command -v codexometer` on macOS/Linux or `Get-Command codexometer` in PowerShell will show which executable is being run. +The default destination is `/usr/local/bin`. For a user-local installation, set `CODEXOMETER_BIN_DIR="$HOME/.local/bin"` when running the installer. + +On Windows PowerShell: + +```powershell +$installer = Join-Path ([IO.Path]::GetTempPath()) "install-codexometer.ps1" +Invoke-WebRequest https://raw.githubusercontent.com/merefield/codexometer/main/install-release.ps1 -OutFile $installer +& $installer +Remove-Item $installer +``` -If you installed a manually built binary, pull the latest source and rebuild it, or replace the existing executable with a newly compiled copy. +Re-run the relevant installer to upgrade or reinstall Codexometer. It replaces the executable only after the downloaded artifact passes its checksum and version checks. Developers who prefer to build from source can still use `go install github.com/merefield/codexometer@latest`. Full installation, authentication, privacy, monitoring, and benchmarking guidance is available in the [README](https://github.com/merefield/codexometer#readme). From 7e853c8d1a4df5578d4375147195f59cdaa8b302 Mon Sep 17 00:00:00 2001 From: merefield Date: Sun, 23 Aug 2026 14:00:07 +0100 Subject: [PATCH 4/4] fix: harden release installers --- .github/workflows/release.yml | 3 +- README.md | 4 ++ install-release.ps1 | 25 ++++++--- install-release.sh | 43 +++++++++++++--- intro-post.md | 3 ++ test/install-release.bats | 97 +++++++++++++++++++++++++++++++++++ test/install-release.ps1 | 41 ++++++++++++--- 7 files changed, 194 insertions(+), 22 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 54fc0c0..05f4608 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,7 +41,8 @@ jobs: 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 + SEMVER_TAG_PATTERN='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(\+([0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*))?$' + if [[ ! "$RELEASE_TAG" =~ $SEMVER_TAG_PATTERN ]]; then echo "Release tag must be semantic and start with v: $RELEASE_TAG" >&2 exit 1 fi diff --git a/README.md b/README.md index c185221..2086fc8 100644 --- a/README.md +++ b/README.md @@ -125,10 +125,14 @@ On Windows, download and run the PowerShell installer: ```powershell $installer = Join-Path ([IO.Path]::GetTempPath()) "install-codexometer.ps1" Invoke-WebRequest https://raw.githubusercontent.com/merefield/codexometer/main/install-release.ps1 -OutFile $installer +Set-ExecutionPolicy -Scope Process Bypass -Force & $installer Remove-Item $installer ``` +The execution-policy override applies only to that PowerShell process. Inspect +the downloaded script before running it if required by your security policy. + It installs into `%LOCALAPPDATA%\Programs\codexometer\bin` by default. Override that with `CODEXOMETER_BIN_DIR` or `-BinDir`; use `-Version v0.10.0` to select a release. Both installers print a reminder if the destination is not already on diff --git a/install-release.ps1 b/install-release.ps1 index f19b691..cbf0e3e 100644 --- a/install-release.ps1 +++ b/install-release.ps1 @@ -37,6 +37,7 @@ $Version = Get-Setting $Version "CODEXOMETER_VERSION" "latest" $Repository = Get-Setting $Repository "CODEXOMETER_REPOSITORY" "merefield/codexometer" $GitHubUrl = (Get-Setting $GitHubUrl "CODEXOMETER_GITHUB_URL" "https://github.com").TrimEnd("/") $GitHubApiUrl = (Get-Setting $GitHubApiUrl "CODEXOMETER_GITHUB_API_URL" "https://api.github.com").TrimEnd("/") +$semanticTagPattern = '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-((?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$' if ([string]::IsNullOrWhiteSpace($BinDir)) { $BinDir = [Environment]::GetEnvironmentVariable("CODEXOMETER_BIN_DIR") @@ -58,8 +59,8 @@ if ([string]::IsNullOrWhiteSpace($BinDir)) { if ((Test-Path -LiteralPath $BinDir) -and -not (Test-Path -LiteralPath $BinDir -PathType Container)) { Fail "CODEXOMETER_BIN_DIR exists and is not a directory: $BinDir" } -if ($Version -ne "latest" -and $Version -notmatch '^[A-Za-z0-9._-]+$') { - Fail "invalid release tag: $Version" +if ($Version -ne "latest" -and $Version -cnotmatch $semanticTagPattern) { + Fail "invalid semantic release tag: $Version" } $architecture = [Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() @@ -97,8 +98,8 @@ try { } } - if ($releaseTag -notmatch '^[A-Za-z0-9._-]+$') { - Fail "invalid release tag: $releaseTag" + if ($releaseTag -cnotmatch $semanticTagPattern) { + Fail "invalid semantic release tag: $releaseTag" } $releaseVersion = $releaseTag -replace '^v', '' if ([string]::IsNullOrWhiteSpace($releaseVersion)) { @@ -167,13 +168,25 @@ try { Fail "the downloaded binary reported an unexpected version: $versionOutput" } + New-Item -ItemType Directory -Path $BinDir -Force | Out-Null + $target = Join-Path $BinDir "codexometer.exe" + if (Test-Path -LiteralPath $target -PathType Container) { + Fail "installation target exists and is a directory: $target" + } + + $stagedTarget = $null try { - New-Item -ItemType Directory -Path $BinDir -Force | Out-Null - $target = Join-Path $BinDir "codexometer.exe" $stagedTarget = Join-Path $BinDir (".codexometer-" + [Guid]::NewGuid().ToString("N") + ".exe") Copy-Item -LiteralPath $candidate -Destination $stagedTarget + if (Test-Path -LiteralPath $target -PathType Container) { + Remove-Item -LiteralPath $stagedTarget -Force + Fail "installation target exists and is a directory: $target" + } Move-Item -LiteralPath $stagedTarget -Destination $target -Force } catch { + if (-not [string]::IsNullOrWhiteSpace($stagedTarget) -and (Test-Path -LiteralPath $stagedTarget -PathType Leaf)) { + Remove-Item -LiteralPath $stagedTarget -Force + } Fail "could not install into ${BinDir}: $($_.Exception.Message); set CODEXOMETER_BIN_DIR to a writable directory" } diff --git a/install-release.sh b/install-release.sh index 20130d0..a71bda4 100755 --- a/install-release.sh +++ b/install-release.sh @@ -35,6 +35,13 @@ fail() { exit 1 } +is_semver_tag() { + printf '%s\n' "$1" | awk ' + /^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(\+([0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*))?$/ { valid = 1 } + END { exit valid ? 0 : 1 } + ' +} + while [ "$#" -gt 0 ]; do case "$1" in --version) @@ -60,6 +67,10 @@ done case "$repository" in /*|*/|*//*|*[!A-Za-z0-9._/-]*) fail "invalid CODEXOMETER_REPOSITORY: $repository" ;; esac +case "$repository" in + */*) ;; + *) fail "CODEXOMETER_REPOSITORY must have the form owner/repository" ;; +esac repository_owner=${repository%%/*} repository_name=${repository#*/} if [ -z "$repository_owner" ] || [ -z "$repository_name" ] || [ "$repository_name" != "${repository_name#*/}" ]; then @@ -74,7 +85,7 @@ case "$binary_name" in ''|*/*) fail "CODEXOMETER_BIN_NAME must be a single file name" ;; esac -for command_name in tar awk sed tr install mktemp; do +for command_name in tar awk sed tr install mktemp mv; do command -v "$command_name" >/dev/null 2>&1 || fail "required command not found: $command_name" done @@ -113,9 +124,7 @@ if [ "$release_tag" = latest ]; then [ -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 +is_semver_tag "$release_tag" || fail "invalid semantic release tag: $release_tag" release_version=${release_tag#v} [ -n "$release_version" ] || fail "invalid release tag: $release_tag" @@ -162,13 +171,31 @@ version_output=$("$candidate" --version 2>&1) || fail "the downloaded codexomete 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" +[ ! -d "$target" ] || fail "installation target exists and is a directory: $target" + +if mkdir -p "$bin_dir" 2>/dev/null && [ -w "$bin_dir" ]; then + staged_target=$(mktemp "${bin_dir}/.${binary_name}.XXXXXX") || fail "could not create a staged executable in $bin_dir" + if ! install -m 0755 "$candidate" "$staged_target"; then + rm -f "$staged_target" + fail "could not stage the executable in $bin_dir" + fi + if ! mv -f "$staged_target" "$target"; then + rm -f "$staged_target" + fail "could not replace $target" + fi else command -v sudo >/dev/null 2>&1 || fail "$bin_dir is not writable and sudo is unavailable; set CODEXOMETER_BIN_DIR to a writable directory" sudo mkdir -p "$bin_dir" - sudo install -m 0755 "$candidate" "$target" + [ ! -d "$target" ] || fail "installation target exists and is a directory: $target" + staged_target=$(sudo mktemp "${bin_dir}/.${binary_name}.XXXXXX") || fail "could not create a staged executable in $bin_dir" + if ! sudo install -m 0755 "$candidate" "$staged_target"; then + sudo rm -f "$staged_target" + fail "could not stage the executable in $bin_dir" + fi + if ! sudo mv -f "$staged_target" "$target"; then + sudo rm -f "$staged_target" + fail "could not replace $target" + fi fi printf 'Installed %s to %s (%s).\n' "$binary_name" "$target" "$version_output" diff --git a/intro-post.md b/intro-post.md index ac75a68..7277fd1 100644 --- a/intro-post.md +++ b/intro-post.md @@ -102,10 +102,13 @@ On Windows PowerShell: ```powershell $installer = Join-Path ([IO.Path]::GetTempPath()) "install-codexometer.ps1" Invoke-WebRequest https://raw.githubusercontent.com/merefield/codexometer/main/install-release.ps1 -OutFile $installer +Set-ExecutionPolicy -Scope Process Bypass -Force & $installer Remove-Item $installer ``` +The execution-policy override applies only to that PowerShell process; inspect the downloaded script before running it if required by your security policy. + Re-run the relevant installer to upgrade or reinstall Codexometer. It replaces the executable only after the downloaded artifact passes its checksum and version checks. Developers who prefer to build from source can still use `go install github.com/merefield/codexometer@latest`. Full installation, authentication, privacy, monitoring, and benchmarking guidance is available in the [README](https://github.com/merefield/codexometer#readme). diff --git a/test/install-release.bats b/test/install-release.bats index 96a9f59..04f443f 100755 --- a/test/install-release.bats +++ b/test/install-release.bats @@ -78,6 +78,12 @@ teardown() { } @test "release installer resolves, verifies, and installs the latest release" { + cat > "$TEST_ROOT/bin/codexometer" <<'EOF' +#!/bin/sh +echo "codexometer 0.0.0" +EOF + chmod +x "$TEST_ROOT/bin/codexometer" + run env \ PATH="$TEST_ROOT/fakebin:$PATH" \ CODEXOMETER_BIN_DIR="$TEST_ROOT/bin" \ @@ -95,6 +101,42 @@ teardown() { [ "$output" = "codexometer 1.2.3" ] } +@test "release installer accepts strict SemVer prerelease and build metadata" { + export FIXTURE_TAG=v1.2.3-rc.1+build.5 + export FIXTURE_ASSET=codexometer_1.2.3-rc.1+build.5_linux_amd64.tar.gz + cat > "$FIXTURE_DIR/package/codexometer" <<'EOF' +#!/bin/sh +echo "codexometer 1.2.3-rc.1+build.5" +EOF + chmod +x "$FIXTURE_DIR/package/codexometer" + tar -czf "$FIXTURE_DIR/archive.tar.gz" -C "$FIXTURE_DIR/package" codexometer + export FIXTURE_HASH + FIXTURE_HASH=$(sha256sum "$FIXTURE_DIR/archive.tar.gz" | awk '{ print $1 }') + + run env \ + PATH="$TEST_ROOT/fakebin:$PATH" \ + CODEXOMETER_BIN_DIR="$TEST_ROOT/bin" \ + sh ./install-release.sh --version "$FIXTURE_TAG" + + [ "$status" -eq 0 ] + run "$TEST_ROOT/bin/codexometer" --version + [ "$status" -eq 0 ] + [ "$output" = "codexometer 1.2.3-rc.1+build.5" ] +} + +@test "release installer rejects invalid semantic versions before downloading" { + for invalid_tag in v1.2.3- v1.2.3+. v1.2.3-01; do + run env \ + PATH="$TEST_ROOT/fakebin:$PATH" \ + CODEXOMETER_BIN_DIR="$TEST_ROOT/bin" \ + sh ./install-release.sh --version "$invalid_tag" + + [ "$status" -eq 1 ] + [[ "$output" == *"invalid semantic release tag: $invalid_tag"* ]] + [ ! -e "$CURL_LOG" ] + done +} + @test "release installer supports an explicit version and Darwin ARM64" { export FAKE_UNAME_S=Darwin export FAKE_UNAME_M=arm64 @@ -163,6 +205,61 @@ teardown() { [ ! -e "$CURL_LOG" ] } +@test "release installer creates a new nested user-local bin directory" { + nested_bin="$TEST_ROOT/new/nested/bin" + + run env \ + PATH="$TEST_ROOT/fakebin:$PATH" \ + CODEXOMETER_BIN_DIR="$nested_bin" \ + sh ./install-release.sh + + [ "$status" -eq 0 ] + [ -x "$nested_bin/codexometer" ] +} + +@test "release installer rejects a directory at the final target" { + mkdir "$TEST_ROOT/bin/codexometer" + + run env \ + PATH="$TEST_ROOT/fakebin:$PATH" \ + CODEXOMETER_BIN_DIR="$TEST_ROOT/bin" \ + sh ./install-release.sh + + [ "$status" -eq 1 ] + [[ "$output" == *"installation target exists and is a directory: $TEST_ROOT/bin/codexometer"* ]] + [ -d "$TEST_ROOT/bin/codexometer" ] +} + +@test "release installer rejects a repository without owner and name" { + run env \ + PATH="$TEST_ROOT/fakebin:$PATH" \ + CODEXOMETER_REPOSITORY=owner \ + CODEXOMETER_BIN_DIR="$TEST_ROOT/bin" \ + sh ./install-release.sh + + [ "$status" -eq 1 ] + [[ "$output" == *"CODEXOMETER_REPOSITORY must have the form owner/repository"* ]] + [ ! -e "$CURL_LOG" ] +} + +@test "release installer preserves an existing executable when staging fails" { + printf 'working executable\n' > "$TEST_ROOT/bin/codexometer" + cat > "$TEST_ROOT/fakebin/install" <<'EOF' +#!/bin/sh +exit 9 +EOF + chmod +x "$TEST_ROOT/fakebin/install" + + run env \ + PATH="$TEST_ROOT/fakebin:$PATH" \ + CODEXOMETER_BIN_DIR="$TEST_ROOT/bin" \ + sh ./install-release.sh + + [ "$status" -eq 1 ] + [[ "$output" == *"could not stage the executable"* ]] + [ "$(cat "$TEST_ROOT/bin/codexometer")" = "working executable" ] +} + @test "release installer rejects a binary with the wrong version" { cat > "$FIXTURE_DIR/package/codexometer" <<'EOF' #!/bin/sh diff --git a/test/install-release.ps1 b/test/install-release.ps1 index 498f98a..1009c9e 100644 --- a/test/install-release.ps1 +++ b/test/install-release.ps1 @@ -7,10 +7,12 @@ $testRoot = Join-Path ([IO.Path]::GetTempPath()) ("codexometer-release-installer $serverRoot = Join-Path $testRoot "server" $packageDirectory = Join-Path $testRoot "package" $installDirectory = Join-Path $testRoot "installed" +$explicitInstallDirectory = Join-Path $testRoot "explicit-installed" $badInstallDirectory = Join-Path $testRoot "bad-installed" -$releaseTag = "v1.2.3" -$releaseVersion = "1.2.3" -$assetName = "codexometer_1.2.3_windows_amd64.zip" +$directoryTargetInstallDirectory = Join-Path $testRoot "directory-target" +$releaseTag = "v1.2.3+build.5" +$releaseVersion = "1.2.3+build.5" +$assetName = "codexometer_1.2.3+build.5_windows_amd64.zip" $releaseDirectory = Join-Path $serverRoot "merefield\codexometer\releases\download\$releaseTag" $latestDirectory = Join-Path $serverRoot "repos\merefield\codexometer\releases" $fixtureBinary = Join-Path $packageDirectory "codexometer.exe" @@ -25,9 +27,16 @@ function Write-Utf8File { } function Invoke-Installer { - param([string]$Destination) + param( + [string]$Destination, + [string]$RequestedVersion + ) $hostExecutable = (Get-Process -Id $PID).Path - $output = & $hostExecutable -NoProfile -ExecutionPolicy Bypass -File $installer -BinDir $Destination 2>&1 + $arguments = @("-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $installer, "-BinDir", $Destination) + if (-not [string]::IsNullOrWhiteSpace($RequestedVersion)) { + $arguments += @("-Version", $RequestedVersion) + } + $output = & $hostExecutable @arguments 2>&1 return @{ Status = $LASTEXITCODE Output = ($output | Out-String).Trim() @@ -35,7 +44,8 @@ function Invoke-Installer { } try { - New-Item -ItemType Directory -Path $packageDirectory, $releaseDirectory, $latestDirectory | Out-Null + New-Item -ItemType Directory -Path $testRoot | Out-Null + New-Item -ItemType Directory -Path $packageDirectory, $releaseDirectory, $latestDirectory -Force | Out-Null Push-Location $repositoryRoot try { @@ -50,7 +60,7 @@ try { Compress-Archive -LiteralPath $fixtureBinary -DestinationPath $archivePath $archiveHash = (Get-FileHash -LiteralPath $archivePath -Algorithm SHA256).Hash.ToLowerInvariant() Write-Utf8File $checksumsPath "$archiveHash $assetName`n" - Write-Utf8File (Join-Path $latestDirectory "latest") '{"tag_name":"v1.2.3"}' + Write-Utf8File (Join-Path $latestDirectory "latest") '{"tag_name":"v1.2.3+build.5"}' $listener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0) $listener.Start() @@ -101,6 +111,23 @@ try { throw "installed binary reported an unexpected version: $versionOutput" } + $explicitResult = Invoke-Installer $explicitInstallDirectory $releaseTag + if ($explicitResult.Status -ne 0) { + throw "release installer rejected explicit SemVer build metadata:`n$($explicitResult.Output)" + } + + $invalidResult = Invoke-Installer $badInstallDirectory "v1.2.3-01" + if ($invalidResult.Status -eq 0 -or $invalidResult.Output -notmatch 'invalid semantic release tag') { + throw "release installer accepted an invalid semantic version: $($invalidResult.Output)" + } + + New-Item -ItemType Directory -Path $directoryTargetInstallDirectory | Out-Null + New-Item -ItemType Directory -Path (Join-Path $directoryTargetInstallDirectory "codexometer.exe") | Out-Null + $directoryTargetResult = Invoke-Installer $directoryTargetInstallDirectory $releaseTag + if ($directoryTargetResult.Status -eq 0 -or $directoryTargetResult.Output -notmatch 'installation target exists and is a directory') { + throw "release installer accepted a directory at its final target: $($directoryTargetResult.Output)" + } + Write-Utf8File $checksumsPath "$('0' * 64) $assetName`n" $badResult = Invoke-Installer $badInstallDirectory if ($badResult.Status -eq 0) {