Files
shared-actions/.github/workflows/go-pr.yaml
T
lukaszraczylo 6b47cbd317 feat(go-pr): add runner input and a single PR checks report
The workflow takes the same runner input as the release workflows.

Findings from go vet and staticcheck now appear as inline annotations
on the pull request diff. A new report job posts one comment with the
result of every check and the coverage. It fails when a check fails or
coverage is below the threshold. It no longer needs the bc command.

A push to a branch with an open pull request now skips its run, because
the pull_request event already runs the checks. Stale runs on the same
branch are cancelled. Every job has a timeout.
2026-10-02 01:20:26 +01:00

416 lines
15 KiB
YAML

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' || toJSON(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' || toJSON(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@v6
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@v6
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' || toJSON(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@v6
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@v6
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: <pkg>@go<version>" (e.g., crypto/[email protected])
# Third-party vulns show "Found in: <pkg>@v<version>" (e.g., github.com/foo/[email protected])
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' || toJSON(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@v6
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@v6
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' || toJSON(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@v6
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@v6
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' || toJSON(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@v6
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@v6
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' || toJSON(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@v6
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@v6
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 | grep total | awk '{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' || toJSON(inputs.runner || vars.SHARED_ACTIONS_RUNNER || 'ubuntu-latest')) }}
needs: [pre, static-analysis, security-scan, gosec, goreleaser-check, codeql, test]
if: always() && 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 = '<!-- go-pr-report -->\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('<!-- go-pr-report -->') || 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('; ')}`);