From 75036e3b50d292e4cc47f7e72763e830b67c6396 Mon Sep 17 00:00:00 2001 From: Kiro Agent <244629292+kiro-agent@users.noreply.github.com> Date: Tue, 1 Sep 2026 05:09:28 +0000 Subject: [PATCH] ci: harden workflows, add Go version matrix, fix release notes handling CI: - Test against Go 1.21 (the minimum declared in go.mod) through stable, plus macOS and Windows. Previously only a single hardcoded Go 1.21 ran. - Collapse the fmt/vet/build jobs into one static-analysis job, removing three redundant checkout+setup-go cycles. - Verify go.mod/go.sum are tidy and that module checksums verify. - Add least-privilege permissions, per-job timeouts, and concurrency cancellation for superseded runs. - Upload coverage once (from the stable leg) rather than from every leg, and never fail the build on a Codecov error or a missing fork token. - Add an aggregate "All checks passed" job so branch protection can require one stable check name instead of every matrix leg. Release: - Stop interpolating changelog text into a shell command. The 0.2.0 notes contain backticks, so `--notes "${{ ... }}"` would have run `github.com/pkg/errors` and `fmt.Errorf` as command substitutions and silently dropped that text from the published notes. Notes now travel via --notes-file, and the tag/version reach the shell as env vars. - Pass the version to awk with -v and match it as a literal prefix, so dots are not treated as regex wildcards. - Stop release notes at the trailing markdown link-reference block. - Fail loudly when CHANGELOG.md has no section for the tag instead of publishing an empty release body, with workflow_dispatch for recovery. - Build and test the tagged commit before publishing, verify the tag, and mark hyphenated SemVer tags as pre-releases. Also adds a scheduled govulncheck workflow (kept out of PR CI so stdlib CVE churn cannot block unrelated contributions) and Dependabot config for Go modules and GitHub Actions. --- .github/dependabot.yml | 36 +++++++++ .github/workflows/ci.yml | 116 +++++++++++++++++++++------ .github/workflows/release.yml | 141 ++++++++++++++++++++++++++++----- .github/workflows/security.yml | 42 ++++++++++ CHANGELOG.md | 26 ++++++ 5 files changed, 313 insertions(+), 48 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/security.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..275cc7e --- /dev/null +++ b/.github/dependabot.yml @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 71ed44b..7e07ee9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4ad84e5..80c1e1f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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<> "$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 diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..a8c0022 --- /dev/null +++ b/.github/workflows/security.yml @@ -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: ./... diff --git a/CHANGELOG.md b/CHANGELOG.md index 07e117c..dc2c731 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- CI now tests against Go 1.21 through the latest stable release, plus macOS and + Windows, so the minimum version declared in `go.mod` is actually verified +- CI verifies that `go.mod`/`go.sum` are tidy and that module checksums verify +- Scheduled `govulncheck` workflow (weekly, on `main`, and on dependency changes) +- Dependabot configuration for Go modules and GitHub Actions +- Aggregate "All checks passed" status check for branch protection + +### Fixed + +- Release notes are no longer interpolated into a shell command. Changelog + entries containing backticks or `$(...)` were previously evaluated by the + shell, which mangled the published notes and allowed command execution; + notes are now passed to `gh` via `--notes-file` +- Markdown link-reference definitions are no longer appended to release notes +- Releases now fail loudly when `CHANGELOG.md` has no section for the tag, + instead of publishing an empty release body + +### Changed + +- Release workflow builds and tests the tagged commit before publishing +- Pre-release tags (e.g. `v1.0.0-rc.1`) are now marked as pre-releases +- Workflows declare least-privilege `permissions` and per-job timeouts, and + redundant in-progress CI runs are cancelled automatically + ## [0.2.0] ### Changed