Skip to content

Commit fdf1004

Browse files
LukasParkeclaude
andauthored
ci(port): track upstream HEAD instead of the latest published release (#24)
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 089de5b commit fdf1004

7 files changed

Lines changed: 299 additions & 46 deletions

File tree

.github/workflows/publish.yaml

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,41 @@ jobs:
8787
- name: Verify (lint, types, tests, coverage floor, required API)
8888
run: ./.upstreamer/scripts/verify.sh
8989

90+
# The port tracks upstream HEAD, so it is routinely AHEAD of the release
91+
# whose version number pyproject.toml carries. Publishing from that state
92+
# would ship unreleased upstream work as a released version — permanently,
93+
# since a PyPI version can never be reused.
94+
#
95+
# verify.sh reports this, but only when a sync run has left an upstream
96+
# checkout in tmp/. There is none here, so check it explicitly against the
97+
# public repo rather than letting the guard be silent at the one moment it
98+
# matters most.
99+
- name: Refuse to publish a version the port is ahead of
100+
run: |
101+
set -euo pipefail
102+
VERSION="$(grep -m1 '^version' pyproject.toml | sed 's/.*"\(.*\)".*/\1/')"
103+
PORTED="$(grep -m1 '^upstream_commit:' .upstreamer/state.yaml | awk '{print $2}')"
104+
echo "declared version: $VERSION"
105+
echo "ported commit: $PORTED"
106+
107+
rm -rf /tmp/upstream-check
108+
git clone -q --filter=blob:none --no-checkout \
109+
https://github.com/OpenRouterTeam/typescript-agent.git /tmp/upstream-check
110+
git -C /tmp/upstream-check fetch -q --tags origin
111+
112+
TAG="@openrouter/agent@${VERSION}"
113+
if ! git -C /tmp/upstream-check rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
114+
echo "::error::Upstream has no release tag $TAG. This package's version tracks the ported @openrouter/agent version, so publishing $VERSION means upstream released it. Wait for the upstream release, or correct the version."
115+
exit 1
116+
fi
117+
118+
AHEAD="$(git -C /tmp/upstream-check rev-list --count "refs/tags/$TAG..$PORTED" 2>/dev/null || echo 0)"
119+
if [ "${AHEAD:-0}" -gt 0 ]; then
120+
echo "::error::The ported commit is $AHEAD commit(s) ahead of the $TAG release tag. Publishing $VERSION now would ship unreleased upstream work under a released version number, and a PyPI version can never be reused. Publish from a commit level with a release tag, or wait for upstream to release what the port has reached."
121+
exit 1
122+
fi
123+
echo "Ported tree is level with $TAG — $VERSION is honest to publish."
124+
90125
- name: Build sdist and wheel
91126
run: |
92127
set -euo pipefail

.github/workflows/upstreamer-port.yaml

Lines changed: 107 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,27 @@
11
name: 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.
@@ -39,6 +56,13 @@ jobs:
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()

.upstreamer/scripts/verify.sh

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,30 @@ if [ -f "$upstream_pkg" ]; then
8686
else
8787
fail "version drift: pyproject.toml=$declared, upstream @openrouter/agent=$target"
8888
fi
89+
90+
# This port tracks upstream HEAD, so it routinely contains work that upstream
91+
# has not released. package.json still carries the last released number, so the
92+
# check above passes — and the port would publish "0.8.0" while containing
93+
# post-0.8.0 commits. Not a verifier failure (being ahead is the intended
94+
# state), but it must be visible, because publishing it as a released version
95+
# number is a real misrepresentation and PyPI versions cannot be reused.
96+
upstream_git="tmp/upstreamer/upstream"
97+
if [ -d "$upstream_git/.git" ]; then
98+
ported_sha="$(git -C "$upstream_git" rev-parse HEAD 2>/dev/null || true)"
99+
rel_tag="$(git -C "$upstream_git" tag -l "@openrouter/agent@$target" | head -1)"
100+
if [ -n "$ported_sha" ] && [ -n "$rel_tag" ]; then
101+
ahead="$(git -C "$upstream_git" rev-list --count "$rel_tag^{commit}..$ported_sha" 2>/dev/null || echo 0)"
102+
if [ "${ahead:-0}" -gt 0 ]; then
103+
echo " NOTE: ported tree is $ahead commit(s) ahead of the $target release tag."
104+
echo " Declaring $declared is correct for the port, but do NOT publish"
105+
echo " $declared to PyPI from this state — it would ship unreleased"
106+
echo " upstream work under a released version number. Publish only from"
107+
echo " a commit level with a release tag, or after upstream releases."
108+
else
109+
pass "ported tree is level with the $target release tag (publishable)"
110+
fi
111+
fi
112+
fi
89113
else
90114
# Only present during a sync run. Standalone/CI invocations legitimately have no
91115
# upstream checkout; not a failure, but say so rather than passing silently.

.upstreamer/upstreamer.md

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -65,13 +65,24 @@ output. Leave them alone.
6565
## Package Version
6666

6767
`pyproject.toml` `version` tracks the ported `@openrouter/agent` version. Read it
68-
from the upstream `packages/agent/package.json` at the target commit and set it
69-
to match. If the target commit is between releases, keep the last released
70-
version and note the drift in the final report.
71-
72-
Publishing is gated on this: a released version can never be reused on PyPI, so a
73-
sync that bumps the version is what makes the next release possible. Never bump it
74-
past what was actually ported.
68+
from the upstream `packages/agent/package.json` at the target commit and set it to
69+
match. Never set it past what was actually ported.
70+
71+
**This port targets upstream HEAD, so between-releases is the normal state, not the
72+
exception.** `package.json` on `main` still carries the last released number, so
73+
matching it is correct and the verifier passes — but the ported tree then contains
74+
commits upstream has not released. When that is the case:
75+
76+
- Keep `version` at the number `package.json` shows. Do not invent a
77+
pre-release suffix; the verifier compares against `package.json` exactly.
78+
- **Say so in the final report**: how many commits ahead of the release tag the
79+
target is, and what unreleased upstream work is now included. The verifier
80+
prints this as a `NOTE`, but the report is what a reviewer reads.
81+
82+
Publishing is gated on this. A released version can never be reused on PyPI, so
83+
publishing `X.Y.Z` from a tree that is ahead of upstream's `X.Y.Z` tag ships
84+
unreleased work under a released number, permanently. Release from a commit level
85+
with a release tag, or after upstream publishes the version the port has reached.
7586

7687
## Required Public API
7788

0 commit comments

Comments
 (0)