Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 39 additions & 12 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
93 changes: 64 additions & 29 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,85 +19,120 @@ 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
uses: actions/checkout@v7
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
echo "Release tag is not reachable from origin/master: $RELEASE_TAG" >&2
exit 1
fi
printf 'sha=%s\n' "$(git rev-parse "$RELEASE_TAG^{commit}")" >> "$GITHUB_OUTPUT"
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:
Expand Down
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
29 changes: 17 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,22 +49,32 @@ 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:

| Operating system | Architectures | Archive | Installation |
| --- | --- | --- | --- |
| Linux | AMD64, ARM64 | `.tar.gz` | Installer or manual |
| macOS | Intel (AMD64), Apple Silicon (ARM64) | `.tar.gz` | Installer or manual |
| Windows | AMD64, ARM64 | `.zip` | Manual |
| 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`.

Expand Down Expand Up @@ -141,22 +151,17 @@ 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:

```sh
goreleaser release --snapshot --clean --skip=publish
```

Pushing a semantic-version tag runs [the release workflow](.github/workflows/release.yml). It validates the tag syntax and confirms the tagged commit is reachable from `master`, runs the complete check suite, verifies that the tag did not move between validation and publication, and then creates the GitHub Release. No package manager, container registry, or announcement publisher is configured.
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`.

Expand Down
1 change: 1 addition & 0 deletions VERSION
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
0.2.1
Comment thread
merefield marked this conversation as resolved.
Loading
Loading