Skip to content

Commit a1d0e00

Browse files
wikkykclaudeCopilot
committed
ci: enable testing and Sonar scanning for external PRs
External contributions need automated tests and coverage analysis just like internal ones. Done with a layered pull_request_target workflow that isolates PR-supplied code from base-context privilege. Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent d79998d commit a1d0e00

2 files changed

Lines changed: 156 additions & 11 deletions

File tree

.github/workflows/test.yml

Lines changed: 155 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,184 @@
1+
# Triggers on push to main and pull_request_target. PR runs use base-repo
2+
# workflow (a fork can't edit this file), with these layered controls:
3+
# pre-screen — git-only checks (diff size, sensitive paths, file modes).
4+
# No PR code execution.
5+
# test — PR code runs in a digest-pinned Go container with
6+
# permissions:{} in a container to avoid cache poisoning.
7+
# sonar — environment-gated SONAR_TOKEN; upstream and PR trees in
8+
# separate paths. Scanner reads PR sources with upstream config
9+
# (fork PRs) or PR config (internal PRs); never runs PR code.
10+
111
name: Test
12+
213
on:
14+
pull_request_target: {}
315
push:
416
branches: ["main"]
5-
pull_request:
617

718
permissions:
819
contents: read
920

21+
concurrency:
22+
cancel-in-progress: true
23+
group: test-${{ github.event.pull_request.number || github.ref }}
24+
1025
jobs:
26+
pre-screen:
27+
# Fork PRs only. Internal PRs and push events skip; downstream `if` allows skipped.
28+
if: github.event_name == 'pull_request_target' && github.event.pull_request.head.repo.full_name != github.repository
29+
runs-on: ubuntu-slim
30+
permissions: {}
31+
env:
32+
BASE_SHA: ${{ github.event.pull_request.base.sha }}
33+
CHANGED_FILES: ${{ github.event.pull_request.changed_files }}
34+
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
35+
PR_NUMBER: ${{ github.event.pull_request.number }}
36+
steps:
37+
- name: Bail on large diffs
38+
run: |
39+
if [ "$CHANGED_FILES" -gt 20 ]; then
40+
echo "::error::PR changes ${CHANGED_FILES} files (>5%); please split the PR into reviewable chunks or request maintainer adoption"
41+
exit 1
42+
fi
43+
44+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
45+
with:
46+
fetch-depth: 0 # base history back to BASE_SHA
47+
persist-credentials: false # keep token out of .git/config so make verify / make test can't read it
48+
49+
# Fetch by ref (refs/pull/N/head is always advertised); pin to event-context
50+
# HEAD_SHA so we screen exactly what the test job will execute.
51+
- name: Fetch PR head and verify it matches the event payload
52+
run: |
53+
set -euo pipefail
54+
git fetch --no-tags origin "refs/pull/${PR_NUMBER}/head"
55+
if [ "$(git rev-parse FETCH_HEAD)" != "$HEAD_SHA" ]; then
56+
echo "::error::PR head moved since trigger ($HEAD_SHA → $(git rev-parse FETCH_HEAD)); cancel-in-progress should have aborted this run"
57+
exit 1
58+
fi
59+
60+
# --no-renames so a rename yields both paths; either side touching sensitive paths fails.
61+
- name: Sensitive-path scan
62+
run: |
63+
set -euo pipefail
64+
hits=$(git diff --name-only --no-renames -z "$BASE_SHA" "$HEAD_SHA" \
65+
| grep -z -E '^(Makefile|\.github/|go\.(mod|sum)|sonar-project\.properties|Dockerfile|hack/|tilt-provider\.json|\.[^/]+)' \
66+
| tr '\0' '\n' || true)
67+
if [ -n "$hits" ]; then
68+
echo "::error::PR touches sensitive paths; re-submit via maintainer-authored PR. Offending paths:"
69+
echo "$hits"
70+
exit 1
71+
fi
72+
73+
# Allow only regular files (100644) and executables (100755).
74+
- name: Non-normal file mode scan
75+
run: |
76+
set -euo pipefail
77+
bad=$(git ls-tree -r "$HEAD_SHA" | awk '$1 != "100644" && $1 != "100755"')
78+
if [ -n "$bad" ]; then
79+
echo "::error::PR contains non-normal Git tree entries (only regular files allowed). Offending entries:"
80+
echo "$bad"
81+
exit 1
82+
fi
83+
1184
test:
85+
needs: pre-screen
86+
# always() bypasses GHA's "skip when needs.* == skipped"; the conjunction limits to success/skipped (not failure/cancelled).
87+
if: always() && (needs.pre-screen.result == 'success' || needs.pre-screen.result == 'skipped')
1288
runs-on: ubuntu-latest
89+
permissions: {}
90+
container:
91+
image: golang@sha256:298734aec230b5f3e8cee450ce6d7eccc39f1797ba548ee90d57e9803030c6c3 # 1.25-bookworm
92+
env:
93+
GOTOOLCHAIN: local
94+
steps:
95+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
96+
with:
97+
persist-credentials: false
98+
ref: ${{ github.event.pull_request.head.sha || github.sha }}
99+
repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
100+
101+
# Container runs as root; the workspace is owned by the runner UID. Without this, `git diff` in `make verify` would fail with "dubious ownership".
102+
- run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
103+
104+
- run: make verify
105+
- run: make test
106+
107+
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
108+
with:
109+
name: coverage
110+
path: cover.out
111+
retention-days: 7
112+
113+
# SECURITY: holds SONAR_TOKEN. Never execute PR-supplied code, scripts, or actions in this job.
114+
sonar:
115+
needs: test
116+
# Default `if: success()` would skip sonar whenever test failed; on main and dependabot PRs that means flaky/genuine test failures silently bypass the quality gate. Run sonar whenever test actually executed.
117+
if: always() && (needs.test.result == 'success' || needs.test.result == 'failure')
118+
runs-on: ubuntu-slim
119+
environment: sonar
120+
permissions:
121+
contents: read
122+
env:
123+
# Fork PRs scan with the upstream sonar config (untrusted PR); internal PRs and pushes scan with the local pr/ tree, so the upstream checkout is skipped for those.
124+
SONAR_CONFIG_DIR: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.repo.full_name != github.repository && 'upstream' || 'pr' }}
125+
# Hardcode the SonarCloud URL so the scanner can't be redirected via PR-supplied sonar.host.url; SONAR_HOST_URL env beats both -D args and sonar-project.properties.
126+
SONAR_HOST_URL: https://sonarcloud.io
13127
steps:
128+
- if: github.event_name == 'pull_request_target' && github.event.pull_request.head.repo.full_name != github.repository
129+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
130+
with:
131+
path: upstream
132+
persist-credentials: false
133+
ref: ${{ github.event.pull_request.base.sha }} # pin to PR base SHA (matches pre-screen's BASE_SHA)
134+
14135
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
15136
with:
16-
fetch-depth: 0 # for SonarQube
137+
fetch-depth: 0 # SonarQube needs git history for blame and new-code attribution
138+
path: pr
139+
persist-credentials: false
140+
ref: ${{ github.event.pull_request.head.sha || github.sha }}
141+
repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
17142

18-
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
143+
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
19144
with:
20-
go-version-file: go.mod
145+
name: coverage
146+
path: ${{ runner.temp }}/coverage
21147

22-
- run: "make verify"
23-
- run: "make test"
148+
# fail-fast against malformed coverage file
149+
- name: Validate coverage file format
150+
run: head -1 "${RUNNER_TEMP}/coverage/cover.out" | grep -q '^mode:'
24151

25152
- name: SonarQube for PR ${{ github.event.pull_request.number }}
26-
if: github.event_name == 'pull_request'
153+
if: github.event_name == 'pull_request_target'
27154
uses: SonarSource/sonarqube-scan-action@59db25f34e16620e48ab4bb9e4a5dce155cb5432 # v8.0.0
28155
with:
29-
args: >
30-
-Dsonar.pullrequest.key=${{ github.event.pull_request.number }}
31-
-Dsonar.scm.revision=${{ github.event.pull_request.head.sha }}
156+
args: >
157+
-Dproject.settings=${{ env.SONAR_CONFIG_DIR }}/sonar-project.properties
158+
-Dsonar.go.coverage.reportPaths=${{ runner.temp }}/coverage/cover.out
159+
-Dsonar.projectBaseDir=pr
160+
-Dsonar.pullrequest.base=${{ github.event.pull_request.base.ref }}
161+
-Dsonar.pullrequest.branch=${{ github.event.pull_request.head.ref }}
162+
-Dsonar.pullrequest.key=${{ github.event.pull_request.number }}
163+
-Dsonar.scm.revision=${{ github.event.pull_request.head.sha }}
164+
-Dsonar.working.directory=${{ runner.temp }}/scannerwork
32165
env:
33166
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
34167

35168
- name: SonarQube
36169
if: github.event_name == 'push'
37170
uses: SonarSource/sonarqube-scan-action@59db25f34e16620e48ab4bb9e4a5dce155cb5432 # v8.0.0
171+
with:
172+
args: >
173+
-Dproject.settings=${{ env.SONAR_CONFIG_DIR }}/sonar-project.properties
174+
-Dsonar.go.coverage.reportPaths=${{ runner.temp }}/coverage/cover.out
175+
-Dsonar.projectBaseDir=pr
176+
-Dsonar.working.directory=${{ runner.temp }}/scannerwork
177+
env:
178+
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
179+
180+
- uses: SonarSource/sonarqube-quality-gate-action@cf038b0e0cdecfa9e56c198bbb7d21d751d62c3b # v1.2.0
181+
with:
182+
scanMetadataReportFile: ${{ runner.temp }}/scannerwork/report-task.txt
38183
env:
39184
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}

CLAUDE.md

Lines changed: 0 additions & 1 deletion
This file was deleted.

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
read AGENTS.md

0 commit comments

Comments
 (0)