11name : Upstreamer Port
22
3- # Ports @openrouter/agent into this repo. Two triggers:
4- # 1. repository_dispatch from typescript-agent's publish.yaml on a new npm release
5- # (event type: openrouter-agent-published) — the intended path. Ports track
6- # published releases, not every commit to upstream main.
7- # 2. Weekly cron as a safety net for missed dispatches, plus manual dispatch.
3+ # Ports @openrouter/agent into this repo.
4+ #
5+ # The port tracks upstream's **default-branch HEAD**, not the latest published npm
6+ # release. Release tracking sounds safer but produces exactly the failure this
7+ # pipeline exists to prevent: upstream can sit for weeks with large unreleased
8+ # work on main (doom-loop detection, #73, was ~7.5k lines) and the port stays
9+ # blind to it, then absorbs the whole delta in one automated run touching the most
10+ # load-bearing modules. Tracking HEAD keeps each delta small enough to review.
11+ #
12+ # Consequence to keep in mind: the port is then routinely AHEAD of the latest
13+ # release, so its declared version legitimately lags upstream's package.json. The
14+ # verifier reports that rather than failing, and publishing is gated on it — see
15+ # the Package Version section of .upstreamer/upstreamer.md.
16+ #
17+ # Three triggers, all resolving to HEAD unless given an explicit ref:
18+ # 1. Weekly cron — the primary path now that releases are not the trigger.
19+ # 2. repository_dispatch from typescript-agent's publish.yaml on a new npm
20+ # release. Still useful as a "something just shipped, sync promptly" nudge,
21+ # but it no longer pins the ref to that release tag: doing so would port
22+ # BACKWARDS once the port is ahead of the release. scripts/upstream refuses
23+ # an ancestor ref outright.
24+ # 3. Manual dispatch, optionally with an explicit ref.
825#
926# Opens a PR. Never pushes to main. A failed parity eval leaves
1027# .upstreamer/state.yaml unchanged, so the next run retries the same delta.
3956 port :
4057 runs-on : ubuntu-latest
4158 timeout-minutes : 150
59+ env :
60+ # Surfaced as env because the `secrets` context is NOT available in a
61+ # step-level `if:` (only github/needs/strategy/matrix/job/runner/env/vars/
62+ # steps/inputs are). Referencing secrets.* there evaluates to empty and the
63+ # condition silently never matches — so the App-token gate below tests this
64+ # variable instead. Only ever compared against '' ; never echoed.
65+ HAS_APP_KEY : ${{ secrets.PORT_BOT_PRIVATE_KEY != '' }}
4266 steps :
4367 - uses : actions/checkout@v4
4468 with :
@@ -52,21 +76,29 @@ jobs:
5276 - name : Set up language toolchain
5377 uses : ./.github/actions/port-toolchain
5478
55- # Ports track published releases, not upstream main. When no ref arrives
56- # (cron, or a manual dispatch with the input left blank), resolve the
57- # latest published @openrouter/agent version from the public npm registry
58- # and port its release tag. This makes the cron fully equivalent to the
59- # repository_dispatch fast path — same tag either way — so the pipeline
60- # works with no cross-repo token at all if the dispatch is unavailable.
79+ # Blank ref = upstream default-branch HEAD, which scripts/upstream resolves
80+ # itself. That is the normal case for both the cron and a publish dispatch.
81+ #
82+ # Only an EXPLICIT manual `ref` input is honored. The publish dispatch's
83+ # client_payload.ref is deliberately ignored: it carries the release tag,
84+ # which is an ancestor of HEAD once the port is ahead of the release, so
85+ # honoring it would revert landed work. The dispatch still does its real
86+ # job — waking the pipeline promptly after a release — it just syncs to HEAD
87+ # like every other trigger. (scripts/upstream also refuses an ancestor ref
88+ # outright, so this is defense in depth, not the only guard.)
6189 - name : Resolve target ref
6290 id : target
6391 run : |
6492 set -euo pipefail
65- REF="${{ inputs.ref || github.event.client_payload.ref }}"
66- if [ -z "$REF" ]; then
67- VERSION="$(curl -fsSL 'https://registry.npmjs.org/@openrouter%2Fagent/latest' | python3 -c 'import json,sys; print(json.load(sys.stdin)["version"])')"
68- REF="@openrouter/agent@${VERSION}"
69- echo "No ref provided — resolved latest npm release: $REF"
93+ REF="${{ inputs.ref }}"
94+ if [ -n "$REF" ]; then
95+ echo "Explicit ref requested: $REF"
96+ else
97+ PAYLOAD_REF="${{ github.event.client_payload.ref }}"
98+ if [ -n "$PAYLOAD_REF" ]; then
99+ echo "::notice::Ignoring dispatch payload ref '$PAYLOAD_REF' — this port tracks upstream HEAD, and a release tag is an ancestor once the port is ahead of it. Syncing to HEAD instead."
100+ fi
101+ echo "No explicit ref — porting upstream default-branch HEAD."
70102 fi
71103 echo "ref=$REF" >> "$GITHUB_OUTPUT"
72104
@@ -84,9 +116,14 @@ jobs:
84116 echo "::error::OPENROUTER_API_KEY secret is not set. See .upstreamer/port.env.example."
85117 exit 1
86118 fi
87- args=(--ref "${{ steps.target.outputs.ref }}")
119+ # Only pass --ref when there is actually a ref. `--ref ""` is not the
120+ # same as omitting it: the arg parser consumes the empty value and the
121+ # script would target an empty ref instead of defaulting to HEAD.
122+ args=()
123+ REF="${{ steps.target.outputs.ref }}"
124+ [ -n "$REF" ] && args+=(--ref "$REF")
88125 [ "${{ inputs.force }}" = "true" ] && args+=(--force)
89- ./scripts/upstream "${args[@]}"
126+ ./scripts/upstream ${args[@]+ "${args[@]}"}
90127
91128 - name : Check for changes
92129 id : diff
@@ -112,12 +149,48 @@ jobs:
112149 echo "passed=true" >> "$GITHUB_OUTPUT"
113150 fi
114151
152+ # Mint a GitHub App installation token so the PR is created by the App
153+ # rather than by the native GITHUB_TOKEN.
154+ #
155+ # Why this exists: GitHub does not trigger workflows from events created
156+ # with GITHUB_TOKEN (recursion guard), so a PR opened with it gets no
157+ # pull_request-event checks — and `main`'s required status checks are
158+ # satisfied ONLY by pull_request-event runs. Measured on PR #24: the commit
159+ # had 14 check-runs, the PR's rollup showed 7; the workflow_dispatch half was
160+ # invisible to branch protection. So the previous "dispatch ci.yaml
161+ # explicitly" workaround produced green runs that could never satisfy the
162+ # required checks, leaving an automated port PR permanently unmergeable.
163+ #
164+ # An App installation token is not recursion-guarded, so the PR gets real
165+ # pull_request checks. Preferred over a PAT: scoped to this repo, not tied to
166+ # a person's account, and independently revocable.
167+ #
168+ # Optional by design — see the fallback below.
169+ - name : Mint App token
170+ id : app-token
171+ # Gate on BOTH halves. Gating on the App ID alone is a trap: the ID is a
172+ # variable and the key is a secret, so they are added in separate places
173+ # and one routinely lands first (it did here — the ID arrived first). With
174+ # only the ID set, this step would run and fail on the missing key, turning
175+ # a working fallback into a broken pipeline — worse than no App at all.
176+ if : >-
177+ steps.diff.outputs.changed == 'true'
178+ && vars.PORT_BOT_APP_ID != ''
179+ && env.HAS_APP_KEY == 'true'
180+ uses : actions/create-github-app-token@v1
181+ with :
182+ app-id : ${{ vars.PORT_BOT_APP_ID }}
183+ private-key : ${{ secrets.PORT_BOT_PRIVATE_KEY }}
184+
115185 - name : Open PR
116186 id : open-pr
117187 if : steps.diff.outputs.changed == 'true'
118188 uses : peter-evans/create-pull-request@v6
119189 with :
120- token : ${{ secrets.GITHUB_TOKEN }}
190+ # App token when configured; GITHUB_TOKEN otherwise. With the fallback
191+ # the PR still opens, but its checks will not attach — the guard step
192+ # below says so loudly rather than leaving a silently stuck PR.
193+ token : ${{ steps.app-token.outputs.token || secrets.GITHUB_TOKEN }}
121194 branch : upstreamer/sync
122195 delete-branch : true
123196 title : >-
@@ -142,16 +215,23 @@ jobs:
142215 `.upstreamer/state.yaml` did not advance, the eval did not pass and this
143216 PR must not be merged as-is.
144217
145- # Events created with the native GITHUB_TOKEN deliberately do not trigger
146- # other workflows (GitHub's recursion guard), so the PR opened above gets
147- # no CI checks on its own. workflow_dispatch is exempt from that guard:
148- # kick ci.yaml at the PR branch explicitly. This keeps the whole pipeline
149- # on the native token — no PAT anywhere in this repo.
150- - name : Trigger CI on the port PR
151- if : steps.diff.outputs.changed == 'true' && steps.open-pr.outputs.pull-request-operation != 'none'
218+ # Fallback path only. With the App configured, the PR above already has real
219+ # pull_request checks and nothing here runs.
220+ #
221+ # Without it, the PR exists but can never satisfy `main`'s required checks.
222+ # Dispatching ci.yaml still gives a human something to read, but the run does
223+ # NOT attach to the PR — so say that plainly instead of leaving a green-looking
224+ # PR that will not merge and no explanation of why.
225+ - name : Trigger CI on the port PR (no App token — checks will not attach)
226+ if : >-
227+ steps.diff.outputs.changed == 'true'
228+ && steps.open-pr.outputs.pull-request-operation != 'none'
229+ && steps.app-token.outputs.token == ''
152230 env :
153231 GH_TOKEN : ${{ secrets.GITHUB_TOKEN }}
154- run : gh workflow run ci.yaml --repo "$GITHUB_REPOSITORY" --ref upstreamer/sync
232+ run : |
233+ 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."
234+ gh workflow run ci.yaml --repo "$GITHUB_REPOSITORY" --ref upstreamer/sync
155235
156236 - name : Upload logs
157237 if : always()
0 commit comments