ci: harden CI workflows against fork PR privilege abuse #315
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Build & Test | |
| on: | |
| pull_request: | |
| branches: | |
| - main | |
| types: | |
| - opened | |
| - synchronize | |
| - reopened | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} | |
| cancel-in-progress: true | |
| # No workflow-level permissions — each job declares exactly what it needs. | |
| permissions: {} | |
| jobs: | |
| # Runs fork/PR code with read-only token. No write access here. | |
| # For fork PRs: also validates that dist is in sync (fails if not). | |
| # For same-repo PRs: uploads the compiled dist as a short-lived artifact | |
| # for commit-dist to consume. | |
| build-test-core: | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| outputs: | |
| artifact-id: ${{ steps.upload-dist.outputs.artifact-id }} | |
| steps: | |
| - name: Checkout PR head | |
| uses: actions/checkout@v6 | |
| with: | |
| fetch-depth: 0 | |
| # For same-repo PRs: branch ref so git status compares against the branch tip. | |
| # For fork PRs: refs/pull/N/head — GitHub maintains this ref in the base repo, | |
| # so no cross-repo clone is required and the CodeQL unsafe-checkout rule is satisfied. | |
| ref: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.head.ref || format('refs/pull/{0}/head', github.event.pull_request.number) }} | |
| # persist-credentials: false so the read-only token is not stored in | |
| # the git credential helper and is unavailable to npm scripts. | |
| persist-credentials: false | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v6 | |
| with: | |
| node-version: 24 # use stable LTS; previous 24 caused manifest warning | |
| check-latest: true | |
| - name: Install dependencies | |
| working-directory: .github/actions/core | |
| run: npm ci | |
| - name: Run tests | |
| working-directory: .github/actions/core | |
| run: npm run test | |
| - name: Build action | |
| working-directory: .github/actions/core | |
| run: npm run build | |
| - name: Fail if dist is dirty (fork PRs only) | |
| if: github.event.pull_request.head.repo.full_name != github.repository | |
| working-directory: .github/actions/core | |
| run: | | |
| if [[ -n $(git status --porcelain dist) ]]; then | |
| echo "::error::The 'dist' folder is out of sync with the source code." | |
| echo "::error::Because this is a forked PR, we cannot automatically commit the changes." | |
| echo "::error::Please run 'npm run build' in '.github/actions/core' locally and commit the changes." | |
| exit 1 | |
| fi | |
| - name: Upload dist artifact (same-repo PRs only) | |
| id: upload-dist | |
| if: github.event.pull_request.head.repo.full_name == github.repository | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: dist-artifact | |
| path: .github/actions/core/dist/ | |
| retention-days: 1 # commit-dist deletes it immediately; this is a safety net | |
| # Commits the pre-built dist artifact back to the PR branch. | |
| # Only runs for same-repo PRs — fork PR validation is handled entirely in | |
| # build-test-core, so this job never touches fork data. | |
| # The checkout is of a branch that lives in THIS repository (not a fork), | |
| # so it does not trigger the CodeQL "unsafe checkout in privileged context" rule. | |
| commit-dist: | |
| needs: build-test-core | |
| if: github.event.pull_request.head.repo.full_name == github.repository | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: write | |
| pull-requests: write | |
| actions: write # needed to delete the dist artifact after use | |
| outputs: | |
| committed_sha: ${{ steps.commit_dist.outputs.sha }} | |
| steps: | |
| - name: Checkout PR branch | |
| uses: actions/checkout@v6 | |
| with: | |
| fetch-depth: 0 | |
| # Default checkout (no ref:) avoids the CodeQL CWE-829 "unsafe checkout" | |
| # rule. We switch to the PR branch in the run: step below via an env var | |
| # so the branch name is never interpolated into an actions/checkout input. | |
| token: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Download dist artifact | |
| uses: actions/download-artifact@v4 | |
| with: | |
| name: dist-artifact | |
| path: /tmp/dist-artifact/ | |
| - name: Commit and push dist to PR branch | |
| id: commit_dist | |
| env: | |
| BRANCH: ${{ github.event.pull_request.head.ref }} | |
| run: | | |
| git config user.name "github-actions[bot]" | |
| git config user.email "github-actions[bot]@users.noreply.github.com" | |
| git fetch origin "$BRANCH" | |
| git checkout -B "$BRANCH" "origin/$BRANCH" | |
| cp -r /tmp/dist-artifact/. .github/actions/core/dist/ | |
| git add .github/actions/core/dist/ | |
| if git diff --staged --quiet; then | |
| echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| git commit -m "chore: build core action dist (auto)" | |
| git push origin "$BRANCH" | |
| echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" | |
| - name: Delete dist artifact | |
| if: always() | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| gh api --method DELETE \ | |
| "/repos/${{ github.repository }}/actions/artifacts/${{ needs.build-test-core.outputs.artifact-id }}" | |
| test-python: | |
| needs: [build-test-core, commit-dist] | |
| # commit-dist is skipped for fork PRs; treat 'skipped' as OK so fork PR tests still run. | |
| if: >- | |
| always() && | |
| needs.build-test-core.result == 'success' && | |
| (needs.commit-dist.result == 'success' || needs.commit-dist.result == 'skipped') | |
| runs-on: [ubuntu-latest] | |
| permissions: | |
| contents: read | |
| pull-requests: read | |
| steps: | |
| - uses: actions/checkout@v6 | |
| with: | |
| persist-credentials: false | |
| - name: Patch composite actions for E2E | |
| run: | | |
| sed -i -E 's|sap/pull-request-semver-bumper/.github/actions/core@[^[:space:]"]+|./.github/actions/core|g' .github/actions/version-bumping/*/action.yml | |
| sed -i -E 's|sap/pull-request-semver-bumper/.github/actions/version-bumping/python@[^[:space:]"]+|./.github/actions/version-bumping/python|g' action.yml | |
| - name: Test Python Action (Dry Run) | |
| id: version_bump | |
| uses: ./ | |
| with: | |
| type: python | |
| token: ${{ secrets.GITHUB_TOKEN }} | |
| pyproject-file: "test-resources/pyproject.toml" | |
| dry-run: "true" | |
| - name: Verify Outputs | |
| run: | | |
| echo "Bumped: ${{ steps.version_bump.outputs.bumped }}" | |
| echo "New Version: ${{ steps.version_bump.outputs.new-version }}" | |
| echo "Bump Level: ${{ steps.version_bump.outputs.bumpLevel }}" | |
| echo "### Python Test New Version: ${{ steps.version_bump.outputs.new-version }}" >> $GITHUB_STEP_SUMMARY | |
| if [ -z "${{ steps.version_bump.outputs.bumped }}" ]; then | |
| echo "Error: 'bumped' output is empty" | |
| exit 1 | |
| fi | |
| test-npm: | |
| needs: [build-test-core, commit-dist] | |
| if: >- | |
| always() && | |
| needs.build-test-core.result == 'success' && | |
| (needs.commit-dist.result == 'success' || needs.commit-dist.result == 'skipped') | |
| runs-on: [ubuntu-latest] | |
| permissions: | |
| contents: read | |
| pull-requests: read | |
| steps: | |
| - uses: actions/checkout@v6 | |
| with: | |
| persist-credentials: false | |
| - name: Patch composite actions for E2E | |
| run: | | |
| sed -i -E 's|sap/pull-request-semver-bumper/.github/actions/core@[^[:space:]"]+|./.github/actions/core|g' .github/actions/version-bumping/*/action.yml | |
| sed -i -E 's|sap/pull-request-semver-bumper/.github/actions/version-bumping/npm@[^[:space:]"]+|./.github/actions/version-bumping/npm|g' action.yml | |
| - name: Test NPM Action (Dry Run) | |
| id: version_bump | |
| uses: ./ | |
| with: | |
| type: npm | |
| token: ${{ secrets.GITHUB_TOKEN }} | |
| package-json-file: "test-resources/package.json" | |
| dry-run: "true" | |
| - name: Verify Outputs | |
| run: | | |
| echo "Bumped: ${{ steps.version_bump.outputs.bumped }}" | |
| echo "New Version: ${{ steps.version_bump.outputs.new-version }}" | |
| echo "Bump Level: ${{ steps.version_bump.outputs.bumpLevel }}" | |
| echo "### NPM Test New Version: ${{ steps.version_bump.outputs.new-version }}" >> $GITHUB_STEP_SUMMARY | |
| if [ -z "${{ steps.version_bump.outputs.bumped }}" ]; then | |
| echo "Error: 'bumped' output is empty" | |
| exit 1 | |
| fi | |
| test-maven: | |
| needs: [build-test-core, commit-dist] | |
| if: >- | |
| always() && | |
| needs.build-test-core.result == 'success' && | |
| (needs.commit-dist.result == 'success' || needs.commit-dist.result == 'skipped') | |
| runs-on: [ubuntu-latest] | |
| permissions: | |
| contents: read | |
| pull-requests: read | |
| steps: | |
| - uses: actions/checkout@v6 | |
| with: | |
| persist-credentials: false | |
| - name: Patch composite actions for E2E | |
| run: | | |
| sed -i -E 's|sap/pull-request-semver-bumper/.github/actions/core@[^[:space:]"]+|./.github/actions/core|g' .github/actions/version-bumping/*/action.yml | |
| sed -i -E 's|sap/pull-request-semver-bumper/.github/actions/version-bumping/maven@[^[:space:]"]+|./.github/actions/version-bumping/maven|g' action.yml | |
| - name: Test Maven Action (Dry Run) | |
| id: version_bump | |
| uses: ./ | |
| with: | |
| type: maven | |
| token: ${{ secrets.GITHUB_TOKEN }} | |
| dry-run: "true" | |
| pom-file: "test-resources/pom.xml" | |
| # removed actual settings.xml to avoid fetch setting in this simple test | |
| bump-command: "mvn org.codehaus.mojo:versions-maven-plugin:set -DnewVersion=@NEW_VERSION@" | |
| - name: Verify Outputs | |
| run: | | |
| echo "Bumped: ${{ steps.version_bump.outputs.bumped }}" | |
| echo "New Version: ${{ steps.version_bump.outputs.new-version }}" | |
| echo "Bump Level: ${{ steps.version_bump.outputs.bumpLevel }}" | |
| echo "### Maven Test New Version: ${{ steps.version_bump.outputs.new-version }}" >> $GITHUB_STEP_SUMMARY | |
| if [ -z "${{ steps.version_bump.outputs.bumped }}" ]; then | |
| echo "Error: 'bumped' output is empty" | |
| exit 1 | |
| fi | |
| test-version-file: | |
| needs: [build-test-core, commit-dist] | |
| if: >- | |
| always() && | |
| needs.build-test-core.result == 'success' && | |
| (needs.commit-dist.result == 'success' || needs.commit-dist.result == 'skipped') | |
| runs-on: [ubuntu-latest] | |
| permissions: | |
| contents: read | |
| pull-requests: read | |
| steps: | |
| - uses: actions/checkout@v6 | |
| with: | |
| persist-credentials: false | |
| - name: Patch composite actions for E2E | |
| run: | | |
| sed -i -E 's|sap/pull-request-semver-bumper/.github/actions/core@[^[:space:]"]+|./.github/actions/core|g' .github/actions/version-bumping/*/action.yml | |
| sed -i -E 's|sap/pull-request-semver-bumper/.github/actions/version-bumping/version-file@[^[:space:]"]+|./.github/actions/version-bumping/version-file|g' action.yml | |
| - name: Test Version File Action (Dry Run) | |
| id: version_bump | |
| uses: ./ | |
| with: | |
| type: version-file | |
| token: ${{ secrets.GITHUB_TOKEN }} | |
| version-file: "test-resources/VERSION" | |
| dry-run: "true" | |
| - name: Verify Outputs | |
| run: | | |
| echo "Bumped: ${{ steps.version_bump.outputs.bumped }}" | |
| echo "New Version: ${{ steps.version_bump.outputs.new-version }}" | |
| echo "Bump Level: ${{ steps.version_bump.outputs.bumpLevel }}" | |
| echo "### Version File Test New Version: ${{ steps.version_bump.outputs.new-version }}" >> $GITHUB_STEP_SUMMARY | |
| if [ -z "${{ steps.version_bump.outputs.bumped }}" ]; then | |
| echo "Error: 'bumped' output is empty" | |
| exit 1 | |
| fi | |
| all-tests-passed: | |
| if: always() | |
| needs: [build-test-core, commit-dist, test-python, test-npm, test-maven, test-version-file] | |
| runs-on: ubuntu-latest | |
| permissions: | |
| statuses: write | |
| steps: | |
| - name: Determine Status | |
| id: status | |
| run: | | |
| if [[ ${{ contains(needs.*.result, 'failure') }} == 'true' || ${{ contains(needs.*.result, 'cancelled') }} == 'true' ]]; then | |
| echo "status=failure" >> $GITHUB_OUTPUT | |
| else | |
| echo "status=success" >> $GITHUB_OUTPUT | |
| fi | |
| - name: Set Commit Status | |
| uses: myrotvorets/set-commit-status-action@master | |
| with: | |
| token: ${{ secrets.GITHUB_TOKEN }} | |
| status: ${{ steps.status.outputs.status }} | |
| sha: ${{ needs.commit-dist.outputs.committed_sha || github.event.pull_request.head.sha }} | |
| context: "all-tests-passed" | |
| description: "Aggregation of all tests" | |
| - name: Fail if needed | |
| if: steps.status.outputs.status == 'failure' | |
| run: exit 1 |