Skip to content

ci(release): dispatch the Python and Go ports on publish (HOP C) - #84

Merged
LukasParke merged 4 commits into
mainfrom
feat/dispatch-port-repos
Jul 30, 2026
Merged

ci(release): dispatch the Python and Go ports on publish (HOP C)#84
LukasParke merged 4 commits into
mainfrom
feat/dispatch-port-repos

Conversation

@LukasParke

@LukasParke LukasParke commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Adds a HOP C dispatch so a published @openrouter/agent tells its Python and Go ports to sync.

Third of three PRs:

Why

python-agent and go-agent treat this repo as their reference spec, but nothing ever told them a version shipped. Both were generated by hand against 0.7.2 and have sat a minor version behind ever since — missing HooksManager (#7, #67), versioned state serialization (#66), and the #61#68 fixes. The existing release chain deliberately routes around them:

typescript-sdk publishes @openrouter/sdk
  → HOP A: bump-openrouter-sdk.yaml
  → publish.yaml (changesets → npm)
  → HOP B: repository_dispatch → openrouter-web
  → HOP C: repository_dispatch → python-agent, go-agent    ← this PR

What it does

One step after the existing HOP B dispatch. On a real publish, both port repos receive openrouter-agent-published and each opens a PR porting the delta, gated on a mechanical verifier plus a behavioral parity eval before their sync state advances.

Four details that matter

Sends the release tag, not a branch. client_payload[ref] is @openrouter/agent@X.Y.Z, so a port reproduces the exact published tree rather than whatever main drifted to afterwards. Verified this matches the tag format changesets actually creates in this repo.

continue-on-error: true — cannot fail the release. By the time this step runs, the packages are already on npm. A red job here would misreport a successful publish. It warns and names the recovery path (run the port repo's Upstreamer Port workflow manually with that tag, or wait for its weekly cron).

!cancelled() so it's independent of HOP B. A failed monorepo dispatch shouldn't also stop the ports from being notified.

Partial failure tolerated. One port failing to dispatch still dispatches the other; both are named in the warning.

Token requirement

Reuses the same GH_TOKEN PAT as HOP B. It additionally needs contents:write on OpenRouterTeam/python-agent and OpenRouterTeam/go-agent. If that scope isn't added, this step warns and the ports fall back to their weekly cron — no release breakage either way, but the fast path won't work until the PAT covers both repos.

Conflict with #82

#82 also edits publish.yaml, but its hunks stop just before the dispatch region (its last change is removing NODE_AUTH_TOKEN from the publish steps). This adds only appended steps after HOP B, so they should merge cleanly in either order. Happy to rebase behind #82 if you'd prefer it lands first.

Also

Documents the full publish fan-out in .agents/skills/changeset-versioning/SKILL.md. Worth a read in review: a breaking callModel change now produces port PRs in two other repos, which is a new consequence of releasing here.

Verified

  • YAML parses; the step's shell body passes bash -n
  • Simulated the dispatch loop including a partial failure — warns, reports which repo failed, exits 0
  • Confirmed the receiving workflow consumes client_payload[ref] correctly: a dispatch with no manual inputs resolves to ./scripts/upstream --ref @openrouter/agent@0.8.0
  • Confirmed @openrouter/agent@0.8.0 resolves to a real commit in this repo

Nothing here runs until the next publish, and the first port runs are intended to be driven manually anyway.


Open in Devin Review

The Python and Go ports of @openrouter/agent track this repo as their reference
spec, but nothing told them when a version shipped. Both were generated by hand
against 0.7.2 and have sat a minor version behind since — missing HooksManager,
versioned state serialization, and the #61-#68 fixes.

Adds a HOP C dispatch beside the existing HOP B monorepo dispatch. On a real
publish, python-agent and go-agent each receive openrouter-agent-published and
open a PR porting the delta. Their pipelines gate the result on a mechanical
verifier plus a behavioral parity eval before advancing sync state, so a bad
port cannot land silently.

Details worth noting:

- Sends the release TAG (@openrouter/agent@X.Y.Z), not a branch, so a port
  reproduces the exact published tree rather than whatever main drifted to.
- continue-on-error: the packages are already on npm when this runs, so a failed
  dispatch must not turn a successful release red. It warns instead, and names
  the manual recovery path.
- !cancelled() so a failed HOP B dispatch doesn't also skip the ports.
- Partial failure is tolerated: one port failing still dispatches the other.

Reuses the same GH_TOKEN PAT as HOP B, which additionally needs contents:write
on OpenRouterTeam/python-agent and OpenRouterTeam/go-agent.

Also documents the full publish fan-out in the changeset-versioning skill, since
a breaking callModel change now produces port PRs in two other repos.

Companion PRs: OpenRouterTeam/python-agent#19, OpenRouterTeam/go-agent#1
cortex-github-agent[bot]

This comment was marked as resolved.

cortex-github-agent[bot]

This comment was marked as resolved.

@cortex-github-agent cortex-github-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Appends a HOP C repository_dispatch step to publish.yaml notifying python-agent/go-agent on a real publish, plus docs in the changeset-versioning skill. Structure and failure isolation (continue-on-error, !cancelled(), per-repo tolerance) are right, but the ref payload is passed with gh api -F, whose @-prefix magic will make every dispatch fail silently, and on the manual publish path the tag it names is never pushed.

Findings (4)

🔴 critical · .github/workflows/publish.yaml:189
-F "client_payload[ref]=${TAG}"gh api --field interprets a value starting with @ as a filename to read. TAG is @openrouter/agent@X.Y.Z, so gh errors out before sending the request and both dispatches take the failure branch; combined with continue-on-error + exit 0 the whole hop is a silent no-op. Use -f/--raw-field for that field.

🟠 major · .github/workflows/publish.yaml:182
ref is set to the changesets git tag, but the manual mode=publish path (pnpm exec changeset publish --no-git-checks, publish.yaml:97) only creates tags locally and nothing pushes them, while steps.published.outputs.version is still populated via the npm version diff. HOP C then dispatches a tag the port repos cannot resolve. Push tags on that path, or fall back to github.sha when git ls-remote --tags misses.

🟡 minor · .github/workflows/publish.yaml:181
set -uo pipefail drops -e (needed for the explicit if gh api check), so any other command failure in the body is silently ignored. Scoping the relaxed mode to just the gh call (e.g. keep set -euo pipefail and use if ! gh api ... ; then — which is already -e-exempt) would keep the rest of the script strict.

nit · .github/workflows/publish.yaml:190
Mixed interpolation styles: VERSION is passed via env: (correct, avoids injection) while github.server_url/github.run_id are inlined as ${{ }} in the shell body. Harmless for these values but inconsistent with the env-var approach used one line above.

devin-ai-integration[bot]

This comment was marked as resolved.

- Use -f (--raw-field) for all dispatch payload fields: -F treats
  values starting with @ (the release tag) as filenames, which made
  every port dispatch fail before the request was sent
- Push release tags on the manual mode=publish path so the dispatched
  ref actually exists on the remote
- Restore set -e; the gh api call sits in an if-condition and is
  already -e-exempt, so the rest of the script stays strict
- Pass source_run_url via env like VERSION instead of inline ${{ }}

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@LukasParke

Copy link
Copy Markdown
Contributor Author

Addressed all 4 review findings in d259bfc:

  • critical: all dispatch payload fields now use -f (--raw-field) so the @-prefixed tag is sent literally instead of being read as a filename
  • major: added a "Push release tags" step on the manual mode=publish path so the dispatched ref resolves on the remote
  • minor: restored set -euo pipefail — the gh api call is inside an if so it stays -e-exempt while the rest of the script is strict
  • nit: source_run_url now passed via env: like VERSION

cortex-github-agent[bot]

This comment was marked as resolved.

@cortex-github-agent cortex-github-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

The update fixes both blocking findings from the previous review: all dispatch fields now use -f/--raw-field (publish.yaml:186-189), set -euo pipefail is restored, source_run_url moved to env:, and a tag-push step was added for the manual publish path. The tag-push fix itself introduces a smaller regression: it is a fatal step placed after npm publish, so a rejected push reddens a successful release and suppresses both HOP B and HOP C.

Findings (3)

🟠 major · .github/workflows/publish.yaml:96-98
NEW: Push release tags (manual publish) runs after changeset publish with no continue-on-error. A rejected push (tag protection ruleset, or a re-run where the tag already exists at a different commit) fails the job post-npm-publish and, because Resolve published @openrouter/agent version and Dispatch monorepo bump have no status-check if:, leaves steps.published.outputs.version empty so HOP B and HOP C are both skipped despite !cancelled(). Add continue-on-error: true (optionally with the git ls-remote tag-existence guard + github.sha fallback for ref).

🟡 minor · .github/workflows/publish.yaml:93-95
RESOLVED (partially, see above) — prior finding that ref named an unpushed tag on the manual publish path is addressed by the new push step; residual gap is only the failure mode above, i.e. ref still has no fallback if the push doesn't land.

🟡 minor · .github/workflows/publish.yaml:186-189
RESOLVED — prior critical: dispatch fields switched from -F to -f, so @openrouter/agent@X.Y.Z is no longer interpreted as a filename; the inline comment records why. Also resolved: set -euo pipefail restored (publish.yaml:181) and SOURCE_RUN_URL moved into env: instead of inline ${{ }}.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 new potential issues.

Open in Devin Review

Comment thread .github/workflows/publish.yaml
Comment on lines +197 to +201
if gh api "repos/OpenRouterTeam/${REPO}/dispatches" \
-f event_type=openrouter-agent-published \
-f "client_payload[version]=${VERSION}" \
-f "client_payload[ref]=${TAG}" \
-f "client_payload[source_run_url]=${SOURCE_RUN_URL}"; then

@devin-ai-integration devin-ai-integration Bot Jul 28, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Nested client_payload[...] bracket syntax with gh api

HOP C builds the dispatch payload with -f 'client_payload[version]=...', -f 'client_payload[ref]=...', etc. This mirrors the pre-existing HOP B step (.github/workflows/publish.yaml:152-155), which uses the same bracket notation. Whether gh api expands client_payload[key]=value into a nested JSON object versus a flat top-level key literally named client_payload[key] depends on the gh CLI version's behavior. Since HOP B already relies on this and is presumably verified in production, HOP C is consistent with it and I did not flag it — but if HOP B were ever found to send a flat/literal key, HOP C would inherit the same issue for the ref payload the ports depend on.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked this empirically rather than leaving it as an open question — the bracket notation does nest correctly.

Ran the same call shape against gh 2.96.0 with GH_DEBUG=api and the serialized request body was:

{
  "client_payload": { "ref": "@openrouter/agent@1.2.3", "version": "1.2.3" },
  "event_type": "..."
}

So -f 'client_payload[key]=value' expands into a nested object, not a flat literal key. HOP B's production behavior and HOP C's ref payload are both fine on this axis. Leaving the thread open for a human to resolve since it was a question rather than a defect.

Worth noting your -f vs -F distinction still matters and is handled: ref starts with @, so -F would have treated it as a filename. HOP C uses -f throughout.

LukasParke added 2 commits July 29, 2026 11:17
…origin

Address review findings from cortex and Devin on the HOP C dispatch:

- Make the manual-publish tag push non-fatal. It runs after packages are
  already on npm, so a rejected push (tag protection, or a re-run where the
  tag exists at a different commit) turned a successful publish red and,
  because the HOP B/C steps carry no status guard, skipped both dispatches.

- Verify the tag is on origin before dispatching it, falling back to the
  run's commit SHA. Previously the manual path could dispatch a ref the port
  repos cannot resolve, making them fail on checkout instead of degrading.
# Conflicts:
#	.agents/skills/changeset-versioning/SKILL.md

@cortex-github-agent cortex-github-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

All three prior findings are addressed: dispatch fields use -f so the @-prefixed tag isn't read as a file, the manual-path tag push is now continue-on-error: true (publish.yaml:148-150) so it can't redden a successful publish or suppress HOP B/C, and HOP C verifies the tag is on origin with a github.sha fallback (publish.yaml:250-256) before dispatching. Remaining risk is limited to the cross-repo receiving side, which isn't in this repo.

Findings (1)

nit · .github/workflows/publish.yaml:253
The github.sha fallback is only useful if the port repos' checkout of this repo can resolve an arbitrary commit SHA (GitHub supports fetch-by-SHA, but a shallow branch clone plus a bare git checkout <sha> will not). Worth confirming once against python-agent#19 / go-agent#1 since the fallback path is exactly the one nobody exercises until it matters.

@LukasParke

Copy link
Copy Markdown
Contributor Author

@devin please re-review — addressed your findings and cortex's in 435b2d0e.

  • The manual-publish tag push is now continue-on-error: true. Confirmed your and cortex's analysis against the file: Resolve published version (:172) and Dispatch monorepo bump (:199) carry no success() override, so a rejected push really did skip both hops.
  • HOP C now runs git ls-remote --exit-code --tags origin "refs/tags/$TAG" and falls back to ${{ github.sha }}, so the tag-push-failed + !cancelled() interaction you described can no longer send the ports at a ref that doesn't exist.
  • On your bracket-notation question: verified empirically with GH_DEBUG=api on gh 2.96.0 — -f 'client_payload[key]=value' does serialize to a nested JSON object, so HOP B and HOP C are both fine there.

Also merged main (which brought in OIDC trusted publishing).

@devin-ai-integration

Copy link
Copy Markdown
Contributor

Could not match GitHub user LukasParke to a Devin account. Please make sure your GitHub account is linked in your Devin settings.

@devin-ai-integration

Copy link
Copy Markdown
Contributor

Review of 435b2d0e

Both blocking findings are genuinely closed. -f throughout means the @-prefixed ref no longer gets read as a filename; the tag push is continue-on-error; and the git ls-remote --exit-code --tags check with a ${{ github.sha }} fallback means the manual path can't dispatch an unresolvable ref regardless of whether the push was skipped or rejected. The github.sha fallback names the same tree that was published, since changeset publish runs from that checkout.

One remaining gap and two nits, none blocking.

🟠 The failure mode you just fixed on the manual path still exists on the primary push path

The fix hardened Push release tags (manual publish), but the equivalent post-publish work on path 1 happens inside changesets/action, which has no continue-on-error. Verified against the action source: runPublish publishes to npm, then does github.pushTag(tag) and createRelease(...) for each release (run.ts:235-245). A throw there — tag protection ruleset on refs/tags/*, a re-run where the tag already exists at a different commit, a releases-API error — rejects runPublish, and index.ts surfaces it as core.setFailed.

The consequence is the one cortex traced for the manual path, but on the normal release path:

  1. published was pre-set to "false" before runPublish and is only set to "true" after it returns (index.ts:62,97), so path 1 of Resolve published @openrouter/agent version sees published != true even though npm was updated.
  2. Path 2 can't cover for it either: Capture pre-publish version is gated on workflow_dispatch && mode == 'publish' (publish.yaml:133), so on a push release steps.pre-publish.outputs.version is empty.
  3. Resolve published @openrouter/agent version (:173) carries only if: ${{ !inputs.dry-run }} — implicit success() — so it is skipped outright, version is empty, and both HOP B and HOP C are skipped. HOP C's !cancelled() buys nothing, because it is gated on that step's output.

Net: a successful npm publish plus a failed tag/release push loses both dispatches and reports red — the exact outcome the HOP C comment block argues against.

This is pre-existing exposure for HOP B, so it's reasonable to leave out of scope. But it's cheap to close and it's the difference between "the dispatch degrades" and "the dispatch degrades on one of two paths":

- name: Resolve published @openrouter/agent version
  id: published
  if: ${{ !cancelled() && !inputs.dry-run }}

That alone is necessary but not sufficient, since path-1 detection reads the failed step's own outputs. Dropping the event/mode condition from Capture pre-publish version (leaving if: ${{ !inputs.dry-run }}) gives the push path the same npm before/after diff the manual path already has, at the cost of one npm view per run. With both, a post-publish failure anywhere degrades to a warning plus a SHA-ref dispatch instead of silently dropping the hops. Safe in the other direction too: if the run fails before publishing, both paths yield an empty VERSION and nothing dispatches.

github.sha is inlined in the shell body

VERSION and SOURCE_RUN_URL come through env: (per the earlier nit), but REF="${{ github.sha }}" (:259) is interpolated directly into the script. github.sha is a 40-hex value from GitHub, so there's no injection risk — it's only inconsistent with the convention the rest of the step now follows.

⚪ Confirming cortex's fallback nit

Agreed it's worth one check against python-agent#19 / go-agent#1, with the sharper version: actions/checkout resolves both a tag ref and a bare commit SHA, so this only matters if the port's scripts/upstream does its own git fetch/git checkout against a shallow clone — a bare git checkout <sha> there fails where checkout would have succeeded. Same class of question applies to the tag ref itself, since @openrouter/agent@X.Y.Z needs fetch-tags if the port fetches manually.

CI

e2e-tests is red on tests/e2e/call-model.test.ts > allowFinalResponse > should coerce a text response when stopWhen halts mid-tool-call, asserting expected 0 to be greater than 0 on a live model call. Not attributable to this branch: the diff touches only publish.yaml and SKILL.md, neither of which CI executes; this branch was green at the same commit content on 07-28; and everything main contributed via the merge is confined to those same two files. The two other CI failures in the same window were structural-gate on an unrelated branch, so it isn't a shared-infra outage either — it reads as a model-dependent flake. Re-run to confirm rather than treating it as pre-existing.

Verdict

Approve, with the push-path gap as a follow-up if you'd rather not widen this PR.

@LukasParke
LukasParke merged commit d030602 into main Jul 30, 2026
10 of 11 checks passed
@LukasParke
LukasParke deleted the feat/dispatch-port-repos branch July 30, 2026 15:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant