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/5] 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/5] 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/5] 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/5] 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: From e6f041ef5d081f3ce79682c175efbd3035c97d10 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Fri, 30 Oct 2026 13:59:33 +0100 Subject: [PATCH 5/5] ci: Skip lockfile dedupe on pushes that are not from Dependabot The deduped commit pushed by dependabot-dedupe-push.yml triggers synchronize on the PR, which ran a second full pnpm dedupe (install plus resolution, up to 20 minutes on a paid runner) only to find nothing to change. Require Dependabot to be the actor as well as the author, so the app's own push and human pushes to the branch do not start another run. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/dependabot-dedupe.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/dependabot-dedupe.yml b/.github/workflows/dependabot-dedupe.yml index 1232a07c94da..05fcf2f42448 100644 --- a/.github/workflows/dependabot-dedupe.yml +++ b/.github/workflows/dependabot-dedupe.yml @@ -23,8 +23,11 @@ concurrency: jobs: dedupe: name: Dedupe lockfile + # Only run on pushes made by Dependabot itself. The deduped commit pushed by `dependabot-dedupe-push.yml` + # also triggers `synchronize`, and a second full `pnpm dedupe` on it would only confirm there is nothing left to do. if: | github.event.pull_request.user.login == 'dependabot[bot]' + && github.actor == 'dependabot[bot]' && github.event.pull_request.head.repo.full_name == github.repository # 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