5858 # scope, so a compromised latest release must not be auto-pulled here.
5959 - run : pnpm install -wD @vscode/vsce@3.9.2 ovsx@1.0.1
6060
61+ # Re-derive every native-binary digest from the registry and diff it
62+ # against the committed pin file, BEFORE anything is packaged.
63+ #
64+ # What this uniquely catches: a pin file that is internally consistent —
65+ # correct version, correct integrity — but carries WRONG DIGESTS. The two
66+ # neighbouring failure modes are already covered offline and would never
67+ # reach this step: a stale or missing pin fails closed in `binaryPin()`
68+ # during vendoring, and a re-published tarball fails the lockfile SHA-512
69+ # on download. A poisoned pin file is the residual trust root, and it is
70+ # the one thing only a fresh re-derivation can expose.
71+ #
72+ # Placed here deliberately: the first publish is far below and the version
73+ # bump/commit/tag lower still, so a failure here ships nothing and tags
74+ # nothing. Kept OUT of `pnpm test` — it needs the network and pulls ~150 MB.
75+ #
76+ # THIS STEP IS THE GATE. `.github/workflows/verify-binary-pins.yml` runs
77+ # the same check on pull requests, but it is path-filtered and therefore
78+ # SKIPPED on most PRs — and a skipped run is not a verification. That
79+ # workflow is early warning for the contributor; this step is the safety
80+ # boundary. Do not remove it on the grounds that the PR job covers it.
81+ - name : Verify native-binary digest pins
82+ if : inputs.target == 'extension' || inputs.target == 'both'
83+ run : pnpm check:binary-digests
84+
6185 # ── Compute versions ───────────────────────────────────────
6286 # For each target, we check both package.json AND the registry.
6387 # We bump from whichever is higher, so we never collide with
@@ -154,13 +178,27 @@ jobs:
154178 echo "current=${BASE}" >> $GITHUB_OUTPUT
155179 echo "next=${NEXT}" >> $GITHUB_OUTPUT
156180
157- # Write new version to package.json
181+ # Write new version to package.json AND to server.json.
182+ #
183+ # server.json is the MCP Registry manifest and carries its own version in
184+ # two places. check-tool-sync asserts server.json.version === package.json
185+ # .version, and `pnpm build` runs that guard first — so bumping only
186+ # package.json here fails the build on EVERY mcp-server release. It cannot
187+ # be worked around by hand-bumping server.json before dispatch either: the
188+ # BASE above is max(local, published), so the workflow always lands one
189+ # above whatever is committed. The bump has to happen here, together.
158190 node -e "
159191 const fs = require('fs');
160192 const p = '$PKG';
161193 const pkg = JSON.parse(fs.readFileSync(p, 'utf8'));
162194 pkg.version = '${NEXT}';
163195 fs.writeFileSync(p, JSON.stringify(pkg, null, 2) + '\n');
196+
197+ const sp = 'packages/mcp-server/server.json';
198+ const srv = JSON.parse(fs.readFileSync(sp, 'utf8'));
199+ srv.version = '${NEXT}';
200+ for (const entry of srv.packages ?? []) entry.version = '${NEXT}';
201+ fs.writeFileSync(sp, JSON.stringify(srv, null, 2) + '\n');
164202 "
165203 echo "MCP server: ${BASE} → ${NEXT}"
166204
@@ -218,14 +256,25 @@ jobs:
218256 - name : Run tests
219257 run : pnpm test
220258
221- # ── Package extension VSIX ─────────────────────────────────
222- - name : Package VSIX
259+ # ── Package extension VSIX (one per platform target) ───────
260+ #
261+ # `impit` and `@ngrok/ngrok` keep their compiled .node binary in separate
262+ # per-platform npm packages, so a single untargeted VSIX built on this
263+ # ubuntu runner would ship Linux-x64 binaries to every user and break
264+ # `mcp.httpClient: "impit"` and the ngrok tunnel everywhere else. We build
265+ # one VSIX per target instead, each vendoring only its own platform
266+ # packages (pinned to pnpm-lock.yaml by version AND integrity), and assert
267+ # the contents of every artifact before anything can be published.
268+ # The target matrix lives in scripts/vsix-targets.mjs.
269+ #
270+ # This runs on ONE runner rather than a job matrix: the foreign binaries
271+ # are fetched as plain tarballs (nothing is compiled), which avoids
272+ # needing arm64/alpine runners and keeps the version-bump → publish →
273+ # commit → tag sequence below in a single, ordered job.
274+ - name : Package platform-specific VSIXes
223275 id : vsix
224276 if : inputs.target == 'extension' || inputs.target == 'both'
225277 run : |
226- # Prepare deps for VSIX
227- node scripts/prepare-package-deps.mjs
228-
229278 # Copy README: strip SVGs + collapse <picture> to single <img> for VS Code Marketplace
230279 node -e "
231280 const fs = require('fs');
@@ -245,19 +294,45 @@ jobs:
245294 fs.writeFileSync('packages/extension/README.md', readme);
246295 "
247296
248- # Package
249- cd packages/extension
250- pnpm exec vsce package --no-dependencies
251- VSIX=$(ls *.vsix | head -1)
252- echo "file=packages/extension/${VSIX}" >> $GITHUB_OUTPUT
253- echo "Packaged: ${VSIX}"
297+ # Eight target artifact packaging/assertion smokes: build one VSIX per
298+ # published target and verify the CONTENTS of each — every .node
299+ # matched byte-for-byte against scripts/native-binary-digests.json,
300+ # which was recorded from tarballs verified against pnpm-lock.yaml.
301+ # package-targets.mjs aborts on the first target that fails, so a bad
302+ # artifact can never reach a publish step.
303+ #
304+ # These are packaging smokes, not runtime smokes. A single runner can
305+ # only ever `require()` the binding built for itself, so only the host
306+ # target's binding could receive a genuine runtime load; the other
307+ # seven are verified by exact content, which is what the digests are
308+ # for. Nothing here should be described as runtime-loading eight
309+ # native bindings.
310+ node scripts/package-targets.mjs --out-dir=artifacts
311+
312+ # Re-assert the finished set: every published target present, no
313+ # untargeted artifact, right binaries — byte-exact — in each.
314+ node scripts/assert-vsix-binaries.mjs --dir=artifacts
315+
316+ ls -la artifacts/
254317
255318 # ── Publish extension ──────────────────────────────────────
319+ # Every artifact is published; there is deliberately NO untargeted
320+ # fallback. A user on a platform we do not build for sees the extension
321+ # as unavailable rather than installing one with the wrong binaries.
256322 - name : Publish extension to VS Code Marketplace
257323 if : |
258324 !inputs.dry_run &&
259325 (inputs.target == 'extension' || inputs.target == 'both')
260- run : pnpm exec vsce publish --packagePath "${{ steps.vsix.outputs.file }}"
326+ run : |
327+ ARGS=()
328+ COUNT=0
329+ for f in artifacts/*.vsix; do
330+ # Two argv elements per file, so ${#ARGS[@]} is not the build count.
331+ ARGS+=(--packagePath "$f")
332+ COUNT=$((COUNT + 1))
333+ done
334+ echo "Publishing ${COUNT} platform builds to the Marketplace..."
335+ pnpm exec vsce publish "${ARGS[@]}"
261336 env :
262337 VSCE_PAT : ${{ secrets.VSCE_PAT }}
263338
@@ -266,26 +341,51 @@ jobs:
266341 !inputs.dry_run &&
267342 (inputs.target == 'extension' || inputs.target == 'both')
268343 run : |
269- pnpm exec ovsx publish "${{ steps.vsix.outputs.file }}" --pat "$OVSX_PAT"
344+ for f in artifacts/*.vsix; do
345+ echo "Publishing $(basename "$f") to Open VSX..."
346+ pnpm exec ovsx publish "$f" --pat "$OVSX_PAT"
347+ done
270348
271- # Verify the version actually landed (Open VSX indexes asynchronously)
349+ # Verify each target actually landed (Open VSX indexes asynchronously).
350+ # Target list comes from the same source of truth the build used.
272351 VERSION="${{ steps.ext_version.outputs.next }}"
273- echo "Verifying Open VSX indexed v${VERSION}..."
274- for i in $(seq 1 18); do
275- FOUND=$(curl -s "https://open-vsx.org/api/Nskha/airtable-formula/${VERSION}" \
276- | node -e "let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{
277- try{const p=JSON.parse(d);console.log(p.version||'not-found')}
278- catch{console.log('error')}
279- })")
280- if [[ "$FOUND" == "$VERSION" ]]; then
281- echo "✓ Open VSX confirmed: v${VERSION}"
282- break
283- fi
284- echo " not indexed yet (attempt $i/18), waiting 10s..."
285- sleep 10
352+ TARGETS=$(node -e "import('./scripts/vsix-targets.mjs').then(m=>console.log(m.ALL_TARGETS.join(' ')))")
353+
354+ # ONE wall-clock budget shared by all targets, enforced whether or not
355+ # anything has been confirmed yet. A per-target budget would multiply
356+ # by the number of targets, so an index that never catches up (queued
357+ # publish, namespace problem) would sleep ~24 minutes and emit a
358+ # warning per target. This is a best-effort check that never fails the
359+ # release, so it must not dominate the job's runtime.
360+ VERIFY_BUDGET=180
361+ VERIFY_START=$(date +%s)
362+ UNVERIFIED=()
363+
364+ for TARGET in $TARGETS; do
365+ echo "Verifying Open VSX indexed v${VERSION} (${TARGET})..."
366+ FOUND=""
367+ while true; do
368+ FOUND=$(curl -s "https://open-vsx.org/api/Nskha/airtable-formula/${TARGET}/${VERSION}" \
369+ | node -e "let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{
370+ try{const p=JSON.parse(d);console.log(p.version||'not-found')}
371+ catch{console.log('error')}
372+ })")
373+ if [ "$FOUND" = "$VERSION" ]; then
374+ echo "✓ Open VSX confirmed: v${VERSION} (${TARGET})"
375+ break
376+ fi
377+ if [ $(( $(date +%s) - VERIFY_START )) -ge "$VERIFY_BUDGET" ]; then
378+ UNVERIFIED+=("$TARGET")
379+ break
380+ fi
381+ echo " not indexed yet, waiting 10s..."
382+ sleep 10
383+ done
286384 done
287- if [[ "$FOUND" != "$VERSION" ]]; then
288- echo "::warning::Open VSX did not index v${VERSION} within 3 minutes — indexing may still be in progress (publish succeeded)"
385+
386+ ELAPSED=$(( $(date +%s) - VERIFY_START ))
387+ if [ ${#UNVERIFIED[@]} -gt 0 ]; then
388+ echo "::warning::Open VSX did not index v${VERSION} for ${UNVERIFIED[*]} within ${ELAPSED}s — indexing may still be in progress (publish succeeded)"
289389 fi
290390 env :
291391 OVSX_PAT : ${{ secrets.OVSX_PAT }}
@@ -330,7 +430,9 @@ jobs:
330430 fi
331431
332432 if [[ "${{ inputs.target }}" == "mcp-server" || "${{ inputs.target }}" == "both" ]]; then
333- git add packages/mcp-server/package.json
433+ # server.json is bumped alongside package.json (see the bump step) and
434+ # must be committed with it, or the next build fails the version guard.
435+ git add packages/mcp-server/package.json packages/mcp-server/server.json
334436 TARGETS="${TARGETS} mcp-server v${{ steps.mcp_version.outputs.next }}"
335437 TAGS="${TAGS} mcp-server/v${{ steps.mcp_version.outputs.next }}"
336438 fi
@@ -358,8 +460,9 @@ jobs:
358460 !inputs.dry_run &&
359461 (inputs.target == 'extension' || inputs.target == 'both')
360462 run : |
463+ # Attach every platform build so users can sideload the right one.
361464 gh release create "extension/v${{ steps.ext_version.outputs.next }}" \
362- "${{ steps .vsix.outputs.file }}" \
465+ artifacts/* .vsix \
363466 --title "Extension v${{ steps.ext_version.outputs.next }}" \
364467 --generate-notes \
365468 --target main
@@ -402,6 +505,13 @@ jobs:
402505 fi
403506 if [[ "${{ inputs.target }}" == "extension" || "${{ inputs.target }}" == "both" ]]; then
404507 echo "- Extension: ${{ steps.ext_version.outputs.current }} → **${{ steps.ext_version.outputs.next }}**" >> $GITHUB_STEP_SUMMARY
508+ if [[ -d artifacts ]]; then
509+ echo "" >> $GITHUB_STEP_SUMMARY
510+ echo " Platform builds (no untargeted fallback is published):" >> $GITHUB_STEP_SUMMARY
511+ for f in artifacts/*.vsix; do
512+ echo " - \`$(basename "$f")\`" >> $GITHUB_STEP_SUMMARY
513+ done
514+ fi
405515 fi
406516 if [[ "${{ inputs.target }}" == "mcp-server" || "${{ inputs.target }}" == "both" ]]; then
407517 echo "- MCP Server: ${{ steps.mcp_version.outputs.current }} → **${{ steps.mcp_version.outputs.next }}**" >> $GITHUB_STEP_SUMMARY
0 commit comments