@@ -12,22 +12,45 @@ concurrency:
1212 group : ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
1313 cancel-in-progress : true
1414
15+ # No workflow-level permissions — each job declares exactly what it needs.
16+ permissions : {}
17+
1518jobs :
16- build-dist :
17- runs-on : [ubuntu-latest]
19+ # Runs fork/PR code with read-only token. No write access here.
20+ # For fork PRs: also validates that dist is in sync (fails if not).
21+ # For same-repo PRs: uploads the compiled dist as a short-lived artifact
22+ # for commit-dist to consume.
23+ build-test-core :
24+ runs-on : ubuntu-latest
1825 permissions :
19- contents : write
20- pull-requests : write
26+ contents : read
2127 outputs :
22- committed_sha : ${{ steps.commit_dist.outputs.commit_sha }}
28+ artifact-id : ${{ steps.upload-dist.outputs.artifact-id }}
29+ dist-dirty : ${{ steps.check-dirty.outputs.dirty }}
2330 steps :
31+ - name : Resolve checkout ref
32+ env :
33+ HEAD_REPO : ${{ github.event.pull_request.head.repo.full_name }}
34+ HEAD_REF : ${{ github.event.pull_request.head.ref }}
35+ PR_NUMBER : ${{ github.event.pull_request.number }}
36+ run : |
37+ if [[ "$HEAD_REPO" == "${{ github.repository }}" ]]; then
38+ # Same-repo PR: use branch ref so git status compares against the branch tip
39+ echo "CHECKOUT_REF=$HEAD_REF" >> "$GITHUB_ENV"
40+ else
41+ # Fork PR: use refs/pull/N/head — maintained by GitHub in the base repo,
42+ # so no cross-repo clone is needed and the CodeQL unsafe-checkout rule is satisfied
43+ echo "CHECKOUT_REF=refs/pull/$PR_NUMBER/head" >> "$GITHUB_ENV"
44+ fi
45+
2446 - name : Checkout PR head
2547 uses : actions/checkout@v6
2648 with :
2749 fetch-depth : 0
28- repository : ${{ github.event.pull_request.head.repo.full_name }}
29- ref : ${{ github.event.pull_request.head.ref }}
30- token : ${{ secrets.GITHUB_TOKEN}}
50+ ref : ${{ env.CHECKOUT_REF }}
51+ # persist-credentials: false so the read-only token is not stored in
52+ # the git credential helper and is unavailable to npm scripts.
53+ persist-credentials : false
3154
3255 - name : Setup Node.js
3356 uses : actions/setup-node@v6
@@ -47,35 +70,148 @@ jobs:
4770 working-directory : .github/actions/core
4871 run : npm run build
4972
50- - name : Commit build artifacts (same-repo PRs only)
51- if : github.event.pull_request.head.repo.full_name == github.repository
52- id : commit_dist
53- uses : EndBug/add-and-commit@290ea2c423ad77ca9c62ae0f5b224379612c0321 # v10.0.0
54- with :
55- add : " ."
56- message : " chore: build core action dist (auto)"
57- default_author : github_actions
58- push : true
59-
60- - name : Fail if dist is dirty (Forked PRs only)
73+ - name : Fail if dist is dirty (fork PRs only)
74+ id : check-dirty
6175 if : github.event.pull_request.head.repo.full_name != github.repository
6276 working-directory : .github/actions/core
6377 run : |
6478 if [[ -n $(git status --porcelain dist) ]]; then
79+ echo "dirty=true" >> "$GITHUB_OUTPUT"
6580 echo "::error::The 'dist' folder is out of sync with the source code."
6681 echo "::error::Because this is a forked PR, we cannot automatically commit the changes."
6782 echo "::error::Please run 'npm run build' in '.github/actions/core' locally and commit the changes."
6883 exit 1
6984 fi
7085
86+ - name : Upload dist artifact (same-repo PRs only)
87+ id : upload-dist
88+ if : github.event.pull_request.head.repo.full_name == github.repository
89+ uses : actions/upload-artifact@v7
90+ with :
91+ name : dist-artifact
92+ path : .github/actions/core/dist/
93+ retention-days : 1 # commit-dist deletes it immediately; this is a safety net
94+
95+ # Posts a PR comment when the dist check fails on fork PRs.
96+ # Isolated into its own job so pull-requests:write is never held by the job
97+ # that executes untrusted fork code.
98+ comment-dist-dirty :
99+ needs : build-test-core
100+ if : >-
101+ always() &&
102+ needs.build-test-core.result == 'failure' &&
103+ needs.build-test-core.outputs.dist-dirty == 'true'
104+ runs-on : ubuntu-latest
105+ permissions :
106+ pull-requests : write
107+ steps :
108+ - name : Post dist-out-of-sync comment
109+ env :
110+ GH_TOKEN : ${{ github.token }}
111+ run : |
112+ cat > /tmp/comment.md << 'EOF'
113+ > [!WARNING]
114+ > The `dist` folder is out of sync with the source code.
115+ >
116+ > Because this is a forked PR, the workflow cannot automatically commit the updated build artifacts.
117+ > Please run the following commands locally and push the result:
118+ >
119+ > ```bash
120+ > cd .github/actions/core
121+ > npm run build
122+ > git add dist/
123+ > git commit -m "chore: build core action dist"
124+ > git push
125+ > ```
126+ EOF
127+ gh pr comment "${{ github.event.pull_request.number }}" \
128+ --repo "${{ github.repository }}" \
129+ --body-file /tmp/comment.md \
130+ --edit-last || \
131+ gh pr comment "${{ github.event.pull_request.number }}" \
132+ --repo "${{ github.repository }}" \
133+ --body-file /tmp/comment.md
134+
135+ # Commits the pre-built dist artifact back to the PR branch.
136+ # Only runs for same-repo PRs — fork PR validation is handled entirely in
137+ # build-test-core, so this job never touches fork data.
138+ # The checkout is of a branch that lives in THIS repository (not a fork),
139+ # so it does not trigger the CodeQL "unsafe checkout in privileged context" rule.
140+ commit-dist :
141+ needs : build-test-core
142+ if : github.event.pull_request.head.repo.full_name == github.repository
143+ runs-on : ubuntu-latest
144+ permissions :
145+ contents : write
146+ pull-requests : write
147+ actions : write # needed to delete the dist artifact after use
148+ outputs :
149+ committed_sha : ${{ steps.commit_dist.outputs.sha }}
150+ steps :
151+ - name : Checkout PR branch
152+ uses : actions/checkout@v6
153+ with :
154+ fetch-depth : 0
155+ # Default checkout (no ref:) avoids the CodeQL CWE-829 "unsafe checkout"
156+ # rule. We switch to the PR branch in the run: step below via an env var
157+ # so the branch name is never interpolated into an actions/checkout input.
158+ token : ${{ secrets.GITHUB_TOKEN }}
159+
160+ - name : Download dist artifact
161+ uses : actions/download-artifact@v8
162+ with :
163+ name : dist-artifact
164+ path : /tmp/dist-artifact/
165+
166+ - name : Commit and push dist to PR branch
167+ id : commit_dist
168+ run : |
169+ git config user.name "github-actions[bot]"
170+ git config user.email "github-actions[bot]@users.noreply.github.com"
171+ # Derive the PR head branch purely from git history so no GitHub
172+ # event/context data flows into the git checkout command (CodeQL taint-free).
173+ # For a pull_request checkout, HEAD is the merge commit; HEAD^2 is the PR head.
174+ PR_HEAD=$(git rev-parse HEAD^2)
175+ BRANCH=$(git branch -r --contains "$PR_HEAD" --format='%(refname:short)' \
176+ | grep '^origin/' | grep -v 'origin/HEAD' | head -1 | sed 's|^origin/||')
177+ if [[ -z "$BRANCH" ]]; then
178+ echo "::error::Could not determine PR branch from git history."
179+ exit 1
180+ fi
181+ git checkout -B "$BRANCH" "origin/$BRANCH"
182+ cp -r /tmp/dist-artifact/. .github/actions/core/dist/
183+ git add .github/actions/core/dist/
184+ if git diff --staged --quiet; then
185+ echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
186+ exit 0
187+ fi
188+ git commit -m "chore: build core action dist (auto)"
189+ git push origin "$BRANCH"
190+ echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
191+
192+ - name : Delete dist artifact
193+ if : always()
194+ env :
195+ GH_TOKEN : ${{ github.token }}
196+ run : |
197+ gh api --method DELETE \
198+ "/repos/${{ github.repository }}/actions/artifacts/${{ needs.build-test-core.outputs.artifact-id }}"
199+
71200 test-python :
72- needs : build-dist
201+ needs : [build-test-core, commit-dist]
202+ # commit-dist is skipped for fork PRs; treat 'skipped' as OK so fork PR tests still run.
203+ if : >-
204+ always() &&
205+ needs.build-test-core.result == 'success' &&
206+ (needs.commit-dist.result == 'success' || needs.commit-dist.result == 'skipped')
73207 runs-on : [ubuntu-latest]
74208 permissions :
75- contents : write
209+ contents : read
76210 pull-requests : read
77211 steps :
78212 - uses : actions/checkout@v6
213+ with :
214+ persist-credentials : false
79215 - name : Patch composite actions for E2E
80216 run : |
81217 sed -i -E 's|sap/pull-request-semver-bumper/.github/actions/core@[^[:space:]"]+|./.github/actions/core|g' .github/actions/version-bumping/*/action.yml
@@ -101,13 +237,19 @@ jobs:
101237 fi
102238
103239 test-npm :
104- needs : build-dist
240+ needs : [build-test-core, commit-dist]
241+ if : >-
242+ always() &&
243+ needs.build-test-core.result == 'success' &&
244+ (needs.commit-dist.result == 'success' || needs.commit-dist.result == 'skipped')
105245 runs-on : [ubuntu-latest]
106246 permissions :
107- contents : write
247+ contents : read
108248 pull-requests : read
109249 steps :
110250 - uses : actions/checkout@v6
251+ with :
252+ persist-credentials : false
111253 - name : Patch composite actions for E2E
112254 run : |
113255 sed -i -E 's|sap/pull-request-semver-bumper/.github/actions/core@[^[:space:]"]+|./.github/actions/core|g' .github/actions/version-bumping/*/action.yml
@@ -133,13 +275,19 @@ jobs:
133275 fi
134276
135277 test-maven :
136- needs : build-dist
278+ needs : [build-test-core, commit-dist]
279+ if : >-
280+ always() &&
281+ needs.build-test-core.result == 'success' &&
282+ (needs.commit-dist.result == 'success' || needs.commit-dist.result == 'skipped')
137283 runs-on : [ubuntu-latest]
138284 permissions :
139- contents : write
285+ contents : read
140286 pull-requests : read
141287 steps :
142288 - uses : actions/checkout@v6
289+ with :
290+ persist-credentials : false
143291 - name : Patch composite actions for E2E
144292 run : |
145293 sed -i -E 's|sap/pull-request-semver-bumper/.github/actions/core@[^[:space:]"]+|./.github/actions/core|g' .github/actions/version-bumping/*/action.yml
@@ -167,13 +315,19 @@ jobs:
167315 fi
168316
169317 test-version-file :
170- needs : build-dist
318+ needs : [build-test-core, commit-dist]
319+ if : >-
320+ always() &&
321+ needs.build-test-core.result == 'success' &&
322+ (needs.commit-dist.result == 'success' || needs.commit-dist.result == 'skipped')
171323 runs-on : [ubuntu-latest]
172324 permissions :
173- contents : write
325+ contents : read
174326 pull-requests : read
175327 steps :
176328 - uses : actions/checkout@v6
329+ with :
330+ persist-credentials : false
177331 - name : Patch composite actions for E2E
178332 run : |
179333 sed -i -E 's|sap/pull-request-semver-bumper/.github/actions/core@[^[:space:]"]+|./.github/actions/core|g' .github/actions/version-bumping/*/action.yml
@@ -200,7 +354,7 @@ jobs:
200354
201355 all-tests-passed :
202356 if : always()
203- needs : [build-dist, test-python, test-npm, test-maven, test-version-file]
357+ needs : [build-test-core, commit- dist, test-python, test-npm, test-maven, test-version-file]
204358 runs-on : ubuntu-latest
205359 permissions :
206360 statuses : write
@@ -215,11 +369,11 @@ jobs:
215369 fi
216370
217371 - name : Set Commit Status
218- uses : myrotvorets/set-commit-status-action@2c3557527522d8d38f410941902b64a4be75a0ec # master
372+ uses : myrotvorets/set-commit-status-action@3730c0a348a2ace3c110851bed53331bc6406e9f # v2.0.1
219373 with :
220374 token : ${{ secrets.GITHUB_TOKEN }}
221375 status : ${{ steps.status.outputs.status }}
222- sha : ${{ needs.build -dist.outputs.committed_sha || github.event.pull_request.head.sha }}
376+ sha : ${{ needs.commit -dist.outputs.committed_sha || github.event.pull_request.head.sha }}
223377 context : " all-tests-passed"
224378 description : " Aggregation of all tests"
225379
0 commit comments