Skip to content

Commit ce2b239

Browse files
LukasParkeclaude
andcommitted
ci(port): track upstream HEAD instead of the latest published release
The pipeline resolved the latest npm release and ported its tag. That is why today's cron run found "no changes" while upstream main carried an unported doom-loop commit (#73) — ~7,500 lines, ~4,700 of it tests, rewriting a large part of model-result.ts. Release tracking sounds conservative and produces exactly the failure this pipeline exists to prevent: the port stays blind to unreleased work, then absorbs the whole delta in one automated run against the most load-bearing module in the package. Tracking HEAD keeps each delta reviewable. Ref resolution - The workflow no longer queries the npm registry. A blank ref means upstream default-branch HEAD, which scripts/upstream already resolves on its own. - The publish dispatch's client_payload.ref is now deliberately IGNORED (with a ::notice:: saying so). It carries the release tag, which becomes an ancestor of HEAD as soon as the port is ahead of the release — honoring it would revert landed work. The dispatch still does its real job: waking the pipeline promptly after a release. It just syncs to HEAD like every other trigger. - Fixed a latent bug in the Run port step: it passed `--ref ""` when no ref was resolved. The arg parser consumes the empty value, so that is not the same as omitting the flag. Now the flag is only passed when there is a ref. Verified all four arg combinations parse correctly. Refusing to port backwards scripts/upstream now exits 3 if the target commit is an ancestor of state.yaml's upstream_commit, printing how many commits behind it is and pointing at --force as the deliberate override. Without this, a single stale --ref (or a dispatch payload, before the change above) would instruct the converter to faithfully port an older tree and silently revert everything since. Verified: the guard fires on the real 0.8.0 tag once state claims HEAD, and --force bypasses it. Version honesty when ahead of a release Being ahead is now the normal state, and it is invisible to the old check: package.json on main still carries the last released number, so pyproject.toml matches it and the check passes — while the tree contains unreleased work. - verify.sh now also compares the ported commit against the release tag for the declared version. Level with the tag PASSes as publishable; ahead prints a NOTE with the commit count and an explicit "do not publish this version" warning. Ahead is not a failure — it is the intended state — so it does not fail the run. - publish.yaml gets a hard gate, because verify.sh's NOTE only appears when a sync run has left an upstream checkout in tmp/, and there is none at publish time. It clones upstream, requires a release tag matching the declared version, and refuses if the ported commit is ahead of it. Verified in all three states: level allows, ahead refuses, missing tag refuses. Publishing X.Y.Z from a tree ahead of upstream's X.Y.Z tag ships unreleased work under a released number, and a PyPI version can never be reused. Also: exclude tmp/ from ruff Found while testing: a port run writes scratch files under tmp/ (gitignored, but ruff still walks it), and one stray probe script failed `ruff check` and `ruff format` — 22 errors that had nothing to do with the port. A sync run could fail its own mechanical gate on its own scratch space. mypy was already scoped to src/tests and unaffected. Docs PORTING.md's pipeline diagram now shows the real trigger set and the backwards refusal, plus a section on why HEAD over releases and the two consequences. The contract's Package Version section says between-releases is now the normal state, tells a sync to report how far ahead it is, and states the publishing rule. Verification: verify.sh PASS (0 failures) · all three workflows parse · guard and publish-gate logic each verified in three states · ruff/mypy clean with a deliberately broken file in tmp/ · 114 passed, coverage 83.89%. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 089de5b commit ce2b239

7 files changed

Lines changed: 202 additions & 36 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: 48 additions & 18 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.
@@ -52,21 +69,29 @@ jobs:
5269
- name: Set up language toolchain
5370
uses: ./.github/actions/port-toolchain
5471

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.
72+
# Blank ref = upstream default-branch HEAD, which scripts/upstream resolves
73+
# itself. That is the normal case for both the cron and a publish dispatch.
74+
#
75+
# Only an EXPLICIT manual `ref` input is honored. The publish dispatch's
76+
# client_payload.ref is deliberately ignored: it carries the release tag,
77+
# which is an ancestor of HEAD once the port is ahead of the release, so
78+
# honoring it would revert landed work. The dispatch still does its real
79+
# job — waking the pipeline promptly after a release — it just syncs to HEAD
80+
# like every other trigger. (scripts/upstream also refuses an ancestor ref
81+
# outright, so this is defense in depth, not the only guard.)
6182
- name: Resolve target ref
6283
id: target
6384
run: |
6485
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"
86+
REF="${{ inputs.ref }}"
87+
if [ -n "$REF" ]; then
88+
echo "Explicit ref requested: $REF"
89+
else
90+
PAYLOAD_REF="${{ github.event.client_payload.ref }}"
91+
if [ -n "$PAYLOAD_REF" ]; then
92+
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."
93+
fi
94+
echo "No explicit ref — porting upstream default-branch HEAD."
7095
fi
7196
echo "ref=$REF" >> "$GITHUB_OUTPUT"
7297
@@ -84,9 +109,14 @@ jobs:
84109
echo "::error::OPENROUTER_API_KEY secret is not set. See .upstreamer/port.env.example."
85110
exit 1
86111
fi
87-
args=(--ref "${{ steps.target.outputs.ref }}")
112+
# Only pass --ref when there is actually a ref. `--ref ""` is not the
113+
# same as omitting it: the arg parser consumes the empty value and the
114+
# script would target an empty ref instead of defaulting to HEAD.
115+
args=()
116+
REF="${{ steps.target.outputs.ref }}"
117+
[ -n "$REF" ] && args+=(--ref "$REF")
88118
[ "${{ inputs.force }}" = "true" ] && args+=(--force)
89-
./scripts/upstream "${args[@]}"
119+
./scripts/upstream ${args[@]+"${args[@]}"}
90120
91121
- name: Check for changes
92122
id: diff

.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

PORTING.md

Lines changed: 42 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,27 +9,58 @@ listed in the Idiomatic Divergences section of `.upstreamer/upstreamer.md`.
99

1010
## How it works
1111

12+
The port tracks upstream's **default-branch HEAD**, not the latest published npm
13+
release.
14+
1215
```
13-
typescript-agent publishes @openrouter/agent to npm
14-
15-
repository_dispatch: openrouter-agent-published
16+
weekly cron · publish dispatch · manual dispatch
17+
(a nudge, (optional
18+
not a ref) explicit ref)
1619
1720
.github/workflows/upstreamer-port.yaml
18-
21+
resolve ref: explicit input, else upstream HEAD
1922
2023
scripts/upstream
2124
│ 1. fetch upstream, resolve target commit
22-
│ 2. compare against .upstreamer/state.yaml — skip if unchanged
23-
│ 3. opencode runs the port against .upstreamer/upstreamer.md
24-
│ 4. .upstreamer/scripts/verify.sh (mechanical gate)
25-
│ 5. .upstreamer/eval.md (parity gate, fresh context)
26-
│ 6. advance state.yaml — ONLY if both gates pass
25+
│ 2. REFUSE if target is behind state.yaml (would revert work)
26+
│ 3. compare against .upstreamer/state.yaml — skip if unchanged
27+
│ 4. opencode runs the port against .upstreamer/upstreamer.md
28+
│ 5. .upstreamer/scripts/verify.sh (mechanical gate)
29+
│ 6. .upstreamer/eval.md (parity gate, fresh context)
30+
│ 7. advance state.yaml — ONLY if both gates pass
2731
2832
Pull request (never a direct push to main)
2933
```
3034

31-
A weekly cron backs up the dispatch in case one is missed, and
32-
`workflow_dispatch` allows a manual run against any ref.
35+
### Why HEAD and not the latest release
36+
37+
Release tracking sounds more conservative and is worse in practice. Upstream can
38+
sit for weeks with large unreleased work on `main` — doom-loop detection (#73) was
39+
~7,500 lines, ~4,700 of it tests, and rewrote a big part of `model-result.ts`. A
40+
release-tracking port stays blind to that, then absorbs the entire delta in one
41+
automated run touching the most load-bearing module in the package. Tracking HEAD
42+
keeps each delta small enough that a human can actually review it.
43+
44+
Two consequences follow, and both are handled rather than ignored:
45+
46+
**The port is routinely ahead of the latest release.** So a release ref is now
47+
*dangerous*: it resolves to an ancestor of what is already ported, and the
48+
converter would faithfully "port" the older tree, reverting landed work.
49+
`scripts/upstream` refuses a target that is behind `state.yaml` (exit 3) unless
50+
`--force` is given, and the workflow ignores the publish dispatch's
51+
`client_payload.ref` for the same reason.
52+
53+
**Its declared version legitimately lags upstream's `package.json`.** Being ahead
54+
of a release means carrying commits upstream has not versioned yet, while
55+
`package.json` still shows the last released number. The verifier reports how many
56+
commits ahead the ported tree is and warns not to publish that version to PyPI —
57+
shipping unreleased upstream work under a released version number is a
58+
misrepresentation, and a PyPI version can never be reused. Publish from a commit
59+
level with a release tag.
60+
61+
The weekly cron is the primary trigger. The publish dispatch still fires on a new
62+
npm release — useful as "something shipped, sync promptly" — but it syncs to HEAD
63+
like everything else. `workflow_dispatch` allows a manual run against any ref.
3364

3465
## The contract is the product
3566

pyproject.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,12 @@ include = [
7777
[tool.ruff]
7878
line-length = 120
7979
target-version = "py310"
80+
# tmp/ is the port pipeline's scratch space (upstream checkout, probe scripts a
81+
# sync run writes while exploring). It is gitignored, but ruff still walks it, so
82+
# a stray scratch file there fails the verifier on grounds unrelated to the port.
83+
# Ruff honors .gitignore for untracked files but not for an explicitly-walked
84+
# directory, so exclude it outright.
85+
extend-exclude = ["tmp"]
8086

8187
[tool.ruff.lint]
8288
select = ["E", "F", "I", "UP", "B"]

scripts/upstream

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,35 @@ echo "target: $target_commit${ref:+ ($ref)}" >&2
149149
echo "last port: ${last_upstream_commit:-none}" >&2
150150
echo "model: ${model:-<opencode default>}" >&2
151151

152+
# Refuse to port backwards.
153+
#
154+
# The port tracks upstream's default branch, so it is routinely AHEAD of the
155+
# latest published release. That makes a release ref actively dangerous: a
156+
# publish dispatch (or a manual --ref v0.8.0) resolves to a commit that is an
157+
# ancestor of what we already ported, and the converter would dutifully "port"
158+
# the older tree — silently reverting everything landed since.
159+
#
160+
# Ancestor of last-ported, and not equal to it, means strictly behind. Unrelated
161+
# histories (no merge-base) are not caught here; they surface as a normal diff
162+
# for review rather than a silent revert.
163+
if [ "$force" -eq 0 ] && [ -n "$last_upstream_commit" ] && [ "$target_commit" != "$last_upstream_commit" ] \
164+
&& git -C "$upstream_dir" merge-base --is-ancestor "$target_commit" "$last_upstream_commit" 2>/dev/null; then
165+
behind_by="$(git -C "$upstream_dir" rev-list --count "$target_commit..$last_upstream_commit" 2>/dev/null || echo "?")"
166+
cat >&2 <<MSG
167+
ERROR: refusing to port backwards.
168+
169+
target: $target_commit${ref:+ ($ref)}
170+
last ported: $last_upstream_commit
171+
target is $behind_by commit(s) BEHIND what is already ported.
172+
173+
This repo tracks upstream's default branch, so it is normally ahead of the latest
174+
release tag. Porting an older ref would revert work already landed and eval-passed.
175+
176+
If you genuinely intend to move the port back to this ref, re-run with --force.
177+
MSG
178+
exit 3
179+
fi
180+
152181
if [ "$force" -eq 0 ] && [ "$target_commit" = "$last_upstream_commit" ]; then
153182
{
154183
echo "Run summary"

0 commit comments

Comments
 (0)