From dcfbbe68e5c142917d0434b94d63f03e4de3d8f1 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:52:04 +0000 Subject: [PATCH 1/4] ci(deps): Dedupe lockfile on Dependabot PRs Co-Authored-By: Charly Gomez --- .github/workflows/dependabot-dedupe.yml | 73 +++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 .github/workflows/dependabot-dedupe.yml diff --git a/.github/workflows/dependabot-dedupe.yml b/.github/workflows/dependabot-dedupe.yml new file mode 100644 index 000000000000..30825fdbb5a6 --- /dev/null +++ b/.github/workflows/dependabot-dedupe.yml @@ -0,0 +1,73 @@ +name: 'Dependabot: Dedupe lockfile' + +# Dependabot updates pnpm-lock.yaml without deduping it, so `Check lockfile` (`pnpm dedupe --check`) +# fails on most of its PRs. This pushes a `pnpm dedupe` commit onto the PR branch instead. + +on: + pull_request_target: + types: [opened, synchronize, reopened] + branches: + - develop + paths: + - 'pnpm-lock.yaml' + +permissions: + contents: read + +concurrency: + group: dependabot-dedupe-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + dedupe: + name: Dedupe lockfile + if: | + github.event.pull_request.user.login == 'dependabot[bot]' + && github.event.pull_request.head.repo.full_name == github.repository + runs-on: namespace-profile-sentry-javascript + # `pnpm dedupe` fetches metadata for every package from npm. + timeout-minutes: 20 + steps: + - name: Check out PR branch + uses: actions/checkout@v7 + with: + ref: ${{ github.event.pull_request.head.ref }} + persist-credentials: false + + - name: Install pnpm + uses: ./.github/actions/install-pnpm + + - name: Set up Node + uses: actions/setup-node@v7 + with: + node-version-file: 'package.json' + + # This job runs with access to secrets, so install scripts from the updated dependencies must not run. + - name: Dedupe lockfile + id: diff + run: | + pnpm dedupe --ignore-scripts + git diff --quiet pnpm-lock.yaml || echo "changed=true" >> "$GITHUB_OUTPUT" + + # An app token is needed so the push triggers CI again; pushes made with GITHUB_TOKEN do not. + - name: Generate GitHub App token + if: steps.diff.outputs.changed == 'true' + id: app-token + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ vars.GITFLOW_APP_ID }} + private-key: ${{ secrets.GITFLOW_APP_PRIVATE_KEY }} + + # `[dependabot skip]` lets Dependabot keep rebasing the PR over this commit. + - name: Commit and push + if: steps.diff.outputs.changed == 'true' + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + APP_SLUG: ${{ steps.app-token.outputs.app-slug }} + HEAD_REF: ${{ github.event.pull_request.head.ref }} + run: | + git config user.name "${APP_SLUG}[bot]" + git config user.email "${APP_SLUG}[bot]@users.noreply.github.com" + git add pnpm-lock.yaml + git commit -m "chore(deps): Dedupe pnpm-lock.yaml [dependabot skip]" + git push "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "HEAD:refs/heads/${HEAD_REF}" From 9483863e699e762fd8b613b0f89fdf3b971c1e3a Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:59:45 +0000 Subject: [PATCH 2/4] ci(deps): Keep app token away from PR-controlled code in dedupe workflow --- .github/workflows/dependabot-dedupe.yml | 52 ++++++++++++++++++++++--- 1 file changed, 46 insertions(+), 6 deletions(-) diff --git a/.github/workflows/dependabot-dedupe.yml b/.github/workflows/dependabot-dedupe.yml index 30825fdbb5a6..1350b11387ff 100644 --- a/.github/workflows/dependabot-dedupe.yml +++ b/.github/workflows/dependabot-dedupe.yml @@ -2,6 +2,9 @@ name: 'Dependabot: Dedupe lockfile' # Dependabot updates pnpm-lock.yaml without deduping it, so `Check lockfile` (`pnpm dedupe --check`) # fails on most of its PRs. This pushes a `pnpm dedupe` commit onto the PR branch instead. +# +# The PR branch is untrusted: anyone with write access can push to it. So the job that runs pnpm on it +# has no secrets, and the job that holds the app token runs no code from the PR, only git on a lockfile. on: pull_request_target: @@ -18,6 +21,9 @@ concurrency: group: dependabot-dedupe-${{ github.event.pull_request.number }} cancel-in-progress: true +env: + PR_DIR: pr + jobs: dedupe: name: Dedupe lockfile @@ -27,11 +33,20 @@ jobs: runs-on: namespace-profile-sentry-javascript # `pnpm dedupe` fetches metadata for every package from npm. timeout-minutes: 20 + outputs: + changed: ${{ steps.dedupe.outputs.changed }} steps: + # Actions and tool versions come from the base branch, not from the PR. + - name: Check out base branch + uses: actions/checkout@v7 + with: + persist-credentials: false + - name: Check out PR branch uses: actions/checkout@v7 with: - ref: ${{ github.event.pull_request.head.ref }} + ref: ${{ github.event.pull_request.head.sha }} + path: ${{ env.PR_DIR }} persist-credentials: false - name: Install pnpm @@ -42,16 +57,41 @@ jobs: with: node-version-file: 'package.json' - # This job runs with access to secrets, so install scripts from the updated dependencies must not run. - name: Dedupe lockfile - id: diff + id: dedupe + working-directory: ${{ env.PR_DIR }} run: | - pnpm dedupe --ignore-scripts + pnpm dedupe --ignore-scripts --ignore-pnpmfile git diff --quiet pnpm-lock.yaml || echo "changed=true" >> "$GITHUB_OUTPUT" + - name: Upload deduped lockfile + if: steps.dedupe.outputs.changed == 'true' + uses: actions/upload-artifact@v7 + with: + name: pnpm-lock + path: ${{ env.PR_DIR }}/pnpm-lock.yaml + retention-days: 1 + + push: + name: Push deduped lockfile + needs: dedupe + if: needs.dedupe.outputs.changed == 'true' + runs-on: namespace-profile-sentry-javascript-small + timeout-minutes: 5 + steps: + - name: Check out PR branch + uses: actions/checkout@v7 + with: + ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false + + - name: Download deduped lockfile + uses: actions/download-artifact@v8 + with: + name: pnpm-lock + # An app token is needed so the push triggers CI again; pushes made with GITHUB_TOKEN do not. - name: Generate GitHub App token - if: steps.diff.outputs.changed == 'true' id: app-token uses: actions/create-github-app-token@v3 with: @@ -59,8 +99,8 @@ jobs: private-key: ${{ secrets.GITFLOW_APP_PRIVATE_KEY }} # `[dependabot skip]` lets Dependabot keep rebasing the PR over this commit. + # Pushing the PR's head SHA without force fails if the branch moved in the meantime. - name: Commit and push - if: steps.diff.outputs.changed == 'true' env: GH_TOKEN: ${{ steps.app-token.outputs.token }} APP_SLUG: ${{ steps.app-token.outputs.app-slug }} From 3f5050fa0ec4ed60d1f3317653d3a6bc422cdc59 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:09:58 +0000 Subject: [PATCH 3/4] ci(deps): Move lockfile push to an isolated workflow_run workflow Co-Authored-By: Charly Gomez --- .github/workflows/dependabot-dedupe-push.yml | 130 +++++++++++++++++++ .github/workflows/dependabot-dedupe.yml | 67 +--------- 2 files changed, 137 insertions(+), 60 deletions(-) create mode 100644 .github/workflows/dependabot-dedupe-push.yml diff --git a/.github/workflows/dependabot-dedupe-push.yml b/.github/workflows/dependabot-dedupe-push.yml new file mode 100644 index 000000000000..1003980b6c95 --- /dev/null +++ b/.github/workflows/dependabot-dedupe-push.yml @@ -0,0 +1,130 @@ +name: 'Dependabot: Push deduped lockfile' + +# Pushes the lockfile from `dependabot-dedupe.yml` to the Dependabot PR branch. +# +# This workflow holds the Gitflow app token, so it never runs code from the PR. Everything it +# receives is untrusted: anyone with write access can push to a Dependabot branch, and the run that +# produced the artifact used the PR's version of `dependabot-dedupe.yml`. So it looks the PR up +# through the API, accepts only a single regular `pnpm-lock.yaml` file and commits nothing else. + +on: + workflow_run: + workflows: ['Dependabot: Dedupe lockfile'] + types: [completed] + +permissions: + actions: read + contents: read + pull-requests: read + +concurrency: + group: dependabot-dedupe-push-${{ github.event.workflow_run.head_sha }} + cancel-in-progress: false + +jobs: + push: + name: Push deduped lockfile + if: | + github.event.workflow_run.conclusion == 'success' + && github.event.workflow_run.event == 'pull_request' + && github.event.workflow_run.head_repository.full_name == github.repository + runs-on: namespace-profile-sentry-javascript-small + timeout-minutes: 5 + env: + GH_TOKEN: ${{ github.token }} + RUN_ID: ${{ github.event.workflow_run.id }} + HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + steps: + - name: Find artifact and Dependabot PR + id: pr + run: | + artifacts=$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${RUN_ID}/artifacts" \ + --jq '[.artifacts[] | select(.name == "pnpm-lock")] | length') + if [ "$artifacts" != "1" ]; then + echo "No deduped lockfile to push." + exit 0 + fi + + head_ref=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${HEAD_SHA}/pulls" | + jq -r --arg repo "$GITHUB_REPOSITORY" --arg sha "$HEAD_SHA" ' + [.[] | select(.state == "open" + and .base.ref == "develop" + and .user.login == "dependabot[bot]" + and .head.repo.full_name == $repo + and .head.sha == $sha)] + | if length == 1 then .[0].head.ref else empty end') + if [[ ! "$head_ref" =~ ^dependabot/[A-Za-z0-9._/@+-]+$ ]]; then + echo "::error::No open Dependabot PR into develop has head commit ${HEAD_SHA}." + exit 1 + fi + echo "head_ref=${head_ref}" >> "$GITHUB_OUTPUT" + + - name: Download deduped lockfile + if: steps.pr.outputs.head_ref + uses: actions/download-artifact@v8 + with: + name: pnpm-lock + path: ${{ runner.temp }}/pnpm-lock + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ github.token }} + + - name: Check out PR branch + if: steps.pr.outputs.head_ref + uses: actions/checkout@v7 + with: + ref: ${{ github.event.workflow_run.head_sha }} + persist-credentials: false + + # Replace the file instead of writing through it, so a symlinked lockfile in the PR cannot redirect the write. + - name: Apply deduped lockfile + if: steps.pr.outputs.head_ref + id: apply + env: + LOCKFILE_DIR: ${{ runner.temp }}/pnpm-lock + run: | + if [ "$(find "$LOCKFILE_DIR" -mindepth 1 | wc -l)" != "1" ] || [ ! -f "$LOCKFILE_DIR/pnpm-lock.yaml" ] || [ -L "$LOCKFILE_DIR/pnpm-lock.yaml" ]; then + echo "::error::The artifact must contain exactly one regular file named pnpm-lock.yaml." + exit 1 + fi + if [ "$(git ls-files --stage -- pnpm-lock.yaml | cut -d' ' -f1)" != "100644" ]; then + echo "::error::pnpm-lock.yaml in the PR is not a regular file." + exit 1 + fi + rm pnpm-lock.yaml + cp "$LOCKFILE_DIR/pnpm-lock.yaml" pnpm-lock.yaml + + changes=$(git status --porcelain) + if [ -z "$changes" ]; then + echo "Lockfile is already deduped." + exit 0 + fi + if [ "$changes" != " M pnpm-lock.yaml" ]; then + echo "::error::Unexpected changes:" + echo "$changes" + exit 1 + fi + echo "changed=true" >> "$GITHUB_OUTPUT" + + # An app token is needed so the push triggers CI again; pushes made with GITHUB_TOKEN do not. + - name: Generate GitHub App token + if: steps.apply.outputs.changed == 'true' + id: app-token + uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + with: + client-id: ${{ vars.GITFLOW_APP_ID }} + private-key: ${{ secrets.GITFLOW_APP_PRIVATE_KEY }} + permission-contents: write + + # `[dependabot skip]` lets Dependabot keep rebasing the PR over this commit. + # The push is not forced, so it fails if the branch moved past HEAD_SHA in the meantime. + - name: Commit and push + if: steps.apply.outputs.changed == 'true' + env: + APP_TOKEN: ${{ steps.app-token.outputs.token }} + APP_SLUG: ${{ steps.app-token.outputs.app-slug }} + HEAD_REF: ${{ steps.pr.outputs.head_ref }} + run: | + git config user.name "${APP_SLUG}[bot]" + git config user.email "${APP_SLUG}[bot]@users.noreply.github.com" + git commit -m "chore(deps): Dedupe pnpm-lock.yaml [dependabot skip]" -- pnpm-lock.yaml + git push "https://x-access-token:${APP_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "HEAD:refs/heads/${HEAD_REF}" diff --git a/.github/workflows/dependabot-dedupe.yml b/.github/workflows/dependabot-dedupe.yml index 1350b11387ff..1232a07c94da 100644 --- a/.github/workflows/dependabot-dedupe.yml +++ b/.github/workflows/dependabot-dedupe.yml @@ -1,13 +1,12 @@ name: 'Dependabot: Dedupe lockfile' # Dependabot updates pnpm-lock.yaml without deduping it, so `Check lockfile` (`pnpm dedupe --check`) -# fails on most of its PRs. This pushes a `pnpm dedupe` commit onto the PR branch instead. -# -# The PR branch is untrusted: anyone with write access can push to it. So the job that runs pnpm on it -# has no secrets, and the job that holds the app token runs no code from the PR, only git on a lockfile. +# fails on most of its PRs. This job dedupes the lockfile and uploads it as an artifact. It runs on +# `pull_request`, so it has no secrets, a read-only token and PR-scoped caches. +# `dependabot-dedupe-push.yml` validates the result and pushes it to the PR branch. on: - pull_request_target: + pull_request: types: [opened, synchronize, reopened] branches: - develop @@ -21,32 +20,21 @@ concurrency: group: dependabot-dedupe-${{ github.event.pull_request.number }} cancel-in-progress: true -env: - PR_DIR: pr - jobs: dedupe: name: Dedupe lockfile if: | github.event.pull_request.user.login == 'dependabot[bot]' && github.event.pull_request.head.repo.full_name == github.repository - runs-on: namespace-profile-sentry-javascript + # PR code runs here, so keep its cache writes away from the cache used by develop, like the PR jobs in build.yml. + runs-on: namespace-profile-sentry-javascript;overrides.cache-tag=sentry-javascript-pr # `pnpm dedupe` fetches metadata for every package from npm. timeout-minutes: 20 - outputs: - changed: ${{ steps.dedupe.outputs.changed }} steps: - # Actions and tool versions come from the base branch, not from the PR. - - name: Check out base branch - uses: actions/checkout@v7 - with: - persist-credentials: false - - name: Check out PR branch uses: actions/checkout@v7 with: ref: ${{ github.event.pull_request.head.sha }} - path: ${{ env.PR_DIR }} persist-credentials: false - name: Install pnpm @@ -59,7 +47,6 @@ jobs: - name: Dedupe lockfile id: dedupe - working-directory: ${{ env.PR_DIR }} run: | pnpm dedupe --ignore-scripts --ignore-pnpmfile git diff --quiet pnpm-lock.yaml || echo "changed=true" >> "$GITHUB_OUTPUT" @@ -69,45 +56,5 @@ jobs: uses: actions/upload-artifact@v7 with: name: pnpm-lock - path: ${{ env.PR_DIR }}/pnpm-lock.yaml + path: pnpm-lock.yaml retention-days: 1 - - push: - name: Push deduped lockfile - needs: dedupe - if: needs.dedupe.outputs.changed == 'true' - runs-on: namespace-profile-sentry-javascript-small - timeout-minutes: 5 - steps: - - name: Check out PR branch - uses: actions/checkout@v7 - with: - ref: ${{ github.event.pull_request.head.sha }} - persist-credentials: false - - - name: Download deduped lockfile - uses: actions/download-artifact@v8 - with: - name: pnpm-lock - - # An app token is needed so the push triggers CI again; pushes made with GITHUB_TOKEN do not. - - name: Generate GitHub App token - id: app-token - uses: actions/create-github-app-token@v3 - with: - client-id: ${{ vars.GITFLOW_APP_ID }} - private-key: ${{ secrets.GITFLOW_APP_PRIVATE_KEY }} - - # `[dependabot skip]` lets Dependabot keep rebasing the PR over this commit. - # Pushing the PR's head SHA without force fails if the branch moved in the meantime. - - name: Commit and push - env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} - APP_SLUG: ${{ steps.app-token.outputs.app-slug }} - HEAD_REF: ${{ github.event.pull_request.head.ref }} - run: | - git config user.name "${APP_SLUG}[bot]" - git config user.email "${APP_SLUG}[bot]@users.noreply.github.com" - git add pnpm-lock.yaml - git commit -m "chore(deps): Dedupe pnpm-lock.yaml [dependabot skip]" - git push "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "HEAD:refs/heads/${HEAD_REF}" From 33420fa4b026262ec456c803f2b204877177d8ed Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Fri, 30 Oct 2026 09:52:36 +0100 Subject: [PATCH 4/4] ci: Gate optional E2E tests on the PR author, not the actor Once the Gitflow app pushes the deduped lockfile to a Dependabot branch, the next pull_request run has the app as github.actor. The optional E2E job keyed its Dependabot skip on the actor, so that run would dispatch optional-e2e.yml with the E2E and Cloudflare secrets against a dependency tree no human has reviewed. Check the PR author instead, which stays dependabot[bot] regardless of who pushed. Also document the real scope of the app token and this actor side effect in the push workflow header. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/build.yml | 7 +++++-- .github/workflows/dependabot-dedupe-push.yml | 4 ++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a546292a32ef..0d470f273e39 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1141,13 +1141,16 @@ jobs: # them. optional-e2e.yml reports the results in a PR comment. job_optional_e2e_tests: name: E2E Tests (optional) - # We only run E2E tests for non-fork PRs because the E2E tests require secrets to work and they can't be accessed from forks + # We only run E2E tests for non-fork PRs because the E2E tests require secrets to work and they can't be accessed from forks. + # Dependabot PRs are skipped by author, not by `github.actor`: once the Gitflow app pushes the deduped lockfile + # (dependabot-dedupe-push.yml), the actor is the app and the run would otherwise get the E2E secrets. # We need to add the `always()` check here because the previous step has this as well :( # See: https://github.com/actions/runner/issues/2205 if: always() && needs.job_get_metadata.outputs.is_release != 'true' && needs.job_build_tarballs.result == 'success' && needs.job_build.outputs.e2e-matrix-optional != '{"include":[]}' && (github.event_name != 'pull_request' || - github.event.pull_request.head.repo.full_name == github.repository) && github.actor != 'dependabot[bot]' + github.event.pull_request.head.repo.full_name == github.repository) && github.event.pull_request.user.login != + 'dependabot[bot]' needs: [job_get_metadata, job_build, job_build_tarballs] # This job only waits for optional-e2e.yml, which can take most of an hour. GitHub runners are free for public # repos and Namespace runners are paid per minute, so the wait runs here and the tests run on Namespace. diff --git a/.github/workflows/dependabot-dedupe-push.yml b/.github/workflows/dependabot-dedupe-push.yml index 1003980b6c95..3fce5447b7ba 100644 --- a/.github/workflows/dependabot-dedupe-push.yml +++ b/.github/workflows/dependabot-dedupe-push.yml @@ -6,6 +6,10 @@ name: 'Dependabot: Push deduped lockfile' # receives is untrusted: anyone with write access can push to a Dependabot branch, and the run that # produced the artifact used the PR's version of `dependabot-dedupe.yml`. So it looks the PR up # through the API, accepts only a single regular `pnpm-lock.yaml` file and commits nothing else. +# +# The app token is limited to `contents: write` on this repository, not to `dependabot/**` branches, so +# nothing from the PR may run before it is minted. Pushing as the app also changes `github.actor` on the +# PR's next CI run, so jobs that keep secrets away from Dependabot must check the PR author, not the actor. on: workflow_run: