Files
shared-actions/.github/workflows/go-pr.yaml
T
lukaszraczylo 9a042ecafd fix(go-pr): read only the total line of the coverage report
The coverage step matched every line that contains the word total, so a
function name such as subtotal added a second value. GitHub then
rejected the multi-line step output. The step now reads the line that
starts with total:.
2026-10-02 04:33:59 +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') || 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@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') || 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@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') || 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@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') || 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@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') || 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@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') || 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@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 | 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 = '<!-- 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('; ')}`);