Skip to content
Open
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
36 changes: 36 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
version: 2

updates:
# Keep the Go module dependencies current.
- package-ecosystem: gomod
directory: "/"
schedule:
interval: weekly
day: monday
open-pull-requests-limit: 5
commit-message:
prefix: "chore(deps)"
labels:
- dependencies

# Keep the workflow actions current. Without this, pinned major versions go
# stale and eventually break on runner/Node.js deprecations.
- package-ecosystem: github-actions
directory: "/"
schedule:
interval: weekly
day: monday
open-pull-requests-limit: 5
commit-message:
prefix: "chore(actions)"
labels:
- dependencies
- github-actions
groups:
# One PR for routine action bumps instead of one per action.
actions:
patterns:
- "*"
update-types:
- minor
- patch
116 changes: 90 additions & 26 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,55 +6,119 @@ on:
pull_request:
branches: [main]

# A new push to the same branch makes older runs pointless. Cancel them so PR
# feedback is fast and we don't burn queue time on superseded commits.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

# Least privilege: nothing in CI needs to write to the repo.
permissions:
contents: read

jobs:
fmt:
name: Format
static:
name: Static analysis
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v5

- uses: actions/setup-go@v6
with:
go-version: '1.21'
- name: Check formatting
go-version: stable
check-latest: true

- name: gofmt
run: |
unformatted=$(gofmt -l .)
if [ -n "$unformatted" ]; then
echo "Files not formatted:"
echo "::error::These files are not gofmt-clean. Run 'gofmt -w .'"
echo "$unformatted"
gofmt -d .
exit 1
fi

vet:
name: Vet
- name: go vet
run: go vet ./...

- name: go.mod and go.sum are tidy
run: |
go mod tidy
if ! git diff --quiet -- go.mod go.sum; then
echo "::error::go.mod/go.sum are stale. Run 'go mod tidy' and commit the result."
git diff -- go.mod go.sum
exit 1
fi

- name: go mod verify
run: go mod verify

test:
name: Test (Go ${{ matrix.go }})
runs-on: ubuntu-latest
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
# '1.21' is the minimum declared in go.mod; keep the two in sync so we
# never ship a release that silently requires a newer toolchain.
go: ['1.21', '1.22', '1.23', '1.24', 'stable']
steps:
- uses: actions/checkout@v5

- uses: actions/setup-go@v6
with:
go-version: '1.21'
- run: go vet ./...
go-version: ${{ matrix.go }}

build:
name: Build
runs-on: ubuntu-latest
- name: Build
run: go build ./...

- name: Test (race detector + coverage)
run: go test -race -covermode=atomic -coverprofile=coverage.txt ./...

- name: Upload coverage to Codecov
if: matrix.go == 'stable'
uses: codecov/codecov-action@v5
with:
files: coverage.txt
token: ${{ secrets.CODECOV_TOKEN }}
# Coverage reporting is informational: a Codecov outage (or a fork PR
# with no token) must not turn an otherwise green build red.
fail_ci_if_error: false

test-cross-platform:
name: Test (${{ matrix.os }})
runs-on: ${{ matrix.os }}
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
os: [macos-latest, windows-latest]
steps:
- uses: actions/checkout@v5

- uses: actions/setup-go@v6
with:
go-version: '1.21'
- run: go build ./...
go-version: stable

test:
name: Test + Coverage
# No -race here; the Linux matrix already covers it, and this job exists
# to catch path/filesystem assumptions (e.g. loading testdata fixtures).
- name: Test
run: go test ./...

# Single aggregate check so branch protection can require one stable name
# ("CI / All checks passed") instead of every individual matrix leg, whose
# names change every time the Go version matrix is updated.
all-checks-passed:
name: All checks passed
if: always()
needs: [static, test, test-cross-platform]
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v5
- uses: actions/setup-go@v6
with:
go-version: '1.21'
- name: Run tests with coverage
run: go test -v -race -coverprofile=coverage.txt -covermode=atomic ./...
- uses: codecov/codecov-action@v5
with:
files: coverage.txt
token: ${{ secrets.CODECOV_TOKEN }}
- name: Verify all jobs succeeded
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
run: |
echo "::error::One or more required CI jobs did not succeed."
exit 1
141 changes: 119 additions & 22 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,42 +4,139 @@ on:
push:
tags:
- "v[0-9]+.[0-9]+.[0-9]+*"
# Recovery path: if a tag was pushed before CHANGELOG.md was updated, fix the
# changelog on main and re-run this workflow against the existing tag.
workflow_dispatch:
inputs:
tag:
description: "Existing tag to publish or re-publish, e.g. v0.3.0"
required: true

# Read-only by default; only the publishing job escalates to contents: write.
permissions:
contents: write
contents: read

# Never let two runs publish the same tag concurrently, and never cancel a
# release that is already mid-publish.
concurrency:
group: release-${{ github.event.inputs.tag || github.ref_name }}
cancel-in-progress: false

jobs:
release:
name: Release
verify:
name: Verify
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v5
with:
ref: ${{ github.event.inputs.tag || github.ref_name }}

- uses: actions/setup-go@v6
with:
go-version: stable
check-latest: true

- name: Build
run: go build ./...

- name: Extract version from tag
id: version
run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
- name: Test
run: go test -race ./...

- name: Extract changelog
id: changelog
release:
name: Publish release
needs: verify
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
steps:
- uses: actions/checkout@v5
with:
ref: ${{ github.event.inputs.tag || github.ref_name }}
# Tags are needed for `gh release create --verify-tag`.
fetch-depth: 0

- name: Resolve tag and version
id: meta
env:
INPUT_TAG: ${{ github.event.inputs.tag }}
run: |
notes=$(awk '/^## \[${{ steps.version.outputs.version }}\]/{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md)
set -euo pipefail
tag="${INPUT_TAG:-$GITHUB_REF_NAME}"

if ! printf '%s' "$tag" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+'; then
echo "::error::Tag '$tag' is not a vMAJOR.MINOR.PATCH tag."
exit 1
fi

version="${tag#v}"
# SemVer: anything after a hyphen is a pre-release (e.g. v1.0.0-rc.1).
if [ "$version" != "${version%%-*}" ]; then
prerelease=true
else
prerelease=false
fi

{
echo "notes<<CHANGELOG_EOF"
echo "$notes"
echo "CHANGELOG_EOF"
echo "tag=$tag"
echo "version=$version"
echo "prerelease=$prerelease"
} >> "$GITHUB_OUTPUT"

- name: Create or update GitHub Release
echo "Releasing $tag (version $version, prerelease=$prerelease)"

- name: Extract release notes from CHANGELOG.md
env:
# Passed through the environment rather than interpolated into the
# script, so tag contents can never be evaluated as shell.
VERSION: ${{ steps.meta.outputs.version }}
run: |
if gh release view "$GITHUB_REF_NAME" &>/dev/null; then
echo "Release $GITHUB_REF_NAME already exists, updating..."
gh release edit "$GITHUB_REF_NAME" \
--title "$GITHUB_REF_NAME" \
--notes "${{ steps.changelog.outputs.notes }}"
else
gh release create "$GITHUB_REF_NAME" \
--title "$GITHUB_REF_NAME" \
--notes "${{ steps.changelog.outputs.notes }}"
set -euo pipefail

# Matches '## [1.2.3]' and '## [1.2.3] - 2026-01-01'. index() is a
# literal prefix test, so dots in the version aren't regex wildcards.
# Stops at the next '## [' heading, or at the trailing block of
# link-reference definitions ('[0.2.0]: https://...') that
# Keep a Changelog puts at the bottom of the file.
awk -v ver="$VERSION" '
index($0, "## [" ver "]") == 1 { found = 1; next }
/^## \[/ { if (found) exit }
$0 ~ /^\[/ && $0 ~ /\]:[ \t]/ { if (found) exit }
found { print }
' CHANGELOG.md > notes.raw

# Strip leading and trailing blank lines.
awk 'NF {found = 1} found' notes.raw | tac | awk 'NF {found = 1} found' | tac > release-notes.md

if [ ! -s release-notes.md ]; then
echo "::error::CHANGELOG.md has no '## [$VERSION]' section with content. Add the entry, then re-run this workflow with tag input '$VERSION'."
exit 1
fi

echo "----- release notes -----"
cat release-notes.md
echo "-------------------------"

- name: Create or update GitHub release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.meta.outputs.tag }}
PRERELEASE: ${{ steps.meta.outputs.prerelease }}
run: |
set -euo pipefail

# --notes-file keeps the changelog out of the command line entirely,
# so backticks, quotes and $(...) in release notes stay literal text.
flags=(--title "$TAG" --notes-file release-notes.md)
if [ "$PRERELEASE" = "true" ]; then
flags+=(--prerelease)
fi

if gh release view "$TAG" >/dev/null 2>&1; then
echo "Release $TAG already exists; updating it."
gh release edit "$TAG" "${flags[@]}"
else
echo "Creating release $TAG."
gh release create "$TAG" --verify-tag "${flags[@]}"
fi
42 changes: 42 additions & 0 deletions .github/workflows/security.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: Security

# govulncheck reports vulnerabilities in the Go standard library as well as in
# dependencies, so a new Go patch release can make it fail on a tree that did
# not change. That is useful signal, but it must not block unrelated
# contributor PRs, so it runs on a schedule and on main rather than in CI.
on:
push:
branches: [main]
paths:
- 'go.mod'
- 'go.sum'
- '**/*.go'
- '.github/workflows/security.yml'
pull_request:
branches: [main]
# On PRs, only run when the dependency graph actually changes.
paths:
- 'go.mod'
- 'go.sum'
schedule:
# Mondays at 06:17 UTC (off the hour to avoid GitHub's cron rush).
- cron: '17 6 * * 1'
workflow_dispatch:

permissions:
contents: read

jobs:
govulncheck:
name: govulncheck
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
# This action performs its own checkout and Go setup, so no separate
# actions/checkout or actions/setup-go step is needed here.
- name: Run govulncheck
uses: golang/govulncheck-action@v1
with:
go-version-input: stable
check-latest: true
go-package: ./...
Loading
Loading