@@ -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+
1619jobs :
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