You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Recommendation in one line: move all four repos to release-please in manifest mode — one aggregated Release PR per repo, all tags created atomically on merge, and every build/publish job triggered by tags (or by the GitHub Release), so no release job ever pushes to main.
1. Verification of the stated failures
Every claim below was checked against real run logs, workflow files at HEAD, and repo settings. Three of five claims are confirmed as stated; two need correction.
At the time of the run, release-client and release-mcp in .github/workflows/release.yml both keyed off needs: test and ran concurrently. release-mcp won; release-client failed at the "Python Semantic Release (Client)" step:
Correction to the premise: PR #1001 is merged (2026-07-25T19:44:21Z) and is not a band-aid in the pejorative sense — it is a genuine dependency-graph serialization: release-mcp's needs changed from test to [test, release-client], with if: always() && needs.test.result == 'success' so MCP can still release when the client doesn't, plus ref: main and git fetch/reset --hard origin/main so release-mcp starts from the post-client-release tip. This specific race is closed. It does not, however, address the cascade or the direct-push-to-main topology.
✅ 1.2 Stale-checkout race — stocktrim run 30169605854 — CONFIRMED
Head SHA 3c2d1166. release-client succeeded (client v0.13.0). release-mcp (job 89708535182) failed at "Update MCP client dependency":
[main d017a1e] chore(mcp): update client dependency to v0.13.0
To https://github.com/dougborg/stocktrim-openapi-client
! [rejected] main -> main (non-fast-forward)
error: failed to push some refs to '...'
hint: Updates were rejected because the tip of your current branch is behind
The checkout had no ref:, so it used the stale triggering SHA. Fixed point-wise by PR #236 (merged f7709511): adds ref: main, a "fast-forward to the post-release tip" step (git fetch origin main; git reset --hard origin/main), and git pull --rebase origin main before pushing.
release.yml is literally on: push: branches: [main] with no paths-ignore and no actor filter, so every bot commit re-enters the workflow. One human push produced three Release runs in ~4 minutes:
Run
Head SHA
Triggering commit
Time (UTC)
Result
30169605854
3c2d1166
ci(release): mint GitHub App tokens…
18:25:47
failure
30169656024
7938829e
chore(release): client v0.13.0
18:27:18
failure
30169740059
cb2cc64d
chore(release): mcp v0.16.0
18:29:50
success (no-op)
Each bot commit also fired CI, Security, and Deploy Documentation. The chain is exactly as described.
✅ 1.4 Immutable releases — stocktrim run 30169656024 — CONFIRMED, and permanent
"Attach .mcpb to GitHub release" failed:
HTTP 422: Cannot upload assets to an immutable release.
(https://uploads.github.com/repos/dougborg/stocktrim-openapi-client/releases/359829310/assets?label=&name=stocktrim-mcp-server-0.16.0.mcpb)
gh release view mcp-v0.16.0 shows immutable: true, assets: []. This is not recoverable — immutable releases can never accept assets afterwards. The bundle survives only as workflow artifact mcpb on run 30169656024, expiring 2026-10-23. client-v0.13.0 likewise has zero assets. PyPI publish for mcp 0.16.0 did succeed.
PR #237 (merged 03b93fad) fixes this forward by setting vcs_release: false on the MCP PSR invocation and replacing gh release upload --clobber with an awk changelog-extract step plus gh release create <tag> <file> --title --notes-file — i.e. create the release with its asset. GitHub's own guidance for immutable releases is the same shape: build assets first and create atomically, or create as draft → attach → publish (GitHub Docs: Immutable releases). This principle must be carried forward into any new design — see §5.4.
The premise says "the MCP package pins the client version, so a client release requires a follow-up commit bumping the MCP's pin, plus a uv.lock re-sync commit." What's actually true differs per repo:
Repo
MCP → client dependency (committed state)
uv.lock re-sync commit?
katana
katana-openapi-client>=0.80.0 (floating floor, not an exact pin) + [tool.uv.sources] … {workspace = true}
Yes — a dedicated sync-lockfile job pushes chore: sync uv.lock after release to main after each release
stocktrim
bare "stocktrim-openapi-client", no version constraint at all + workspace source
No — release jobs never touch uv.lock
statuspro
statuspro-openapi-client>=0.1.0 (was >=0.51.0)
No landed commits — pipeline has never succeeded
frontapp
frontapp-openapi-client>=0.51.0
No — push triggers disabled
So: the pin-bump commit is real in katana and attempted-in-CI in stocktrim, but stocktrim's pin never landed — the "Update MCP client dependency" step failed in run 1 and was skipped in run 2 (release-client.outputs.released was empty on the re-triggered run). Consequence worth noting on its own: stocktrim mcp 0.16.0 shipped to PyPI with an unconstrained client dependency. No chore(mcp): update client dependency commit exists in stocktrim's history at all.
The lockfile re-sync commit is real only in katana. It is the residue of katana issue #901 ("Release automation leaves root uv.lock behind the package pyproject version"), which is closed but whose proposed durable fix — fold uv lock into PSR's build_command and add uv.lock to assets — was never applied. Recent katana history still shows the two-commit pattern repeating: chore(release): client v0.81.0 → chore: sync uv.lock after release → chore(release): mcp v0.115.0 → chore: sync uv.lock after release.
🔎 1.6 Additional findings not in the premise
statuspro's release pipeline has never worked. Every push-triggered release.yml run fails at the semantic-release git-push step: fatal: could not read Username for 'https://github.com': terminal prompts disabled / exit 128 (e.g. run 26194603402, 2026-05-20) — a missing/invalid SEMANTIC_RELEASE_TOKEN. Same for release-ts.yml (run 26194603409). release-mcp.yml (tag-triggered) has zero runs, because the tag is never created.
frontapp's push triggers are commented out entirely, with the comment "Push trigger disabled pre-v0.1.0: PYPI_TOKEN / SEMANTIC_RELEASE_TOKEN aren't configured as repo secrets yet." Tracked in frontapp issue Fix AutoAPI duplicate object warnings and ensure generated code formatting consistency #10. Every historical run (pre-disable, 2026-04-24) failed the same way.
The pin-drift failure mode has already caused a real user-facing bug: statuspro issue MCP-04: Package and Deploy MCP Server to PyPI #63 — statuspro-mcp-server required client>=0.51.0 when only 0.1.0 existed on PyPI, i.e. an uninstallable package. Root cause per the issue: the constraint was copy-pasted from a sister repo (named as likely katana-openapi-client) at initial commit, and the automation meant to keep it truthful never ran. Fixed by hand in PR feat: implement Inventory domain class with full CRUD support (Phase 1 of #60) #62. frontapp carries the identical >=0.51.0 pin today with nothing published at all — the same bug, unfiled.
Two divergent MCP release surfaces. katana has both release.yml (push: main, releases MCP) andrelease-mcp.yml (push: tags: mcp-v*, publishes MCP to PyPI). statuspro/frontapp have the same duplication. These overlap and can double-publish.
statuspro/frontapp add two more self-trigger hops beyond the ones described: release.yml creates an mcp-v* tag → release-mcp.yml fires; and update-mcp-dependency.yml runs on: workflow_run after Release completes, commits the pin + lockfile, and git push origin main → re-enters release.yml.
statuspro and frontapp release*.yml are byte-for-byte structurally identical. Any redesign should be a single shared architecture applied to both.
Rulesets differ materially:
Repo
main protected?
Required PR?
Bypass actors
katana
yes ("Protect Main", id 6756621)
yes (0 approvals) + required checks
Admin role (id 5, always) and Integration app id 4392719 (= vars.RELEASE_PLEASE_APP_ID)
statuspro
yes (id 19738529, created 2026-07-25)
yes + required checks
Admin role + Integration app 4392719
stocktrim
yes (id 9325772)
no — only deletion / non-fast-forward / linear-history / copilot review
Two of the four repos have no working release pipeline at all. Wiring credentials into the existing design would be spending the migration budget on the design we want to leave.
The failure that actually reached users — statuspro MCP-04: Package and Deploy MCP Server to PyPI #63's uninstallable MCP package — is a correctness failure of inter-package coupling, not a concurrency failure, and none of the three patches touches it.
Artifacts are being permanently lost (mcp-v0.16.0 has no .mcpb, forever).
PSR 9.x, per-package [tool.semantic_release], scope-filtered commit parser
PSR 9.15.2, same
PSR, same
PSR, same
JS releaser
—
—
semantic-release via packages/statuspro-client/.releaserc.json, tagFormat: ts-v${version}, npmPublish: false
same shape
Working today?
yes
yes (with patches)
no (bad token)
no (triggers disabled)
Important compatibility observation: every existing tag is of the form <component>-v<version>. release-please's default tag format when include-component-in-tag: true is exactly <component>-v<version> with - as separator — so existing tags remain valid and continuity of version history is preserved without custom tag templating. (Inferred from release-please's manifest/component documentation; verify empirically on the pilot repo before rolling out.)
Versioning is already fully independent per package via scope-filtered commit parsers ((client) / (mcp) / (ts)). That convention transfers directly to release-please, which partitions commits by path rather than scope — a behavioural difference worth calling out (see §4 downsides).
3. What the research says
release-please (manifest mode) — the recommended target
With release-please-config.json + .release-please-manifest.json, release-please raises a single combined Release PR covering every configured package: "the motivation of the manifest-based releaser is support for monorepos… a combined Release PR will be created for all configured packages" (manifest-releaser.md). Merging that one PR bumps versions and changelogs for all changed packages in one commit, and release-please tags each package at that commit — all tags land together.
Release-please explicitly "does not handle publication to package managers" (README) — it updates changelogs, tags, and creates GitHub Releases. Your build/publish jobs are separate and are meant to trigger on the tag/release it produces.
Inter-package pinning and lockfile resync are NOT built in. The intended mechanisms are extra-files (with typed toml/json/yaml variants and jsonpath-style selectors) or a custom manifest plugin in the plugins array. Known friction: extra-files not reliably updated in some cases (release-please-action#879); updating files outside a package's own path in monorepos (release-please#2477).
python-semantic-release — the incumbent
PSR has no native multi-package/workspace concept. Even after the v10.4.0 "Conventional Commits Monorepo Parser," there is no workspace-level config: each package needs its own pyproject.toml with its own [tool.semantic_release], its own tag_format, its own scope and path filters, and you invoke the CLI once per package directory (PSR monorepo guide).
So the current one-job-per-package pattern is the only topology PSR offers for monorepos — but it is documented nowhere as concurrency-safe. The official monorepo doc doesn't address parallel invocations at all. PSR has also had scope-filtering gaps causing incorrect cross-package bumps (PSR#1215).
Upstream JS semantic-release (PSR's design ancestor) has open issues describing precisely the observed failures: parallel per-component jobs where the first push succeeds and the rest fail because the branch moved (semantic-release#1628); concurrent same-repo runs breaking because git fetch/push can't be done concurrently by multiple writers (#1545). The community's own recommendation is concurrency: groups or splitting version-generation from publishing (discussion#4103).
Answering the question directly: no, the current one-job-per-package topology is not a supported-safe topology. It is a documented workaround that inherits same-branch race risk unless you serialize it yourself — which is exactly what #1001 and #236 did, by hand, per repo.
Changesets
Architecturally very close to release-please (aggregate "Version Packages" PR → merge → publish), and it does natively resolve internal workspace dependency bumps — the thing release-please makes you script (changesets/action, automating-changesets.md). But it is npm/JS-workspace-specific with no Python awareness. Adopting it would mean running two independent release systems in statuspro/frontapp and none of the benefit in katana/stocktrim. Rejected.
The general principle
Do version-bump + tag exactly once, via a merged PR rather than a direct push from a job matrix; trigger the actual build+publish from tag or release events, not push: main; scope a concurrency: group to workflow+ref. This simultaneously eliminates (a) concurrent pushes racing on one branch, (b) release commits re-triggering on: push: main, and (c) the immutable-release asset problem — because the publish job now owns asset creation end-to-end instead of appending to a release someone else already finalized.
4. Recommendation
Primary: release-please in manifest mode, one config per repo, tag-triggered publishing
Why this one:
It removes the generating condition rather than each symptom. Nothing pushes to main during a release. The version bump, the changelog, the MCP→client pin bump, and the uv.lock re-sync all happen on the release PR's branch, and land as one merge commit. There is no second push to race with, and no bot commit on main to re-trigger anything.
It scales to N packages. Adding the TS client, a Docker image, or a fifth artifact doesn't add another writer to main.
You already run it.harness-kit and AirHound use release-please, and the dougborg-release-please GitHub App (ID 4392719) is installed org-wide with credentials already wired into all four repos — confirmed as the ruleset bypass actor on katana and statuspro. Migration cost is mostly config, not new infrastructure or new secrets.
It works with branch protection instead of around it. release-please's normal mode of operation is opening a PR. On katana/statuspro (required-PR rulesets), that removes the dependency on bypass entirely for the version-bump step — a direct improvement to the posture katana Migrate publishing workflows from PAT to GitHub App auth #429 is worried about. Bypass is then needed only if you want the release PR to auto-merge without approval.
Existing tag names carry over (client-v* / mcp-v* / ts-v* match release-please's default component tag format), so version history is continuous.
One review surface. Today a release is invisible until it has already happened. Under release-please, the pending release — every package, every version, every changelog entry — is a PR you can look at before it ships.
Honest downsides:
Path-based, not scope-based, change detection. PSR currently partitions by commit scope ((client) vs (mcp)). release-please partitions by path. A commit touching both trees will bump both packages. That's arguably more correct, but it is a behaviour change and will produce different bump decisions than today. It also means the commit-scope convention becomes advisory rather than load-bearing.
Inter-package pin bumps are hand-written glue. release-please will not natively bump mcp/pyproject.toml's client>=X when the client releases. You must use extra-files (with the known reliability caveats above) or a small step on the release-PR branch. This is genuinely the weakest part of the recommendation — Changesets does this natively and release-please does not.
uv.lock needs a job. release-please cannot run uv lock. You need a workflow that reacts to the release PR branch, runs uv lock, and commits back to that branch. Cheaper and far safer than today's push-to-mainsync-lockfile job, but it is still glue you own.
Draft-release dance for assets. See §5.4 — not hard, but not free either.
Releases become batched, not continuous. Today a merge to main ships immediately. Under release-please, it ships when you merge the release PR. For these repos (internal API clients) that is a feature; if you want continuity, enable auto-merge on the release PR and you're back to roughly the current latency with none of the races.
Runner-up (rejected): keep PSR, restructure to "one version job, then tag-triggered publish"
Concretely: collapse release-client + release-mcp into a single job that runs the PSR CLI once per package directory sequentially, with push.ci disabled, then does exactly one git push --atomic origin main --tags at the end; add paths-ignore/actor guards so bot commits don't re-trigger; move all publishing (publish-*-pypi, Docker, .mcpb) into separate workflows triggered on: push: tags.
Why it's attractive: far cheaper migration, keeps a tool the repos already configure correctly, keeps scope-based partitioning, and would genuinely fix failures 1–4. If the appetite for change is low, this is a defensible place to stop — I'd rate it a clear improvement over today.
Self-triggering is suppressed by guards (paths-ignore, actor checks) rather than by construction. Guards are one careless edit away from regressing, and paths-ignore interacts badly with required status checks.
PSR still has no cross-package model, so the MCP→client pin and uv.lock remain bespoke steps mutating main — the failure that actually shipped a broken package (statuspro MCP-04: Package and Deploy MCP Server to PyPI #63) stays structurally possible.
It leaves four repos on a topology upstream describes as a workaround, while a supported topology is already deployed elsewhere in the org.
.release-please-manifest.json is seeded with today's live versions so history is continuous.
5.2 Workflow A — release-please.yml (the only thing watching main)
on:
push: { branches: [main] }concurrency: { group: release-please, cancel-in-progress: false }jobs:
release-please:
steps:
- googleapis/release-please-action@<sha> # App token, ID 4392719# Creates/updates ONE release PR, or — if the release PR was just merged —# creates all tags + GitHub Releases at that single commit.# NOTE: this job NEVER pushes to main. It only writes to the release-PR branch.
This workflow re-triggering itself is harmless and expected: after the release PR merges it runs once more and tags. It performs no push to main, so there is no cascade.
5.3 Workflow B — release-pr-prepare.yml (the glue, on the PR branch only)
on:
pull_request: { branches: [main] }jobs:
sync:
if: startsWith(github.head_ref, 'release-please--')steps:
- checkout (ref: github.head_ref, App token)
- run: uv lock # resync lockfile to the bumped versions
- run: scripts/sync-mcp-pin.py # belt-and-braces for the MCP -> client floor,# in case extra-files under-delivers (#879)
- commit + push to the RELEASE PR BRANCH # <-- never to main
This is where katana's sync-lockfile job and statuspro/frontapp's update-mcp-dependency.yml go to die. Their work still happens; it just happens somewhere harmless, and lands atomically with the version bump instead of as a trailing commit on main.
5.4 Workflow C — publish.yml (tag-driven; the only thing that builds and ships)
On immutability: set "draft": true in the release-please config so release-please creates draft releases. Assets are attached while draft; gh release edit --draft=false publishes, and only then does immutability lock the release. This is the pattern GitHub documents, and it generalises stocktrim #237's fix (create-with-assets) to the case where the release creator and the asset builder are different jobs — which is unavoidable once release-please owns release creation.
5.5 What gets deleted
katana release.yml's release-client, release-mcp, sync-lockfile jobs — and release-mcp.yml merges into publish.yml
stocktrim release.yml's release-client, release-mcp jobs
statuspro/frontapp release.yml, release-ts.yml, update-mcp-dependency.yml; release-mcp.yml folds into publish.yml
every [tool.semantic_release] block, every .releaserc.json
SEMANTIC_RELEASE_TOKEN becomes unnecessary — which is, not incidentally, the exact secret whose absence has kept statuspro and frontapp broken
5.6 Ruleset interaction (no settings changes proposed here)
Repo
Behaviour under the new design
katana
Required-PR ruleset is satisfied by construction — release-please opens a PR. App bypass (4392719) becomes optional, needed only for auto-merge. Net reduction in privilege, aligned with #429.
statuspro
Same as katana.
stocktrim
No PR requirement today; release-please's PR flow works regardless. Linear history is satisfied since the release PR is a single squash/rebase merge — the pull --rebase gymnastics from #236 disappear.
frontapp
Unprotected. Works as-is; would be a good moment to add a ruleset matching the others, but that's out of scope for this proposal.
Explicitly: no ruleset, secret, variable, or repo-setting change is proposed. The App and its credentials are already in place.
6. Migration path
Pilot: frontapp-openapi-client. Counter-intuitive but correct — its release pipeline is already disabled and has never worked, so there is literally nothing to break. It also has no branch ruleset, so no protection interactions to debug, and it exercises the full matrix (Python client + TS client + MCP + inter-package pin). Its issue #10 currently proposes re-enabling the old design once tokens are configured; the right resolution is to migrate instead, and close #10 as superseded.
Order:
frontapp — greenfield pilot. Validates: tag-format continuity, extra-files pin bumping, the uv lock on-PR-branch job, the draft-release asset dance, TS+Python coexistence. Fix the latent >=0.51.0 pin bug in passing.
statuspro — structurally identical, also currently broken. Copy the validated config. Adds the MCPB bundle and GHCR Docker to the validated set. Removes the need to ever debug the SEMANTIC_RELEASE_TOKEN failure.
stocktrim — first repo with a working pipeline to migrate, but the simplest one (2 Python packages, no TS, no required-PR ruleset). Also the repo carrying the two most recent band-aids.
katana — last, because it's the most complex (3 artifacts incl. GHCR), the highest-traffic, and the only one with a genuinely nontrivial existing sync-lockfile dependency.
Incremental steps available before any big-bang — each is independently valuable and reversible:
Step 0 (do this regardless, any repo): move publishing out of the release workflow onto on: push: tags. This alone decouples build/publish from versioning and can be done while PSR still owns versioning.
Step 0.5 (katana, cheap): apply the fix from Release automation leaves root uv.lock behind the package pyproject version #901's comment that was never applied — fold uv lock into PSR's build_command and add uv.lock to assets. This retires the sync-lockfile job and one push-to-main per release today, independent of any migration.
Step 1: land release-please config + manifest in a repo alongside PSR, with release-please's release creation disabled, and compare the release PR it proposes against what PSR actually did for a few cycles. Zero risk, high confidence gain.
Redundant. No release job checks out or pushes main.
stocktrim #237 (vcs_release: false, gh release create with asset)
Principle retained, implementation replaced. "Build all assets before finalising the release" survives as the draft→attach→publish flow in §5.4; the PSR-specific vcs_release: false flag disappears with PSR.
katana #1001 (serialize release-mcp after release-client)
Redundant. There is only one version-producing step, and it produces one commit.
katana sync-lockfile job
Redundant (superseded by the on-PR-branch uv lock job). Retireable earlier via Step 0.5.
statuspro/frontapp update-mcp-dependency.yml
Redundant (folds into extra-files + the on-PR-branch job).
7. Open questions
Confirm empirically that release-please's include-component-in-tag produces exactly client-vX.Y.Z / mcp-vX.Y.Z and picks up existing tags for changelog ranges. (Inferred from docs; must be verified on the pilot.)
Does the path-based → scope-based partitioning change produce materially different bump decisions on these repos' commit history? Worth a dry-run comparison during Step 1.
Should the release PR auto-merge? Auto-merge preserves today's ship-on-merge latency but gives up the review surface that is one of the main benefits.
Design: rework multi-module release flow for independent versions
Status: proposal · Date: 2026-07-25 · Scope:
katana-openapi-client,stocktrim-openapi-client,statuspro-openapi-client,frontapp-openapi-clientRecommendation in one line: move all four repos to release-please in manifest mode — one aggregated Release PR per repo, all tags created atomically on merge, and every build/publish job triggered by tags (or by the GitHub Release), so no release job ever pushes to
main.1. Verification of the stated failures
Every claim below was checked against real run logs, workflow files at HEAD, and repo settings. Three of five claims are confirmed as stated; two need correction.
✅ 1.1 Job race — katana run 30171587653 — CONFIRMED (but already fixed)
At the time of the run,
release-clientandrelease-mcpin.github/workflows/release.ymlboth keyed offneeds: testand ran concurrently.release-mcpwon;release-clientfailed at the "Python Semantic Release (Client)" step:MCP released (v0.115.0 + Docker), client did not.
Correction to the premise: PR #1001 is merged (2026-07-25T19:44:21Z) and is not a band-aid in the pejorative sense — it is a genuine dependency-graph serialization:
release-mcp'sneedschanged fromtestto[test, release-client], withif: always() && needs.test.result == 'success'so MCP can still release when the client doesn't, plusref: mainandgit fetch/reset --hard origin/mainsorelease-mcpstarts from the post-client-release tip. This specific race is closed. It does not, however, address the cascade or the direct-push-to-maintopology.✅ 1.2 Stale-checkout race — stocktrim run 30169605854 — CONFIRMED
Head SHA
3c2d1166.release-clientsucceeded (client v0.13.0).release-mcp(job 89708535182) failed at "Update MCP client dependency":The checkout had no
ref:, so it used the stale triggering SHA. Fixed point-wise by PR #236 (mergedf7709511): addsref: main, a "fast-forward to the post-release tip" step (git fetch origin main; git reset --hard origin/main), andgit pull --rebase origin mainbefore pushing.✅ 1.3 Cascading self-trigger — stocktrim — CONFIRMED
release.ymlis literallyon: push: branches: [main]with nopaths-ignoreand no actor filter, so every bot commit re-enters the workflow. One human push produced three Release runs in ~4 minutes:3c2d1166ci(release): mint GitHub App tokens…7938829echore(release): client v0.13.0cb2cc64dchore(release): mcp v0.16.0Each bot commit also fired CI, Security, and Deploy Documentation. The chain is exactly as described.
✅ 1.4 Immutable releases — stocktrim run 30169656024 — CONFIRMED, and permanent
"Attach .mcpb to GitHub release" failed:
gh release view mcp-v0.16.0showsimmutable: true,assets: []. This is not recoverable — immutable releases can never accept assets afterwards. The bundle survives only as workflow artifactmcpbon run 30169656024, expiring 2026-10-23.client-v0.13.0likewise has zero assets. PyPI publish for mcp 0.16.0 did succeed.PR #237 (merged
03b93fad) fixes this forward by settingvcs_release: falseon the MCP PSR invocation and replacinggh release upload --clobberwith anawkchangelog-extract step plusgh release create <tag> <file> --title --notes-file— i.e. create the release with its asset. GitHub's own guidance for immutable releases is the same shape: build assets first and create atomically, or create as draft → attach → publish (GitHub Docs: Immutable releases). This principle must be carried forward into any new design — see §5.4.The premise says "the MCP package pins the client version, so a client release requires a follow-up commit bumping the MCP's pin, plus a
uv.lockre-sync commit." What's actually true differs per repo:katana-openapi-client>=0.80.0(floating floor, not an exact pin) +[tool.uv.sources] … {workspace = true}sync-lockfilejob pusheschore: sync uv.lock after releasetomainafter each release"stocktrim-openapi-client", no version constraint at all + workspace sourceuv.lockstatuspro-openapi-client>=0.1.0(was>=0.51.0)frontapp-openapi-client>=0.51.0So: the pin-bump commit is real in katana and attempted-in-CI in stocktrim, but stocktrim's pin never landed — the "Update MCP client dependency" step failed in run 1 and was skipped in run 2 (
release-client.outputs.releasedwas empty on the re-triggered run). Consequence worth noting on its own: stocktrim mcp 0.16.0 shipped to PyPI with an unconstrained client dependency. Nochore(mcp): update client dependencycommit exists in stocktrim's history at all.The lockfile re-sync commit is real only in katana. It is the residue of katana issue #901 ("Release automation leaves root uv.lock behind the package pyproject version"), which is closed but whose proposed durable fix — fold
uv lockinto PSR'sbuild_commandand adduv.locktoassets— was never applied. Recent katana history still shows the two-commit pattern repeating:chore(release): client v0.81.0→chore: sync uv.lock after release→chore(release): mcp v0.115.0→chore: sync uv.lock after release.🔎 1.6 Additional findings not in the premise
statuspro's release pipeline has never worked. Every push-triggered
release.ymlrun fails at the semantic-release git-push step:fatal: could not read Username for 'https://github.com': terminal prompts disabled/ exit 128 (e.g. run26194603402, 2026-05-20) — a missing/invalidSEMANTIC_RELEASE_TOKEN. Same forrelease-ts.yml(run26194603409).release-mcp.yml(tag-triggered) has zero runs, because the tag is never created.frontapp's push triggers are commented out entirely, with the comment "Push trigger disabled pre-v0.1.0: PYPI_TOKEN / SEMANTIC_RELEASE_TOKEN aren't configured as repo secrets yet." Tracked in frontapp issue Fix AutoAPI duplicate object warnings and ensure generated code formatting consistency #10. Every historical run (pre-disable, 2026-04-24) failed the same way.
The pin-drift failure mode has already caused a real user-facing bug: statuspro issue MCP-04: Package and Deploy MCP Server to PyPI #63 —
statuspro-mcp-serverrequiredclient>=0.51.0when only0.1.0existed on PyPI, i.e. an uninstallable package. Root cause per the issue: the constraint was copy-pasted from a sister repo (named as likelykatana-openapi-client) at initial commit, and the automation meant to keep it truthful never ran. Fixed by hand in PR feat: implement Inventory domain class with full CRUD support (Phase 1 of #60) #62. frontapp carries the identical>=0.51.0pin today with nothing published at all — the same bug, unfiled.Two divergent MCP release surfaces. katana has both
release.yml(push: main, releases MCP) andrelease-mcp.yml(push: tags: mcp-v*, publishes MCP to PyPI). statuspro/frontapp have the same duplication. These overlap and can double-publish.statuspro/frontapp add two more self-trigger hops beyond the ones described:
release.ymlcreates anmcp-v*tag →release-mcp.ymlfires; andupdate-mcp-dependency.ymlrunson: workflow_runafter Release completes, commits the pin + lockfile, andgit push origin main→ re-entersrelease.yml.statuspro and frontapp
release*.ymlare byte-for-byte structurally identical. Any redesign should be a single shared architecture applied to both.Rulesets differ materially:
always) and Integration app id 4392719 (= vars.RELEASE_PLEASE_APP_ID)bypass_actors: []Note stocktrim needs no bypass because it has no PR requirement — but it does enforce linear history, which is why fix(client): add missing is_archived field to UpdateProductRequest #236 had to use
pull --rebase.katana issue Supply-chain hardening for CI/CD (umbrella) #952 is a supply-chain-hardening umbrella (SHA-pinning, zizmor, Harden-Runner, Scorecard, SLSA) — orthogonal to release topology, no overlap. katana Migrate publishing workflows from PAT to GitHub App auth #429 (open) tracks migrating publishing workflows from PAT to GitHub App auth and flags the Admin-role bypass as an over-broad escape hatch.
Verdict on the premise
A rework is warranted, but not for the reason of "races we haven't patched yet" — those particular races have been patched. It's warranted because:
maininside a workflow that triggers on pushes tomain. Every future package added to a repo re-opens the same class of bug.mcp-v0.16.0has no.mcpb, forever).2. Repo inventory
.mcpbbundle (release asset)client-v*,mcp-v*client-v*,mcp-v*client-v*,mcp-v*,ts-v*client-v*,mcp-v*,ts-v*[tool.semantic_release], scope-filtered commit parsersemantic-releaseviapackages/statuspro-client/.releaserc.json,tagFormat: ts-v${version},npmPublish: falseImportant compatibility observation: every existing tag is of the form
<component>-v<version>. release-please's default tag format wheninclude-component-in-tag: trueis exactly<component>-v<version>with-as separator — so existing tags remain valid and continuity of version history is preserved without custom tag templating. (Inferred from release-please's manifest/component documentation; verify empirically on the pilot repo before rolling out.)Versioning is already fully independent per package via scope-filtered commit parsers (
(client)/(mcp)/(ts)). That convention transfers directly to release-please, which partitions commits by path rather than scope — a behavioural difference worth calling out (see §4 downsides).3. What the research says
release-please (manifest mode) — the recommended target
release-please-config.json+.release-please-manifest.json, release-please raises a single combined Release PR covering every configured package: "the motivation of the manifest-based releaser is support for monorepos… a combined Release PR will be created for all configured packages" (manifest-releaser.md). Merging that one PR bumps versions and changelogs for all changed packages in one commit, and release-please tags each package at that commit — all tags land together.pyproject.toml,setup.py,setup.cfg,version.py,<project>/__init__.py, plus changelog generation.extra-files(with typedtoml/json/yamlvariants and jsonpath-style selectors) or a custom manifest plugin in thepluginsarray. Known friction:extra-filesnot reliably updated in some cases (release-please-action#879); updating files outside a package's own path in monorepos (release-please#2477).python-semantic-release — the incumbent
pyproject.tomlwith its own[tool.semantic_release], its owntag_format, its own scope and path filters, and you invoke the CLI once per package directory (PSR monorepo guide).semantic-release(PSR's design ancestor) has open issues describing precisely the observed failures: parallel per-component jobs where the first push succeeds and the rest fail because the branch moved (semantic-release#1628); concurrent same-repo runs breaking because git fetch/push can't be done concurrently by multiple writers (#1545). The community's own recommendation isconcurrency:groups or splitting version-generation from publishing (discussion#4103).Answering the question directly: no, the current one-job-per-package topology is not a supported-safe topology. It is a documented workaround that inherits same-branch race risk unless you serialize it yourself — which is exactly what #1001 and #236 did, by hand, per repo.
Changesets
Architecturally very close to release-please (aggregate "Version Packages" PR → merge → publish), and it does natively resolve internal workspace dependency bumps — the thing release-please makes you script (changesets/action, automating-changesets.md). But it is npm/JS-workspace-specific with no Python awareness. Adopting it would mean running two independent release systems in statuspro/frontapp and none of the benefit in katana/stocktrim. Rejected.
The general principle
Do version-bump + tag exactly once, via a merged PR rather than a direct push from a job matrix; trigger the actual build+publish from tag or release events, not
push: main; scope aconcurrency:group to workflow+ref. This simultaneously eliminates (a) concurrent pushes racing on one branch, (b) release commits re-triggeringon: push: main, and (c) the immutable-release asset problem — because the publish job now owns asset creation end-to-end instead of appending to a release someone else already finalized.4. Recommendation
Primary: release-please in manifest mode, one config per repo, tag-triggered publishing
Why this one:
mainduring a release. The version bump, the changelog, the MCP→client pin bump, and theuv.lockre-sync all happen on the release PR's branch, and land as one merge commit. There is no second push to race with, and no bot commit onmainto re-trigger anything.main.harness-kitandAirHounduse release-please, and thedougborg-release-pleaseGitHub App (ID 4392719) is installed org-wide with credentials already wired into all four repos — confirmed as the ruleset bypass actor on katana and statuspro. Migration cost is mostly config, not new infrastructure or new secrets.client-v*/mcp-v*/ts-v*match release-please's default component tag format), so version history is continuous.Honest downsides:
(client)vs(mcp)). release-please partitions by path. A commit touching both trees will bump both packages. That's arguably more correct, but it is a behaviour change and will produce different bump decisions than today. It also means the commit-scope convention becomes advisory rather than load-bearing.mcp/pyproject.toml'sclient>=Xwhen the client releases. You must useextra-files(with the known reliability caveats above) or a small step on the release-PR branch. This is genuinely the weakest part of the recommendation — Changesets does this natively and release-please does not.uv.lockneeds a job. release-please cannot runuv lock. You need a workflow that reacts to the release PR branch, runsuv lock, and commits back to that branch. Cheaper and far safer than today's push-to-mainsync-lockfilejob, but it is still glue you own.mainships immediately. Under release-please, it ships when you merge the release PR. For these repos (internal API clients) that is a feature; if you want continuity, enable auto-merge on the release PR and you're back to roughly the current latency with none of the races.Runner-up (rejected): keep PSR, restructure to "one version job, then tag-triggered publish"
Concretely: collapse
release-client+release-mcpinto a single job that runs the PSR CLI once per package directory sequentially, withpush.cidisabled, then does exactly onegit push --atomic origin main --tagsat the end; addpaths-ignore/actor guards so bot commits don't re-trigger; move all publishing (publish-*-pypi, Docker,.mcpb) into separate workflows triggeredon: push: tags.Why it's attractive: far cheaper migration, keeps a tool the repos already configure correctly, keeps scope-based partitioning, and would genuinely fix failures 1–4. If the appetite for change is low, this is a defensible place to stop — I'd rate it a clear improvement over today.
Why I rejected it as primary:
main, which means it still depends on ruleset bypass on katana/statuspro — moving in the opposite direction from Migrate publishing workflows from PAT to GitHub App auth #429.paths-ignore, actor checks) rather than by construction. Guards are one careless edit away from regressing, andpaths-ignoreinteracts badly with required status checks.uv.lockremain bespoke steps mutatingmain— the failure that actually shipped a broken package (statuspro MCP-04: Package and Deploy MCP Server to PyPI #63) stays structurally possible.5. Target topology
Pseudo-YAML; illustrative, not for copy-paste.
5.1 Config
release-please-config.json:{ "separate-pull-requests": false, // ONE aggregated release PR "packages": { ".": { "release-type": "python", "component": "client", "changelog-path": "docs/CHANGELOG.md" }, "katana_mcp_server": { "release-type": "python", "component": "mcp", "extra-files": [ // MCP -> client pin { "type": "toml", "path": "katana_mcp_server/pyproject.toml", "jsonpath": "$.project.dependencies" } ] }, "packages/xxx-client": { "release-type": "node", "component": "ts" } // statuspro/frontapp only }, "include-component-in-tag": true // -> client-vX.Y.Z, mcp-vX.Y.Z, ts-vX.Y.Z }.release-please-manifest.jsonis seeded with today's live versions so history is continuous.5.2 Workflow A —
release-please.yml(the only thing watchingmain)This workflow re-triggering itself is harmless and expected: after the release PR merges it runs once more and tags. It performs no push to
main, so there is no cascade.5.3 Workflow B —
release-pr-prepare.yml(the glue, on the PR branch only)This is where katana's
sync-lockfilejob and statuspro/frontapp'supdate-mcp-dependency.ymlgo to die. Their work still happens; it just happens somewhere harmless, and lands atomically with the version bump instead of as a trailing commit onmain.5.4 Workflow C —
publish.yml(tag-driven; the only thing that builds and ships)On immutability: set
"draft": truein the release-please config so release-please creates draft releases. Assets are attached while draft;gh release edit --draft=falsepublishes, and only then does immutability lock the release. This is the pattern GitHub documents, and it generalises stocktrim #237's fix (create-with-assets) to the case where the release creator and the asset builder are different jobs — which is unavoidable once release-please owns release creation.5.5 What gets deleted
release.yml'srelease-client,release-mcp,sync-lockfilejobs — andrelease-mcp.ymlmerges intopublish.ymlrelease.yml'srelease-client,release-mcpjobsrelease.yml,release-ts.yml,update-mcp-dependency.yml;release-mcp.ymlfolds intopublish.yml[tool.semantic_release]block, every.releaserc.jsonSEMANTIC_RELEASE_TOKENbecomes unnecessary — which is, not incidentally, the exact secret whose absence has kept statuspro and frontapp broken5.6 Ruleset interaction (no settings changes proposed here)
pull --rebasegymnastics from #236 disappear.Explicitly: no ruleset, secret, variable, or repo-setting change is proposed. The App and its credentials are already in place.
6. Migration path
Pilot:
frontapp-openapi-client. Counter-intuitive but correct — its release pipeline is already disabled and has never worked, so there is literally nothing to break. It also has no branch ruleset, so no protection interactions to debug, and it exercises the full matrix (Python client + TS client + MCP + inter-package pin). Its issue #10 currently proposes re-enabling the old design once tokens are configured; the right resolution is to migrate instead, and close #10 as superseded.Order:
extra-filespin bumping, theuv lockon-PR-branch job, the draft-release asset dance, TS+Python coexistence. Fix the latent>=0.51.0pin bug in passing.SEMANTIC_RELEASE_TOKENfailure.sync-lockfiledependency.Incremental steps available before any big-bang — each is independently valuable and reversible:
on: push: tags. This alone decouples build/publish from versioning and can be done while PSR still owns versioning.uv lockinto PSR'sbuild_commandand adduv.locktoassets. This retires thesync-lockfilejob and one push-to-mainper release today, independent of any migration.Band-aid disposition:
ref: main,reset --hard,pull --rebase)main.vcs_release: false,gh release createwith asset)vcs_release: falseflag disappears with PSR.release-mcpafterrelease-client)sync-lockfilejobuv lockjob). Retireable earlier via Step 0.5.update-mcp-dependency.ymlextra-files+ the on-PR-branch job).7. Open questions
include-component-in-tagproduces exactlyclient-vX.Y.Z/mcp-vX.Y.Zand picks up existing tags for changelog ranges. (Inferred from docs; must be verified on the pilot.)extra-filesfor a TOML dependency-array edit specifically? Given feat(mcp): surface landed-cost apportionment at receive time — accept additional_costs on receive_purchase_order.items[] (or document the modify path) #879, plan for the scripted fallback in §5.3 from day one.Sources