From 95d4b3ed5452a99525c240688de146553e57cee0 Mon Sep 17 00:00:00 2001 From: Jake Thomas Date: Sat, 5 Sep 2026 20:12:28 -0400 Subject: [PATCH] ci: centralize Go checks and tool versions --- .github/workflows/check-go.yaml | 195 ++++++++++++++++++++++++++++++ .github/workflows/ci.yaml | 35 +++++- .github/workflows/gate.yaml | 22 ++++ .github/workflows/release-go.yaml | 4 +- README.md | 121 +++++++++++++++++- 5 files changed, 373 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/check-go.yaml create mode 100644 .github/workflows/gate.yaml diff --git a/.github/workflows/check-go.yaml b/.github/workflows/check-go.yaml new file mode 100644 index 0000000..89a43b7 --- /dev/null +++ b/.github/workflows/check-go.yaml @@ -0,0 +1,195 @@ +name: Check Go project + +on: + workflow_call: + inputs: + runner: + description: Runner for this check (callers may supply a platform matrix) + default: ubuntu-24.04 + type: string + go-version: + description: Override only for explicit toolchain compatibility checks + default: "1.26.8" + type: string + timeout-minutes: + default: 30 + type: number + fetch-depth: + default: 1 + type: number + cache: + default: true + type: boolean + cache-dependency-path: + default: | + **/go.mod + **/go.sum + type: string + setup-just: + default: false + type: boolean + browser: + description: Install Node, locked npm dependencies, and Playwright Chromium + default: false + type: boolean + lint: + description: Run the shared golangci-lint version with repository configuration + default: false + type: boolean + security: + description: Run govulncheck and gosec + default: false + type: boolean + gosec-excludes: + description: Repository-specific rule exclusions, separated by commas + default: "" + type: string + gosec-exclude-generated: + default: true + type: boolean + static-analysis: + description: Supply errcheck, staticcheck, actionlint, and govulncheck to repository scripts + default: false + type: boolean + command: + description: Reviewed repository checks, run with Bash fail-fast and pipefail + default: "" + type: string + environment: + description: JSON object of environment variables for repository checks + default: '{}' + type: string + artifact-name: + default: "" + type: string + artifact-enabled: + description: Allow a platform matrix to retain evidence on selected runners + default: true + type: boolean + artifact-path: + description: Evidence to retain even when repository checks fail + default: "" + type: string + failure-artifact-name: + default: "" + type: string + failure-artifact-path: + description: Additional evidence to retain only on failure, such as fuzz seeds + default: "" + type: string + +permissions: + contents: read + +jobs: + check: + runs-on: ${{ inputs.runner }} + timeout-minutes: ${{ inputs.timeout-minutes }} + defaults: + run: + shell: bash + env: + GOTOOLCHAIN: local + OPEN_SHIPS_CI: "true" + + steps: + - name: Require an executable check + if: inputs.command == '' && !inputs.lint && !inputs.security + run: | + echo 'Set command, lint, or security; installing tools alone is not a check.' >&2 + exit 1 + + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: ${{ inputs.fetch-depth }} + + - name: Set up Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version: ${{ inputs.go-version }} + cache: ${{ inputs.cache }} + cache-dependency-path: ${{ inputs.cache-dependency-path }} + + - name: Set up just + if: inputs.setup-just + uses: extractions/setup-just@53165ef7e734c5c07cb06b3c8e7b647c5aa16db3 # v4 + with: + just-version: "1.58.0" + + - name: Set up Node + if: inputs.browser + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: "22.23.2" + cache: npm + + - name: Install browser dependencies + if: inputs.browser + run: | + npm ci + npx playwright install --with-deps chromium + + - name: Lint + if: inputs.lint + uses: golangci/golangci-lint-action@db9de0fc1a667e1a49d2291a1a042dff081d78f6 # v9 + with: + version: v2.12.0 + + - name: Install vulnerability scanner + if: inputs.security || inputs.static-analysis + run: go install golang.org/x/vuln/cmd/govulncheck@v1.6.0 + + - name: Install static analysis tools + if: inputs.static-analysis + run: | + go install github.com/kisielk/errcheck@v1.20.0 + go install honnef.co/go/tools/cmd/staticcheck@v0.7.0 + go install github.com/rhysd/actionlint/cmd/actionlint@v1.7.12 + + - name: Check vulnerabilities + if: inputs.security + run: govulncheck ./... + + - name: Check security + if: inputs.security + env: + GOSEC_EXCLUDES: ${{ inputs.gosec-excludes }} + GOSEC_EXCLUDE_GENERATED: ${{ inputs.gosec-exclude-generated }} + run: | + go install github.com/securego/gosec/v2/cmd/gosec@v2.27.1 + args=(-exclude-dir=.claude) + if [ "$GOSEC_EXCLUDE_GENERATED" = true ]; then + args+=(-exclude-generated) + fi + if [ -n "$GOSEC_EXCLUDES" ]; then + args+=("-exclude=$GOSEC_EXCLUDES") + fi + gosec "${args[@]}" ./... + + - name: Prepare repository checks + if: inputs.command != '' + env: + CHECK_COMMAND: ${{ inputs.command }} + run: printf '%s\n' "$CHECK_COMMAND" > "$RUNNER_TEMP/open-ships-checks.sh" + + - name: Run repository checks + if: inputs.command != '' + env: ${{ fromJSON(inputs.environment) }} + run: bash --noprofile --norc -e -o pipefail "$RUNNER_TEMP/open-ships-checks.sh" + + - name: Retain check evidence + if: always() && inputs.artifact-enabled && inputs.artifact-path != '' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ${{ inputs.artifact-name }} + path: ${{ inputs.artifact-path }} + if-no-files-found: warn + + - name: Retain failure evidence + if: failure() && inputs.failure-artifact-path != '' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ${{ inputs.failure-artifact-name }} + path: ${{ inputs.failure-artifact-path }} + if-no-files-found: ignore diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index bbb8ae4..1b0ff43 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -18,8 +18,41 @@ jobs: - name: Set up Go uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version: "1.26.0" + go-version: "1.26.8" cache: false - name: Lint workflows run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12 + + # Exercise the reusable workflow at this commit before publishing a new tag. + smoke: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + uses: ./.github/workflows/check-go.yaml + with: + runner: ${{ matrix.os }} + cache: false + setup-just: true + environment: '{"CHECK_MESSAGE":"shared workflow smoke test"}' + artifact-name: smoke-${{ matrix.os }} + artifact-path: smoke/evidence.txt + command: | + test "$OPEN_SHIPS_CI" = true + test "$GOTOOLCHAIN" = local + test "$CHECK_MESSAGE" = 'shared workflow smoke test' + just --version + mkdir smoke + cd smoke + go mod init example.com/ci-smoke + printf 'package smoke\nimport "testing"\nfunc TestSmoke(t *testing.T) {}\n' > smoke_test.go + go test ./... + go version > evidence.txt + + smoke-gate: + if: ${{ always() }} + needs: [actionlint, smoke] + uses: ./.github/workflows/gate.yaml + with: + results: ${{ toJSON(needs) }} diff --git a/.github/workflows/gate.yaml b/.github/workflows/gate.yaml new file mode 100644 index 0000000..4693e3c --- /dev/null +++ b/.github/workflows/gate.yaml @@ -0,0 +1,22 @@ +name: Require checks + +on: + workflow_call: + inputs: + results: + description: JSON needs context from a caller job with if always() + required: true + type: string + +permissions: + contents: read + +jobs: + gate: + runs-on: ubuntu-24.04 + steps: + - name: Require every check to succeed + env: + RESULTS: ${{ inputs.results }} + run: | + printf '%s\n' "$RESULTS" | jq -e 'length > 0 and all(.[]; .result == "success")' diff --git a/.github/workflows/release-go.yaml b/.github/workflows/release-go.yaml index 95739a2..98e928e 100644 --- a/.github/workflows/release-go.yaml +++ b/.github/workflows/release-go.yaml @@ -16,8 +16,8 @@ on: required: true type: string go-version: - description: Exact Go toolchain version - required: true + description: Shared Go toolchain version (override only for compatibility) + default: "1.26.8" type: string default-branch: description: Branch whose current tip may be released diff --git a/README.md b/README.md index ac6109d..0b698ad 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,125 @@ Changes to reusable workflows are linted in this repository before a new semantic-version tag is published. A caller remains on its reviewed policy version until its exact tag reference is deliberately updated. +## Go checks + +`.github/workflows/check-go.yaml` owns checkout, Go setup, tool installation, +lint and security execution, Bash command execution, and artifact retention. +Callers own triggers, platform and compatibility matrices, dependencies between +jobs, and repository-specific checks. Every check job should call this workflow; +do not add another checkout/setup/install sequence in a caller. + +```yaml +jobs: + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + uses: open-ships/ci/.github/workflows/check-go.yaml@v1.2.0 + with: + runner: ${{ matrix.os }} + command: go test ./... + + lint: + uses: open-ships/ci/.github/workflows/check-go.yaml@v1.2.0 + with: + lint: true + + secure: + uses: open-ships/ci/.github/workflows/check-go.yaml@v1.2.0 + with: + security: true + + release-gate: + if: ${{ always() }} + needs: [test, lint, secure] + uses: open-ships/ci/.github/workflows/gate.yaml@v1.2.0 + with: + results: ${{ toJSON(needs) }} +``` + +Grant check workflows `contents: read`. Keep their workflow name `CI` when a +release workflow listens for successful `CI` runs. `gate.yaml` rejects failed, +cancelled, skipped, and empty dependency sets. List every required job in +`needs`; `if: always()` ensures a skipped dependency cannot bypass the gate. + +The check and release workflows default to Go **1.26.8**. Routine callers omit +`go-version`; only explicit compatibility jobs override it. Checks set +`GOTOOLCHAIN=local` so module requirements cannot silently switch toolchains. +The shared check toolset is: + +| Tool | Version | Enabled by | +| --- | --- | --- | +| golangci-lint | 2.12.0 | `lint: true` | +| govulncheck | 1.6.0 | `security: true` or `static-analysis: true` | +| gosec | 2.27.1 | `security: true` | +| errcheck | 1.20.0 | `static-analysis: true` | +| staticcheck | 0.7.0 | `static-analysis: true` | +| actionlint | 1.7.12 | `static-analysis: true` | +| just | 1.58.0 | `setup-just: true` | +| Node | 22.23.2 | `browser: true` | + +`browser` also runs `npm ci` and installs Playwright Chromium. The repository's +lockfile owns browser-test dependencies. `static-analysis` supplies binaries for +repository scripts; those scripts must use the supplied tools when +`OPEN_SHIPS_CI=true`, rather than downloading their own CI versions. Local +developer recipes can still manage their own installations. + +`command` is reviewed Bash code, executed with `-e -o pipefail` on every OS. +Pass dispatch inputs through the `environment` JSON object and quote them in +the command, so user-provided values stay data: + +```yaml +with: + environment: >- + {"FUZZTIME":${{ toJSON(inputs.fuzztime || '5m') }}} + command: ./scripts/fuzz.sh +``` + +Use `artifact-name` and `artifact-path` for evidence retained on success or +failure; a platform matrix can set `artifact-enabled` to select the upload OS. +Use `failure-artifact-name` and `failure-artifact-path` for failing fuzz seeds. +`timeout-minutes` defaults to 30 and can be raised for bounded scheduled runs. +Use `fetch-depth: 0` for release metadata checks and `cache: false` for +repositories without a Go module. Gosec rule exclusions stay explicit in caller +inputs; generated files and `.claude` worktrees are excluded by default. + +## Caller migration and rollout + +The September 2026 audit found that all five repositories already shared release +automation, while their ordinary and scheduled checks installed tools separately. +The v1.2.0 migration routes every workflow job through this repository: + +| Repository | Checks retained through the shared toolset | +| --- | --- | +| n2k | Three platforms, PGN sync, conformance, race/fuzz/soak evidence, lint, security, release metadata, weekly reliability | +| n2k-cli | Three platforms, install/uninstall smoke checks, race, lint, security | +| teleop | Three platforms, CGO-disabled tests, race, coverage, formatting, vet | +| beacon | Three platforms, build, browser, race, lint, security, vessel resource/recovery gates | +| statemachine | Three platforms, static analysis, 90% coverage, SQLite integration, benchmarks, minimum/current Go compatibility, smoke/nightly fuzzing | + +This preserves each project's required checks. For example, teleop continues +using the Go built-in checks; the migration does not silently add new golangci +or gosec policy. Statemachine retains `coverage.out` and `benchmark.out` together +in its `source-assurance-` artifact. + +Merge the shared policy **before** merging caller changes: + +1. Merge and run this repository's `CI`, including the local reusable workflow + smoke matrix and aggregate gate. Review the exact commit being released. +2. Publish a new annotated `v1.2.0` tag at that passing commit, or pin callers to + the exact reviewed commit SHA. Never move an existing version tag. +3. Merge caller updates to that policy version, preserving `CI` names and release + permissions. Update branch protection if it requires the old job check names; + reusable workflows introduce nested check names such as `test / check`. +4. Verify caller CI and the existing release follow-up on `main`. + +For coordinated pull requests, callers can pin the shared policy PR's exact +commit SHA so their checks run before a version tag exists. Keep those caller +PRs dependent on the shared policy PR, and update all pins if that commit changes. +References to the `v1.2.0` tag only resolve after publication. + ## Go releases `.github/workflows/release-go.yaml` is a reusable release workflow for Go @@ -35,6 +154,6 @@ Both strategies: Callers must grant `contents: write`, `id-token: write`, `attestations: write`, and `artifact-metadata: write`. They must guard the privileged reusable job so it accepts only a successful `push` CI run from the caller repository. Reference -an exact semantic-version tag such as `v1.1.0`; do not reference `main` or a +an exact semantic-version tag such as `v1.2.0`; do not reference `main` or a moving major-version tag. Callers that set `container-image` must additionally grant `packages: write`.