name: Go Pull Request on: workflow_call: inputs: go-version: description: "Go version to use" required: false type: string default: ">=1.24" coverage-threshold: description: "Minimum coverage percentage required (0 to disable)" required: false type: number default: 0 lfs: description: "Enable Git LFS checkout (for repos with large files)" required: false type: boolean default: false build-tags: description: "Build tags to pass to go test (e.g., 'fts5')" required: false type: string default: "" runner: description: "Runner label, or JSON object such as {\"group\":\"ci\",\"labels\":[\"linux\"]} to select a runner group. Falls back to vars.SHARED_ACTIONS_RUNNER, then ubuntu-latest." required: false type: string default: "" # Caller must declare these permissions: # permissions: # contents: read # pull-requests: write # security-events: write concurrency: group: go-pr-${{ github.repository }}-${{ github.event_name }}-${{ github.head_ref || github.ref_name }} cancel-in-progress: true jobs: pre: name: Detect duplicate run runs-on: ${{ fromJSON(startsWith(inputs.runner || vars.SHARED_ACTIONS_RUNNER || 'ubuntu-latest', '{') && (inputs.runner || vars.SHARED_ACTIONS_RUNNER || 'ubuntu-latest') || format('"{0}"', inputs.runner || vars.SHARED_ACTIONS_RUNNER || 'ubuntu-latest')) }} timeout-minutes: 5 outputs: skip: ${{ steps.dup.outputs.skip }} steps: # A push to a branch with an open PR also fires pull_request; run only the PR one. - name: Skip push run when a PR is open id: dup uses: actions/github-script@v9 with: script: | let skip = false; if (context.eventName === 'push') { try { const { data } = await github.rest.pulls.list({ owner: context.repo.owner, repo: context.repo.repo, head: `${context.repo.owner}:${context.ref.replace('refs/heads/', '')}`, state: 'open', }); skip = data.length > 0; } catch (e) { core.warning(`PR lookup failed, running checks: ${e.message}`); } } core.setOutput('skip', String(skip)); if (skip) core.notice('Open PR found for this branch; checks run on the pull_request event.'); static-analysis: name: Static Analysis runs-on: ${{ fromJSON(startsWith(inputs.runner || vars.SHARED_ACTIONS_RUNNER || 'ubuntu-latest', '{') && (inputs.runner || vars.SHARED_ACTIONS_RUNNER || 'ubuntu-latest') || format('"{0}"', inputs.runner || vars.SHARED_ACTIONS_RUNNER || 'ubuntu-latest')) }} needs: pre if: needs.pre.outputs.skip != 'true' timeout-minutes: 15 steps: - name: Checkout uses: actions/checkout@v7 with: fetch-depth: 0 lfs: ${{ inputs.lfs }} - name: Run workflow prepare script shell: bash run: | if [ -f "./workflow-prepare.sh" ]; then chmod +x ./workflow-prepare.sh ./workflow-prepare.sh fi - name: Setup Go uses: actions/setup-go@v7 with: go-version: ${{ inputs.go-version }} cache: true - name: Install staticcheck run: go install honnef.co/go/tools/cmd/staticcheck@latest - name: Register problem matcher shell: bash run: | cat > "$RUNNER_TEMP/go-problem-matcher.json" <<'JSON' {"problemMatcher":[{"owner":"go-lint","severity":"error","pattern":[{"regexp":"^(?:vet: )?([^\\s:]+\\.go):(\\d+):(\\d+): (.+)$","file":1,"line":2,"column":3,"message":4}]}]} JSON echo "::add-matcher::$RUNNER_TEMP/go-problem-matcher.json" - name: Run go vet run: go vet ./... - name: Run staticcheck run: staticcheck ./... security-scan: name: Security Scan runs-on: ${{ fromJSON(startsWith(inputs.runner || vars.SHARED_ACTIONS_RUNNER || 'ubuntu-latest', '{') && (inputs.runner || vars.SHARED_ACTIONS_RUNNER || 'ubuntu-latest') || format('"{0}"', inputs.runner || vars.SHARED_ACTIONS_RUNNER || 'ubuntu-latest')) }} needs: pre if: needs.pre.outputs.skip != 'true' timeout-minutes: 20 steps: - name: Checkout uses: actions/checkout@v7 with: fetch-depth: 0 lfs: ${{ inputs.lfs }} - name: Run workflow prepare script shell: bash run: | if [ -f "./workflow-prepare.sh" ]; then chmod +x ./workflow-prepare.sh ./workflow-prepare.sh fi - name: Setup Go uses: actions/setup-go@v7 with: go-version: ${{ inputs.go-version }} cache: true - name: Run TruffleHog uses: trufflesecurity/trufflehog@main with: extra_args: --only-verified - name: Install govulncheck run: go install golang.org/x/vuln/cmd/govulncheck@latest - name: Run govulncheck run: | # Run govulncheck and capture output set +e OUTPUT=$(govulncheck -show verbose ./... 2>&1) EXIT_CODE=$? echo "$OUTPUT" # If there are vulnerabilities, check if they're only in stdlib if [ $EXIT_CODE -ne 0 ]; then # Check if there are any third-party package vulnerabilities # Stdlib vulnerabilities show "Found in: @go" (e.g., crypto/x509@go1.25.4) # Third-party vulns show "Found in: @v" (e.g., github.com/foo/bar@v1.2.3) if echo "$OUTPUT" | grep -q "Found in:"; then # If no "Found in:" lines contain @v (third-party version), only stdlib affected if ! echo "$OUTPUT" | grep "Found in:" | grep -qE "@v[0-9]"; then echo "" echo "⚠️ Only stdlib vulnerabilities found - these require a Go version upgrade" echo "✅ No vulnerabilities in third-party dependencies" exit 0 fi fi exit $EXIT_CODE fi gosec: name: Gosec SARIF runs-on: ${{ fromJSON(startsWith(inputs.runner || vars.SHARED_ACTIONS_RUNNER || 'ubuntu-latest', '{') && (inputs.runner || vars.SHARED_ACTIONS_RUNNER || 'ubuntu-latest') || format('"{0}"', inputs.runner || vars.SHARED_ACTIONS_RUNNER || 'ubuntu-latest')) }} needs: pre if: needs.pre.outputs.skip != 'true' timeout-minutes: 15 permissions: contents: read security-events: write steps: - name: Checkout uses: actions/checkout@v7 with: fetch-depth: 0 lfs: ${{ inputs.lfs }} - name: Run workflow prepare script shell: bash run: | if [ -f "./workflow-prepare.sh" ]; then chmod +x ./workflow-prepare.sh ./workflow-prepare.sh fi - name: Setup Go uses: actions/setup-go@v7 with: go-version: ${{ inputs.go-version }} cache: true - name: Install gosec run: go install github.com/securego/gosec/v2/cmd/gosec@latest - name: Run gosec run: gosec -no-fail -fmt sarif -out gosec-results.sarif ./... || true - name: Upload gosec SARIF if: always() && hashFiles('gosec-results.sarif') != '' uses: github/codeql-action/upload-sarif@v4 with: sarif_file: gosec-results.sarif category: gosec continue-on-error: true goreleaser-check: name: GoReleaser Config Validation runs-on: ${{ fromJSON(startsWith(inputs.runner || vars.SHARED_ACTIONS_RUNNER || 'ubuntu-latest', '{') && (inputs.runner || vars.SHARED_ACTIONS_RUNNER || 'ubuntu-latest') || format('"{0}"', inputs.runner || vars.SHARED_ACTIONS_RUNNER || 'ubuntu-latest')) }} needs: pre if: needs.pre.outputs.skip != 'true' timeout-minutes: 10 steps: - name: Checkout uses: actions/checkout@v7 with: fetch-depth: 0 lfs: ${{ inputs.lfs }} - name: Run workflow prepare script shell: bash run: | if [ -f "./workflow-prepare.sh" ]; then chmod +x ./workflow-prepare.sh ./workflow-prepare.sh fi - name: Setup Go uses: actions/setup-go@v7 with: go-version: ${{ inputs.go-version }} cache: true - name: Check goreleaser config if: hashFiles('.goreleaser.yml', '.goreleaser.yaml') != '' uses: goreleaser/goreleaser-action@v7 with: distribution: goreleaser version: "~> v2" args: check codeql: name: CodeQL Analysis runs-on: ${{ fromJSON(startsWith(inputs.runner || vars.SHARED_ACTIONS_RUNNER || 'ubuntu-latest', '{') && (inputs.runner || vars.SHARED_ACTIONS_RUNNER || 'ubuntu-latest') || format('"{0}"', inputs.runner || vars.SHARED_ACTIONS_RUNNER || 'ubuntu-latest')) }} needs: pre if: needs.pre.outputs.skip != 'true' timeout-minutes: 45 permissions: contents: read security-events: write steps: - name: Checkout uses: actions/checkout@v7 with: lfs: ${{ inputs.lfs }} - name: Run workflow prepare script shell: bash run: | if [ -f "./workflow-prepare.sh" ]; then chmod +x ./workflow-prepare.sh ./workflow-prepare.sh fi - name: Setup Go uses: actions/setup-go@v7 with: go-version: ${{ inputs.go-version }} cache: true - name: Initialize CodeQL uses: github/codeql-action/init@v4 with: languages: go - name: Autobuild uses: github/codeql-action/autobuild@v4 - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v4 test: name: Tests & Coverage runs-on: ${{ fromJSON(startsWith(inputs.runner || vars.SHARED_ACTIONS_RUNNER || 'ubuntu-latest', '{') && (inputs.runner || vars.SHARED_ACTIONS_RUNNER || 'ubuntu-latest') || format('"{0}"', inputs.runner || vars.SHARED_ACTIONS_RUNNER || 'ubuntu-latest')) }} needs: pre if: needs.pre.outputs.skip != 'true' timeout-minutes: 30 outputs: coverage: ${{ steps.coverage.outputs.coverage }} steps: - name: Checkout uses: actions/checkout@v7 with: fetch-depth: 0 lfs: ${{ inputs.lfs }} - name: Run workflow prepare script shell: bash run: | if [ -f "./workflow-prepare.sh" ]; then chmod +x ./workflow-prepare.sh ./workflow-prepare.sh fi - name: Setup Go uses: actions/setup-go@v7 with: go-version: ${{ inputs.go-version }} cache: true - name: Run tests with coverage env: CGO_ENABLED: 1 run: | TAGS="${{ inputs.build-tags }}" if [ -n "$TAGS" ]; then go test -tags "$TAGS" -race -coverprofile=coverage.out -covermode=atomic ./... else go test -race -coverprofile=coverage.out -covermode=atomic ./... fi go tool cover -func=coverage.out -o=coverage.txt - name: Calculate coverage id: coverage run: | COVERAGE=$(go tool cover -func=coverage.out | awk '/^total:/ {print $3}' | sed 's/%//') echo "coverage=$COVERAGE" >> $GITHUB_OUTPUT echo "Total Coverage: $COVERAGE%" report: name: PR Checks Report runs-on: ${{ fromJSON(startsWith(inputs.runner || vars.SHARED_ACTIONS_RUNNER || 'ubuntu-latest', '{') && (inputs.runner || vars.SHARED_ACTIONS_RUNNER || 'ubuntu-latest') || format('"{0}"', inputs.runner || vars.SHARED_ACTIONS_RUNNER || 'ubuntu-latest')) }} needs: [pre, static-analysis, security-scan, gosec, goreleaser-check, codeql, test] if: ${{ !cancelled() && needs.pre.outputs.skip != 'true' }} timeout-minutes: 10 permissions: contents: read pull-requests: write steps: - name: Summarise, comment and gate uses: actions/github-script@v9 env: NEEDS: ${{ toJSON(needs) }} THRESHOLD: ${{ inputs.coverage-threshold }} with: script: | const needs = JSON.parse(process.env.NEEDS); const threshold = Number(process.env.THRESHOLD); const labels = { 'static-analysis': 'Static analysis (vet, staticcheck)', 'security-scan': 'Security scan (TruffleHog, govulncheck)', 'gosec': 'Gosec', 'goreleaser-check': 'GoReleaser config', 'codeql': 'CodeQL', 'test': 'Tests (race) & coverage', }; const icon = { success: '✅', failure: '❌', cancelled: '🚫', skipped: '⏭️' }; const failed = []; let body = '\n## PR checks\n\n| Check | Result |\n|---|---|\n'; for (const [key, label] of Object.entries(labels)) { const result = needs[key]?.result ?? 'skipped'; if (result === 'failure' || result === 'cancelled') failed.push(label); body += `| ${label} | ${icon[result] ?? result} ${result} |\n`; } const coverage = parseFloat(needs.test?.outputs?.coverage); const hasCoverage = !Number.isNaN(coverage); if (hasCoverage) { body += `\n**Total coverage:** ${coverage}%`; if (threshold > 0) { const ok = coverage >= threshold; body += ` (threshold ${threshold}%: ${ok ? '✅ met' : '❌ below'})`; if (!ok) failed.push(`Coverage ${coverage}% below ${threshold}%`); } body += '\n'; } body += `\n[Workflow run](${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId})`; await core.summary.addRaw(body, true).write(); if (context.eventName === 'pull_request') { try { const comments = await github.paginate(github.rest.issues.listComments, { owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, }); const existing = comments.find(c => c.user.type === 'Bot' && (c.body.includes('') || c.body.includes('Test Coverage Report'))); const args = { owner: context.repo.owner, repo: context.repo.repo, body }; if (existing) { await github.rest.issues.updateComment({ ...args, comment_id: existing.id }); } else { await github.rest.issues.createComment({ ...args, issue_number: context.issue.number }); } } catch (e) { core.warning(`Could not post PR comment (fork PR or missing pull-requests: write): ${e.message}`); } } if (failed.length) core.setFailed(`Failed: ${failed.join('; ')}`);