diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml new file mode 100644 index 00000000..89a0fc76 --- /dev/null +++ b/.github/workflows/npm-publish.yml @@ -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 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" diff --git a/DECISIONS.md b/DECISIONS.md index 4011f581..43b0b16f 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1247,6 +1247,7 @@ One line per decision: `[YYYY-MM-DD] : `. 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/, and /.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). diff --git a/packages/accounted-mcp/README.md b/packages/accounted-mcp/README.md index 27e2fff5..009aaca5 100644 --- a/packages/accounted-mcp/README.md +++ b/packages/accounted-mcp/README.md @@ -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. diff --git a/packages/accounted-mcp/index.mjs b/packages/accounted-mcp/index.mjs old mode 100644 new mode 100755 diff --git a/packages/accounted-mcp/package.json b/packages/accounted-mcp/package.json index ac1b39ba..e030d7b9 100644 --- a/packages/accounted-mcp/package.json +++ b/packages/accounted-mcp/package.json @@ -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" diff --git a/packages/gnubok-mcp/README.md b/packages/gnubok-mcp/README.md index ea270e18..0490ea1a 100644 --- a/packages/gnubok-mcp/README.md +++ b/packages/gnubok-mcp/README.md @@ -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 diff --git a/packages/gnubok-mcp/package.json b/packages/gnubok-mcp/package.json index bdcc2340..990a0797 100644 --- a/packages/gnubok-mcp/package.json +++ b/packages/gnubok-mcp/package.json @@ -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"