diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d955638..7978c20 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,38 +2,65 @@ name: CI on: push: + branches: + - master pull_request: permissions: contents: read jobs: - check: - runs-on: ubuntu-latest + test: + name: Go ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] steps: - - name: Checkout - uses: actions/checkout@v7 + - uses: actions/checkout@v7 with: fetch-depth: 0 - - name: Setup Go - uses: actions/setup-go@v7 + - uses: actions/setup-go@v7 with: go-version-file: go.mod cache: true - - name: Install Bats - run: | - sudo apt-get update - sudo apt-get install --yes --no-install-recommends bats + - 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 ./... + + - name: Build + run: go build -buildvcs=false -trimpath ./cmd/termcourse - name: Validate release configuration + if: runner.os == 'Linux' uses: goreleaser/goreleaser-action@v7 with: distribution: goreleaser version: "~> v2" args: check - - name: Run checks - run: make check + - name: Test Unix release installer + if: runner.os == 'Linux' + run: bats test + + - 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 index e6f9c47..3b74dcb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,13 +19,14 @@ concurrency: jobs: validate: - name: Validate and test + name: Validate release tag runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 10 permissions: contents: read outputs: commit: ${{ steps.release_commit.outputs.sha }} + tag: ${{ steps.release_commit.outputs.tag }} steps: - name: Checkout release tag @@ -33,16 +34,22 @@ jobs: 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 + 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 + source_version=$(tr -d '\r\n' < VERSION) + if [[ "$RELEASE_TAG" != "v$source_version" ]]; then + echo "Release tag $RELEASE_TAG does not match source version v$source_version" >&2 + exit 1 + fi git show-ref --verify --quiet "refs/tags/$RELEASE_TAG" git fetch --no-tags origin master if ! git merge-base --is-ancestor "$RELEASE_TAG^{commit}" FETCH_HEAD; then @@ -50,54 +57,82 @@ jobs: exit 1 fi printf 'sha=%s\n' "$(git rev-parse "$RELEASE_TAG^{commit}")" >> "$GITHUB_OUTPUT" + printf 'tag=%s\n' "$RELEASE_TAG" >> "$GITHUB_OUTPUT" - - name: Setup Go - uses: actions/setup-go@v7 + 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: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + ref: ${{ needs.validate.outputs.tag }} + - uses: actions/setup-go@v7 with: go-version-file: go.mod cache: true - - - name: Install Bats - run: | - sudo apt-get update - sudo apt-get install --yes --no-install-recommends bats - - - name: Run checks - run: make check + - 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 ./... + - name: Validate release configuration + if: runner.os == 'Linux' + uses: goreleaser/goreleaser-action@v7 + with: + distribution: goreleaser + version: "~> v2" + args: check + - name: Test Unix release installer + if: runner.os == 'Linux' + run: bats test + - name: Test Windows release installer + if: runner.os == 'Windows' + shell: pwsh + run: ./test/install-release.ps1 release: name: Build and publish - needs: validate + needs: [validate, test] runs-on: ubuntu-latest timeout-minutes: 20 permissions: contents: write - steps: - - name: Checkout release tag - uses: actions/checkout@v7 + - uses: actions/checkout@v7 with: fetch-depth: 0 persist-credentials: false - ref: ${{ inputs.tag || github.ref }} - + ref: ${{ needs.validate.outputs.tag }} - name: Verify validated commit + shell: bash env: EXPECTED_COMMIT: ${{ needs.validate.outputs.commit }} - RELEASE_TAG: ${{ inputs.tag || github.ref_name }} run: | - actual_commit=$(git rev-parse "$RELEASE_TAG^{commit}") - if [[ "$actual_commit" != "$EXPECTED_COMMIT" ]]; then - echo "Release tag changed after validation: $RELEASE_TAG" >&2 + test "$(git rev-parse HEAD)" = "$EXPECTED_COMMIT" || { + echo "Release tag changed after validation." >&2 exit 1 - fi - - - name: Setup Go - uses: actions/setup-go@v7 + } + - uses: actions/setup-go@v7 with: go-version-file: go.mod cache: true - - name: Build and publish GitHub release uses: goreleaser/goreleaser-action@v7 with: diff --git a/Makefile b/Makefile index 8a9b032..e1f426a 100644 --- a/Makefile +++ b/Makefile @@ -3,8 +3,8 @@ BINARY := termcourse OUTPUT ?= $(BINARY) PACKAGE := ./cmd/termcourse -VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo v0.2.1) -LDFLAGS := -X github.com/merefield/termcourse.buildVersion=$(VERSION) +VERSION ?= $(shell git describe --tags --dirty 2>/dev/null) +LDFLAGS := $(if $(VERSION),-X github.com/merefield/termcourse.buildVersion=$(VERSION),) .PHONY: build test race-test fmt fmt-check vet integration-test check install clean diff --git a/README.md b/README.md index 536ac7e..4d6ba8e 100644 --- a/README.md +++ b/README.md @@ -49,14 +49,24 @@ curl -fsSL https://raw.githubusercontent.com/merefield/termcourse/master/install TERMCOURSE_BIN_DIR="$HOME/.local/bin" sh ``` -Ensure `$HOME/.local/bin` is on `PATH` when using that location. To install a particular release reproducibly: +Ensure `$HOME/.local/bin` is on `PATH` when using that location. To install a particular release reproducibly, replace `vX.Y.Z` with a tag from [Releases](https://github.com/merefield/termcourse/releases): ```sh +release_tag=vX.Y.Z curl -fsSL https://raw.githubusercontent.com/merefield/termcourse/master/install-release.sh | - sh -s -- --version v0.2.1 + sh -s -- --version "$release_tag" ``` -You can download and inspect [install-release.sh](install-release.sh) before running it. The installer supports `--help`, `--version TAG`, and `--bin-dir DIR`; the equivalent environment variables are `TERMCOURSE_VERSION` and `TERMCOURSE_BIN_DIR`. +On Windows, download and inspect the PowerShell installer, then run it for the current process without changing the machine-wide execution policy: + +```powershell +Invoke-WebRequest https://raw.githubusercontent.com/merefield/termcourse/master/install-release.ps1 -OutFile install-release.ps1 +powershell -NoProfile -ExecutionPolicy Bypass -File .\install-release.ps1 +``` + +The Unix installer supports `--help`, `--version TAG`, and `--bin-dir DIR`; the PowerShell installer accepts `-Version`, `-BinDir`, and `-Repository`. Both also support the corresponding `TERMCOURSE_*` environment variables. + +The Windows installer defaults to `%LOCALAPPDATA%\Programs\termcourse\bin` and reports when that directory must be added to `PATH`. Prebuilt releases do not require Go. Each [GitHub Release](https://github.com/merefield/termcourse/releases) contains these assets: @@ -64,7 +74,7 @@ Prebuilt releases do not require Go. Each [GitHub Release](https://github.com/me | --- | --- | --- | --- | | Linux | AMD64, ARM64 | `.tar.gz` | Installer or manual | | macOS | Intel (AMD64), Apple Silicon (ARM64) | `.tar.gz` | Installer or manual | -| Windows | AMD64, ARM64 | `.zip` | Manual | +| Windows | AMD64, ARM64 | `.zip` | Installer or manual | For a manual installation, verify the selected archive against the release's `checksums.txt`, extract `termcourse` (or `termcourse.exe` on Windows), and place it on `PATH`. @@ -141,7 +151,7 @@ For contributors, `make check` runs formatting validation, vet, race-enabled Go ## Releases and versioning -Termcourse uses semantic Git tags such as `v0.2.1` as the release-version source of truth. Go embeds that module version in binaries installed with `go install`; GoReleaser injects it into release binaries; and `make build` injects the current `git describe` value. `termcourse --version` and the wide masthead subtitle use the same resolved build version. Untagged direct development builds append their embedded commit and dirty state to the development version declared in [termcourse.go](termcourse.go). +[`VERSION`](VERSION) is the maintained release-version source of truth. Go embeds it for local builds, tagged module installs can report their module version, and GoReleaser injects the validated tag into release binaries. `termcourse --version` and the wide masthead subtitle use the same resolved build version. Untagged development builds append their embedded commit and dirty state to the maintained version. [GoReleaser](.goreleaser.yaml) builds static Linux, macOS, and Windows archives for AMD64 and ARM64, plus `checksums.txt`. Test the configuration locally without publishing: @@ -149,14 +159,9 @@ Termcourse uses semantic Git tags such as `v0.2.1` as the release-version source goreleaser release --snapshot --clean --skip=publish ``` -Pushing a semantic-version tag runs [the release workflow](.github/workflows/release.yml). It validates the tag syntax and confirms the tagged commit is reachable from `master`, runs the complete check suite, verifies that the tag did not move between validation and publication, and then creates the GitHub Release. No package manager, container registry, or announcement publisher is configured. +Pushing a semantic-version tag that matches [`VERSION`](VERSION) runs [the release workflow](.github/workflows/release.yml). It validates the tag syntax and source version, confirms the tagged commit is reachable from `master`, runs the complete cross-platform check suite, verifies that the tag did not move between validation and publication, and then creates the GitHub Release. No package manager, container registry, or announcement publisher is configured. -After this release workflow reaches `master`, create `v0.2.1` from the intended release commit. The existing `v0.2.0` tag remains immutable and has no generated binary release: - -```sh -git tag -a v0.2.1 -m "termcourse v0.2.1" -git push origin v0.2.1 -``` +For a new release, update `VERSION` in the release PR, merge it, then create and push the matching `vX.Y.Z` tag from that merge commit. Do not maintain the release number in any other source or workflow file. An existing unpublished tag containing the release configuration can also be published explicitly with `gh workflow run release.yml --ref master -f tag=TAG`. diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..0c62199 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.2.1 diff --git a/install-release.ps1 b/install-release.ps1 new file mode 100644 index 0000000..0f118de --- /dev/null +++ b/install-release.ps1 @@ -0,0 +1,115 @@ +[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 "termcourse installer: $Message" } + +$Version = Get-Setting $Version "TERMCOURSE_VERSION" "latest" +$Repository = Get-Setting $Repository "TERMCOURSE_REPOSITORY" "merefield/termcourse" +$GitHubUrl = (Get-Setting $GitHubUrl "TERMCOURSE_GITHUB_URL" "https://github.com").TrimEnd("/") +$GitHubApiUrl = (Get-Setting $GitHubApiUrl "TERMCOURSE_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("TERMCOURSE_BIN_DIR") } +if ([string]::IsNullOrWhiteSpace($BinDir)) { + if (-not [string]::IsNullOrWhiteSpace($env:LOCALAPPDATA)) { $BinDir = Join-Path $env:LOCALAPPDATA "Programs\termcourse\bin" } + else { $BinDir = Join-Path $HOME ".local\bin" } +} +if ($Repository -notmatch '^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$') { Fail "TERMCOURSE_REPOSITORY must have the form owner/repository" } +if ((Test-Path -LiteralPath $BinDir) -and -not (Test-Path -LiteralPath $BinDir -PathType Container)) { Fail "TERMCOURSE_BIN_DIR exists and is not a directory: $BinDir" } +if ($Version -ne "latest" -and $Version -cnotmatch $semanticTagPattern) { Fail "invalid semantic release tag: $Version" } + +switch ([Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString()) { + "X64" { $releaseArch = "amd64" } + "Arm64" { $releaseArch = "arm64" } + default { Fail "unsupported architecture" } +} + +[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 +$headers = @{ "User-Agent" = "termcourse-release-installer" } +$webRequestArgs = @{} +if ($PSVersionTable.PSVersion.Major -lt 6) { $webRequestArgs.UseBasicParsing = $true } +$temporaryDirectory = Join-Path ([IO.Path]::GetTempPath()) ("termcourse-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 Termcourse release..." + try { $release = Invoke-RestMethod -Uri "$GitHubApiUrl/repos/$Repository/releases/latest" -Headers $headers } + catch { Fail "could not resolve the latest release: $($_.Exception.Message)" } + if ($release -is [string]) { $release = $release | ConvertFrom-Json } + $releaseTag = [string]$release.tag_name + } + if ($releaseTag -cnotmatch $semanticTagPattern) { Fail "invalid semantic release tag: $releaseTag" } + $releaseVersion = $releaseTag -replace '^v', '' + $archiveName = "termcourse_${releaseVersion}_windows_${releaseArch}.zip" + $releaseUrl = "$GitHubUrl/$Repository/releases/download/$releaseTag" + $archivePath = Join-Path $temporaryDirectory $archiveName + $checksumsPath = Join-Path $temporaryDirectory "checksums.txt" + + Write-Host "Downloading Termcourse $releaseTag for windows/$releaseArch..." + try { + Invoke-WebRequest -Uri "$releaseUrl/$archiveName" -Headers $headers -OutFile $archivePath @webRequestArgs + Invoke-WebRequest -Uri "$releaseUrl/checksums.txt" -Headers $headers -OutFile $checksumsPath @webRequestArgs + } 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" } + if ((Get-FileHash -LiteralPath $archivePath -Algorithm SHA256).Hash.ToLowerInvariant() -ne $matchingChecksums[0].ToLowerInvariant()) { + 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 { + $entries = @($archive.Entries | Where-Object { $_.FullName -eq "termcourse.exe" }) + if ($entries.Count -ne 1) { Fail "release archive does not contain exactly one root-level termcourse.exe binary" } + $candidate = Join-Path $temporaryDirectory "termcourse.exe" + $source = $entries[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 termcourse binary failed its version check" } + if ($versionOutput -ne "termcourse $releaseVersion") { Fail "the downloaded binary reported an unexpected version: $versionOutput" } + + New-Item -ItemType Directory -Path $BinDir -Force | Out-Null + $target = Join-Path $BinDir "termcourse.exe" + if (Test-Path -LiteralPath $target -PathType Container) { Fail "installation target exists and is a directory: $target" } + $stagedTarget = Join-Path $BinDir (".termcourse-" + [Guid]::NewGuid().ToString("N") + ".exe") + try { + Copy-Item -LiteralPath $candidate -Destination $stagedTarget + Move-Item -LiteralPath $stagedTarget -Destination $target -Force + } catch { + if (Test-Path -LiteralPath $stagedTarget -PathType Leaf) { Remove-Item -LiteralPath $stagedTarget -Force } + Fail "could not install into ${BinDir}: $($_.Exception.Message); set TERMCOURSE_BIN_DIR to a writable directory" + } + Write-Host "Installed termcourse to $target ($versionOutput)." + if (@($env:PATH -split ';') -notcontains $BinDir) { Write-Host "Add $BinDir to PATH before invoking termcourse." } +} finally { + if (Test-Path -LiteralPath $temporaryDirectory) { Remove-Item -LiteralPath $temporaryDirectory -Recurse -Force } +} diff --git a/install-release.sh b/install-release.sh index a921a5f..81c77b4 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) @@ -74,7 +81,7 @@ case "$binary_name" in ''|*/*) fail "TERMCOURSE_BIN_NAME must be a single file name" ;; esac -for command_name in tar awk sed tr install mktemp; do +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 +120,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 +167,30 @@ version_output=$("$candidate" --version 2>&1) || fail "the downloaded termcourse 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 TERMCOURSE_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/internal/theme/theme_test.go b/internal/theme/theme_test.go index 45c6d0d..bcef9ec 100644 --- a/internal/theme/theme_test.go +++ b/internal/theme/theme_test.go @@ -3,6 +3,7 @@ package theme import ( "os" "path/filepath" + "runtime" "strings" "testing" ) @@ -61,8 +62,17 @@ func TestDefaultThemeFileUsesUserConfigNotWorkingDirectory(t *testing.T) { if err := os.WriteFile(filepath.Join(workingDirectory, "theme.yml"), []byte("theme: hacker\n"), 0o600); err != nil { t.Fatal(err) } - configDirectory := t.TempDir() - t.Setenv("XDG_CONFIG_HOME", configDirectory) + configRoot := t.TempDir() + configDirectory := configRoot + switch runtime.GOOS { + case "darwin": + t.Setenv("HOME", configRoot) + configDirectory = filepath.Join(configRoot, "Library", "Application Support") + case "windows": + t.Setenv("AppData", configRoot) + default: + t.Setenv("XDG_CONFIG_HOME", configRoot) + } termcourseDirectory := filepath.Join(configDirectory, "termcourse") if err := os.MkdirAll(termcourseDirectory, 0o700); err != nil { t.Fatal(err) diff --git a/internal/ui/ui_test.go b/internal/ui/ui_test.go index f12fc55..9e72364 100644 --- a/internal/ui/ui_test.go +++ b/internal/ui/ui_test.go @@ -693,7 +693,7 @@ func TestSemanticHeaderStylesUseHeaderBackgroundAndSeparators(t *testing.T) { func TestPanelsUseRoundedIntegratedTitlesAndResponsiveBranding(t *testing.T) { t.Setenv("TERMCOURSE_COLOR_MODE", "truecolor") style := NewStyle(testTheme(), &bytes.Buffer{}) - compact := style.AppHeader("Latest Topics", "community.example", "0.2.1", []string{"arrows: move | q: quit", "Logged in: member"}, 52, 24) + compact := style.AppHeader("Latest Topics", "community.example", "9.8.7", []string{"arrows: move | q: quit", "Logged in: member"}, 52, 24) plain := stripANSI(strings.Join(compact, "\n")) if !strings.HasPrefix(plain, "▰ TERMCOURSE ▰") || !strings.Contains(plain, "╭─ LATEST TOPICS ") || !strings.HasSuffix(plain, "╰"+strings.Repeat("─", 50)+"╯") { t.Fatalf("compact app header =\n%s", plain) @@ -707,8 +707,8 @@ func TestPanelsUseRoundedIntegratedTitlesAndResponsiveBranding(t *testing.T) { } } - wide := stripANSI(strings.Join(style.AppHeader("Latest", "community.example", "0.2.1", []string{"controls", "status"}, 90, 32), "\n")) - if !strings.Contains(wide, "▀█▀ █▀▀ █▀█ █▀▄▀█") || !strings.Contains(wide, "◉ DISCOURSE TERMINAL · VERSION 0.2.1") || strings.Contains(wide, "· TEST") || !strings.Contains(wide, "● ONLINE") { + wide := stripANSI(strings.Join(style.AppHeader("Latest", "community.example", "9.8.7", []string{"controls", "status"}, 90, 32), "\n")) + if !strings.Contains(wide, "▀█▀ █▀▀ █▀█ █▀▄▀█") || !strings.Contains(wide, "◉ DISCOURSE TERMINAL · VERSION 9.8.7") || strings.Contains(wide, "· TEST") || !strings.Contains(wide, "● ONLINE") { t.Fatalf("wide app header =\n%s", wide) } } diff --git a/meta-topic.md b/meta-topic.md index b57f5f2..f4e6871 100644 --- a/meta-topic.md +++ b/meta-topic.md @@ -61,7 +61,14 @@ curl -fsSL https://raw.githubusercontent.com/merefield/termcourse/master/install Each [GitHub Release](https://github.com/merefield/termcourse/releases) provides SHA-256 checksums and prebuilt archives for Linux, macOS and Windows on AMD64 and ARM64. Linux/macOS use `.tar.gz`; Windows uses `.zip`. Prebuilt releases do not require Go. -The installer can also pin a release, for example `sh -s -- --version v0.2.1`, after the download pipe. Go 1.26.6 or newer is only required when installing from source. +On Windows, download and inspect the installer, then run it without changing the machine-wide execution policy: + +```powershell +Invoke-WebRequest https://raw.githubusercontent.com/merefield/termcourse/master/install-release.ps1 -OutFile install-release.ps1 +powershell -NoProfile -ExecutionPolicy Bypass -File .\install-release.ps1 +``` + +It installs to `%LOCALAPPDATA%\Programs\termcourse\bin` by default and performs the same checksum and version verification. The installers can also pin a release with `--version` or `-Version`. Go 1.26.6 or newer is only required when installing from source. To build a local executable from a checkout instead: diff --git a/termcourse.go b/termcourse.go index 79d3a4a..3a50502 100644 --- a/termcourse.go +++ b/termcourse.go @@ -2,13 +2,13 @@ package termcourse import ( + _ "embed" "runtime/debug" "strings" ) -// Version is the development fallback. Tagged module installs and builds made -// through the Makefile use their embedded semantic version instead. -const Version = "0.2.1" +//go:embed VERSION +var sourceVersion string // buildVersion is populated by the Makefile from the nearest Git tag. var buildVersion string @@ -27,7 +27,7 @@ func CurrentVersion() string { return value } } - return Version + return maintainedVersion() } func developmentVersion(settings []debug.BuildSetting) string { @@ -47,13 +47,17 @@ func developmentVersion(settings []debug.BuildSetting) string { if len(revision) > 12 { revision = revision[:12] } - value := Version + "-dev+" + revision + value := maintainedVersion() + "-dev+" + revision if modified { value += ".dirty" } return value } +func maintainedVersion() string { + return strings.TrimSpace(sourceVersion) +} + func normalizeVersion(value string) string { value = strings.TrimSpace(value) if value == "" || value == "(devel)" { diff --git a/termcourse_test.go b/termcourse_test.go index 033f759..75de36d 100644 --- a/termcourse_test.go +++ b/termcourse_test.go @@ -7,11 +7,11 @@ import ( func TestCurrentVersionUsesBuildOverride(t *testing.T) { previous := buildVersion - buildVersion = "v0.2.1" + buildVersion = "v9.8.7" t.Cleanup(func() { buildVersion = previous }) - if actual := CurrentVersion(); actual != "0.2.1" { - t.Fatalf("CurrentVersion() = %q, want 0.2.1", actual) + if actual := CurrentVersion(); actual != "9.8.7" { + t.Fatalf("CurrentVersion() = %q, want 9.8.7", actual) } } @@ -26,7 +26,7 @@ func TestDevelopmentVersionIncludesRevisionAndDirtyState(t *testing.T) { {Key: "vcs.revision", Value: "0123456789abcdef"}, {Key: "vcs.modified", Value: "true"}, }) - if actual != "0.2.1-dev+0123456789ab.dirty" { + if actual != maintainedVersion()+"-dev+0123456789ab.dirty" { t.Fatalf("developmentVersion() = %q", actual) } } diff --git a/test/install-release.bats b/test/install-release.bats index bd0fba6..ceff924 100644 --- a/test/install-release.bats +++ b/test/install-release.bats @@ -4,9 +4,9 @@ setup() { export TEST_ROOT TEST_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/termcourse-release-install-test.XXXXXX")" export FIXTURE_DIR="$TEST_ROOT/fixture" - export FIXTURE_TAG=v0.2.1 - export FIXTURE_ASSET=termcourse_0.2.1_linux_amd64.tar.gz - export FIXTURE_VERSION_OUTPUT="termcourse 0.2.1" + export FIXTURE_TAG=v1.2.3 + export FIXTURE_ASSET=termcourse_1.2.3_linux_amd64.tar.gz + export FIXTURE_VERSION_OUTPUT="termcourse 1.2.3" export CURL_LOG="$TEST_ROOT/curl.log" export FAKE_UNAME_S=Linux export FAKE_UNAME_M=x86_64 @@ -87,31 +87,47 @@ teardown() { [ "$status" -eq 0 ] [ -x "$TEST_ROOT/bin/termcourse" ] [[ "$output" == *"Verified the release checksum."* ]] - [[ "$output" == *"Installed termcourse to $TEST_ROOT/bin/termcourse (termcourse 0.2.1)."* ]] + [[ "$output" == *"Installed termcourse to $TEST_ROOT/bin/termcourse (termcourse 1.2.3)."* ]] grep -q '/repos/merefield/termcourse/releases/latest$' "$CURL_LOG" - grep -q '/releases/download/v0.2.1/termcourse_0.2.1_linux_amd64.tar.gz$' "$CURL_LOG" + grep -q '/releases/download/v1.2.3/termcourse_1.2.3_linux_amd64.tar.gz$' "$CURL_LOG" run "$TEST_ROOT/bin/termcourse" --version [ "$status" -eq 0 ] - [ "$output" = "termcourse 0.2.1" ] + [ "$output" = "termcourse 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_TAG=v0.2.1+build.1 - export FIXTURE_ASSET=termcourse_0.2.1+build.1_darwin_arm64.tar.gz - export FIXTURE_VERSION_OUTPUT="termcourse 0.2.1+build.1" + export FIXTURE_TAG=v1.2.3+build.1 + export FIXTURE_ASSET=termcourse_1.2.3+build.1_darwin_arm64.tar.gz + export FIXTURE_VERSION_OUTPUT="termcourse 1.2.3+build.1" run env \ PATH="$TEST_ROOT/fakebin:$PATH" \ TERMCOURSE_BIN_DIR="$TEST_ROOT/bin" \ - sh ./install-release.sh --version v0.2.1+build.1 + sh ./install-release.sh --version v1.2.3+build.1 [ "$status" -eq 0 ] [ -x "$TEST_ROOT/bin/termcourse" ] ! grep -q '/releases/latest$' "$CURL_LOG" - grep -Fq '/releases/download/v0.2.1+build.1/termcourse_0.2.1+build.1_darwin_arm64.tar.gz' "$CURL_LOG" + grep -Fq '/releases/download/v1.2.3+build.1/termcourse_1.2.3+build.1_darwin_arm64.tar.gz' "$CURL_LOG" +} + +@test "release installer rejects invalid semantic versions" { + run env PATH="$TEST_ROOT/fakebin:$PATH" TERMCOURSE_BIN_DIR="$TEST_ROOT/bin" \ + sh ./install-release.sh --version v1.2.3-01 + + [ "$status" -eq 1 ] + [[ "$output" == *"invalid semantic release tag"* ]] + [ ! -e "$CURL_LOG" ] +} + +@test "release installer creates a nested user bin directory" { + run env PATH="$TEST_ROOT/fakebin:$PATH" TERMCOURSE_BIN_DIR="$TEST_ROOT/nested/user/bin" sh ./install-release.sh + + [ "$status" -eq 0 ] + [ -x "$TEST_ROOT/nested/user/bin/termcourse" ] } @test "release installer refuses a checksum mismatch" { @@ -190,3 +206,11 @@ teardown() { [[ "$output" == *"TERMCOURSE_REPOSITORY must have the form owner/repository"* ]] [ ! -e "$CURL_LOG" ] } + +@test "release installer rejects a directory at the final target" { + mkdir "$TEST_ROOT/bin/termcourse" + run env PATH="$TEST_ROOT/fakebin:$PATH" TERMCOURSE_BIN_DIR="$TEST_ROOT/bin" sh ./install-release.sh + + [ "$status" -eq 1 ] + [[ "$output" == *"installation target exists and is a directory"* ]] +} diff --git a/test/install-release.ps1 b/test/install-release.ps1 new file mode 100644 index 0000000..98474cf --- /dev/null +++ b/test/install-release.ps1 @@ -0,0 +1,97 @@ +$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()) ("termcourse-release-installer-test-" + [Guid]::NewGuid().ToString("N")) +$serverRoot = Join-Path $testRoot "server" +$packageDirectory = Join-Path $testRoot "package" +$installDirectory = Join-Path $testRoot "installed" +$releaseTag = "v1.2.3+build.5" +$releaseVersion = "1.2.3+build.5" +$assetName = "termcourse_1.2.3+build.5_windows_amd64.zip" +$releaseDirectory = Join-Path $serverRoot "merefield\termcourse\releases\download\$releaseTag" +$latestDirectory = Join-Path $serverRoot "repos\merefield\termcourse\releases" +$fixtureBinary = Join-Path $packageDirectory "termcourse.exe" +$archivePath = Join-Path $releaseDirectory $assetName +$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) + $arguments = @("-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $installer, "-BinDir", $Destination) + if ($RequestedVersion) { $arguments += @("-Version", $RequestedVersion) } + $output = & (Get-Process -Id $PID).Path @arguments 2>&1 + return @{ Status = $LASTEXITCODE; Output = ($output | Out-String).Trim() } +} + +try { + New-Item -ItemType Directory -Path $packageDirectory, $releaseDirectory, $latestDirectory -Force | Out-Null + Push-Location $repositoryRoot + try { & go build -trimpath -ldflags "-X github.com/merefield/termcourse.buildVersion=$releaseTag" -o $fixtureBinary ./cmd/termcourse } + finally { Pop-Location } + if ($LASTEXITCODE -ne 0) { throw "failed to build the Windows installer fixture" } + Compress-Archive -LiteralPath $fixtureBinary -DestinationPath $archivePath + $archiveHash = (Get-FileHash -LiteralPath $archivePath -Algorithm SHA256).Hash.ToLowerInvariant() + Write-Utf8File (Join-Path $releaseDirectory "checksums.txt") "$archiveHash $assetName`n" + Write-Utf8File (Join-Path $latestDirectory "latest") '{"tag_name":"v1.2.3+build.5"}' + + $python = Get-Command python -ErrorAction SilentlyContinue + if ($null -eq $python) { $python = Get-Command python3 -ErrorAction Stop } + $serverScript = Join-Path $testRoot "fixture-server.py" + $portFile = Join-Path $testRoot "fixture-server.port" + Write-Utf8File $serverScript @' +import http.server +import os +import sys + +os.chdir(sys.argv[1]) +server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), http.server.SimpleHTTPRequestHandler) +with open(sys.argv[2], "w", encoding="ascii") as port_file: + port_file.write(str(server.server_port)) +server.serve_forever() +'@ + $serverProcess = Start-Process -FilePath $python.Source -ArgumentList @($serverScript, $serverRoot, $portFile) -PassThru + for ($attempt = 0; $attempt -lt 50 -and -not (Test-Path -LiteralPath $portFile); $attempt++) { + if ($serverProcess.HasExited) { throw "fixture HTTP server exited before reporting its port" } + Start-Sleep -Milliseconds 100 + } + if (-not (Test-Path -LiteralPath $portFile)) { throw "fixture HTTP server did not report its port" } + $port = (Get-Content -LiteralPath $portFile -Raw).Trim() + if ($port -notmatch '^\d+$') { throw "fixture HTTP server reported an invalid port: $port" } + $baseUrl = "http://127.0.0.1:$port" + $webRequestArgs = @{} + if ($PSVersionTable.PSVersion.Major -lt 6) { $webRequestArgs.UseBasicParsing = $true } + $ready = $false + for ($attempt = 0; $attempt -lt 50; $attempt++) { + try { Invoke-WebRequest -Uri "$baseUrl/repos/merefield/termcourse/releases/latest" @webRequestArgs | Out-Null; $ready = $true; break } + catch { Start-Sleep -Milliseconds 100 } + } + if (-not $ready) { throw "fixture HTTP server did not start" } + + foreach ($name in @("TERMCOURSE_VERSION", "TERMCOURSE_REPOSITORY", "TERMCOURSE_GITHUB_URL", "TERMCOURSE_GITHUB_API_URL")) { $savedEnvironment[$name] = [Environment]::GetEnvironmentVariable($name) } + $env:TERMCOURSE_VERSION = "latest"; $env:TERMCOURSE_REPOSITORY = "merefield/termcourse"; $env:TERMCOURSE_GITHUB_URL = $baseUrl; $env:TERMCOURSE_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 verify the checksum" } + $installedBinary = Join-Path $installDirectory "termcourse.exe" + if ((& $installedBinary --version 2>&1 | Out-String).Trim() -ne "termcourse $releaseVersion") { throw "installed binary reported an unexpected version" } + + $explicitResult = Invoke-Installer (Join-Path $testRoot "explicit") $releaseTag + if ($explicitResult.Status -ne 0) { throw "installer rejected valid build metadata: $($explicitResult.Output)" } + $invalidResult = Invoke-Installer (Join-Path $testRoot "invalid") "v1.2.3-01" + if ($invalidResult.Status -eq 0 -or $invalidResult.Output -notmatch 'invalid semantic release tag') { throw "installer accepted an invalid semantic version" } + + $directoryTarget = Join-Path $testRoot "directory-target" + New-Item -ItemType Directory -Path (Join-Path $directoryTarget "termcourse.exe") -Force | Out-Null + $directoryResult = Invoke-Installer $directoryTarget $releaseTag + if ($directoryResult.Status -eq 0 -or $directoryResult.Output -notmatch 'installation target exists and is a directory') { throw "installer accepted a directory target" } + 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 } +} +exit 0