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