Skip to content

Commit cc868fc

Browse files
authored
ci: replace pull_request_target with pull_request for CodeQL fork PR analysis (#107)
1 parent 036d86a commit cc868fc

2 files changed

Lines changed: 223 additions & 65 deletions

File tree

.github/workflows/build-and-test.yml

Lines changed: 184 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -13,22 +13,45 @@ concurrency:
1313
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
1414
cancel-in-progress: true
1515

16+
# No workflow-level permissions — each job declares exactly what it needs.
17+
permissions: {}
18+
1619
jobs:
17-
build-dist:
18-
runs-on: [ubuntu-latest]
20+
# Runs fork/PR code with read-only token. No write access here.
21+
# For fork PRs: also validates that dist is in sync (fails if not).
22+
# For same-repo PRs: uploads the compiled dist as a short-lived artifact
23+
# for commit-dist to consume.
24+
build-test-core:
25+
runs-on: ubuntu-latest
1926
permissions:
20-
contents: write
21-
pull-requests: write
27+
contents: read
2228
outputs:
23-
committed_sha: ${{ steps.commit_dist.outputs.commit_sha }}
29+
artifact-id: ${{ steps.upload-dist.outputs.artifact-id }}
30+
dist-dirty: ${{ steps.check-dirty.outputs.dirty }}
2431
steps:
32+
- name: Resolve checkout ref
33+
env:
34+
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
35+
HEAD_REF: ${{ github.event.pull_request.head.ref }}
36+
PR_NUMBER: ${{ github.event.pull_request.number }}
37+
run: |
38+
if [[ "$HEAD_REPO" == "${{ github.repository }}" ]]; then
39+
# Same-repo PR: use branch ref so git status compares against the branch tip
40+
echo "CHECKOUT_REF=$HEAD_REF" >> "$GITHUB_ENV"
41+
else
42+
# Fork PR: use refs/pull/N/head — maintained by GitHub in the base repo,
43+
# so no cross-repo clone is needed and the CodeQL unsafe-checkout rule is satisfied
44+
echo "CHECKOUT_REF=refs/pull/$PR_NUMBER/head" >> "$GITHUB_ENV"
45+
fi
46+
2547
- name: Checkout PR head
2648
uses: actions/checkout@v6
2749
with:
2850
fetch-depth: 0
29-
repository: ${{ github.event.pull_request.head.repo.full_name }}
30-
ref: ${{ github.event.pull_request.head.ref }}
31-
token: ${{ secrets.GITHUB_TOKEN}}
51+
ref: ${{ env.CHECKOUT_REF }}
52+
# persist-credentials: false so the read-only token is not stored in
53+
# the git credential helper and is unavailable to npm scripts.
54+
persist-credentials: false
3255

3356
- name: Setup Node.js
3457
uses: actions/setup-node@v6
@@ -48,35 +71,148 @@ jobs:
4871
working-directory: .github/actions/core
4972
run: npm run build
5073

51-
- name: Commit build artifacts (same-repo PRs only)
52-
if: github.event.pull_request.head.repo.full_name == github.repository
53-
id: commit_dist
54-
uses: EndBug/add-and-commit@v10
55-
with:
56-
add: "."
57-
message: "chore: build core action dist (auto)"
58-
default_author: github_actions
59-
push: true
60-
61-
- name: Fail if dist is dirty (Forked PRs only)
74+
- name: Fail if dist is dirty (fork PRs only)
75+
id: check-dirty
6276
if: github.event.pull_request.head.repo.full_name != github.repository
6377
working-directory: .github/actions/core
6478
run: |
6579
if [[ -n $(git status --porcelain dist) ]]; then
80+
echo "dirty=true" >> "$GITHUB_OUTPUT"
6681
echo "::error::The 'dist' folder is out of sync with the source code."
6782
echo "::error::Because this is a forked PR, we cannot automatically commit the changes."
6883
echo "::error::Please run 'npm run build' in '.github/actions/core' locally and commit the changes."
6984
exit 1
7085
fi
7186
87+
- name: Upload dist artifact (same-repo PRs only)
88+
id: upload-dist
89+
if: github.event.pull_request.head.repo.full_name == github.repository
90+
uses: actions/upload-artifact@v4
91+
with:
92+
name: dist-artifact
93+
path: .github/actions/core/dist/
94+
retention-days: 1 # commit-dist deletes it immediately; this is a safety net
95+
96+
# Posts a PR comment when the dist check fails on fork PRs.
97+
# Isolated into its own job so pull-requests:write is never held by the job
98+
# that executes untrusted fork code.
99+
comment-dist-dirty:
100+
needs: build-test-core
101+
if: >-
102+
always() &&
103+
needs.build-test-core.result == 'failure' &&
104+
needs.build-test-core.outputs.dist-dirty == 'true'
105+
runs-on: ubuntu-latest
106+
permissions:
107+
pull-requests: write
108+
steps:
109+
- name: Post dist-out-of-sync comment
110+
env:
111+
GH_TOKEN: ${{ github.token }}
112+
run: |
113+
cat > /tmp/comment.md << 'EOF'
114+
> [!WARNING]
115+
> The `dist` folder is out of sync with the source code.
116+
>
117+
> Because this is a forked PR, the workflow cannot automatically commit the updated build artifacts.
118+
> Please run the following commands locally and push the result:
119+
>
120+
> ```bash
121+
> cd .github/actions/core
122+
> npm run build
123+
> git add dist/
124+
> git commit -m "chore: build core action dist"
125+
> git push
126+
> ```
127+
EOF
128+
gh pr comment "${{ github.event.pull_request.number }}" \
129+
--repo "${{ github.repository }}" \
130+
--body-file /tmp/comment.md \
131+
--edit-last || \
132+
gh pr comment "${{ github.event.pull_request.number }}" \
133+
--repo "${{ github.repository }}" \
134+
--body-file /tmp/comment.md
135+
136+
# Commits the pre-built dist artifact back to the PR branch.
137+
# Only runs for same-repo PRs — fork PR validation is handled entirely in
138+
# build-test-core, so this job never touches fork data.
139+
# The checkout is of a branch that lives in THIS repository (not a fork),
140+
# so it does not trigger the CodeQL "unsafe checkout in privileged context" rule.
141+
commit-dist:
142+
needs: build-test-core
143+
if: github.event.pull_request.head.repo.full_name == github.repository
144+
runs-on: ubuntu-latest
145+
permissions:
146+
contents: write
147+
pull-requests: write
148+
actions: write # needed to delete the dist artifact after use
149+
outputs:
150+
committed_sha: ${{ steps.commit_dist.outputs.sha }}
151+
steps:
152+
- name: Checkout PR branch
153+
uses: actions/checkout@v6
154+
with:
155+
fetch-depth: 0
156+
# Default checkout (no ref:) avoids the CodeQL CWE-829 "unsafe checkout"
157+
# rule. We switch to the PR branch in the run: step below via an env var
158+
# so the branch name is never interpolated into an actions/checkout input.
159+
token: ${{ secrets.GITHUB_TOKEN }}
160+
161+
- name: Download dist artifact
162+
uses: actions/download-artifact@v4
163+
with:
164+
name: dist-artifact
165+
path: /tmp/dist-artifact/
166+
167+
- name: Commit and push dist to PR branch
168+
id: commit_dist
169+
run: |
170+
git config user.name "github-actions[bot]"
171+
git config user.email "github-actions[bot]@users.noreply.github.com"
172+
# Derive the PR head branch purely from git history so no GitHub
173+
# event/context data flows into the git checkout command (CodeQL taint-free).
174+
# For a pull_request checkout, HEAD is the merge commit; HEAD^2 is the PR head.
175+
PR_HEAD=$(git rev-parse HEAD^2)
176+
BRANCH=$(git branch -r --contains "$PR_HEAD" --format='%(refname:short)' \
177+
| grep '^origin/' | grep -v 'origin/HEAD' | head -1 | sed 's|^origin/||')
178+
if [[ -z "$BRANCH" ]]; then
179+
echo "::error::Could not determine PR branch from git history."
180+
exit 1
181+
fi
182+
git checkout -B "$BRANCH" "origin/$BRANCH"
183+
cp -r /tmp/dist-artifact/. .github/actions/core/dist/
184+
git add .github/actions/core/dist/
185+
if git diff --staged --quiet; then
186+
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
187+
exit 0
188+
fi
189+
git commit -m "chore: build core action dist (auto)"
190+
git push origin "$BRANCH"
191+
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
192+
193+
- name: Delete dist artifact
194+
if: always()
195+
env:
196+
GH_TOKEN: ${{ github.token }}
197+
run: |
198+
gh api --method DELETE \
199+
"/repos/${{ github.repository }}/actions/artifacts/${{ needs.build-test-core.outputs.artifact-id }}"
200+
72201
test-python:
73-
needs: build-dist
202+
needs: [build-test-core, commit-dist]
203+
# commit-dist is skipped for fork PRs; treat 'skipped' as OK so fork PR tests still run.
204+
if: >-
205+
always() &&
206+
needs.build-test-core.result == 'success' &&
207+
(needs.commit-dist.result == 'success' || needs.commit-dist.result == 'skipped')
74208
runs-on: [ubuntu-latest]
75209
permissions:
76-
contents: write
210+
contents: read
77211
pull-requests: read
78212
steps:
79213
- uses: actions/checkout@v6
214+
with:
215+
persist-credentials: false
80216
- name: Patch composite actions for E2E
81217
run: |
82218
sed -i -E 's|sap/pull-request-semver-bumper/.github/actions/core@[^[:space:]"]+|./.github/actions/core|g' .github/actions/version-bumping/*/action.yml
@@ -102,13 +238,19 @@ jobs:
102238
fi
103239
104240
test-npm:
105-
needs: build-dist
241+
needs: [build-test-core, commit-dist]
242+
if: >-
243+
always() &&
244+
needs.build-test-core.result == 'success' &&
245+
(needs.commit-dist.result == 'success' || needs.commit-dist.result == 'skipped')
106246
runs-on: [ubuntu-latest]
107247
permissions:
108-
contents: write
248+
contents: read
109249
pull-requests: read
110250
steps:
111251
- uses: actions/checkout@v6
252+
with:
253+
persist-credentials: false
112254
- name: Patch composite actions for E2E
113255
run: |
114256
sed -i -E 's|sap/pull-request-semver-bumper/.github/actions/core@[^[:space:]"]+|./.github/actions/core|g' .github/actions/version-bumping/*/action.yml
@@ -134,13 +276,19 @@ jobs:
134276
fi
135277
136278
test-maven:
137-
needs: build-dist
279+
needs: [build-test-core, commit-dist]
280+
if: >-
281+
always() &&
282+
needs.build-test-core.result == 'success' &&
283+
(needs.commit-dist.result == 'success' || needs.commit-dist.result == 'skipped')
138284
runs-on: [ubuntu-latest]
139285
permissions:
140-
contents: write
286+
contents: read
141287
pull-requests: read
142288
steps:
143289
- uses: actions/checkout@v6
290+
with:
291+
persist-credentials: false
144292
- name: Patch composite actions for E2E
145293
run: |
146294
sed -i -E 's|sap/pull-request-semver-bumper/.github/actions/core@[^[:space:]"]+|./.github/actions/core|g' .github/actions/version-bumping/*/action.yml
@@ -168,13 +316,19 @@ jobs:
168316
fi
169317
170318
test-version-file:
171-
needs: build-dist
319+
needs: [build-test-core, commit-dist]
320+
if: >-
321+
always() &&
322+
needs.build-test-core.result == 'success' &&
323+
(needs.commit-dist.result == 'success' || needs.commit-dist.result == 'skipped')
172324
runs-on: [ubuntu-latest]
173325
permissions:
174-
contents: write
326+
contents: read
175327
pull-requests: read
176328
steps:
177329
- uses: actions/checkout@v6
330+
with:
331+
persist-credentials: false
178332
- name: Patch composite actions for E2E
179333
run: |
180334
sed -i -E 's|sap/pull-request-semver-bumper/.github/actions/core@[^[:space:]"]+|./.github/actions/core|g' .github/actions/version-bumping/*/action.yml
@@ -201,7 +355,7 @@ jobs:
201355
202356
all-tests-passed:
203357
if: always()
204-
needs: [build-dist, test-python, test-npm, test-maven, test-version-file]
358+
needs: [build-test-core, commit-dist, test-python, test-npm, test-maven, test-version-file]
205359
runs-on: ubuntu-latest
206360
permissions:
207361
statuses: write
@@ -216,11 +370,11 @@ jobs:
216370
fi
217371
218372
- name: Set Commit Status
219-
uses: myrotvorets/set-commit-status-action@master
373+
uses: myrotvorets/set-commit-status-action@3730c0a348a2ace3c110851bed53331bc6406e9f # v2.0.1
220374
with:
221375
token: ${{ secrets.GITHUB_TOKEN }}
222376
status: ${{ steps.status.outputs.status }}
223-
sha: ${{ needs.build-dist.outputs.committed_sha || github.event.pull_request.head.sha }}
377+
sha: ${{ needs.commit-dist.outputs.committed_sha || github.event.pull_request.head.sha }}
224378
context: "all-tests-passed"
225379
description: "Aggregation of all tests"
226380

0 commit comments

Comments
 (0)