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 new file mode 100644 index 000000000000..3fce5447b7ba --- /dev/null +++ b/.github/workflows/dependabot-dedupe-push.yml @@ -0,0 +1,134 @@ +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. +# +# 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: + 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 new file mode 100644 index 000000000000..1232a07c94da --- /dev/null +++ b/.github/workflows/dependabot-dedupe.yml @@ -0,0 +1,60 @@ +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 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: + 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 + # 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 + steps: + - name: Check out PR branch + uses: actions/checkout@v7 + with: + ref: ${{ github.event.pull_request.head.sha }} + 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' + + - name: Dedupe lockfile + id: dedupe + run: | + 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: pnpm-lock.yaml + retention-days: 1