Skip to content

Commit 64f4f30

Browse files
anvansterclaude
andauthored
build(release): check publish credentials before packing (#22)
* chore(release): authenticate both registries before packing A stale credential used to surface at the very end of a publish. The last release spent several minutes on tests, the engine-asset probe and the pack before `npm publish` failed on an expired session, and `mcp-publisher` failed after that - so every one of those steps had to be repeated. Both logins now run first. Each has to succeed for the publish to happen anyway, so checking them up front costs nothing and turns a late failure into an immediate one. Gated on --publish. Packing needs no credentials, and this script also runs as a plain build step and inside the validation gate, where prompting for a login would hang it. npm is only prompted for when `npm whoami` already fails, so an existing session is left alone; it stays interactive because the account has 2FA. The mcp-publisher login uses `gh auth token` because the MCP Registry decides which namespaces a token may publish to by calling GET /user/memberships/orgs, which needs the read:org scope. Its own device flow mints a token without that scope, GitHub answers 403, and the registry treats the 403 as "no admin orgs" rather than an error - so publishing silently degrades to io.github.<user>/* and then fails on io.github.codegraph-ai/* with a message blaming organization membership, which is not the cause. That cost a release cycle to diagnose, so the reasoning is recorded next to the call. CODEGRAPH_MCP_TOKEN overrides it for anyone preferring a PAT scoped to read:org alone, since gh's token also carries repo and workflow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017rVbt7rENTwXkdHt3Bpgb5 * no-mistakes(review): mint MCP token at publish, preflight GitHub org ownership * no-mistakes(document): document --publish credential prerequisites; fix shellcheck tr classes --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 489ccf1 commit 64f4f30

1 file changed

Lines changed: 153 additions & 0 deletions

File tree

scripts/package-npm.sh

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@
1414
# Usage:
1515
# ./scripts/package-npm.sh # pack only
1616
# ./scripts/package-npm.sh --publish # also publish to npmjs.com
17+
#
18+
# --publish checks both registries' credentials before doing any work, so it
19+
# needs a terminal: an expired npm session is renewed with an interactive
20+
# `npm login` (the account has 2FA). The MCP Registry is reached with the token
21+
# from `gh auth token`, which must carry read:org; set CODEGRAPH_MCP_TOKEN to
22+
# use a PAT limited to that scope instead. Packing alone needs no credentials.
1723

1824
set -euo pipefail
1925

@@ -31,6 +37,140 @@ BIN_DIR="$PKG_DIR/bin"
3137
echo "=== CodeGraph npm package builder ==="
3238
echo ""
3339

40+
# ------------------------------------------------------------------ auth
41+
#
42+
# The credentials are checked here, before the tests, the asset probe and the
43+
# pack - not at the point of use. Every one of those has to pass anyway, and
44+
# discovering an unusable credential after them means doing them again. The last
45+
# release failed exactly there: `npm publish` ran after several minutes of work
46+
# and `mcp-publisher` after that, so a stale token surfaced at the end.
47+
#
48+
# What is checked here is deliberately not what is minted here. The npm session
49+
# is long-lived, so logging in now is the whole fix for that half. The MCP
50+
# Registry's token lives 300 seconds - less than the rest of this script takes -
51+
# so it is minted at the publish step instead, and what is checked now is the
52+
# GitHub token it will be minted from, which is the credential that goes stale.
53+
#
54+
# Only for --publish. Packing needs no credentials, and this script also runs as
55+
# a plain build step, where prompting for a login would hang it.
56+
MCP_TOKEN=""
57+
if [ "${1:-}" = "--publish" ]; then
58+
echo "Checking publish credentials..."
59+
60+
# npm's own session. Left interactive on purpose: the account has 2FA, so this
61+
# needs a human and a TTY, and that is better spent now than after the pack.
62+
if npm_user="$(npm whoami 2>/dev/null)"; then
63+
echo " ✓ npm authenticated as $npm_user"
64+
else
65+
echo " npm: not logged in - starting login (2FA expected)"
66+
npm login || { echo " ✗ npm login failed - not packaging" >&2; exit 1; }
67+
npm_user="$(npm whoami 2>/dev/null || echo '<unknown>')"
68+
echo " ✓ npm authenticated as $npm_user"
69+
fi
70+
71+
# The MCP Registry decides which namespaces a token may publish to by calling
72+
# GET /user/memberships/orgs and granting io.github.<org>/* for every org the
73+
# account owns; GitHub gates that call behind read:org. A token without the
74+
# scope does not fail to log in - GitHub answers 403, the registry reads that
75+
# as "owns no organisations", and it issues a perfectly valid token scoped to
76+
# io.github.<user>/* alone. The 403 then arrives at the publish, carrying a
77+
# message about organisation membership that is not the actual cause.
78+
#
79+
# A successful login therefore cannot tell the two cases apart, so the
80+
# precondition is checked against GitHub directly instead of inferred from one.
81+
#
82+
# `gh auth token` already carries read:org. It also carries repo and workflow,
83+
# which is broader than the registry needs; a PAT limited to read:org can be
84+
# substituted by setting CODEGRAPH_MCP_TOKEN.
85+
if command -v mcp-publisher >/dev/null 2>&1; then
86+
MCP_TOKEN="${CODEGRAPH_MCP_TOKEN:-}"
87+
if [ -z "$MCP_TOKEN" ] && command -v gh >/dev/null 2>&1; then
88+
MCP_TOKEN="$(gh auth token 2>/dev/null || true)"
89+
fi
90+
if [ -z "$MCP_TOKEN" ]; then
91+
echo " ✗ no GitHub token for the MCP Registry - not packaging" >&2
92+
echo " Run 'gh auth login', or set CODEGRAPH_MCP_TOKEN to a PAT with read:org." >&2
93+
exit 1
94+
fi
95+
96+
gh_body="$(mktemp)"
97+
trap 'rm -f "$gh_body"' EXIT
98+
gh_api() {
99+
curl -sS -o "$gh_body" -w '%{http_code}' \
100+
-H "Authorization: Bearer $MCP_TOKEN" \
101+
-H "Accept: application/vnd.github+json" \
102+
-H "X-GitHub-Api-Version: 2022-11-28" \
103+
"https://api.github.com/$1" 2>/dev/null || echo 000
104+
}
105+
# GitHub's error bodies carry no trailing newline, which would otherwise run
106+
# the hint that follows onto the last line of the JSON.
107+
gh_body_err() { printf '%s\n' "$(sed 's/^/ /' "$gh_body")" >&2; }
108+
109+
gh_status="$(gh_api user)"
110+
if [ "$gh_status" != "200" ]; then
111+
echo " ✗ GitHub rejected the token (HTTP $gh_status) - not packaging" >&2
112+
gh_body_err
113+
echo " Run 'gh auth login', or set CODEGRAPH_MCP_TOKEN to a live PAT with read:org." >&2
114+
exit 1
115+
fi
116+
gh_login="$(node -e "
117+
console.log(JSON.parse(require('fs').readFileSync(process.argv[1], 'utf8')).login);
118+
" "$gh_body")"
119+
120+
# Read the namespace from server.json rather than naming it here: that file is
121+
# what the publish is authorised against, so renaming the server must not be
122+
# able to leave this check passing against the namespace it used to use.
123+
MCP_NAMESPACE="$(node -e "console.log(require('$PKG_DIR/server.json').name.split('/')[0])")"
124+
case "$MCP_NAMESPACE" in
125+
io.github.*) mcp_owner="${MCP_NAMESPACE#io.github.}" ;;
126+
*) mcp_owner="" ;;
127+
esac
128+
129+
if [ -z "$mcp_owner" ]; then
130+
echo "$MCP_NAMESPACE is not an io.github.* namespace - ownership not checked"
131+
elif [ "$(printf '%s' "$mcp_owner" | tr '[:upper:]' '[:lower:]')" = "$(printf '%s' "$gh_login" | tr '[:upper:]' '[:lower:]')" ]; then
132+
echo "$MCP_NAMESPACE is the token's own user namespace ($gh_login)"
133+
else
134+
gh_status="$(gh_api 'user/memberships/orgs?per_page=100')"
135+
if [ "$gh_status" = "403" ]; then
136+
echo " ✗ the GitHub token cannot read organisation membership - not packaging" >&2
137+
echo " GitHub answered 403 for GET /user/memberships/orgs, which needs read:org." >&2
138+
echo " Without it the Registry sees no organisations and refuses $MCP_NAMESPACE." >&2
139+
echo " Use 'gh auth token', or set CODEGRAPH_MCP_TOKEN to a PAT with read:org." >&2
140+
exit 1
141+
fi
142+
if [ "$gh_status" != "200" ]; then
143+
echo " ✗ could not read organisation membership from GitHub (HTTP $gh_status)" >&2
144+
gh_body_err
145+
exit 1
146+
fi
147+
# The Registry grants io.github.<org>/* to owners only, which this endpoint
148+
# reports as role "admin". An active plain membership is refused at the
149+
# publish just as a missing one is, so both are refused here.
150+
membership="$(node -e "
151+
const want = process.argv[2].toLowerCase();
152+
const orgs = JSON.parse(require('fs').readFileSync(process.argv[1], 'utf8'));
153+
const m = (Array.isArray(orgs) ? orgs : []).find(
154+
(o) => ((o.organization || {}).login || '').toLowerCase() === want);
155+
console.log(m ? m.role + '/' + m.state : 'none/none');
156+
" "$gh_body" "$mcp_owner")"
157+
if [ "$membership" != "admin/active" ]; then
158+
echo "$gh_login does not own the $mcp_owner organisation - not packaging" >&2
159+
echo " GET /user/memberships/orgs reports role/state: $membership" >&2
160+
echo " The Registry grants $MCP_NAMESPACE to owners (role admin) only." >&2
161+
exit 1
162+
fi
163+
echo "$gh_login owns $mcp_owner - $MCP_NAMESPACE is publishable"
164+
fi
165+
166+
rm -f "$gh_body"
167+
trap - EXIT
168+
else
169+
echo " ⚠ mcp-publisher not on PATH - the MCP Registry step will be skipped"
170+
fi
171+
echo ""
172+
fi
173+
34174
echo "Removing any bundled binaries (the engine is fetched at install time)..."
35175
for stale in "$BIN_DIR"/codegraph-server-* "$BIN_DIR/onnxruntime.dll"; do
36176
if [ -e "$stale" ]; then
@@ -172,6 +312,19 @@ if [ "${1:-}" = "--publish" ]; then
172312
echo ""
173313
echo "Updating MCP Registry..."
174314
if command -v mcp-publisher &>/dev/null; then
315+
# The Registry's token is minted here, not in the preflight above: it lives
316+
# 300 seconds, and the tests, the asset probe, the pack and an interactive
317+
# npm 2FA prompt all happen in between. The preflight established that this
318+
# GitHub token can reach the namespace, so this is expected to succeed - it
319+
# is checked anyway, because the npm publish above is already irreversible.
320+
if ! login_log="$(mcp-publisher login github -token "${MCP_TOKEN:-}" 2>&1)"; then
321+
printf '%s\n' "$login_log" >&2
322+
echo "✗ mcp-publisher login failed - npmjs.com has $PKG_VERSION but the MCP" >&2
323+
echo " Registry does not. Nothing else is needed; re-run just that step:" >&2
324+
echo " cd mcp-package && mcp-publisher login github -token \"\$(gh auth token)\" \\" >&2
325+
echo " && mcp-publisher publish --server-json server.json" >&2
326+
exit 1
327+
fi
175328
mcp-publisher publish --server-json server.json
176329
echo "✓ MCP Registry updated"
177330
else

0 commit comments

Comments
 (0)