mirror of
https://github.com/lukaszraczylo/shared-actions.git
synced 2026-10-13 04:58:51 +00:00
A run cancelled by a newer push still ran the report job. The gate then failed and marked the superseded run as failed. The report job now skips when the run is cancelled.
416 lines
15 KiB
YAML
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 | 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') || 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('; ')}`);
|