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..05f4608 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,163 @@ +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: | + 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 + 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..2086fc8 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,75 @@ 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 +Set-ExecutionPolicy -Scope Process Bypass -Force +& $installer +Remove-Item $installer ``` -Open a new terminal after changing the Windows `Path`. Confirm the command is -available everywhere: +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 +`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 +1269,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 +1297,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 +1319,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..cbf0e3e --- /dev/null +++ b/install-release.ps1 @@ -0,0 +1,202 @@ +[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("/") +$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") +} +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 -cnotmatch $semanticTagPattern) { + Fail "invalid semantic 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 -cnotmatch $semanticTagPattern) { + Fail "invalid semantic 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" + } + + 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 { + $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" + } + + 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..a71bda4 --- /dev/null +++ b/install-release.sh @@ -0,0 +1,205 @@ +#!/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 +} + +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) + [ "$#" -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 +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 + 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 mv; 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 + +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" +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} +[ ! -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" + [ ! -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" +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/intro-post.md b/intro-post.md index 63396eb..7277fd1 100644 --- a/intro-post.md +++ b/intro-post.md @@ -87,16 +87,29 @@ 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 +``` + +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 +Set-ExecutionPolicy -Scope Process Bypass -Force +& $installer +Remove-Item $installer ``` -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 execution-policy override applies only to that PowerShell process; inspect the downloaded script before running it if required by your security policy. -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). diff --git a/test/install-release.bats b/test/install-release.bats new file mode 100755 index 0000000..04f443f --- /dev/null +++ b/test/install-release.bats @@ -0,0 +1,281 @@ +#!/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" { + 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" \ + 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 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 + 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 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 +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..1009c9e --- /dev/null +++ b/test/install-release.ps1 @@ -0,0 +1,158 @@ +$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" +$explicitInstallDirectory = Join-Path $testRoot "explicit-installed" +$badInstallDirectory = Join-Path $testRoot "bad-installed" +$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" +$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, + [string]$RequestedVersion + ) + $hostExecutable = (Get-Process -Id $PID).Path + $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() + } +} + +try { + New-Item -ItemType Directory -Path $testRoot | Out-Null + New-Item -ItemType Directory -Path $packageDirectory, $releaseDirectory, $latestDirectory -Force | 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+build.5"}' + + $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" + } + + $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) { + 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 + } +} + +# The expected checksum-failure child leaves LASTEXITCODE set to 1. Reaching +# this point means every assertion passed, so report success explicitly. +exit 0