.github/workflows/block_fork_main_prs.yml YAML 147 lines View on github.com → Search inside
1# Block PRs whose head ref is `main` (or `master`) from a fork. This topology2# (`<fork>:master -> langchain-ai/langchain:master`) lets contributors click3# "Update branch" on the PR, producing a `Merge branch 'master' into master`4# commit on the source side that  under admin merge override  can land5# directly on `master` as a 2-parent merge commit, bypassing the repo's6# squash-only policy and polluting the changelog.7#8# `pull_request_target` is required so the job receives a token scoped to9# write PR labels/comments on fork PRs (the standard `pull_request` token is10# read-only for forks). This also means the job MUST NOT check out PR code 11# see the inline warning in the trigger block below.12#13# Maintainer bypass: add the `bypass-fork-main-check` label to the PR.1415name: Block fork main PRs1617on:18  pull_request_target:19    # NEVER CHECK OUT UNTRUSTED CODE FROM A PR's HEAD IN A pull_request_target JOB.20    # Doing so would allow attackers to execute arbitrary code in the context of your repository.21    types: [opened, reopened, synchronize, labeled, unlabeled]2223permissions:24  contents: read2526jobs:27  guard:28    if: >-29      github.repository_owner == 'langchain-ai' &&30      github.event.pull_request.head.repo.fork == true &&31      (github.event.pull_request.head.ref == 'main' || github.event.pull_request.head.ref == 'master') &&32      !contains(github.event.pull_request.labels.*.name, 'bypass-fork-main-check')33    runs-on: ubuntu-latest34    permissions:35      pull-requests: write36    steps:37      - name: Close PR and post guidance38        uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.039        with:40          script: |41            const { owner, repo } = context.repo;42            const prNumber = context.payload.pull_request.number;43            const headRef = context.payload.pull_request.head.ref;44            const marker = '<!-- block-fork-main -->';4546            // Ensure the warning label exists and apply it47            const labelName = 'fork-main-head';48            try {49              await github.rest.issues.getLabel({ owner, repo, name: labelName });50            } catch (e) {51              if (e.status !== 404) {52                throw new Error(`getLabel(${labelName}) failed: ${e.message}`);53              }54              try {55                await github.rest.issues.createLabel({56                  owner, repo, name: labelName, color: 'b76e79',57                });58              } catch (createErr) {59                // A 422 with code `already_exists` means a race created the60                // label between getLabel and createLabel — safe to ignore.61                // Any other 422 (bad color, name too long) indicates a real62                // bug introduced by editing this step, so rethrow.63                const alreadyExists =64                  createErr.status === 422 &&65                  Array.isArray(createErr.errors) &&66                  createErr.errors.some(e => e.code === 'already_exists');67                if (!alreadyExists) throw createErr;68              }69            }70            await github.rest.issues.addLabels({71              owner, repo, issue_number: prNumber, labels: [labelName],72            });7374            const defaultBranch = context.payload.repository.default_branch;75            const lines = [76              marker,77              `**This PR has been automatically closed** because its head branch is \`${headRef}\` on a fork.`,78              '',79              'PRs opened from a fork\'s `main` (or `master`) branch can produce a `Merge branch \'main\' into main` commit on the source side. Under an admin merge override that commit can land directly on this repo\'s default branch, bypassing the squash-only policy and polluting the changelog.',80              '',81              'To fix:',82              `1. Sync your fork's \`${defaultBranch}\` first (\`git fetch upstream && git switch ${defaultBranch} && git merge --ff-only upstream/${defaultBranch}\`)`,83              '2. Create a feature branch: `git switch -c feat/my-change`',84              '3. Push it: `git push -u origin feat/my-change`',85              `4. Open a new PR from \`feat/my-change\` → \`langchain-ai/langchain:${defaultBranch}\``,86              '',87              '*Maintainers: add the `bypass-fork-main-check` label to override.*',88            ];89            const body = lines.join('\n');9091            // Dedup: update existing marker comment instead of stacking.92            const comments = await github.paginate(93              github.rest.issues.listComments,94              { owner, repo, issue_number: prNumber, per_page: 100 },95            );96            const existing = comments.find(c => c.body && c.body.includes(marker));9798            if (!existing) {99              await github.rest.issues.createComment({100                owner, repo, issue_number: prNumber, body,101              });102            } else if (existing.body !== body) {103              await github.rest.issues.updateComment({104                owner, repo, comment_id: existing.id, body,105              });106            }107108            if (context.payload.pull_request.state === 'open') {109              await github.rest.pulls.update({110                owner, repo, pull_number: prNumber, state: 'closed',111              });112            }113114            // Cancel still-queued/in-progress checks on this PR head.115            // Best-effort: new runs may still queue after this loop (e.g., other116            // pull_request triggers fanning out). The PR is already closed above,117            // so leftover runs are wasted compute, not a correctness issue.118            // We track the cancel ratio so a wholesale failure (token-scope119            // regression making EVERY cancel return 403) is surfaced rather120            // than silently producing N warnings + green job.121            const headSha = context.payload.pull_request.head.sha;122            let attempted = 0;123            let cancelled = 0;124            for (const status of ['in_progress', 'queued']) {125              const runs = await github.paginate(126                github.rest.actions.listWorkflowRunsForRepo,127                { owner, repo, head_sha: headSha, status, per_page: 100 },128              );129              for (const run of runs) {130                if (run.id === context.runId) continue;131                attempted++;132                try {133                  await github.rest.actions.cancelWorkflowRun({134                    owner, repo, run_id: run.id,135                  });136                  cancelled++;137                } catch (err) {138                  core.warning(`Could not cancel run ${run.id}: ${err.message}`);139                }140              }141            }142            if (attempted > 0 && cancelled === 0) {143              core.warning(`Attempted to cancel ${attempted} run(s) on head ${headSha} but none succeeded — check token scope.`);144            }145146            core.setFailed(`PR head ref is \`${headRef}\` on a fork — open from a feature branch instead.`);

Findings

✓ No findings reported for this file.

Get this view in your editor

Same data, no extra tab — call code_get_file + code_get_findings over MCP from Claude/Cursor/Copilot.