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:
@@ -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"
|
||||
Reference in New Issue
Block a user