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"
+1
View File
@@ -1247,6 +1247,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-25] Woo bulk revenue template = per-rate account choice, no hardcoded varor/tjanster preset: BAS 2026 has no standard 30xx goods/services subdivision (3040-series is company-specific), so presets would invent accounts; chosen accounts are validated against the company chart instead, and only diffs from the 3001-series default are sent.
[2026-08-26] Support-dialog attachments use the existing email delivery path without storage or schema changes: this keeps the feature scoped to the contact form. The budget is 5 files / 4 MB total under the 4.5 MB hosted request-body ceiling, with client-side image shrinking when needed.
[2026-08-26] RFC 9728 protected-resource metadata is served at THREE locations (root, path-based /.well-known/oauth-protected-resource/<mcp path>, and <mcp url>/.well-known/oauth-protected-resource): Claude.ai's connector setup derives the metadata URL from the server URL and fetches it before any 401, so the root document our WWW-Authenticate header points at was not enough ('Authorization with Accounted failed' with only 404s in the logs). One builder, three routes; the path-based route answers 404 for any path other than the MCP endpoint so no phantom resource is advertised.
[2026-08-26] npm publishing of packages/accounted-mcp and packages/gnubok-mcp is gated on "package.json version not on the registry" (push to main touching packages/*/package.json), not on git tags: the repo's v*.*.* tags belong to the Docker image, the bridges version independently, and a version gate makes re-runs and unrelated package.json edits no-ops. Auth is an NPM_TOKEN secret rather than npm trusted publishing (OIDC) because accounted-mcp has never been published and npm cannot bind a trusted publisher to a package that does not exist yet; --provenance still attaches the Sigstore attestation, which is why gnubok-mcp's repository.url had to move from erp-mafia/gnubok to erp-mafia/accounted (the registry rejects a mismatch).
[2026-08-26] Archived customers/suppliers hidden via archived_at IS NULL on every non-v1 list/picker (not is_active): customers have no is_active column and v1 already treats archived_at as canonical; is_active on suppliers stays a legacy mirror. MCP list tools got a bare include_archived boolean and the tools/list ceiling moved 60.7K to 60.8K instead of trimming unrelated tool prose: main had ~6 tokens of headroom, so any contract at all crossed.
[2026-08-26] MCP serverInfo.version, extension version and /api/health version reuse currentAppVersion() (12-char SHA, '1.0.0' fallback) instead of a new 7-char slice: one identifier across behandlingshistorik, health and MCP so a support thread can match a deploy by a single string; gnubok_get_vacation_balance got a real estimated_liability_sek by exporting semesterberedning's dayValueSek rather than dropping the description's promise, with descriptions trimmed to stay under the tools/list ceiling.
[2026-08-26] raw-route-auth guard judges each top-level export segment of a route file, not the whole file: transactions/[id] (wrapped PATCH + hand-rolled DELETE) and transactions (wrapped POST + hand-rolled GET) passed the file-level check for months because one withRouteContext call exempted every sibling handler. Baseline unchanged (mcp-oauth/authorize is the one grandfathered file).
+19
View File
@@ -81,3 +81,22 @@ account.
The legacy `gnubok-mcp` package, environment variables, endpoint behavior, and
`gnubok_*` tool aliases remain supported. Existing installations do not need to
change.
## Releasing
The package is published to npm by the `Publish MCP bridges to npm` workflow
(`.github/workflows/npm-publish.yml`), never by hand:
1. Bump `version` in `packages/accounted-mcp/package.json`.
2. Merge the change to `main`.
3. The workflow compares the new version with the registry and, if it is not
there yet, runs `npm publish --provenance --access public`. A version that
already exists on npm is skipped, so other `package.json` edits are harmless.
The workflow needs the repository secret `NPM_TOKEN`: an npm granular access
token with read and write access to `accounted-mcp` and `gnubok-mcp`, with
two-factor bypass enabled so CI can publish. npm caps the lifetime of such
tokens (90 days at the time of writing), so rotate the secret before it lapses.
Without the secret the run fails at its first step. The workflow can also be
started from the Actions tab, for one package or both, with a dry-run option
that packs and validates without publishing.
Regular → Executable
View File
+2 -1
View File
@@ -16,7 +16,8 @@
],
"repository": {
"type": "git",
"url": "https://github.com/erp-mafia/accounted"
"url": "git+https://github.com/erp-mafia/accounted.git",
"directory": "packages/accounted-mcp"
},
"engines": {
"node": ">=18"
+21
View File
@@ -57,6 +57,27 @@ If you use **claude.ai** or Claude Desktop's custom-connector flow, you can skip
Full setup, sample prompts, and a 10-minute reviewer test: **[Connect with Claude](https://app.gnubok.se/docs/api/connect-claude)**.
## Releasing
The package is published to npm by the `Publish MCP bridges to npm` workflow
(`.github/workflows/npm-publish.yml`), never by hand:
1. Bump `version` in `packages/gnubok-mcp/package.json`.
This is the legacy package: bump it only for compatibility fixes; new
functionality goes to `accounted-mcp`.
2. Merge the change to `main`.
3. The workflow compares the new version with the registry and, if it is not
there yet, runs `npm publish --provenance --access public`. A version that
already exists on npm is skipped, so other `package.json` edits are harmless.
The workflow needs the repository secret `NPM_TOKEN`: an npm granular access
token with read and write access to `accounted-mcp` and `gnubok-mcp`, with
two-factor bypass enabled so CI can publish. npm caps the lifetime of such
tokens (90 days at the time of writing), so rotate the secret before it lapses.
Without the secret the run fails at its first step. The workflow can also be
started from the Actions tab, for one package or both, with a dry-run option
that packs and validates without publishing.
## License
MIT
+2 -1
View File
@@ -16,7 +16,8 @@
],
"repository": {
"type": "git",
"url": "https://github.com/erp-mafia/gnubok"
"url": "git+https://github.com/erp-mafia/accounted.git",
"directory": "packages/gnubok-mcp"
},
"engines": {
"node": ">=18"