From 87867e2f298d6677f4ab3b12dbf4d625c3fea7c5 Mon Sep 17 00:00:00 2001 From: david morales <118213696+davidmoralesz@users.noreply.github.com> Date: Fri, 9 Oct 2026 11:54:02 -0600 Subject: [PATCH] feat(workflows): add a reusable Go CI workflow ci-go checks a Go module the way ci-java-gradle checks a Gradle build: tidy modules, formatting, golangci-lint, race tests with a coverage summary, govulncheck and a build. go.mod names the Go version and .golangci-lint-version the linter version, so a repo pins both next to its code. The linter and govulncheck run with `go run pkg@version`, built with the module's own toolchain, because a linter built with an older Go than the code refuses to run. Also adds the workflow-templates starter and the README entry. --- .github/workflows/ci-go.yml | 174 +++++++++++++++++++++++ README.md | 21 +-- workflow-templates/ci-go.properties.json | 8 ++ workflow-templates/ci-go.yml | 42 ++++++ 4 files changed, 235 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/ci-go.yml create mode 100644 workflow-templates/ci-go.properties.json create mode 100644 workflow-templates/ci-go.yml diff --git a/.github/workflows/ci-go.yml b/.github/workflows/ci-go.yml new file mode 100644 index 0000000..1fb030a --- /dev/null +++ b/.github/workflows/ci-go.yml @@ -0,0 +1,174 @@ +name: CI (Go) + +# The toolchain-level check for a Go module: tidy modules, formatting, lint, tests +# with the race detector, a vulnerability scan and a build. Repos call it from a thin +# wrapper, so a fix here lands everywhere at once. +# +# go.mod is the source of truth for the Go version. golangci-lint and govulncheck are +# run with `go run @` rather than downloaded as binaries: a linter built +# with an older Go than the code it reads refuses to run, and building it with the +# module's own toolchain rules that out. The Go checksum database verifies what is +# fetched, and the same command works on a developer's machine, so local and CI +# results match. +# +# Nothing here reads a secret, so it is safe on pull requests from forks. +# +# jobs: +# go: +# permissions: +# contents: read +# uses: studiobimo/.github/.github/workflows/ci-go.yml@v1 + +on: + workflow_call: + inputs: + working-directory: + description: Directory holding go.mod. + type: string + required: false + default: . + go-version-file: + description: File that names the Go version, relative to working-directory. + type: string + required: false + default: go.mod + golangci-lint-version-file: + description: >- + File holding the golangci-lint version (for example v2.14.0), relative to + working-directory, so local runs and CI share one pin. Empty to disable. + type: string + required: false + default: .golangci-lint-version + golangci-lint-version: + description: golangci-lint version, used when golangci-lint-version-file does not exist. + type: string + required: false + default: v2.14.0 + govulncheck-version: + description: govulncheck version. Empty skips the vulnerability scan. + type: string + required: false + default: v1.8.0 + packages: + description: Package pattern to lint, test and build. + type: string + required: false + default: ./... + test-args: + description: Extra arguments for `go test`, space separated. + type: string + required: false + default: -race + runs-on: + description: Runner label. + type: string + required: false + default: ubuntu-latest + timeout-minutes: + description: Job timeout. The linter compiles from source on a cold cache. + type: number + required: false + default: 20 + +permissions: {} + +jobs: + check: + name: Go + runs-on: ${{ inputs.runs-on }} + timeout-minutes: ${{ inputs.timeout-minutes }} + permissions: + contents: read + defaults: + run: + working-directory: ${{ inputs.working-directory }} + env: + PACKAGES: ${{ inputs.packages }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: ${{ inputs.working-directory }}/${{ inputs.go-version-file }} + cache-dependency-path: | + ${{ inputs.working-directory }}/go.sum + ${{ inputs.working-directory }}/go.mod + + # One file is the source of truth for the linter version, shared with the + # repo's Makefile. The input is the fallback. + - name: Resolve the golangci-lint version + id: lint + env: + VERSION_FILE: ${{ inputs.golangci-lint-version-file }} + FALLBACK: ${{ inputs.golangci-lint-version }} + run: | + if [ -n "${VERSION_FILE}" ] && [ -f "${VERSION_FILE}" ]; then + version="$(tr -d '[:space:]' < "${VERSION_FILE}")" + echo "Using ${VERSION_FILE}: ${version}" + else + version="${FALLBACK}" + echo "Using the golangci-lint-version input: ${version}" + fi + echo "run=go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@${version}" >> "${GITHUB_OUTPUT}" + + - name: Modules are verified and tidy + run: | + go mod verify + go mod tidy -diff + + - name: Format + env: + GOLANGCI_LINT: ${{ steps.lint.outputs.run }} + run: | + # shellcheck disable=SC2086 # a command line and a package pattern, split deliberately + ${GOLANGCI_LINT} fmt --diff ${PACKAGES} + + - name: Lint + env: + GOLANGCI_LINT: ${{ steps.lint.outputs.run }} + run: | + # shellcheck disable=SC2086 # a command line and a package pattern, split deliberately + ${GOLANGCI_LINT} run ${PACKAGES} + + - name: Test + env: + TEST_ARGS: ${{ inputs.test-args }} + run: | + read -ra args <<< "${TEST_ARGS}" + go test "${args[@]}" -covermode=atomic -coverprofile="${RUNNER_TEMP}/coverage.out" "${PACKAGES}" + + # Always: a failed run is exactly when the numbers are worth reading. + - name: Summarise coverage + if: always() + run: | + profile="${RUNNER_TEMP}/coverage.out" + if [ ! -s "${profile}" ]; then + echo "No coverage profile was written." >> "${GITHUB_STEP_SUMMARY}" + exit 0 + fi + { + echo "### Go test coverage" + echo + echo '```text' + go tool cover -func="${profile}" | tail -n 1 + echo '```' + echo + echo "
Per function" + echo + echo '```text' + go tool cover -func="${profile}" + echo '```' + echo + echo "
" + } >> "${GITHUB_STEP_SUMMARY}" + + - name: Vulnerability scan + if: inputs.govulncheck-version != '' + env: + GOVULNCHECK_VERSION: ${{ inputs.govulncheck-version }} + run: go run "golang.org/x/vuln/cmd/govulncheck@${GOVULNCHECK_VERSION}" "${PACKAGES}" + + - name: Build + run: go build "${PACKAGES}" diff --git a/README.md b/README.md index 8f55f6d..ecdbf69 100644 --- a/README.md +++ b/README.md @@ -7,16 +7,17 @@ everywhere at once. ## Reusable workflows -| Workflow | What it does | -| ----------------------------------------- | -------------------------------------------------------------------------------- | -| `.github/workflows/ci-pr.yml` | Conventional PR title and branch name, PR size limit, Conventional Commits | -| `.github/workflows/ci-java-gradle.yml` | Java toolchain, Gradle build, test reports and artifacts | -| `.github/workflows/ci-workflows.yml` | actionlint, zizmor and shellcheck over a repo's own workflows | -| `.github/workflows/ci-pre-commit.yml` | A repo's own pre-commit hooks, over the whole tree | -| `.github/workflows/ci-gitleaks.yml` | Secret scan of what a pull request adds | -| `.github/workflows/ci-template-drift.yml` | Compares a repo with the project template and tracks the difference in one issue | -| `.github/workflows/release-please.yml` | Grooms a release pull request from Conventional Commits, then tags and releases | -| `.github/workflows/publish-mod.yml` | Builds, attests and publishes a Minecraft mod to Modrinth, CurseForge and GitHub | +| Workflow | What it does | +| ----------------------------------------- | ---------------------------------------------------------------------------------- | +| `.github/workflows/ci-pr.yml` | Conventional PR title and branch name, PR size limit, Conventional Commits | +| `.github/workflows/ci-java-gradle.yml` | Java toolchain, Gradle build, test reports and artifacts | +| `.github/workflows/ci-go.yml` | Go modules, formatting, golangci-lint, race tests, coverage, govulncheck and build | +| `.github/workflows/ci-workflows.yml` | actionlint, zizmor and shellcheck over a repo's own workflows | +| `.github/workflows/ci-pre-commit.yml` | A repo's own pre-commit hooks, over the whole tree | +| `.github/workflows/ci-gitleaks.yml` | Secret scan of what a pull request adds | +| `.github/workflows/ci-template-drift.yml` | Compares a repo with the project template and tracks the difference in one issue | +| `.github/workflows/release-please.yml` | Grooms a release pull request from Conventional Commits, then tags and releases | +| `.github/workflows/publish-mod.yml` | Builds, attests and publishes a Minecraft mod to Modrinth, CurseForge and GitHub | A repo with a `.pre-commit-config.yaml` should call `ci-pre-commit.yml` **instead of** `ci-workflows.yml` and `ci-gitleaks.yml`, not alongside them. Those two download actionlint, diff --git a/workflow-templates/ci-go.properties.json b/workflow-templates/ci-go.properties.json new file mode 100644 index 0000000..c103c9d --- /dev/null +++ b/workflow-templates/ci-go.properties.json @@ -0,0 +1,8 @@ +{ + "name": "CI — Go", + "description": "Runs the centralized ci-go, ci-workflows and ci-gitleaks reusables on every pull request. Trim the jobs this repo does not need.", + "creator": "studiobimo", + "iconName": "checklist", + "categories": ["Continuous integration", "Go"], + "filePatterns": ["go.mod$"] +} diff --git a/workflow-templates/ci-go.yml b/workflow-templates/ci-go.yml new file mode 100644 index 0000000..05120d7 --- /dev/null +++ b/workflow-templates/ci-go.yml @@ -0,0 +1,42 @@ +name: CI + +# Starter: the per-pull-request checks for a Go repo, each a thin caller of a +# centralized reusable. Copied into your repo and owned by it from then on -- edit freely. +# +# ci-go reads the Go version from go.mod and the golangci-lint version from +# .golangci-lint-version, so both stay pinned in the repo, next to the code. +# +# This is deliberately not path-filtered: a skipped job reports no status, and a +# required check that never reports blocks the pull request forever. +# +# A repo with a .pre-commit-config.yaml should call ci-pre-commit instead of the +# `workflows` and `secrets-scan` jobs, and pass `skip:` for any Go hooks, which the +# `go` job already covers. + +on: + push: + branches: [$default-branch] + pull_request: + merge_group: + +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + go: + permissions: + contents: read + uses: studiobimo/.github/.github/workflows/ci-go.yml@v1 + + workflows: + permissions: + contents: read + uses: studiobimo/.github/.github/workflows/ci-workflows.yml@v1 + + secrets-scan: + permissions: + contents: read + uses: studiobimo/.github/.github/workflows/ci-gitleaks.yml@v1