| name | release |
| description | Publishing a jMunch release (jcodemunch-mcp, jdocmunch-mcp, jdatamunch-mcp, jragmunch-cli), reviewing/merging/closing PRs, and responding to the community. Load before any version bump, PyPI upload, tag, GitHub release, MCP registry publish, or PR merge. |
Release & PR Workflow
REVISED 2026-09-04 (docs/workflows/DESIGN.md section 5). The PUBLISH
half of this file (steps 4-7 below: local build, twine upload, git tag,
gh release create, mcp-publisher) is SUPERSEDED by release.yml,
dispatched per docs/cicd/RUNBOOK.md section 1; the /release command
prepares the release PR and stops. Those steps stay here as the record of
the retired path and for RUNBOOK section 1a's hand-finish while PyPI
persists no publisher (C-15). The PR-workflow and CLA halves are current.
This copy is TRACKED in this repo; the suite copy under C:\MCPs\.claude
serves jdoc and jdata.
⚠⚠ THIS FILE MIXES TWO AUDIENCES AND THE SPLIT IS LOAD-BEARING. Almost every
command below is run BY THE AGENT through the Bash tool (Git Bash), so bash
syntax is correct for them: PYTHONPATH=src python ..., GITHUB_TOKEN="" gh ...,
BR=$(...). Inline env-var prefixes and $(...) do NOT exist in cmd.exe or
PowerShell, which is what jjg is at when he types.
Step 7 is the ONE command a HUMAN types interactively, and on 2026-08-13 it
failed mid-release because it had silently inherited bash path syntax from its
neighbours (~\mcp-publisher.exe -> "The system cannot find the path
specified"). The dev platform is Windows. Any line added here that a human
will type must be given in cmd.exe AND PowerShell form, with no ~, no inline
VAR=x cmd prefix, and no $(...). Mark the shell explicitly.
Publishing a release
⚠⚠ BEFORE ANY OF THIS: are you holding the release for something that is not
ours? A signature, a contributor PR, a reply, a re-run, "so it can go out
together" — if the thing being waited for needs someone else to act, ship
now and let them ride the next one. Policy 2 is not broken by anyone
overruling it; it is broken by a batching argument that never mentions it
(2026-08-18: five merged green fixes held two days to avoid three cheap conflict
resolutions). "Reduce our churn" is not a release criterion — conflict
resolution and re-merges are our costs to absorb, and the moment avoiding them
shapes when users get fixes, we are spending their latency for our
convenience. See CLAUDE.md policy 2e.
PYTHONPATH=src python -m pytest tests/ -q
uv run ruff check src/
uv --locked --group dev --extra watch --python 3.13
uv run --python 3.13 pytest tests/ -q
git commit -m
uvx --from build pyproject-build
uvx --from twine twine check dist/*X.Y.Z*
uvx --from twine twine upload dist/*X.Y.Z*
BR=$(GITHUB_TOKEN= gh repo view jgravelle/<repo> --json defaultBranchRef -q .defaultBranchRef.name)
git rev-parse --abbrev-ref HEAD
git tag vX.Y.Z
GITHUB_TOKEN= git push origin && GITHUB_TOKEN= git push origin vX.Y.Z
GITHUB_TOKEN= gh release create vX.Y.Z dist/*X.Y.Z* --repo jgravelle/<repo> --title --notes
GITHUB_TOKEN= gh run list --repo jgravelle/<repo> -- 3 \
--json headSha,conclusion,displayTitle
⚠⚠ Step 4 uses uvx, and the global python -m twine must NOT be restored.
2026-08-12, jdoc 1.132.0, mid-release: InvalidDistribution: '2.5' is not a valid metadata version. python -m build fetches an always-latest hatchling into
an isolated env, which now emits Metadata-Version: 2.5; the global twine 6.2.0
validates with packaging 24.2, which tops out at 2.4. Build and upload were
reading two different toolchains and only one of them was moving.
⚠⚠ The obvious fix — pip install -U packaging — is the wrong one. That
interpreter is a kitchen sink, and three installed packages cap it:
langchain-core (<25), streamlit (<25), inference-gpu (~=24.0).
Upgrading breaks three working packages to satisfy a release tool. uvx resolves
twine and its packaging in a throwaway env and leaves the global alone, so it
also cannot rot when metadata 2.6 lands.
⚠ twine check is the load-bearing half, not the upload. The failure is
otherwise discovered during upload, i.e. after the wheel may already be on
PyPI and the sdist not — a half-published version that cannot be re-uploaded. The
check is a step 4 gate for the same reason step 8 exists.
⚠ Not a jdoc quirk — it is ecosystem-wide. jcm's already-built
1.108.272-py3-none-any.whl carries Metadata-Version: 2.5 and fails global
twine check identically; jdata uses hatchling too, so it is next. ⚠ jragmunch
is the exception and the reason to run the check rather than reason about it: it
builds with setuptools, and its last wheel (0.4.8) is Metadata-Version: 2.4
— under the old ceiling, so it would have uploaded fine and taught you the wrong
lesson about which repos are affected.
⚠⚠ Step 8 is not ceremony. Lint was RED on jcm .259/.260/.261/.262 — four
consecutive releases published, tagged, announced and PyPI-uploaded on a failing
build, because the local suite was green and nobody looked at the check. The test
matrix (8 jobs) passed the whole time, so the failure was invisible from every
signal that was actually being read.
Step 7 is the step that rots. Both registry entries sat frozen at their
2026-03-21 publish for five months while PyPI went to 1.108.x, advertising
1.8.6 to every downstream aggregator. The registry version is a liveness
signal to anyone comparing servers; a stale one reads as abandoned regardless
of what ships.
⚠⚠ "jdatamunch was never published at all" WAS TRUE AND IS NOT — corrected
2026-08-25. It has been in the registry since 2026-08-06 and now carries nine
versions. The stale line was quoted from this file four times in one session
before anyone ran the query, and it was quoted to justify calling a routine
refresh a "first publish". A publication-state claim about any of the three
servers expires the moment someone publishes; it belongs in the API, never
here. Same rule this repo already applies to open-issue counts and timebox
dates — run it, do not quote it:
⚠⚠ Rows are NESTED: {server: {...}, _meta: {...}}. name and version
sit under server; isLatest sits under _meta. A flat .name read returns
zero rows on a good publish — a false negative independent of the paging trap,
and one that survives &limit=100.
curl -s 'https://registry.modelcontextprotocol.io/v0/servers?search=<pkg>&limit=100' -o reg.json
python - reg.json <<'PY'
import json, sys
rows = json.load(open(sys.argv[1], encoding="utf-8"))["servers"]
ours = [r for r in rows if r["server"]["name"].endswith("/<pkg>")]
print(f"{len(ours)} rows")
for r in ours:
off = r["_meta"]["io.modelcontextprotocol.registry/official"]
if off.get("isLatest"):
print("isLatest:", r["server"]["version"],
"| packages:", [p.get("version") for p in r["server"].get("packages", [])])
PY
⚠⚠ Why it rots is now measured, not guessed. The registry JWT is issued with
a five minute lifetime (2026-08-08: iat 13:14:40, exp 13:19:40). Any flow
that logs in, hands off, and publishes later loses the window, and the failure
arrives as a bare 401 ... token is expired, which reads as "the login did not
work" and sends you back to re-authenticate instead of re-sequencing. Run the two
as one command. If a publish 401s, check the token mtime before re-authenticating:
nothing newer than the last publish means the login ran in a different directory.
PR workflow
⚠⚠ A FORK PR SHOWING ONLY license/cla HAS NOT BEEN TESTED — IT HAS BEEN
SILENTLY HELD. gh pr checks lists only checks that RAN, so a held run is
invisible from the place you would look. Diagnosed 2026-08-13, after #459 was
merged having never run the matrix once.
GITHUB_TOKEN="" gh api "repos/jgravelle/<repo>/actions/runs?status=action_required&per_page=30" \
--jq '.workflow_runs[] | "\(.id)|\(.name)|\(.head_branch)|\(.head_sha[0:7])"'
GITHUB_TOKEN="" gh api --method POST "repos/jgravelle/<repo>/actions/runs/<id>/approve"
git ls-remote origin "refs/pull/<n>/merge"
⚠ Two independent causes that present identically, and the second is not a
settings problem: (1) actions/permissions/fork-pr-contributor-approval was
first_time_contributors, so a first-time fork contributor's runs were created
with conclusion=action_required and never executed — relaxed to
first_time_contributors_new_to_github on 2026-08-13; (2) a CONFLICTING PR has
no refs/pull/N/merge, and pull_request workflows run against the MERGE ref,
so a conflicting fork PR gets no run at all regardless of policy. It must be
rebased first. #451 was cause 2; #443 was cause 1.
⚠ gh pr close + gh pr reopen does NOT re-provoke the run — measured on
both PRs, no run appeared. The reliable re-trigger is a CONTRIBUTOR PUSH
(synchronize). Do not assume a policy fix worked until a real push proves it.
⚠⚠ OUR OWN PUSH TO A FORK BRANCH ERASES THE license/cla STATUS
Measured twice on #443. Pushing a conflict resolution moves the head, and
commit STATUSES (legacy API) do not follow a new SHA the way Actions CHECK RUNS
do. The matrix reappears, license/cla does not, and the PR can read
mergeStateStatus: CLEAN with the CLA unsigned — the blocker is not passing,
it is absent. gh pr checks shows only checks that RAN, so it looks clean there
too.
GITHUB_TOKEN="" gh api "repos/jgravelle/<repo>/commits/<head-sha>/status" \
--jq '"state=\(.state) count=\(.statuses|length)"'
⚠⚠ A MISSING CLA check counts as NOT SIGNED. Never merge on its absence.
⚠ Close + immediately reopen is NOT a dependable remedy — corrected
2026-08-15. It restored the status once (head 6aec667) and failed to
restore it on the very next occurrence (head d4760a1), so the earlier note
claiming it works was written from a single success. There is no maintainer-side
fix: the status returns when the CONTRIBUTOR acts (signing posts a fresh status
against the current head). Say so on the thread so the contributor is not left
reading eleven green checks as done.
⚠⚠ CLA Assistant can fail to fire on PR OPEN, and then there is no status to
erase — measured 2026-08-15 on #479. Zero statuses on the head SHA and no bot
comment, on a repo where the same app posted both on #473 and #443. It reads
identically to "we pushed and wiped it" and has the opposite cause. Check the
comments as well as the statuses before concluding anything about a signature:
GITHUB_TOKEN="" gh api repos/jgravelle/<repo>/issues/<n>/comments --jq '.[].user.login'
⚠ OUR OWN push woke it. Pushing a fix onto their branch is a synchronize,
and the badge plus a pending license/cla appeared within seconds. So the same
push that ERASES an existing status is what PROVOKES a missing one — the two
notes are not in conflict, they are about statuses that exist and statuses that
never did. Either way the verdict is unchanged: absent means NOT SIGNED.
⚠⚠ Until a fork PR shows a green matrix, a local TRIAL MERGE onto current main
is the substitute, not the branch's own result — branch-green is not
merged-green. Merge base drifts every release.
An ORG-OWNED fork cannot be pushed to, and the PR says otherwise
⚠⚠ maintainerCanModify: true is DISPLAYED AND WRONG when the fork belongs to
an organization. GitHub's "Allow edits by maintainers" only grants push access
for forks under a personal account. Against an org fork the push returns
403 Permission to <Org>/<repo>.git denied with FULL repo scope — it is not
a token problem and there is no flag to flip. Measured 2026-08-13 on #451
(Nexusmill, an Organization; the PR reported maintainerCanModify: true).
GITHUB_TOKEN="" gh pr view <n> --json headRepositoryOwner --jq .headRepositoryOwner.login
GITHUB_TOKEN="" gh api users/<that-login> --jq .type
⚠ CHECK THIS BEFORE PROMISING A CONTRIBUTOR YOU WILL FIX THEIR BRANCH. The
whole point of resolving it yourself is to stop sending them back; discovering
mid-way that you cannot is worse than never offering.
When we cannot push and the conflict is OURS, land it from our side:
GITHUB_TOKEN="" gh pr checkout <n> --force
git merge origin/main
GITHUB_TOKEN="" git push origin HEAD:refs/heads/contrib/<n>-merged
GITHUB_TOKEN="" gh pr create --base main --head contrib/<n>-merged
⚠ Their head commit becomes an ancestor of main, so GitHub flips their PR to
MERGED, not closed — the contribution stays on their record. Verify it:
git merge-base --is-ancestor <their-head> origin/main.
⚠ Say in the PR comment that the conflict was ours and why the normal route was
unavailable. A contributor whose branch is bypassed with no explanation
reasonably reads it as their work being taken over.
⚠⚠ A MERGEABLE CONTRIBUTOR PR MERGES FIRST. CHECK BEFORE EVERY MERGE OF OURS
THAT TOUCHES CHANGELOG.md (jjg, 2026-08-14), including a release commit:
GITHUB_TOKEN="" gh pr list --state open --json number,author,mergeable,mergeStateStatus \
--jq '.[] | select(.author.login != "jgravelle") | "#\(.number) \(.author.login) \(.mergeable) \(.mergeStateStatus)"'
Any row reading MERGEABLE CLEAN goes in before ours. The reason is mechanical:
our [Unreleased] edits land in the same block a contributor's entry occupies,
so each of our merges conflicts their branch, and a conflicting fork PR has no
refs/pull/N/merge and therefore gets NO CI AT ALL — their branch goes dark
for a reason unrelated to their change.
⚠⚠ Measured 2026-08-14: #443 conflicted FIVE TIMES IN ONE DAY (two PR merges,
two releases, one docs change), every one resolved by us pushing to their fork.
That is one wrong merge order repeated, not five incidents.
⚠ The boundary, or the rule fails on its first real case. A BLOCKED PR
cannot go first — #443 was unsigned-CLA throughout, so "contributor first" was
never available. Blocked means we ship anyway (a release is never blocked on an
open issue) and we own the resolution: push the merge to their branch,
resolve it, and say on the thread that the conflict was ours. The rule governs
ORDER when we have a choice; it never holds our work behind someone else's
form.
- Review diff carefully before approving
- Approve:
GITHUB_TOKEN="" gh pr review <n> --repo jgravelle/<repo> --approve --body "..."
- Merge:
GITHUB_TOKEN="" gh pr merge <n> --repo jgravelle/<repo> --merge
- Pull after merge:
GITHUB_TOKEN="" git pull origin main
- Close without merging:
GITHUB_TOKEN="" gh pr close <n> --repo jgravelle/<repo> --comment "..."
Community responses