From a269534debc977e14639811a94c542f85d8a818b Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Mon, 11 May 2026 13:13:14 +0200 Subject: [PATCH] docs: add plan for public npm release handoff --- .../2026-05-11-public-npm-release-handoff.md | 1332 +++++++++++++++++ 1 file changed, 1332 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-11-public-npm-release-handoff.md diff --git a/docs/superpowers/plans/2026-05-11-public-npm-release-handoff.md b/docs/superpowers/plans/2026-05-11-public-npm-release-handoff.md new file mode 100644 index 00000000..11100184 --- /dev/null +++ b/docs/superpowers/plans/2026-05-11-public-npm-release-handoff.md @@ -0,0 +1,1332 @@ +# Public NPM Release Handoff Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Turn the remaining npm-managed Python runtime release gap into a +guarded public `@kaelio/ktx` npm release handoff for version `0.1.0`. + +**Architecture:** Keep one public npm package and keep Python packages +unpublished. The public package builder stamps the assembled `@kaelio/ktx` +package as `0.1.0`, release readiness accepts a publish-ready policy only when +all blocking decisions are encoded, and a new publish script performs a dry-run +by default before any live registry publish. + +**Tech Stack:** Node 22 ESM scripts, `node:test`, pnpm 10 publish, JSON release +policy, GitHub Actions workflow validation. + +--- + +## Spec trace and current state + +This plan follows +`docs/superpowers/specs/2026-05-11-npm-managed-python-runtime-design.md`. + +The existing plan files that reference that spec are: + +- `docs/superpowers/plans/2026-05-11-bundled-python-runtime-wheel.md` +- `docs/superpowers/plans/2026-05-11-managed-agent-mcp-semantic-runtime.md` +- `docs/superpowers/plans/2026-05-11-managed-local-embeddings-release-smoke.md` +- `docs/superpowers/plans/2026-05-11-managed-local-embeddings-runtime.md` +- `docs/superpowers/plans/2026-05-11-managed-local-ingest-daemon-runtime.md` +- `docs/superpowers/plans/2026-05-11-managed-python-runtime-command-integration.md` +- `docs/superpowers/plans/2026-05-11-managed-python-runtime-daemon-lifecycle.md` +- `docs/superpowers/plans/2026-05-11-managed-python-runtime-installer.md` +- `docs/superpowers/plans/2026-05-11-managed-python-runtime-release-smoke.md` +- `docs/superpowers/plans/2026-05-11-managed-runtime-docs-and-postgres-smoke-cleanup.md` +- `docs/superpowers/plans/2026-05-11-public-kaelio-ktx-npm-package.md` +- `docs/superpowers/plans/2026-05-11-published-package-managed-runtime-smoke.md` + +All twelve are implemented in the current tree: their referenced source and +test files exist, and the runtime command, daemon, package artifact, +published-package smoke, local-embedding smoke, and README markers are present. + +The remaining release gap is explicit in `release-policy.json`: the repository +still uses `ci-artifact-only`, `npm.publish` is `false`, and the README states +that registry publishing is disabled. This plan changes that to a guarded +handoff for the first public npm release while leaving Python registry +publication disabled because the spec says KTX-owned Python code ships inside +the npm package as a bundled wheel for this release. + +## File structure + +- Modify `scripts/build-public-npm-package.mjs`: make the assembled public npm + package version and tarball name `0.1.0` instead of `0.0.0-private`. +- Modify `scripts/build-public-npm-package.test.mjs`: cover public version + stamping and the versioned tarball path. +- Modify `scripts/package-artifacts.mjs`: make artifact metadata report + `@kaelio/ktx` as version `0.1.0`. +- Modify `scripts/package-artifacts.test.mjs`: update artifact manifest, + metadata, runtime smoke, and demo smoke expectations for the public tarball. +- Modify `scripts/local-embeddings-runtime-smoke.test.mjs`: update public + tarball selection coverage for `kaelio-ktx-0.1.0.tgz`. +- Modify `scripts/release-readiness.mjs`: add the + `npm-public-release-ready` release mode and policy validation. +- Modify `scripts/release-readiness.test.mjs`: cover the publish-ready policy + and validation failures. +- Modify `release-policy.json`: encode the first public npm release handoff. +- Create `scripts/publish-public-npm-package.mjs`: verify readiness and run + `pnpm publish` in dry-run mode by default. +- Create `scripts/publish-public-npm-package.test.mjs`: cover publish command + construction and policy gating. +- Modify `package.json`: add `release:npm-publish`. +- Create `.github/workflows/release.yml`: add a manual dry-run/live publish + workflow for the public npm tarball. +- Create `scripts/release-workflow.test.mjs`: validate that the release + workflow is manual, uses pnpm, runs readiness checks, and gates live publish. +- Modify `README.md`: replace the disabled publishing note with the guarded + handoff commands. + +### Task 1: Stamp public npm artifacts as `0.1.0` + +**Files:** + +- Modify: `scripts/build-public-npm-package.mjs` +- Modify: `scripts/build-public-npm-package.test.mjs` +- Modify: `scripts/package-artifacts.mjs` +- Modify: `scripts/package-artifacts.test.mjs` +- Modify: `scripts/local-embeddings-runtime-smoke.test.mjs` + +- [ ] **Step 1: Write failing public version tests** + +In `scripts/build-public-npm-package.test.mjs`, extend the import from +`./build-public-npm-package.mjs` so it includes `PUBLIC_NPM_PACKAGE_VERSION` +and `publicNpmPackageTarballName`: + +```js +import { + PUBLIC_BUNDLED_WORKSPACE_PACKAGES, + PUBLIC_NPM_PACKAGE_NAME, + PUBLIC_NPM_PACKAGE_VERSION, + collectPublicDependencies, + createPublicNpmPackageTree, + publicNpmPackageJson, + publicNpmPackageLayout, + publicNpmPackageTarballName, + publicNpmPackCommand, +} from './build-public-npm-package.mjs'; +``` + +Replace the `publicNpmPackageLayout` test expectation with: + +```js +describe('publicNpmPackageLayout', () => { + it('uses the first public npm release version for the tarball name', () => { + const layout = publicNpmPackageLayout('/repo/ktx'); + + assert.equal(PUBLIC_NPM_PACKAGE_VERSION, '0.1.0'); + assert.equal(publicNpmPackageTarballName(), 'kaelio-ktx-0.1.0.tgz'); + assert.equal(layout.tarballPath, '/repo/ktx/dist/artifacts/npm/kaelio-ktx-0.1.0.tgz'); + }); +}); +``` + +In the `publicNpmPackageJson` test, add this assertion after the package name +assertion: + +```js +assert.equal(packageJson.version, '0.1.0'); +``` + +In the `publicNpmPackCommand` test, replace the tarball assertion block with: + +```js +assert.deepEqual(publicNpmPackCommand(layout), { + command: 'pnpm', + args: [ + '--config.node-linker=hoisted', + 'pack', + '--out', + '/repo/ktx/dist/artifacts/npm/kaelio-ktx-0.1.0.tgz', + ], + cwd: '/repo/ktx/dist/public-npm-package', +}); +``` + +- [ ] **Step 2: Run public package tests to verify failure** + +Run: + +```bash +node --test scripts/build-public-npm-package.test.mjs +``` + +Expected: FAIL. The failure mentions at least one stale +`kaelio-ktx-0.0.0-private.tgz` or `0.0.0-private` public package version +expectation. + +- [ ] **Step 3: Implement public version stamping** + +In `scripts/build-public-npm-package.mjs`, replace the current public version +constants and layout helper with: + +```js +export const PUBLIC_NPM_PACKAGE_NAME = '@kaelio/ktx'; +export const PUBLIC_NPM_PACKAGE_VERSION = '0.1.0'; + +export function publicNpmPackageTarballName(version = PUBLIC_NPM_PACKAGE_VERSION) { + return `kaelio-ktx-${version}.tgz`; +} +``` + +Replace `publicNpmPackageLayout` with: + +```js +export function publicNpmPackageLayout(rootDir = scriptRootDir(), version = PUBLIC_NPM_PACKAGE_VERSION) { + return { + rootDir, + packageVersion: version, + cliPackageRoot: join(rootDir, 'packages', 'cli'), + packRoot: join(rootDir, 'dist', 'public-npm-package'), + npmDir: join(rootDir, 'dist', 'artifacts', 'npm'), + tarballPath: join(rootDir, 'dist', 'artifacts', 'npm', publicNpmPackageTarballName(version)), + }; +} +``` + +Change `publicNpmPackageJson` so it accepts the public version explicitly: + +```js +export function publicNpmPackageJson(cliPackageJson, dependencies, version = PUBLIC_NPM_PACKAGE_VERSION) { + return { + name: PUBLIC_NPM_PACKAGE_NAME, + version, + description: 'Standalone KTX context layer for database agents', + private: false, + type: 'module', + engines: cliPackageJson.engines ?? { node: '>=22.0.0' }, + bin: { ktx: './dist/bin.js' }, + main: cliPackageJson.main ?? 'dist/index.js', + types: cliPackageJson.types ?? 'dist/index.d.ts', + exports: cliPackageJson.exports ?? { + '.': { + types: './dist/index.d.ts', + import: './dist/index.js', + default: './dist/index.js', + }, + './package.json': './package.json', + }, + files: ['dist', 'assets'], + dependencies, + bundledDependencies: PUBLIC_BUNDLED_WORKSPACE_PACKAGES, + license: cliPackageJson.license ?? 'Apache-2.0', + repository: { + type: 'git', + url: 'git+https://github.com/kaelio/ktx.git', + }, + bugs: { + url: 'https://github.com/kaelio/ktx/issues', + }, + homepage: 'https://github.com/kaelio/ktx#readme', + }; +} +``` + +In `copyCliPackage`, pass the layout version: + +```js +await writeJson( + join(layout.packRoot, 'package.json'), + publicNpmPackageJson(cliPackageJson, dependencies, layout.packageVersion), +); +``` + +In `createPublicNpmPackageTree`, return the versioned package JSON: + +```js +return { + layout, + packageJson: publicNpmPackageJson(cliPackageJson, dependencies, layout.packageVersion), + bundledPackages: PUBLIC_BUNDLED_WORKSPACE_PACKAGES, +}; +``` + +- [ ] **Step 4: Run public package tests to verify pass** + +Run: + +```bash +node --test scripts/build-public-npm-package.test.mjs +``` + +Expected: PASS. + +- [ ] **Step 5: Write failing artifact metadata tests** + +In `scripts/package-artifacts.test.mjs`, replace expectations that use the +public npm tarball or package version: + +```js +assert.equal(layout.cliTarball, '/repo/ktx/dist/artifacts/npm/kaelio-ktx-0.1.0.tgz'); +``` + +```js +{ + ecosystem: 'npm', + packageName: '@kaelio/ktx', + packageRoot: 'packages/cli', + packageVersion: '0.1.0', + private: false, + releaseMode: 'ci-artifact-only', +} +``` + +```js +{ + ecosystem: 'npm', + packageName: '@kaelio/ktx', + packageVersion: '0.1.0', + path: 'npm/kaelio-ktx-0.1.0.tgz', + bytes: Buffer.byteLength('@kaelio/ktx-tarball'), + sha256: createHash('sha256').update('@kaelio/ktx-tarball').digest('hex'), +} +``` + +In the runtime smoke source expectation, replace: + +```js +requireOutput('ktx public package version', version, /@kaelio\/ktx 0\.1\.0/); +``` + +In `scripts/local-embeddings-runtime-smoke.test.mjs`, replace the public +tarball selection assertion with: + +```js +assert.equal( + publicKtxTarballName(['kaelio-ktx-0.1.0.tgz', 'ignore-me.tgz']), + 'kaelio-ktx-0.1.0.tgz', +); +``` + +- [ ] **Step 6: Run artifact tests to verify failure** + +Run: + +```bash +node --test scripts/package-artifacts.test.mjs scripts/local-embeddings-runtime-smoke.test.mjs +``` + +Expected: FAIL. The failure mentions stale artifact metadata or tarball +expectations for `0.0.0-private`. + +- [ ] **Step 7: Implement artifact metadata versioning** + +In `scripts/package-artifacts.mjs`, change the build-public import to: + +```js +import { + PUBLIC_NPM_PACKAGE_NAME, + PUBLIC_NPM_PACKAGE_VERSION, + publicNpmPackageTarballName, +} from './build-public-npm-package.mjs'; +``` + +Replace `npmPackageTarballName` with: + +```js +function npmPackageTarballName(packageName) { + if (packageName === PUBLIC_NPM_PACKAGE_NAME) { + return publicNpmPackageTarballName(PUBLIC_NPM_PACKAGE_VERSION); + } + return `${packageName.replace('@ktx/', 'ktx-')}-${PACKAGE_VERSION}.tgz`; +} +``` + +In `readNpmPackageMetadata`, return the public package version for +`@kaelio/ktx`: + +```js + const isPublicKtxPackage = packageInfo.name === PUBLIC_NPM_PACKAGE_NAME; + return releaseMetadataEntry({ + ecosystem: 'npm', + packageName: packageInfo.name, + packageRoot: packageInfo.packageRoot, + packageVersion: isPublicKtxPackage ? PUBLIC_NPM_PACKAGE_VERSION : packageJson.version, + privatePackage: isPublicKtxPackage ? false : packageJson.private === true, + }); +``` + +In `npmRuntimeSmokeSource`, replace the version output regex with: + +```js +requireOutput('ktx public package version', version, /@kaelio\/ktx 0\.1\.0/); +``` + +- [ ] **Step 8: Run artifact tests to verify pass** + +Run: + +```bash +node --test scripts/package-artifacts.test.mjs scripts/local-embeddings-runtime-smoke.test.mjs +``` + +Expected: PASS. + +- [ ] **Step 9: Commit public version stamping** + +Run: + +```bash +git add scripts/build-public-npm-package.mjs scripts/build-public-npm-package.test.mjs scripts/package-artifacts.mjs scripts/package-artifacts.test.mjs scripts/local-embeddings-runtime-smoke.test.mjs +git commit -m "build: stamp public npm package version" +``` + +Expected: commit created. + +### Task 2: Add publish-ready release policy validation + +**Files:** + +- Modify: `scripts/release-readiness.mjs` +- Modify: `scripts/release-readiness.test.mjs` +- Modify: `release-policy.json` + +- [ ] **Step 1: Write failing release readiness tests** + +In `scripts/release-readiness.test.mjs`, add `PUBLIC_NPM_PACKAGE_VERSION` to +the imports from `./build-public-npm-package.mjs`: + +```js +import { PUBLIC_NPM_PACKAGE_VERSION } from './build-public-npm-package.mjs'; +``` + +Update `releasePolicy()` so the default npm block includes publish settings: + +```js +npm: { + publish: false, + registry: null, + access: 'public', + tag: 'latest', + packages: ['@kaelio/ktx'], + ...npmOverrides, +}, +``` + +In each existing `releaseReadinessReport` expected object for +`ci-artifact-only` and `published-package-smoke-required`, add: + +```js +npmPublish: null, +``` + +Place it after `publishedPackageSmokeGate` and before +`blockedPublishingDecisions`. + +In `writeReleaseMetadataInputs`, keep internal workspace package versions +private. The public package version comes from artifact metadata: + +```js +version: '0.0.0-private', +private: true, +``` + +Add this test after the existing +`reports required published package smoke when release mode requires it` test: + +```js +it('accepts the npm public release ready policy', async () => { + const root = await mkdtemp(join(tmpdir(), 'ktx-npm-public-ready-test-')); + try { + await writeReadyFixture(root, { + policy: releasePolicy({ + releaseMode: 'npm-public-release-ready', + npm: { + publish: true, + registry: null, + access: 'public', + tag: 'latest', + }, + publishedPackageSmoke: { + packageName: '@kaelio/ktx', + version: PUBLIC_NPM_PACKAGE_VERSION, + registry: null, + }, + requiredBeforePublishing: [], + }), + }); + + const report = await releaseReadinessReport(root); + + assert.deepEqual(report, { + schemaVersion: 1, + releaseMode: 'npm-public-release-ready', + sourceRevision: 'abc123', + npmPublishEnabled: true, + pythonPublishEnabled: false, + packageNames: ['@kaelio/ktx', 'ktx-sl', 'ktx-daemon', 'kaelio-ktx'], + publishedPackageSmokeGate: { + status: 'required', + script: 'pnpm run release:published-smoke', + reason: 'Run the published package smoke after the npm package is published.', + configSource: 'release-policy', + packageName: '@kaelio/ktx', + version: PUBLIC_NPM_PACKAGE_VERSION, + registry: null, + }, + npmPublish: { + packageName: '@kaelio/ktx', + version: PUBLIC_NPM_PACKAGE_VERSION, + access: 'public', + tag: 'latest', + registry: null, + }, + blockedPublishingDecisions: [], + }); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); +``` + +Add this validation test: + +```js +it('rejects npm public release ready mode when npm publish is disabled', async () => { + const root = await mkdtemp(join(tmpdir(), 'ktx-npm-public-ready-disabled-test-')); + try { + await writeReadyFixture(root, { + policy: releasePolicy({ + releaseMode: 'npm-public-release-ready', + npm: { + publish: false, + registry: null, + access: 'public', + tag: 'latest', + }, + publishedPackageSmoke: { + packageName: '@kaelio/ktx', + version: PUBLIC_NPM_PACKAGE_VERSION, + registry: null, + }, + requiredBeforePublishing: [], + }), + }); + + await assert.rejects( + () => releaseReadinessReport(root), + /npm-public-release-ready policy requires npm.publish true/, + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); +``` + +Add this validation test: + +```js +it('rejects npm public release ready mode when Python publishing is enabled', async () => { + const root = await mkdtemp(join(tmpdir(), 'ktx-npm-public-ready-python-test-')); + try { + await writeReadyFixture(root, { + policy: releasePolicy({ + releaseMode: 'npm-public-release-ready', + npm: { + publish: true, + registry: null, + access: 'public', + tag: 'latest', + }, + python: { + publish: true, + repository: 'pypi', + }, + publishedPackageSmoke: { + packageName: '@kaelio/ktx', + version: PUBLIC_NPM_PACKAGE_VERSION, + registry: null, + }, + requiredBeforePublishing: [], + }), + }); + + await assert.rejects( + () => releaseReadinessReport(root), + /npm-public-release-ready policy keeps python.publish false/, + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); +``` + +- [ ] **Step 2: Run release readiness tests to verify failure** + +Run: + +```bash +node --test scripts/release-readiness.test.mjs +``` + +Expected: FAIL with `Unsupported release policy releaseMode: +npm-public-release-ready` or missing `npm.access` validation. + +- [ ] **Step 3: Implement publish-ready policy validation** + +In `scripts/release-readiness.mjs`, import the public package version: + +```js +import { PUBLIC_NPM_PACKAGE_VERSION } from './build-public-npm-package.mjs'; +``` + +Add the release mode constant and include it in `SUPPORTED_RELEASE_MODES`: + +```js +const NPM_PUBLIC_RELEASE_READY_MODE = 'npm-public-release-ready'; +const SUPPORTED_RELEASE_MODES = new Set([ + CI_ARTIFACT_ONLY_RELEASE_MODE, + PUBLISHED_PACKAGE_SMOKE_REQUIRED_RELEASE_MODE, + NPM_PUBLIC_RELEASE_READY_MODE, +]); +``` + +Add string validators for the npm publish settings: + +```js +function assertNpmAccess(value) { + if (value !== 'public') { + throw new Error('Release policy npm.access must be public'); + } +} + +function assertNpmTag(value) { + assertString(value, 'Release policy npm.tag'); + if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(value)) { + throw new Error(`Invalid Release policy npm.tag: ${value}`); + } +} +``` + +In `validateReleasePolicy`, validate the new npm fields after +`assertNullableString(policy.npm.registry, 'Release policy npm.registry');`: + +```js + assertNpmAccess(policy.npm.access); + assertNpmTag(policy.npm.tag); +``` + +Replace `assertRequiredBeforePublishing` with: + +```js +function assertRequiredBeforePublishing(policy) { + assertStringArray(policy.requiredBeforePublishing, 'Release policy requiredBeforePublishing'); + + if (policy.releaseMode === CI_ARTIFACT_ONLY_RELEASE_MODE && policy.requiredBeforePublishing.length === 0) { + throw new Error('Release policy requiredBeforePublishing must list the remaining publishing decisions'); + } + + if ( + (policy.releaseMode === PUBLISHED_PACKAGE_SMOKE_REQUIRED_RELEASE_MODE || + policy.releaseMode === NPM_PUBLIC_RELEASE_READY_MODE) && + policy.requiredBeforePublishing.length > 0 + ) { + throw new Error(`${policy.releaseMode} release mode requires requiredBeforePublishing to be empty`); + } +} +``` + +Replace `publishedPackageSmokeGate` with: + +```js +function publishedPackageSmokeGate(policy) { + const config = readPublishedPackageSmokeConfig({}, [], policy.publishedPackageSmoke); + + if ( + (policy.releaseMode === PUBLISHED_PACKAGE_SMOKE_REQUIRED_RELEASE_MODE || + policy.releaseMode === NPM_PUBLIC_RELEASE_READY_MODE) && + !config.enabled + ) { + throw new Error(`${policy.releaseMode} release mode requires release-policy.json publishedPackageSmoke.packageName`); + } + + const base = + policy.releaseMode === CI_ARTIFACT_ONLY_RELEASE_MODE + ? { + status: 'not_required', + reason: 'Published package smoke remains pending until release-policy.json enables npm registry publishing.', + } + : policy.releaseMode === NPM_PUBLIC_RELEASE_READY_MODE + ? { + status: 'required', + reason: 'Run the published package smoke after the npm package is published.', + } + : { + status: 'required', + reason: 'Run the published package smoke before accepting the hybrid-search release.', + }; + + return { + ...base, + script: 'pnpm run release:published-smoke', + configSource: config.enabled ? config.configSource : null, + packageName: config.enabled ? config.packageName : null, + version: config.enabled ? config.packageVersion : policy.publishedPackageSmoke.version, + registry: config.enabled ? (config.registry ?? null) : policy.publishedPackageSmoke.registry, + }; +} +``` + +Add this function below `assertNonPublishingArtifactPolicy`: + +```js +function assertNpmPublicReleaseReadyPolicy(policy, metadata) { + if (policy.npm.publish !== true) { + throw new Error('npm-public-release-ready policy requires npm.publish true'); + } + if (policy.python.publish !== false) { + throw new Error('npm-public-release-ready policy keeps python.publish false'); + } + if (policy.python.repository !== null) { + throw new Error('npm-public-release-ready policy keeps python.repository null'); + } + + assertSameMembers(policy.npm.packages, ['@kaelio/ktx'], 'Release policy npm.packages'); + assertSameMembers(policy.python.packages, metadataNames(metadata, 'python'), 'Release policy python.packages'); + + const npmMetadata = metadata.find((entry) => entry.ecosystem === 'npm' && entry.packageName === '@kaelio/ktx'); + if (!npmMetadata) { + throw new Error('npm-public-release-ready policy requires @kaelio/ktx artifact metadata'); + } + if (npmMetadata.private !== false) { + throw new Error('npm-public-release-ready policy requires @kaelio/ktx to be publishable'); + } + if (npmMetadata.packageVersion !== PUBLIC_NPM_PACKAGE_VERSION) { + throw new Error( + `npm-public-release-ready policy expected @kaelio/ktx ${PUBLIC_NPM_PACKAGE_VERSION}, got ${npmMetadata.packageVersion}`, + ); + } + if (policy.publishedPackageSmoke.packageName !== '@kaelio/ktx') { + throw new Error('npm-public-release-ready policy requires publishedPackageSmoke.packageName @kaelio/ktx'); + } + if (policy.publishedPackageSmoke.version !== PUBLIC_NPM_PACKAGE_VERSION) { + throw new Error( + `npm-public-release-ready policy requires publishedPackageSmoke.version ${PUBLIC_NPM_PACKAGE_VERSION}`, + ); + } +} +``` + +Inside `assertNonPublishingArtifactPolicy`, replace the npm package version +suffix check with public-package-aware validation: + +```js + if (isPublicKtxPackage) { + if (entry.packageVersion !== PUBLIC_NPM_PACKAGE_VERSION) { + throw new Error( + `${policyLabel} npm package @kaelio/ktx must use public version ${PUBLIC_NPM_PACKAGE_VERSION}`, + ); + } + } else if (!entry.packageVersion.endsWith('-private')) { + throw new Error(`${policyLabel} npm package ${entry.packageName} must use a private version suffix`); + } +``` + +In `releaseReadinessReport`, replace the unconditional +`assertNonPublishingArtifactPolicy(policy, metadata);` call with: + +```js + if (policy.releaseMode === NPM_PUBLIC_RELEASE_READY_MODE) { + assertNpmPublicReleaseReadyPolicy(policy, metadata); + } else { + assertNonPublishingArtifactPolicy(policy, metadata); + } +``` + +Add `npmPublish` to the returned report: + +```js + npmPublish: + policy.releaseMode === NPM_PUBLIC_RELEASE_READY_MODE + ? { + packageName: '@kaelio/ktx', + version: PUBLIC_NPM_PACKAGE_VERSION, + access: policy.npm.access, + tag: policy.npm.tag, + registry: policy.npm.registry, + } + : null, +``` + +Update the text output so it prints the npm publish target when present: + +```js + if (report.npmPublish) { + process.stdout.write( + `NPM publish target: ${report.npmPublish.packageName}@${report.npmPublish.version} (${report.npmPublish.tag})\n`, + ); + } else { + process.stdout.write('Registry publishing remains disabled by release-policy.json.\n'); + } +``` + +- [ ] **Step 4: Update release policy** + +Replace `release-policy.json` with: + +```json +{ + "schemaVersion": 1, + "releaseMode": "npm-public-release-ready", + "npm": { + "publish": true, + "registry": null, + "access": "public", + "tag": "latest", + "packages": ["@kaelio/ktx"] + }, + "python": { + "publish": false, + "repository": null, + "packages": ["ktx-sl", "ktx-daemon", "kaelio-ktx"] + }, + "publishedPackageSmoke": { + "packageName": "@kaelio/ktx", + "version": "0.1.0", + "registry": null + }, + "requiredBeforePublishing": [] +} +``` + +- [ ] **Step 5: Run release readiness tests to verify pass** + +Run: + +```bash +node --test scripts/release-readiness.test.mjs +``` + +Expected: PASS. + +- [ ] **Step 6: Commit release policy validation** + +Run: + +```bash +git add scripts/release-readiness.mjs scripts/release-readiness.test.mjs release-policy.json +git commit -m "release: add npm public release policy" +``` + +Expected: commit created. + +### Task 3: Add guarded npm publish script + +**Files:** + +- Create: `scripts/publish-public-npm-package.test.mjs` +- Create: `scripts/publish-public-npm-package.mjs` +- Modify: `package.json` + +- [ ] **Step 1: Write failing publish script tests** + +Create `scripts/publish-public-npm-package.test.mjs` with: + +```js +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { describe, it } from 'node:test'; + +import { + buildNpmPublishCommand, + requireNpmPublicReleaseReady, + resolvePublishMode, +} from './publish-public-npm-package.mjs'; + +const readyReport = { + releaseMode: 'npm-public-release-ready', + npmPublishEnabled: true, + npmPublish: { + packageName: '@kaelio/ktx', + version: '0.1.0', + access: 'public', + tag: 'latest', + registry: null, + }, +}; + +describe('resolvePublishMode', () => { + it('dry-runs by default', () => { + assert.deepEqual(resolvePublishMode([]), { live: false }); + }); + + it('requires an explicit flag for live publish', () => { + assert.deepEqual(resolvePublishMode(['--publish']), { live: true }); + }); +}); + +describe('requireNpmPublicReleaseReady', () => { + it('accepts the npm public release ready report', () => { + assert.equal(requireNpmPublicReleaseReady(readyReport), readyReport.npmPublish); + }); + + it('rejects artifact-only reports', () => { + assert.throws( + () => + requireNpmPublicReleaseReady({ + releaseMode: 'ci-artifact-only', + npmPublishEnabled: false, + npmPublish: null, + }), + /release-policy.json must use npm-public-release-ready before publishing/, + ); + }); +}); + +describe('buildNpmPublishCommand', () => { + it('builds a dry-run pnpm publish command by default', () => { + assert.deepEqual(buildNpmPublishCommand('/repo/ktx/dist/artifacts/npm/kaelio-ktx-0.1.0.tgz', readyReport.npmPublish, { live: false }), { + command: 'pnpm', + args: [ + 'publish', + '/repo/ktx/dist/artifacts/npm/kaelio-ktx-0.1.0.tgz', + '--access', + 'public', + '--tag', + 'latest', + '--dry-run', + ], + env: {}, + }); + }); + + it('omits dry-run only for explicit live publish', () => { + assert.deepEqual(buildNpmPublishCommand('/repo/ktx/dist/artifacts/npm/kaelio-ktx-0.1.0.tgz', readyReport.npmPublish, { live: true }).args, [ + 'publish', + '/repo/ktx/dist/artifacts/npm/kaelio-ktx-0.1.0.tgz', + '--access', + 'public', + '--tag', + 'latest', + ]); + }); + + it('uses npm_config_registry when a registry is configured', () => { + const publish = { + ...readyReport.npmPublish, + registry: 'https://registry.npmjs.org/', + }; + + assert.deepEqual( + buildNpmPublishCommand('/repo/ktx/dist/artifacts/npm/kaelio-ktx-0.1.0.tgz', publish, { live: false }).env, + { npm_config_registry: 'https://registry.npmjs.org/' }, + ); + }); +}); + +describe('package script', () => { + it('registers release:npm-publish', async () => { + const packageJson = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')); + + assert.equal(packageJson.scripts['release:npm-publish'], 'node scripts/publish-public-npm-package.mjs'); + }); +}); +``` + +- [ ] **Step 2: Run publish script tests to verify failure** + +Run: + +```bash +node --test scripts/publish-public-npm-package.test.mjs +``` + +Expected: FAIL with `Cannot find module` for +`scripts/publish-public-npm-package.mjs`. + +- [ ] **Step 3: Implement publish script** + +Create `scripts/publish-public-npm-package.mjs` with: + +```js +#!/usr/bin/env node + +import { execFile } from 'node:child_process'; +import { access } from 'node:fs/promises'; +import { promisify } from 'node:util'; +import { pathToFileURL } from 'node:url'; + +import { packageArtifactLayout } from './package-artifacts.mjs'; +import { releaseReadinessReport } from './release-readiness.mjs'; + +const execFileAsync = promisify(execFile); + +export function resolvePublishMode(args = process.argv.slice(2)) { + return { live: args.includes('--publish') }; +} + +export function requireNpmPublicReleaseReady(report) { + if (report.releaseMode !== 'npm-public-release-ready' || report.npmPublishEnabled !== true || !report.npmPublish) { + throw new Error('release-policy.json must use npm-public-release-ready before publishing'); + } + return report.npmPublish; +} + +export function buildNpmPublishCommand(tarballPath, publish, mode) { + return { + command: 'pnpm', + args: [ + 'publish', + tarballPath, + '--access', + publish.access, + '--tag', + publish.tag, + ...(mode.live ? [] : ['--dry-run']), + ], + env: publish.registry ? { npm_config_registry: publish.registry } : {}, + }; +} + +async function assertFileExists(path) { + try { + await access(path); + } catch { + throw new Error(`Missing npm tarball: ${path}. Run pnpm run artifacts:check first.`); + } +} + +async function runPublishCommand(command) { + process.stdout.write(`$ ${command.command} ${command.args.join(' ')}\n`); + await execFileAsync(command.command, command.args, { + env: { ...process.env, ...command.env }, + encoding: 'utf8', + maxBuffer: 1024 * 1024 * 20, + }); +} + +export async function publishPublicNpmPackage(options = {}) { + const rootDir = options.rootDir; + const mode = options.mode ?? resolvePublishMode(options.args); + const report = await releaseReadinessReport(rootDir); + const publish = requireNpmPublicReleaseReady(report); + const layout = packageArtifactLayout(rootDir); + const tarballPath = layout.cliTarball; + + await assertFileExists(tarballPath); + const command = buildNpmPublishCommand(tarballPath, publish, mode); + await runPublishCommand(command); + + process.stdout.write( + mode.live + ? `Published ${publish.packageName}@${publish.version} with tag ${publish.tag}\n` + : `Dry-run verified ${publish.packageName}@${publish.version} with tag ${publish.tag}\n`, + ); +} + +async function main() { + await publishPublicNpmPackage({ args: process.argv.slice(2) }); +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { + try { + await main(); + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.stack : String(error)}\n`); + process.exitCode = 1; + } +} +``` + +- [ ] **Step 4: Add the package script** + +In root `package.json`, add this script after `release:local-embeddings-smoke`: + +```json +"release:npm-publish": "node scripts/publish-public-npm-package.mjs", +``` + +- [ ] **Step 5: Run publish script tests to verify pass** + +Run: + +```bash +node --test scripts/publish-public-npm-package.test.mjs +``` + +Expected: PASS. + +- [ ] **Step 6: Run a dry-run publish after artifacts are built** + +Run: + +```bash +pnpm run artifacts:check +pnpm run release:npm-publish +``` + +Expected: PASS. The publish command includes `--dry-run`, and the final line is: + +```text +Dry-run verified @kaelio/ktx@0.1.0 with tag latest +``` + +- [ ] **Step 7: Commit publish script** + +Run: + +```bash +git add scripts/publish-public-npm-package.mjs scripts/publish-public-npm-package.test.mjs package.json +git commit -m "release: add guarded npm publish script" +``` + +Expected: commit created. + +### Task 4: Add manual release workflow and docs + +**Files:** + +- Create: `.github/workflows/release.yml` +- Create: `scripts/release-workflow.test.mjs` +- Modify: `README.md` + +- [ ] **Step 1: Write failing workflow tests** + +Create `scripts/release-workflow.test.mjs` with: + +```js +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { describe, it } from 'node:test'; + +describe('release workflow', () => { + it('publishes only from manual dispatch with an explicit live input', async () => { + const workflow = await readFile(new URL('../.github/workflows/release.yml', import.meta.url), 'utf8'); + + assert.match(workflow, /^name: KTX Release$/m); + assert.match(workflow, /^ workflow_dispatch:$/m); + assert.match(workflow, /publish_live:/); + assert.match(workflow, /default: false/); + assert.match(workflow, /pnpm run artifacts:check/); + assert.match(workflow, /pnpm run release:readiness/); + assert.match(workflow, /pnpm run release:npm-publish$/m); + assert.match(workflow, /pnpm run release:npm-publish -- --publish/); + assert.match(workflow, /NODE_AUTH_TOKEN: \$\{\{ secrets.NPM_TOKEN \}\}/); + assert.doesNotMatch(workflow, /^ push:/m); + assert.doesNotMatch(workflow, /^ pull_request:/m); + }); +}); +``` + +- [ ] **Step 2: Run workflow tests to verify failure** + +Run: + +```bash +node --test scripts/release-workflow.test.mjs +``` + +Expected: FAIL because `.github/workflows/release.yml` does not exist. + +- [ ] **Step 3: Add the release workflow** + +Create `.github/workflows/release.yml` with: + +```yaml +name: KTX Release + +on: + workflow_dispatch: + inputs: + publish_live: + description: "Publish @kaelio/ktx to npm instead of running a dry-run" + required: true + type: boolean + default: false + +permissions: + contents: read + +concurrency: + group: ktx-release-${{ github.ref }} + cancel-in-progress: false + +jobs: + npm-public-release: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup pnpm + uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + with: + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + with: + node-version: "24" + cache: "pnpm" + cache-dependency-path: "pnpm-lock.yaml" + + - name: Install TypeScript dependencies + run: pnpm install --frozen-lockfile + + - name: Setup Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.13" + + - name: Setup uv + uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0 + with: + enable-cache: true + cache-dependency-glob: "uv.lock" + + - name: Install Python dependencies + run: uv sync --all-packages + + - name: Build and verify artifacts + run: pnpm run artifacts:check + + - name: Check release readiness + run: pnpm run release:readiness + + - name: Dry-run npm publish + if: ${{ !inputs.publish_live }} + run: pnpm run release:npm-publish + + - name: Publish npm package + if: ${{ inputs.publish_live }} + run: pnpm run release:npm-publish -- --publish + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} +``` + +- [ ] **Step 4: Update release docs** + +In `README.md`, replace the current `## Release status` section with: + +```markdown +## Release status + +This repository builds one public npm artifact named `@kaelio/ktx`. The first +public npm handoff is policy-gated through `release-policy.json`, which keeps +Python package publishing disabled because KTX-owned Python code ships inside +the npm package as a bundled wheel. + +Build local package artifacts and verify the guarded dry-run publish path with: + +```bash +source .venv/bin/activate +pnpm run artifacts:check +pnpm run release:readiness +pnpm run release:npm-publish +``` + +Run the live npm publish only from the manual `KTX Release` workflow with the +`publish_live` input enabled after the `NPM_TOKEN` secret is configured. +``` + +- [ ] **Step 5: Run workflow and README checks** + +Run: + +```bash +node --test scripts/release-workflow.test.mjs scripts/examples-docs.test.mjs +``` + +Expected: PASS. + +- [ ] **Step 6: Commit workflow and docs** + +Run: + +```bash +git add .github/workflows/release.yml scripts/release-workflow.test.mjs README.md +git commit -m "release: document public npm release handoff" +``` + +Expected: commit created. + +### Task 5: Final verification + +**Files:** + +- Verify: `scripts/*.test.mjs` +- Verify: `packages/cli/src/*` +- Verify: `README.md` +- Verify: `release-policy.json` + +- [ ] **Step 1: Run focused script tests** + +Run: + +```bash +node --test scripts/build-public-npm-package.test.mjs scripts/package-artifacts.test.mjs scripts/local-embeddings-runtime-smoke.test.mjs scripts/release-readiness.test.mjs scripts/publish-public-npm-package.test.mjs scripts/published-package-smoke.test.mjs scripts/release-workflow.test.mjs scripts/examples-docs.test.mjs +``` + +Expected: PASS. + +- [ ] **Step 2: Run workspace type and package checks** + +Run: + +```bash +pnpm run type-check +pnpm run artifacts:check +``` + +Expected: PASS. The artifact build creates +`dist/artifacts/npm/kaelio-ktx-0.1.0.tgz`. + +- [ ] **Step 3: Run release readiness and dry-run publish** + +Run: + +```bash +pnpm run release:readiness +pnpm run release:npm-publish +``` + +Expected: PASS. `release:readiness` prints `KTX release mode: +npm-public-release-ready`, and `release:npm-publish` prints `Dry-run verified +@kaelio/ktx@0.1.0 with tag latest`. + +- [ ] **Step 4: Run pre-commit for changed files** + +Run: + +```bash +uv run pre-commit run --files scripts/build-public-npm-package.mjs scripts/build-public-npm-package.test.mjs scripts/package-artifacts.mjs scripts/package-artifacts.test.mjs scripts/local-embeddings-runtime-smoke.test.mjs scripts/release-readiness.mjs scripts/release-readiness.test.mjs scripts/publish-public-npm-package.mjs scripts/publish-public-npm-package.test.mjs scripts/release-workflow.test.mjs release-policy.json package.json README.md .github/workflows/release.yml +``` + +Expected: PASS. If pre-commit is unavailable because the local `uv` version or +pre-commit environment is missing, report that explicitly and keep the script +tests, `pnpm run type-check`, `pnpm run artifacts:check`, `pnpm run +release:readiness`, and `pnpm run release:npm-publish` results as the closest +checks. + +- [ ] **Step 5: Confirm the worktree is clean** + +Run: + +```bash +git status --short +``` + +Expected: no output. If there are uncommitted tracked changes, inspect them and +commit only files from this plan with the exact task commit commands above. + +## Success criteria + +- `@kaelio/ktx` artifact metadata and tarball names use version `0.1.0`. +- `release-policy.json` encodes `npm-public-release-ready`, + `npm.publish: true`, and `python.publish: false`. +- `pnpm run release:npm-publish` performs a dry-run by default. +- Live npm publishing requires `pnpm run release:npm-publish -- --publish` or + the manual `KTX Release` workflow with `publish_live` enabled. +- Published-package smoke remains the post-publication proof for `npx + @kaelio/ktx`, local `npx ktx`, and global `ktx` invocation modes. +- No Python package publication is added for this release. + +## Self-review + +- Spec coverage: this plan covers the remaining public npm handoff gap while + preserving the bundled Python wheel model and single npm package surface. +- Placeholder scan: no open placeholders or deferred implementation notes are + present. +- Type consistency: the release mode name is consistently + `npm-public-release-ready`; the public npm version is consistently `0.1.0`; + the publish script consumes the `npmPublish` report shape produced by + `release-readiness.mjs`.