Skip to content

Commit 0340d0c

Browse files
LukasParkeclaude
andcommitted
ci(port): open port PRs with a GitHub App so their checks actually count
Branch protection on main requires six status checks, and only pull_request-event runs satisfy them. GitHub does not trigger workflows from events created with the native GITHUB_TOKEN (recursion guard), so a PR opened with it gets no pull_request checks and can never become mergeable — the automated port PR would sit permanently stuck. The previous workaround dispatched ci.yaml at the PR branch. That does not work, and I measured it rather than assuming: on PR #24 the commit carried 14 check-runs while the PR's rollup showed 7. A workflow_dispatch run of the same workflow, on the same commit, succeeding, was entirely invisible to branch protection. (I also had to correct myself here. I first claimed dispatch runs never attach, citing runs on LukasParke/e2e-CI — but that PR was already merged and closed when they ran, so nothing would have shown regardless. Retested on an open PR: 7 checks before the dispatch, 7 after. Same conclusion, real evidence.) Also verified that dropping the PR requirement does not help. On a throwaway protected branch with required checks and no PR requirement, a direct push is rejected outright: remote: error: GH006: Protected branch update failed remote: - Required status check "types" is expected. Required checks gate pushes too, so there is no configuration of branch protection that lets GITHUB_TOKEN-created work through while keeping the gates. An App installation token is not recursion-guarded, so the PR it opens gets real checks. Preferred over a PAT: scoped to this repo, not tied to a personal account, revocable on its own. - New "Mint App token" step (actions/create-github-app-token@v1), gated on vars.PORT_BOT_APP_ID being set. - create-pull-request uses the App token when present, GITHUB_TOKEN otherwise. - The dispatch step is now a FALLBACK, running only when no App token was minted, and it emits a ::warning:: stating plainly that the PR will get no checks and cannot merge as-is. An unconfigured bot should degrade loudly rather than look healthy while producing permanently stuck PRs. Setup is documented in PORTING.md: App with Contents + Pull requests read/write (nothing else), installed on this repo, then PORT_BOT_APP_ID (variable) and PORT_BOT_PRIVATE_KEY (secret). Neither exists yet, so the fallback path is what runs until they are added. Verification: all three workflows parse · mint step gated correctly · PR token falls back as intended · verify.sh PASS (0 failures) · 114 passed. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent ce2b239 commit 0340d0c

2 files changed

Lines changed: 82 additions & 10 deletions

File tree

‎.github/workflows/upstreamer-port.yaml‎

Lines changed: 44 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -142,12 +142,40 @@ jobs:
142142
echo "passed=true" >> "$GITHUB_OUTPUT"
143143
fi
144144
145+
# Mint a GitHub App installation token so the PR is created by the App
146+
# rather than by the native GITHUB_TOKEN.
147+
#
148+
# Why this exists: GitHub does not trigger workflows from events created
149+
# with GITHUB_TOKEN (recursion guard), so a PR opened with it gets no
150+
# pull_request-event checks — and `main`'s required status checks are
151+
# satisfied ONLY by pull_request-event runs. Measured on PR #24: the commit
152+
# had 14 check-runs, the PR's rollup showed 7; the workflow_dispatch half was
153+
# invisible to branch protection. So the previous "dispatch ci.yaml
154+
# explicitly" workaround produced green runs that could never satisfy the
155+
# required checks, leaving an automated port PR permanently unmergeable.
156+
#
157+
# An App installation token is not recursion-guarded, so the PR gets real
158+
# pull_request checks. Preferred over a PAT: scoped to this repo, not tied to
159+
# a person's account, and independently revocable.
160+
#
161+
# Optional by design — see the fallback below.
162+
- name: Mint App token
163+
id: app-token
164+
if: steps.diff.outputs.changed == 'true' && vars.PORT_BOT_APP_ID != ''
165+
uses: actions/create-github-app-token@v1
166+
with:
167+
app-id: ${{ vars.PORT_BOT_APP_ID }}
168+
private-key: ${{ secrets.PORT_BOT_PRIVATE_KEY }}
169+
145170
- name: Open PR
146171
id: open-pr
147172
if: steps.diff.outputs.changed == 'true'
148173
uses: peter-evans/create-pull-request@v6
149174
with:
150-
token: ${{ secrets.GITHUB_TOKEN }}
175+
# App token when configured; GITHUB_TOKEN otherwise. With the fallback
176+
# the PR still opens, but its checks will not attach — the guard step
177+
# below says so loudly rather than leaving a silently stuck PR.
178+
token: ${{ steps.app-token.outputs.token || secrets.GITHUB_TOKEN }}
151179
branch: upstreamer/sync
152180
delete-branch: true
153181
title: >-
@@ -172,16 +200,23 @@ jobs:
172200
`.upstreamer/state.yaml` did not advance, the eval did not pass and this
173201
PR must not be merged as-is.
174202
175-
# Events created with the native GITHUB_TOKEN deliberately do not trigger
176-
# other workflows (GitHub's recursion guard), so the PR opened above gets
177-
# no CI checks on its own. workflow_dispatch is exempt from that guard:
178-
# kick ci.yaml at the PR branch explicitly. This keeps the whole pipeline
179-
# on the native token — no PAT anywhere in this repo.
180-
- name: Trigger CI on the port PR
181-
if: steps.diff.outputs.changed == 'true' && steps.open-pr.outputs.pull-request-operation != 'none'
203+
# Fallback path only. With the App configured, the PR above already has real
204+
# pull_request checks and nothing here runs.
205+
#
206+
# Without it, the PR exists but can never satisfy `main`'s required checks.
207+
# Dispatching ci.yaml still gives a human something to read, but the run does
208+
# NOT attach to the PR — so say that plainly instead of leaving a green-looking
209+
# PR that will not merge and no explanation of why.
210+
- name: Trigger CI on the port PR (no App token — checks will not attach)
211+
if: >-
212+
steps.diff.outputs.changed == 'true'
213+
&& steps.open-pr.outputs.pull-request-operation != 'none'
214+
&& steps.app-token.outputs.token == ''
182215
env:
183216
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
184-
run: gh workflow run ci.yaml --repo "$GITHUB_REPOSITORY" --ref upstreamer/sync
217+
run: |
218+
echo "::warning::PORT_BOT_APP_ID / PORT_BOT_PRIVATE_KEY are not configured, so this PR was opened with GITHUB_TOKEN and will receive NO pull_request-event checks. main's required status checks cannot be satisfied, so the PR cannot merge as-is. The dispatched run below is informational only. Configure the App (see PORTING.md) or close and reopen the PR by hand to generate real checks."
219+
gh workflow run ci.yaml --repo "$GITHUB_REPOSITORY" --ref upstreamer/sync
185220
186221
- name: Upload logs
187222
if: always()

‎PORTING.md‎

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ scripts/upstream
2929
│ 6. .upstreamer/eval.md (parity gate, fresh context)
3030
│ 7. advance state.yaml — ONLY if both gates pass
3131
▼
32-
Pull request (never a direct push to main)
32+
Pull request, opened by the GitHub App so it gets real
33+
pull_request-event CI checks (never a direct push to main)
3334
```
3435

3536
### Why HEAD and not the latest release
@@ -132,6 +133,42 @@ Two values, same names locally and in CI:
132133
| `OPENROUTER_API_KEY` | local: `.upstreamer/port.env` · CI: repo **secret** | `sk-or-…` key opencode uses for inference |
133134
| `OPENCODE_MODEL` | local: `.upstreamer/port.env` · CI: repo **variable** | e.g. `openrouter/~anthropic/claude-opus-latest` |
134135

136+
Two more are needed in CI only, for the bot that opens port PRs:
137+
138+
| Name | Kind | What |
139+
|------|------|------|
140+
| `PORT_BOT_APP_ID` | repo **variable** | The GitHub App's App ID |
141+
| `PORT_BOT_PRIVATE_KEY` | repo **secret** | The App's generated private key (full PEM, including the BEGIN/END lines) |
142+
143+
### Why a GitHub App is required, not optional
144+
145+
`main` requires six status checks, and **only `pull_request`-event runs satisfy
146+
them**. GitHub does not trigger workflows from events created with the native
147+
`GITHUB_TOKEN` (its recursion guard), so a PR opened with that token gets no
148+
`pull_request` checks and can never become mergeable.
149+
150+
Measured on PR #24: the commit carried **14 check-runs, while the PR's rollup
151+
showed 7** — a `workflow_dispatch` run of the same workflow on the same commit was
152+
completely invisible to branch protection. That is why "just dispatch `ci.yaml`
153+
at the branch" does not work; it produces green runs that cannot satisfy anything.
154+
155+
An App installation token is not recursion-guarded, so the PR it opens gets real
156+
checks. Preferred over a PAT: scoped to this repo, not tied to anyone's personal
157+
account, and revocable on its own.
158+
159+
**Setup** — create a GitHub App (org Settings → Developer settings → GitHub Apps):
160+
161+
- Repository permissions: **Contents: Read and write**, **Pull requests: Read and
162+
write**. Nothing else.
163+
- Install it on `OpenRouterTeam/python-agent`.
164+
- Generate a private key, then add `PORT_BOT_APP_ID` (variable) and
165+
`PORT_BOT_PRIVATE_KEY` (secret).
166+
167+
Until those exist the pipeline still runs and still opens a PR, but emits a
168+
`::warning::` saying the PR will receive no checks and cannot merge as-is. That is
169+
deliberate — an unconfigured bot should degrade loudly, not look healthy while
170+
producing permanently stuck PRs.
171+
135172
The wrapper writes the key into `~/.local/share/opencode/auth.json` so headless
136173
runs work without the interactive `opencode /connect` flow.
137174

0 commit comments

Comments
 (0)