From 9b65c7b3537d9bf1d525462bef744629a42135c8 Mon Sep 17 00:00:00 2001 From: aarroyo Date: Mon, 27 Jul 2026 07:08:17 -0500 Subject: [PATCH] feat(release): build the npm publishing path that never existed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seven of the eight public packages had no automated way to reach the registry. `sdk-cli-release.yml` publishes `src/sdk/cli` and nothing else; `ci-cd.yml`'s `publish-npm` published the same directory again, with `--tag beta`, no provenance, and the token written into a .npmrc via `npm config set`. Everything else — including @beyondnet/evolith-mcp, which carries the 2026-07-23 security wave — was published by hand from a developer machine. That is why all eight versions on the registry have no `dist.attestations` (GT-570). Adds `.harness/scripts/release/plan-npm-release.mjs`: the decision of WHAT to publish, deliberately separated from the workflow so it is inspectable and testable without a registry, a token or a runner. Topological order over the internal `@beyondnet/evolith-*` edges, so a consumer never publishes before something it resolves from the registry. Idempotent — a version already on the registry is skipped, so a partial failure resumes without republishing. Never vacuous — resolving zero packages throws rather than emitting an empty plan. Pure core, I/O at the edges. 11 unit tests including a negative case for the empty set and one asserting a cycle throws instead of yielding a guessed order. Adds `.github/workflows/npm-release.yml`: all eight workspaces, dependency order, `--provenance` with `id-token: write`. `dry_run` defaults to **true**, and that is the point. The audit's finding was that this path had never run end to end; a rehearsable mode is how that stops being true without betting a registry version on the first attempt. The dry run resolves, builds, packs and reports tarball contents — it just does not publish. Other properties chosen on purpose: the token travels via NODE_AUTH_TOKEN, never `npm config set`; after publishing, the version is verified against the registry rather than trusted from an exit code; a failure stops before the failed package's consumers; the planner self-test runs on the runner before the planner is trusted; and mcp-server is rebuilt from a deleted tsbuildinfo, because `nest build` exits 0 while emitting nothing when that file is stale and an empty package is not something to discover after publishing. Retires `publish-npm` from ci-cd.yml, with the reasoning left in place. Already surfaced by running the planner against the real registry: @beyondnet/evolith-sdk is 2.0.0 in the tree and was never published — the monorepo fracture the audit described, now measured. cli and mcp-server pin ^1.1.0 and resolve the registry tarball. No version was bumped here. This commit builds the path; shipping 1.2.0 is a separate, deliberate act. Verified: planner tests 11/11, `plan-npm-release.mjs` against the live registry, both workflows parse, 40-validate-path-literals 117 literals / 203 files exit 0, ci-runner governance 17/17 exit 0. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci-cd.yml | 43 +--- .github/workflows/npm-release.yml | 171 ++++++++++++++++ .harness/scripts/release/plan-npm-release.mjs | 184 ++++++++++++++++++ .../scripts/release/plan-npm-release.test.mjs | 114 +++++++++++ 4 files changed, 477 insertions(+), 35 deletions(-) create mode 100644 .github/workflows/npm-release.yml create mode 100644 .harness/scripts/release/plan-npm-release.mjs create mode 100644 .harness/scripts/release/plan-npm-release.test.mjs diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index ad6a63d5..692ca592 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -412,42 +412,15 @@ jobs: - name: Dead-reference ratchet (must not grow) run: node .harness/scripts/ci/41-validate-evidence-commands.mjs --strict --max-dead 305 - publish-npm: - name: Publish npm - needs: [test, test-core-domain, test-core, test-mcp-server, test-core-api, test-sdk-client, test-infra-providers, test-agent-runtime, test-agent-runtime-api, test-contracts] - runs-on: ubuntu-latest - if: startsWith(github.ref, 'refs/tags/v') - steps: - - uses: actions/checkout@v7 - with: - fetch-tags: true - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: '20' - - - name: Install dependencies - run: npm ci - - - name: Publish to npm - run: | - npm config set //registry.npmjs.org/:_authToken ${{ secrets.NPM_TOKEN }} - npm publish --access public --tag beta - working-directory: src/sdk/cli + # RETIRED 2026-07-27 (GT-570): the `publish-npm` job lived here and published + # ONLY `src/sdk/cli`, with `--tag beta` and no provenance, writing the token + # into a .npmrc via `npm config set`. It could not publish the other seven + # public packages at all — including @beyondnet/evolith-mcp, which carries the + # 2026-07-23 security wave — which is why every version on the registry has no + # dist.attestations. Publishing now lives in `npm-release.yml`: all eight + # workspaces, in dependency order, with --provenance, idempotent against the + # registry, and rehearsable via a dry run that defaults to on. - # RETIRED 2026-07-19: the `docker` job built a CLI image - # (beyondnetperu/evolith-cli) whose stated purpose was "MCP server in HTTP - # mode". That role now belongs to the mcp-server image built by - # `docker-services` below, which works. The retired job had never succeeded: - # its build context was ./src/sdk/cli, so `npm ci` could not see the monorepo's - # root package-lock.json, and its CMD invoked `dist/main.js mcp serve` -- a - # subcommand DELETED in dc0b9667 a month ago. Repairing only the build would - # have published an image that dies on start with "unknown command 'mcp'". - # Nothing depended on this job. src/sdk/cli/Dockerfile is left in place; whether - # it should be deleted is a separate decision about that artefact, not about CI. - # GT-324: build + push the deployable services to GHCR (GitHub Container - # Registry). Auth uses the built-in GITHUB_TOKEN — no extra secret required. docker-services: name: Build & Push Services (GHCR) needs: [test, test-core-domain, test-core, test-mcp-server, test-core-api, test-sdk-client, test-infra-providers] diff --git a/.github/workflows/npm-release.yml b/.github/workflows/npm-release.yml new file mode 100644 index 00000000..26a3e4c8 --- /dev/null +++ b/.github/workflows/npm-release.yml @@ -0,0 +1,171 @@ +name: npm Release (all packages) + +# Publishes EVERY public workspace to npm, in dependency order, with provenance. +# +# WHY THIS WORKFLOW EXISTS +# ------------------------ +# Until now there was no automated path to publish seven of the eight public +# packages. `sdk-cli-release.yml` publishes `src/sdk/cli` and nothing else, and +# `ci-cd.yml`'s `publish-npm` published the same directory again with `--tag beta` +# and no provenance. Everything else — including `@beyondnet/evolith-mcp`, which +# carries the 2026-07-23 security wave — was published by hand from a developer +# machine. That is why all eight versions on the registry have no +# `dist.attestations`. See GT-570. +# +# SAFETY MODEL +# ------------ +# Publishing is irreversible: npm forbids unpublishing after 72 hours. So: +# * `dry_run` defaults to TRUE. The default invocation exercises the whole path +# — resolve, build, pack, verify — and publishes nothing. The audit's finding +# was that this path had never run end to end; a rehearsable mode is how it +# stops being true without betting a registry version on the first attempt. +# * The plan is computed by `.harness/scripts/release/plan-npm-release.mjs`, +# which has its own unit tests including a negative case, and is printed in +# full before anything is published. +# * A package whose exact version already exists on the registry is SKIPPED, so +# a partial failure is resumable without republishing. +# * The run fails if the planner resolves zero packages. An empty release that +# reports success is the defect this repository keeps finding in its guards. +# * The token reaches npm through `NODE_AUTH_TOKEN`, never `npm config set`, +# so it is not written into a `.npmrc` on the runner. + +concurrency: + group: npm-release + cancel-in-progress: false + +on: + workflow_dispatch: + inputs: + dry_run: + description: 'Rehearse without publishing. Leave true unless you intend to ship.' + type: boolean + required: true + default: true + push: + tags: + - 'v*' + +permissions: + contents: read + id-token: write # required for npm provenance + +env: + NODE_VERSION: '20' + +jobs: + release: + name: Publish workspaces + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + registry-url: 'https://registry.npmjs.org' + cache: 'npm' + cache-dependency-path: package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Planner self-test + # The planner decides what gets published. It is tested before it is + # trusted, on the same runner, against the same checkout. + run: node --test .harness/scripts/release/plan-npm-release.test.mjs + + - name: Resolve the release plan + id: plan + run: | + node .harness/scripts/release/plan-npm-release.mjs + node .harness/scripts/release/plan-npm-release.mjs --json > /tmp/plan.json + echo "count=$(jq '.toPublish | length' /tmp/plan.json)" >> "$GITHUB_OUTPUT" + echo "denominator=$(jq '.denominator' /tmp/plan.json)" >> "$GITHUB_OUTPUT" + + - name: Build every workspace + # Built in dependency order by `tsc -b`. Seven of the eight packages have + # no `prepublishOnly`, so `npm publish` would otherwise ship whatever + # happens to be in dist/ — here dist/ is always produced by this run. + run: npm run build + + - name: Rebuild mcp-server from a clean state + # `nest build` exits 0 while emitting nothing when tsconfig.tsbuildinfo is + # stale, which has produced an empty dist more than once. Cheap insurance + # against publishing an empty package. + run: | + rm -f src/packages/mcp-server/tsconfig.tsbuildinfo + npm run build --workspace @beyondnet/evolith-mcp + test -f src/packages/mcp-server/dist/main.js + + - name: Publish + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + DRY_RUN: ${{ github.event_name == 'push' && 'false' || inputs.dry_run }} + run: | + set -euo pipefail + COUNT=$(jq '.toPublish | length' /tmp/plan.json) + DENOM=$(jq '.denominator' /tmp/plan.json) + + if [ "$DENOM" -eq 0 ]; then + echo "::error::the planner resolved zero packages — refusing to report a vacuous success" + exit 1 + fi + + if [ "$COUNT" -eq 0 ]; then + echo "Nothing to publish: all $DENOM package versions are already on the registry." + exit 0 + fi + + echo "Publishing $COUNT of $DENOM package(s). DRY_RUN=$DRY_RUN" + FAILED=0 + for i in $(seq 0 $((COUNT - 1))); do + NAME=$(jq -r ".toPublish[$i].name" /tmp/plan.json) + VERSION=$(jq -r ".toPublish[$i].version" /tmp/plan.json) + DIR=$(jq -r ".toPublish[$i].dir" /tmp/plan.json) + + echo "::group::$NAME@$VERSION ($DIR)" + if [ "$DRY_RUN" = "true" ]; then + # --dry-run still resolves files/, runs the packlist and reports the + # tarball contents, so a shipping mistake surfaces here. + ( cd "$DIR" && npm publish --dry-run --provenance --access public ) + else + if ( cd "$DIR" && npm publish --provenance --access public ); then + # Verify against the registry rather than trusting the exit code. + for attempt in 1 2 3 4 5; do + if [ "$(npm view "$NAME@$VERSION" version 2>/dev/null)" = "$VERSION" ]; then + echo "confirmed on registry: $NAME@$VERSION"; break + fi + [ "$attempt" -eq 5 ] && { echo "::error::$NAME@$VERSION not visible on the registry after publish"; FAILED=1; } + sleep 10 + done + else + # Fail fast: a consumer published against a missing dependency is + # worse than a partial release, and the run is resumable. + echo "::error::publish failed for $NAME@$VERSION — stopping before its consumers" + exit 1 + fi + fi + echo "::endgroup::" + done + + [ "$FAILED" -eq 0 ] || exit 1 + + if [ "$DRY_RUN" = "true" ]; then + echo "DRY RUN — nothing was published. Re-run with dry_run=false to ship." + fi + + - name: Summary + if: always() + run: | + { + echo "### npm release" + echo "" + echo "- planner denominator: **${{ steps.plan.outputs.denominator }}** publishable package(s)" + echo "- selected to publish: **${{ steps.plan.outputs.count }}**" + echo "- dry run: **${{ github.event_name == 'push' && 'false' || inputs.dry_run }}**" + echo "" + echo '```json' + cat /tmp/plan.json 2>/dev/null | jq . || echo '{}' + echo '```' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.harness/scripts/release/plan-npm-release.mjs b/.harness/scripts/release/plan-npm-release.mjs new file mode 100644 index 00000000..67c151e2 --- /dev/null +++ b/.harness/scripts/release/plan-npm-release.mjs @@ -0,0 +1,184 @@ +#!/usr/bin/env node +/** + * Plan the npm release of every publishable workspace, in dependency order. + * + * WHY THIS EXISTS + * --------------- + * Until now the repository had no automated way to publish seven of its eight + * public packages. `sdk-cli-release.yml` publishes `src/sdk/cli` and nothing + * else; `ci-cd.yml`'s `publish-npm` published the same directory again, with + * `--tag beta` and no provenance. Everything else — including + * `@beyondnet/evolith-mcp`, the package that carries the 2026-07-23 security + * wave — was published by hand from a developer machine. That is why all eight + * versions on the registry have no `dist.attestations` (GT-570). + * + * The planner is separated from the workflow on purpose: publishing is + * irreversible, so the decision of WHAT to publish has to be inspectable and + * testable on its own, without a registry, a token, or a runner. + * + * PROPERTIES + * ---------- + * - **Dependency order.** Topological sort over the internal `@beyondnet/evolith-*` + * edges. A consumer is never published before something it depends on, because + * the consumer resolves that dependency from the registry. + * - **Idempotent.** A package whose exact version is already on the registry is + * SKIPPED, not republished. A partially failed release can be re-run safely. + * - **Never vacuous.** Resolving zero publishable packages is an ERROR, not an + * empty success. "Nothing to publish because everything is already published" + * is a distinct, legitimate outcome and says so. + * - **Pure core.** `planRelease()` takes data and returns a plan. The I/O — reading + * package.json files, asking the registry — lives at the edges so the decision + * logic can be tested against fixtures. + * + * USAGE + * node .harness/scripts/release/plan-npm-release.mjs # human-readable plan + * node .harness/scripts/release/plan-npm-release.mjs --json # machine-readable + * node .harness/scripts/release/plan-npm-release.mjs --offline # skip registry lookups + */ + +import { readFileSync, existsSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +export const REPO_ROOT = join(HERE, '..', '..', '..'); + +/** Every workspace directory that may hold a publishable package. */ +export const WORKSPACE_DIRS = [ + 'src/packages/core-domain', + 'src/packages/contracts', + 'src/packages/core', + 'src/packages/agent-runtime', + 'src/packages/infra-providers', + 'src/packages/sdk-client', + 'src/packages/mcp-server', + 'src/sdk/cli', +]; + +const INTERNAL = /^@beyondnet\/evolith-/; + +/** + * Topologically sort packages so that a dependency always precedes its consumer. + * Throws on a cycle rather than emitting an arbitrary order — a cycle here would + * mean an unpublishable graph, and guessing would produce a broken release. + */ +export function dependencyOrder(packages) { + const byName = new Map(packages.map((p) => [p.name, p])); + const done = new Set(); + const order = []; + + const visit = (name, stack) => { + if (done.has(name)) return; + if (stack.includes(name)) { + throw new Error(`dependency cycle: ${[...stack, name].join(' -> ')}`); + } + const pkg = byName.get(name); + for (const dep of pkg?.internalDeps ?? []) { + if (byName.has(dep)) visit(dep, [...stack, name]); + } + done.add(name); + if (pkg) order.push(pkg); + }; + + for (const p of packages) visit(p.name, []); + return order; +} + +/** + * Decide what to do with each package. + * + * @param {Array} packages - {name, version, dir, internalDeps} + * @param {Function} isPublished - (name, version) => boolean + * @returns {{order: Array, toPublish: Array, alreadyPublished: Array, denominator: number}} + */ +export function planRelease(packages, isPublished) { + if (!Array.isArray(packages) || packages.length === 0) { + // Never vacuous: an empty scan is a broken planner or a broken checkout, and + // reporting it as "nothing to do" is how a release pipeline silently ships + // nothing while printing green. + throw new Error('resolved zero publishable packages — refusing to emit an empty plan'); + } + + const order = dependencyOrder(packages); + const toPublish = []; + const alreadyPublished = []; + + for (const pkg of order) { + if (isPublished(pkg.name, pkg.version)) alreadyPublished.push(pkg); + else toPublish.push(pkg); + } + + return { order, toPublish, alreadyPublished, denominator: order.length }; +} + +/** Read the publishable packages off disk. Private packages are excluded. */ +export function readWorkspacePackages(root = REPO_ROOT, dirs = WORKSPACE_DIRS) { + const out = []; + for (const dir of dirs) { + const manifest = join(root, dir, 'package.json'); + if (!existsSync(manifest)) continue; + const pkg = JSON.parse(readFileSync(manifest, 'utf8')); + if (pkg.private) continue; + const deps = { ...(pkg.dependencies ?? {}), ...(pkg.peerDependencies ?? {}) }; + out.push({ + name: pkg.name, + version: pkg.version, + dir, + internalDeps: Object.keys(deps).filter((d) => INTERNAL.test(d)), + }); + } + return out; +} + +/** Ask the registry whether an exact version exists. Absent === publishable. */ +export function registryLookup(name, version) { + try { + const out = execFileSync('npm', ['view', `${name}@${version}`, 'version'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + return out === version; + } catch { + return false; // not published, or the registry could not answer + } +} + +function main(argv) { + const json = argv.includes('--json'); + const offline = argv.includes('--offline'); + const packages = readWorkspacePackages(); + const plan = planRelease(packages, offline ? () => false : registryLookup); + + if (json) { + process.stdout.write(JSON.stringify({ + denominator: plan.denominator, + toPublish: plan.toPublish.map(({ name, version, dir }) => ({ name, version, dir })), + alreadyPublished: plan.alreadyPublished.map(({ name, version }) => ({ name, version })), + }) + '\n'); + return 0; + } + + console.log(`Release plan — ${plan.denominator} publishable package(s), dependency order:\n`); + plan.order.forEach((p, i) => { + const state = plan.alreadyPublished.includes(p) ? 'already on registry' : 'WILL PUBLISH'; + console.log(` ${String(i + 1).padStart(2)}. ${p.name.padEnd(36)} ${p.version.padEnd(8)} ${state}`); + }); + + if (plan.toPublish.length === 0) { + console.log(`\n✓ Nothing to publish: all ${plan.denominator} package versions are already on the registry.`); + } else { + console.log(`\n→ ${plan.toPublish.length} of ${plan.denominator} package(s) would be published:`); + for (const p of plan.toPublish) console.log(` ${p.name}@${p.version}`); + } + return 0; +} + +if (process.argv[1] && process.argv[1].endsWith('plan-npm-release.mjs')) { + try { + process.exit(main(process.argv.slice(2))); + } catch (err) { + console.error(`✗ plan-npm-release: ${err.message}`); + process.exit(1); + } +} diff --git a/.harness/scripts/release/plan-npm-release.test.mjs b/.harness/scripts/release/plan-npm-release.test.mjs new file mode 100644 index 00000000..c19cfa14 --- /dev/null +++ b/.harness/scripts/release/plan-npm-release.test.mjs @@ -0,0 +1,114 @@ +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + dependencyOrder, + planRelease, + readWorkspacePackages, +} from './plan-npm-release.mjs'; + +const pkg = (name, version, internalDeps = []) => ({ name, version, dir: `src/packages/${name}`, internalDeps }); + +describe('dependencyOrder', () => { + test('puts a dependency before its consumer', () => { + const order = dependencyOrder([ + pkg('@beyondnet/evolith-cli', '1.0.0', ['@beyondnet/evolith-core-domain']), + pkg('@beyondnet/evolith-core-domain', '1.0.0'), + ]).map((p) => p.name); + + assert.deepEqual(order, ['@beyondnet/evolith-core-domain', '@beyondnet/evolith-cli']); + }); + + test('handles a diamond without duplicating a node', () => { + const order = dependencyOrder([ + pkg('d', '1.0.0', ['b', 'c']), + pkg('b', '1.0.0', ['a']), + pkg('c', '1.0.0', ['a']), + pkg('a', '1.0.0'), + ]).map((p) => p.name); + + assert.equal(order.length, 4, 'every node exactly once'); + assert.equal(order[0], 'a'); + assert.equal(order[3], 'd'); + assert.ok(order.indexOf('b') < order.indexOf('d')); + assert.ok(order.indexOf('c') < order.indexOf('d')); + }); + + test('THROWS on a cycle instead of guessing an order', () => { + // Guessing would produce a release that publishes a consumer against a + // dependency version that does not exist yet. + assert.throws( + () => dependencyOrder([pkg('a', '1.0.0', ['b']), pkg('b', '1.0.0', ['a'])]), + /dependency cycle/, + ); + }); + + test('ignores an external dependency that is not in the workspace set', () => { + const order = dependencyOrder([pkg('a', '1.0.0', ['@beyondnet/evolith-not-here'])]).map((p) => p.name); + assert.deepEqual(order, ['a']); + }); +}); + +describe('planRelease', () => { + const set = [ + pkg('@beyondnet/evolith-core-domain', '1.2.0'), + pkg('@beyondnet/evolith-mcp', '1.2.0', ['@beyondnet/evolith-core-domain']), + ]; + + test('publishes what the registry does not have, in dependency order', () => { + const plan = planRelease(set, () => false); + assert.deepEqual(plan.toPublish.map((p) => p.name), [ + '@beyondnet/evolith-core-domain', + '@beyondnet/evolith-mcp', + ]); + assert.equal(plan.alreadyPublished.length, 0); + assert.equal(plan.denominator, 2); + }); + + test('is idempotent: an already-published version is skipped, not republished', () => { + const plan = planRelease(set, (name) => name === '@beyondnet/evolith-core-domain'); + assert.deepEqual(plan.toPublish.map((p) => p.name), ['@beyondnet/evolith-mcp']); + assert.deepEqual(plan.alreadyPublished.map((p) => p.name), ['@beyondnet/evolith-core-domain']); + }); + + test('a fully-published set yields an empty publish list but a non-zero denominator', () => { + // This is the legitimate "nothing to do" state and must be distinguishable + // from the vacuous one below. + const plan = planRelease(set, () => true); + assert.equal(plan.toPublish.length, 0); + assert.equal(plan.denominator, 2); + }); + + test('NEGATIVE — an empty package set throws instead of reporting an empty plan', () => { + // The vacuous case. A release pipeline that resolves nothing must not print + // green; that is the defect this repository keeps finding in its own guards. + assert.throws(() => planRelease([], () => false), /zero publishable packages/); + assert.throws(() => planRelease(null, () => false), /zero publishable packages/); + }); + + test('matches on the exact version, so a bump is publishable even if the name exists', () => { + const isPublished = (name, version) => version === '1.1.0'; + const plan = planRelease([pkg('@beyondnet/evolith-mcp', '1.2.0')], isPublished); + assert.equal(plan.toPublish.length, 1, '1.2.0 is not 1.1.0'); + }); +}); + +describe('readWorkspacePackages (against the real repository)', () => { + const real = readWorkspacePackages(); + + test('finds the publishable packages and excludes private ones', () => { + assert.ok(real.length >= 8, `expected at least 8 publishable packages, got ${real.length}`); + assert.ok(real.every((p) => p.name.startsWith('@beyondnet/evolith-'))); + assert.ok(!real.some((p) => p.name === 'core-api'), 'core-api is private and must not appear'); + }); + + test('the real graph is acyclic and orders core-domain first, cli last', () => { + const order = dependencyOrder(real).map((p) => p.name); + assert.equal(order[0], '@beyondnet/evolith-core-domain'); + assert.equal(order[order.length - 1], '@beyondnet/evolith-cli'); + assert.ok( + order.indexOf('@beyondnet/evolith-core-domain') < order.indexOf('@beyondnet/evolith-mcp'), + 'mcp depends on core-domain and must follow it', + ); + }); +});