Skip to content

Commit 8c791bc

Browse files
Run claimed PRs CI as a maintainer so it they access secrets (#1984)
## Summary - dispatch `ci.yml` after an external contributor PR is claimed so the mirrored internal PR can run secret-backed CI without per-maintainer tokens - add a PR-context loader to `ci.yml` so dispatched runs can recover labels, PR number, and internal-head gating from the GitHub API - keep the existing mirror/refresh flow intact while returning the owned PR number from the claim finalization step ## Why GitHub attributes PR authorship to the credential that creates the PR. Because we cannot store maintainer PATs in repo secrets, the claim workflow cannot safely recreate mirrored PRs as the approving maintainer. The viable low-churn path is to keep the mirrored PR bot-created, then trigger trusted CI on the mirrored branch via `workflow_dispatch`, which can be started from `GITHUB_TOKEN` and still runs with repository secrets. ## Impact - claimed PRs get a trusted `ci.yml` run on their head SHA after claim succeeds - the CI workflow now works for both `pull_request` and dispatched claimed-PR runs - e2e jobs still stay gated to internal-head PRs, but that decision is now derived from loaded PR context rather than only the event payload ## Validation - `pnpm exec prettier --check .github/workflows/ci.yml .github/workflows/external-contributor-pr.yml` - `git diff --check -- .github/workflows/ci.yml .github/workflows/external-contributor-pr.yml` <!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Run trusted CI for claimed external PRs by dispatching `ci.yml` on the mirrored branch. This keeps the mirror flow intact and enables secret-backed CI without maintainer tokens. - **New Features** - `external-contributor-pr.yml` dispatches `ci.yml` after a successful claim using the mirrored branch ref, passing the owned PR number; exposes `claimed` and `owned-pr-number` outputs and sets `actions: write`. - `ci.yml` adds `workflow_dispatch` with a `pull_request_number` input and a `load-pr-context` job to read labels, PR number, and internal-head; this context drives eval selection, e2e gating, and summary comments; adds `pull-requests: read`. <sup>Written for commit 0b38370. Summary will update on new commits. <a href="https://cubic.dev/pr/browserbase/stagehand/pull/1984">Review in cubic</a></sup> <!-- End of auto-generated description by cubic. --> --------- Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
1 parent e471d2e commit 8c791bc

2 files changed

Lines changed: 98 additions & 17 deletions

File tree

.github/workflows/ci.yml

Lines changed: 74 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,17 @@ on:
99
- unlabeled
1010
paths-ignore:
1111
- "packages/docs/**"
12+
workflow_dispatch:
13+
inputs:
14+
pull_request_number:
15+
description: Internal mirrored pull request number
16+
required: true
17+
type: string
1218

1319
permissions:
1420
contents: read
1521
actions: write
22+
pull-requests: read
1623

1724
env:
1825
BROWSERBASE_FLOW_LOGS: "1"
@@ -107,8 +114,60 @@ jobs:
107114
- 'examples/**'
108115
- '!packages/**/*.md'
109116
117+
load-pr-context:
118+
runs-on: ubuntu-latest
119+
outputs:
120+
pull_request_number: ${{ steps.load.outputs.pull_request_number }}
121+
is_internal_head: ${{ steps.load.outputs.is_internal_head }}
122+
skip_evals: ${{ steps.load.outputs.skip_evals }}
123+
skip_regression_evals: ${{ steps.load.outputs.skip_regression_evals }}
124+
label_combination: ${{ steps.load.outputs.label_combination }}
125+
label_extract: ${{ steps.load.outputs.label_extract }}
126+
label_act: ${{ steps.load.outputs.label_act }}
127+
label_observe: ${{ steps.load.outputs.label_observe }}
128+
label_targeted_extract: ${{ steps.load.outputs.label_targeted_extract }}
129+
label_agent: ${{ steps.load.outputs.label_agent }}
130+
steps:
131+
- id: load
132+
uses: actions/github-script@v7
133+
with:
134+
github-token: ${{ secrets.GITHUB_TOKEN }}
135+
script: |
136+
const repoFullName = `${context.repo.owner}/${context.repo.repo}`;
137+
let pr = context.payload.pull_request;
138+
139+
if (!pr) {
140+
const prNumber = Number('${{ github.event.inputs.pull_request_number }}');
141+
if (!prNumber) {
142+
throw new Error('workflow_dispatch requires pull_request_number input');
143+
}
144+
145+
const { data } = await github.rest.pulls.get({
146+
owner: context.repo.owner,
147+
repo: context.repo.repo,
148+
pull_number: prNumber,
149+
});
150+
pr = data;
151+
}
152+
153+
const labelNames = (pr.labels || [])
154+
.map((label) => typeof label === 'string' ? label : label.name)
155+
.filter(Boolean);
156+
const hasLabel = (name) => labelNames.includes(name);
157+
158+
core.setOutput('pull_request_number', String(pr.number));
159+
core.setOutput('is_internal_head', pr.head.repo?.full_name === repoFullName ? 'true' : 'false');
160+
core.setOutput('skip_evals', hasLabel('skip-evals') ? 'true' : 'false');
161+
core.setOutput('skip_regression_evals', hasLabel('skip-regression-evals') ? 'true' : 'false');
162+
core.setOutput('label_combination', hasLabel('combination') ? 'true' : 'false');
163+
core.setOutput('label_extract', hasLabel('extract') ? 'true' : 'false');
164+
core.setOutput('label_act', hasLabel('act') ? 'true' : 'false');
165+
core.setOutput('label_observe', hasLabel('observe') ? 'true' : 'false');
166+
core.setOutput('label_targeted_extract', hasLabel('targeted-extract') ? 'true' : 'false');
167+
core.setOutput('label_agent', hasLabel('agent') ? 'true' : 'false');
168+
110169
determine-evals:
111-
needs: [determine-changes]
170+
needs: [determine-changes, load-pr-context]
112171
runs-on: ubuntu-latest
113172
outputs:
114173
skip-all-evals: ${{ steps.check-labels.outputs.skip-all-evals }}
@@ -137,7 +196,7 @@ jobs:
137196
}
138197
139198
# Check if skip-evals label is present
140-
if [[ "${{ contains(github.event.pull_request.labels.*.name, 'skip-evals') }}" == "true" ]]; then
199+
if [[ "${{ needs.load-pr-context.outputs.skip_evals }}" == "true" ]]; then
141200
echo "skip-evals label found - skipping all evals"
142201
echo "skip-all-evals=true" >> $GITHUB_OUTPUT
143202
emit_categories
@@ -153,7 +212,7 @@ jobs:
153212
fi
154213
155214
# Check for skip-regression-evals label
156-
if [[ "${{ contains(github.event.pull_request.labels.*.name, 'skip-regression-evals') }}" == "true" ]]; then
215+
if [[ "${{ needs.load-pr-context.outputs.skip_regression_evals }}" == "true" ]]; then
157216
echo "skip-regression-evals label found - regression evals will be skipped"
158217
else
159218
echo "Regression evals will run by default"
@@ -162,22 +221,22 @@ jobs:
162221
163222
# Check for specific labels
164223
echo "skip-all-evals=false" >> $GITHUB_OUTPUT
165-
if [[ "${{ contains(github.event.pull_request.labels.*.name, 'combination') }}" == "true" ]]; then
224+
if [[ "${{ needs.load-pr-context.outputs.label_combination }}" == "true" ]]; then
166225
add_category "combination"
167226
fi
168-
if [[ "${{ contains(github.event.pull_request.labels.*.name, 'extract') }}" == "true" ]]; then
227+
if [[ "${{ needs.load-pr-context.outputs.label_extract }}" == "true" ]]; then
169228
add_category "extract"
170229
fi
171-
if [[ "${{ contains(github.event.pull_request.labels.*.name, 'act') }}" == "true" ]]; then
230+
if [[ "${{ needs.load-pr-context.outputs.label_act }}" == "true" ]]; then
172231
add_category "act"
173232
fi
174-
if [[ "${{ contains(github.event.pull_request.labels.*.name, 'observe') }}" == "true" ]]; then
233+
if [[ "${{ needs.load-pr-context.outputs.label_observe }}" == "true" ]]; then
175234
add_category "observe"
176235
fi
177-
if [[ "${{ contains(github.event.pull_request.labels.*.name, 'targeted-extract') }}" == "true" ]]; then
236+
if [[ "${{ needs.load-pr-context.outputs.label_targeted_extract }}" == "true" ]]; then
178237
add_category "targeted_extract"
179238
fi
180-
if [[ "${{ contains(github.event.pull_request.labels.*.name, 'agent') }}" == "true" ]]; then
239+
if [[ "${{ needs.load-pr-context.outputs.label_agent }}" == "true" ]]; then
181240
add_category "agent"
182241
fi
183242
emit_categories
@@ -510,12 +569,12 @@ jobs:
510569
511570
run-e2e-local-tests:
512571
name: e2e/local/${{ matrix.test.name }}
513-
needs: [run-build, discover-e2e-tests]
572+
needs: [run-build, discover-e2e-tests, load-pr-context]
514573
runs-on: ubuntu-latest
515574
timeout-minutes: 50
516575
if: >
517576
needs.discover-e2e-tests.outputs.has-e2e-tests == 'true' &&
518-
github.event.pull_request.head.repo.full_name == github.repository
577+
needs.load-pr-context.outputs.is_internal_head == 'true'
519578
env:
520579
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
521580
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
@@ -570,12 +629,12 @@ jobs:
570629

571630
run-e2e-bb-tests:
572631
name: e2e/bb/${{ matrix.test.name }}
573-
needs: [run-build, discover-e2e-tests]
632+
needs: [run-build, discover-e2e-tests, load-pr-context]
574633
runs-on: ubuntu-latest
575634
timeout-minutes: 50
576635
if: >
577636
needs.discover-e2e-tests.outputs.has-e2e-tests == 'true' &&
578-
github.event.pull_request.head.repo.full_name == github.repository
637+
needs.load-pr-context.outputs.is_internal_head == 'true'
579638
env:
580639
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
581640
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
@@ -732,6 +791,7 @@ jobs:
732791
- run-e2e-bb-tests
733792
- run-evals
734793
- server-integration-tests
794+
- load-pr-context
735795
# if: always()
736796
if: false
737797
steps:
@@ -844,7 +904,7 @@ jobs:
844904
env:
845905
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
846906
RUN_ID: ${{ github.run_id }}
847-
PULL_NUMBER: ${{ github.event.pull_request.number }}
907+
PULL_NUMBER: ${{ needs.load-pr-context.outputs.pull_request_number }}
848908
TESTS_FAILED: ${{ steps.coverage-status.outputs.tests_failed }}
849909
TOTAL_COVERAGE: ${{ steps.coverage-status.outputs.total_coverage }}
850910
run: |

.github/workflows/external-contributor-pr.yml

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ on:
1414
- completed
1515

1616
permissions:
17-
actions: read
17+
actions: write
1818
contents: write
1919
pull-requests: write
2020
issues: write
@@ -297,7 +297,7 @@ env:
297297
issue_number: prNumber,
298298
body: `The latest approval by @${claimer} could not refresh the mirrored PR automatically (${refreshReason || 'unknown reason'}). The external PR stays open, and the mirrored PR should be updated manually before work continues.`,
299299
});
300-
return;
300+
return { claimed: false, ownedPrNumber: existingNumber || '' };
301301
}
302302
303303
let ownedPr;
@@ -358,6 +358,8 @@ env:
358358
if (externalPr.state !== 'closed') {
359359
await github.rest.pulls.update({ owner: context.repo.owner, repo: context.repo.repo, pull_number: prNumber, state: 'closed' });
360360
}
361+
362+
return { claimed: true, ownedPrNumber: String(ownedPr.number) };
361363
}
362364
363365
async function syncOwnedPr({ github, context }) {
@@ -562,13 +564,14 @@ jobs:
562564
refresh_reason="rebase-conflict"
563565
564566
- name: Finalize approved claim
567+
id: finalize-claim
565568
if: always() && steps.prepare-claim.outputs.should-claim == 'true'
566569
uses: actions/github-script@v7
567570
with:
568571
github-token: ${{ secrets.GITHUB_TOKEN }}
569572
script: |
570573
const lib = eval(process.env.ECPR_LIB);
571-
await lib.finalizeClaim({
574+
const result = await lib.finalizeClaim({
572575
github,
573576
context,
574577
input: {
@@ -584,6 +587,24 @@ jobs:
584587
refreshReason: ${{ toJson(steps.refresh-branch.outputs.reason) }},
585588
},
586589
});
590+
core.setOutput('claimed', result?.claimed ? 'true' : 'false');
591+
core.setOutput('owned-pr-number', result?.ownedPrNumber || '');
592+
593+
- name: Trigger claimed PR CI
594+
if: steps.finalize-claim.outputs.claimed == 'true'
595+
uses: actions/github-script@v7
596+
with:
597+
github-token: ${{ secrets.GITHUB_TOKEN }}
598+
script: |
599+
await github.rest.actions.createWorkflowDispatch({
600+
owner: context.repo.owner,
601+
repo: context.repo.repo,
602+
workflow_id: 'ci.yml',
603+
ref: ${{ toJson(steps.prepare-claim.outputs.branch) }},
604+
inputs: {
605+
pull_request_number: ${{ toJson(steps.finalize-claim.outputs.owned-pr-number) }},
606+
},
607+
});
587608
588609
sync-owned-pr:
589610
if: github.event_name == 'pull_request_target' && github.event.pull_request.head.repo.full_name == github.repository && (github.event.action == 'closed' || github.event.action == 'reopened')

0 commit comments

Comments
 (0)