ci: publish accounted-mcp and gnubok-mcp to npm when their version changes (#1920)

* ci: publish accounted-mcp and gnubok-mcp to npm when their version changes

accounted-mcp has never been published (npm view is E404) although every
"connect Claude" doc says `npx -y accounted-mcp`, and gnubok-mcp is at 1.0.1
on the registry while the repo has carried 1.1.0 since #706. No workflow
published to npm; this adds one.

.github/workflows/npm-publish.yml runs on a push to main that touches a
packages/*/package.json, and on workflow_dispatch (package: all or one,
plus a dry_run that packs and validates without touching the registry).
One matrix job per package: it fails first with a message naming the
NPM_TOKEN secret if it is absent, then compares the package.json version
with `npm view <name> versions` (E404 counts as "never published", any
other failure is an error), skips when the version is already on the
registry, and otherwise runs `npm publish --provenance --access public`.
Permissions are contents: read plus id-token: write for the provenance
attestation. Actions are pinned to the same SHAs as the sibling workflows.

npm rejects a provenance attestation whose package.json repository.url
does not match the source repository, and gnubok-mcp still pointed at
erp-mafia/gnubok, so both repository fields now name
erp-mafia/accounted in npm's canonical form with the monorepo directory.
`npm pkg fix` normalised the bin paths, and accounted-mcp's index.mjs gets
the executable bit gnubok-mcp's already had. Versions are not bumped.

Both READMEs get a Releasing section: bump version, merge to main, the
workflow publishes; the NPM_TOKEN repository secret must exist.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>

* fix(packages): keep the ./index.mjs bin form the package tests pin

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

* ci(npm-publish): scope NPM_TOKEN to the publish step and keep the matrix static

The token was job-level env, visible to checkout, setup-node and the
version gate; it now reaches only npm publish. The matrix no longer
interpolates the workflow_dispatch input into an expression: both packages
always get a job and a Select step skips the one not requested.

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

---------

Signed-off-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-26 13:36:17 +02:00
committed by GitHub
parent 9396e54965
commit 6dd0e951e6
7 changed files with 239 additions and 2 deletions
+194
View File
@@ -0,0 +1,194 @@
name: Publish MCP bridges to npm
# Publishes the two stdio-to-HTTP MCP bridges, packages/accounted-mcp and
# packages/gnubok-mcp, to the public npm registry.
#
# The trigger is a version bump, not a git tag: a push to main that touches a
# packages/*/package.json runs one job per package, and each job publishes only
# if the version in its package.json is not already on the registry. A package
# whose version did not change is skipped, so a merge that bumps one bridge
# never republishes the other, and re-running a finished workflow is a no-op.
#
# Auth is the repository secret NPM_TOKEN, an npm granular access token with
# publish rights on both packages. A run without the secret fails at its first
# step with a message naming it, rather than inside `npm publish` with an opaque
# ENEEDAUTH. A token rather than OIDC trusted publishing because accounted-mcp
# has never been published, and npm cannot bind a trusted publisher to a package
# that does not exist yet.
#
# --provenance attaches a Sigstore attestation that ties the tarball to this
# workflow run and commit; id-token: write exists for that. The registry rejects
# the attestation unless package.json `repository.url` matches this repository,
# which is why both package.jsons point at erp-mafia/accounted.
#
# workflow_dispatch runs the same job on demand, optionally for one package (the
# other package's job is skipped by the Select step), and with dry_run to
# exercise the version gate and `npm publish --dry-run` without touching the
# registry. Dispatching from a branch is the way to test this file
# before merging it.
on:
push:
branches: [main]
paths:
- 'packages/*/package.json'
workflow_dispatch:
inputs:
package:
description: Package to publish
type: choice
options: [all, accounted-mcp, gnubok-mcp]
default: all
dry_run:
description: Pack and validate only, do not publish
type: boolean
default: false
permissions:
contents: read
# A dispatch overlapping a push could race to publish the same version; the
# loser would only fail with a confusing E403. Queue instead of cancelling.
concurrency:
group: npm-publish
cancel-in-progress: false
jobs:
publish:
name: Publish ${{ matrix.package }}
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
# OIDC token for the --provenance attestation.
id-token: write
strategy:
# The packages are independent: a failure in one must not cancel the other.
fail-fast: false
matrix:
# Static on purpose: both packages always get a job. On push the
# version gate skips the one that did not change; on dispatch the
# Select step skips the one that was not requested. A matrix built
# from the dispatch input would put workflow input text into an
# expression, which is the shape injection scanners flag.
package: [accounted-mcp, gnubok-mcp]
env:
DRY_RUN: ${{ inputs.dry_run == true }}
PACKAGE_DIR: packages/${{ matrix.package }}
steps:
- name: Select package
id: select
env:
REQUESTED: ${{ github.event_name == 'push' && 'all' || inputs.package }}
PACKAGE: ${{ matrix.package }}
run: |
set -euo pipefail
if [ "$REQUESTED" = "all" ] || [ "$REQUESTED" = "$PACKAGE" ]; then
echo "run=true" >> "$GITHUB_OUTPUT"
else
echo "Skipping $PACKAGE: dispatch requested $REQUESTED."
echo "run=false" >> "$GITHUB_OUTPUT"
fi
- name: Require the NPM_TOKEN secret
# Before checkout, so a missing secret is the first line of the log.
# Only the presence of the secret is checked here; the token itself is
# exposed to the publish step alone.
if: steps.select.outputs.run == 'true'
env:
NPM_TOKEN_SET: ${{ secrets.NPM_TOKEN != '' }}
run: |
set -euo pipefail
if [ "$DRY_RUN" = "true" ]; then
echo "Dry run: NPM_TOKEN is not required."
exit 0
fi
if [ "$NPM_TOKEN_SET" != "true" ]; then
echo "::error::Repository secret NPM_TOKEN is not set. Create an npm granular access token with read and write access to accounted-mcp and gnubok-mcp (see the Releasing section in packages/*/README.md) and add it under Settings > Secrets and variables > Actions as NPM_TOKEN."
exit 1
fi
echo "NPM_TOKEN is set."
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
if: steps.select.outputs.run == 'true'
with:
# Nothing here pushes over git; the only credential this job needs is
# the npm token, and that never touches the checkout.
persist-credentials: false
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
if: steps.select.outputs.run == 'true'
with:
node-version: 22
# Writes an .npmrc that reads the auth token from NODE_AUTH_TOKEN.
registry-url: https://registry.npmjs.org
- name: Compare package.json version with the registry
id: gate
if: steps.select.outputs.run == 'true'
working-directory: ${{ env.PACKAGE_DIR }}
run: |
set -euo pipefail
NAME=$(node -p "require('./package.json').name")
VERSION=$(node -p "require('./package.json').version")
# `npm view` exits 1 with an E404 body when the package has never
# been published. That is the first-release case and counts as
# "nothing on the registry". Any other failure (network, registry
# outage, bad token) is an error: assuming "not published" there
# would only move the failure into `npm publish`.
set +e
VIEW=$(npm view "$NAME" versions --json 2>&1)
STATUS=$?
set -e
if [ "$STATUS" -ne 0 ]; then
if grep -q 'E404' <<< "$VIEW"; then
echo "$NAME has never been published (E404): $VERSION would be its first release."
VIEW='[]'
else
echo "::error::npm view $NAME failed (exit $STATUS)."
echo "$VIEW"
exit 1
fi
fi
# `npm view <name> versions --json` prints a bare string, not a
# one-element array, when exactly one version exists.
ON_REGISTRY=$(VIEW="$VIEW" VERSION="$VERSION" node -e '
const raw = JSON.parse(process.env.VIEW);
const list = Array.isArray(raw) ? raw : [raw];
console.error("Versions on registry: " + (list.length ? list.join(", ") : "(none)"));
process.stdout.write(list.includes(process.env.VERSION) ? "yes" : "no");
')
if [ "$ON_REGISTRY" = "yes" ]; then
echo "Skipping: $NAME@$VERSION is already on the registry."
echo "publish=false" >> "$GITHUB_OUTPUT"
echo "- \`$NAME@$VERSION\`: already on the registry, skipped" >> "$GITHUB_STEP_SUMMARY"
else
echo "Publishing: $NAME@$VERSION is not on the registry."
echo "publish=true" >> "$GITHUB_OUTPUT"
fi
echo "name=$NAME" >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Publish to npm
if: steps.select.outputs.run == 'true' && steps.gate.outputs.publish == 'true'
working-directory: ${{ env.PACKAGE_DIR }}
env:
# The only step that sees the token.
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
NAME: ${{ steps.gate.outputs.name }}
VERSION: ${{ steps.gate.outputs.version }}
run: |
set -euo pipefail
if [ "$DRY_RUN" = "true" ]; then
# --dry-run packs and validates but never contacts the registry, so
# it also runs without a token.
npm publish --dry-run --access public
echo "- \`$NAME@$VERSION\`: dry run, not published" >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
npm publish --provenance --access public
echo "- \`$NAME@$VERSION\`: published, https://www.npmjs.com/package/$NAME/v/$VERSION" >> "$GITHUB_STEP_SUMMARY"