From 9db546eeedc5f39d2a1085aa3183be224bd7ae31 Mon Sep 17 00:00:00 2001 From: lex00 <121451605+lex00@users.noreply.github.com> Date: Mon, 3 Aug 2026 19:01:21 -0600 Subject: [PATCH] feat: publish to npm, over trusted publishing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #134. behold carried `private: true`, so "open your own project" started with "clone this repo and run it from the checkout". kubemicrovm-ops ships BEHOLD_DIR handling and a bespoke error message for exactly that, and every other consumer would invent its own. `private` goes, `publishConfig` sets `access: public` (a scoped package's first publish is otherwise restricted) and `provenance: true`. ## Trusted publishing rather than a token release.yml publishes over OIDC — npm mints a short-lived credential for this repository and this workflow filename, so there is no NPM_TOKEN to leak, rotate or scope wrongly. That needs `id-token: write`, which the job declares, and a Trusted Publisher configured on npmjs.com naming this repo and `release.yml`. Until that exists the publish step fails rather than falling back to anything, which is the behaviour you want. npm is upgraded explicitly: trusted publishing needs 11.5.1 or newer, and while Node 24 ships an npm 11 the exact patch moves with the runner image. Pinning the floor here beats discovering it on a tag. ## What the workflow refuses to publish Everything that can say no runs first — typecheck, tests, build — then two release-specific gates: The tarball has to carry bin, dist and web. `files` includes web because it is the SPA; a tarball missing it publishes a server with no UI, which would look fine in CI and break on install. The tag has to match package.json's version, so a mistyped tag cannot ship a version nobody asked for. workflow_dispatch rehearses all of it and publishes nothing. ## Verified against a real install `npm pack`, installed into a scratch project, and run from node_modules/.bin: the bin resolves dist/cli.js, the server resolves web/ (it is dirname(module)/../web, which lands the same from src/ in dev and dist/ when installed), and it served the SPA and a real project's graph on a port. Note: the package has no `license` field and the repo has no LICENSE file. npm will warn on publish. Choosing one is not my call, so it is left as is — worth settling before the first publish rather than after. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TCV5kwB1jdnWH9if9ZbNXq --- .github/workflows/release.yml | 98 +++++++++++++++++++++++++++++++++++ package.json | 7 ++- 2 files changed, 103 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..669c630 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,98 @@ +name: release + +# Tag-driven, and published with npm trusted publishing — OIDC from this +# workflow rather than a long-lived NPM_TOKEN in repository secrets. npm mints +# a short-lived credential for this repo + this workflow filename, which means +# there is no token to leak, rotate, or scope wrongly. +# +# Two things have to line up outside this file: +# 1. npmjs.com → the package → Settings → Trusted Publisher, naming this +# repository and `release.yml`. Until that exists the publish step fails +# with a 404/403 rather than falling back to anything. +# 2. `id-token: write` below. Without it there is no OIDC token to exchange +# and npm reports missing credentials. +# +# Provenance rides along automatically: publishing over OIDC attests what built +# the tarball, and `publishConfig.provenance` makes that explicit rather than +# implied. +on: + push: + tags: ["v*"] + # Rehearse everything that can say no, without publishing. + workflow_dispatch: + +permissions: + contents: read + +jobs: + release: + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + # The OIDC token npm exchanges for a publish credential. `contents: read` + # is inherited from the top; both are needed and neither is enough alone. + id-token: write + contents: read + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: '24' + cache: npm + registry-url: 'https://registry.npmjs.org' + + # Trusted publishing needs npm 11.5.1 or newer. Node 24 ships an npm 11, + # but the exact patch moves with the runner image, so pin the floor here + # rather than discover it on a tag. + - name: npm with trusted publishing support + run: | + npm install -g npm@latest + npm --version + + - run: npm ci + + # Everything that can say no runs before the publish, in the order that + # fails cheapest first. + - name: typecheck + run: npm run tsc + + - name: test + run: npm test + + - name: build + run: npm run build + + # What actually ships, before it ships. `files` is bin + dist + web, and + # web is the SPA — a tarball missing it publishes a server with no UI, + # which would look fine here and break on install. + - name: the tarball carries bin, dist and web + run: | + npm pack --dry-run --json > /tmp/pack.json + node -e ' + const f = require("/tmp/pack.json")[0].files.map(x => x.path); + const want = ["bin/behold.js", "dist/cli.js", "web/index.html", "web/app.js"]; + const missing = want.filter(w => !f.includes(w)); + if (missing.length) { + console.error("tarball is missing:", missing.join(", ")); + process.exit(1); + } + console.log(`tarball: ${f.length} files, all four checked present`); + ' + + # The tag names the version, so a tag that disagrees with package.json + # would publish something nobody asked for. + - name: the tag matches the version + if: github.ref_type == 'tag' + run: | + pkg="$(node -p 'require("./package.json").version')" + tag="${GITHUB_REF_NAME#v}" + if [ "$pkg" != "$tag" ]; then + echo "tag ${GITHUB_REF_NAME} does not match package.json ${pkg}" >&2 + exit 1 + fi + echo "publishing ${pkg}" + + - name: publish + if: github.ref_type == 'tag' + run: npm publish diff --git a/package.json b/package.json index 3470373..10f81f2 100644 --- a/package.json +++ b/package.json @@ -2,8 +2,7 @@ "name": "@intentius/behold", "version": "0.2.0", "type": "module", - "description": "behold — a live control plane on chant. See your whole estate (every substrate in one graph), coloured by drift; act through delegated, gated Ops.", - "private": true, + "description": "behold \u2014 a live control plane on chant. See your whole estate (every substrate in one graph), coloured by drift; act through delegated, gated Ops.", "bin": { "behold": "./bin/behold.js" }, @@ -12,6 +11,10 @@ "dist", "web" ], + "publishConfig": { + "access": "public", + "provenance": true + }, "scripts": { "dev": "tsx src/cli.ts", "demo": "npm --prefix example-writes install && tsx src/cli.ts serve example-writes --local --env prod",