ktx/docs/superpowers/plans/2026-05-11-public-npm-release-handoff.md
Andrey Avtomonov 9dad936ac7
feat: npm-managed Python runtime for @kaelio/ktx (#7)
* docs: add npm managed python runtime design

* build: add bundled python runtime wheel builder

* build: make local embedding dependencies optional

* build: bundle python runtime wheel in cli artifacts

* build: track bundled python runtime release artifact

* test: verify bundled python runtime wheel

* docs: add plan for bundled python runtime wheel

* test: cover managed python runtime lifecycle

* feat: add managed python runtime installer

* feat: add runtime command runner

* feat: expose runtime management commands

* test: verify managed python runtime commands

* docs: add plan for managed python runtime installer

* feat: add managed python command helper

* feat: use managed runtime for sl query compute

* feat: route sl query managed runtime policy

* docs: add plan for managed runtime sl query integration

* feat: add managed runtime daemon metadata

* feat: manage python daemon lifecycle

* feat: add runtime daemon start stop commands

* fix: verify managed runtime daemon lifecycle

* docs: add plan for managed runtime daemon lifecycle

* feat: add managed local embeddings config marker

* feat: add managed local embeddings daemon helper

* feat: use managed runtime for local embedding setup

* feat: pass managed runtime policy through setup

* docs: add plan for managed local embeddings runtime

* feat: read CLI package metadata dynamically

* feat: assemble public kaelio ktx npm package

* feat: release one public kaelio ktx npm artifact

* test: cover public kaelio ktx package invocations

* chore: verify public kaelio ktx package artifacts

* docs: add plan for public kaelio ktx npm package

* test: verify managed runtime in public package smoke

* test: finalize managed runtime release smoke

* docs: add plan for managed runtime release smoke

* test: specify local embeddings release smoke

* feat: add local embeddings runtime smoke

* chore: register local embeddings smoke

* fix: verify local embeddings smoke

* fix: restore artifact smoke python env helper

* docs: add plan for managed local embeddings release smoke

* refactor: share managed runtime install policy parsing

* feat: use managed runtime for agent semantic queries

* feat: use managed runtime for MCP semantic compute

* docs: add plan for managed agent and MCP semantic runtime

* feat(cli): add managed daemon HTTP helpers

* feat(cli): route local adapters through managed daemon

* feat(cli): use managed daemon for ingest helpers

* feat(cli): pass managed daemon options to scan

* feat(context): pass MCP ingest pull config options

* feat(cli): pass managed daemon options to serve ingest

* test: verify managed local ingest daemon runtime

* docs: add plan for managed local ingest daemon runtime

* docs: align managed runtime examples

* docs: add plan for managed runtime docs cleanup

* test: cover published package runtime smoke commands

* test: validate published package smoke outputs

* docs: add plan for published package runtime smoke

* build: stamp public npm package version

* release: add npm public release policy

* release: add guarded npm publish script

* release: document public npm release handoff

* docs: add plan for public npm release handoff

* test: cover managed runtime prune in package smoke

* docs: document managed runtime prune

* docs: add plan for managed runtime prune smoke and docs

* chore: encode uv runtime prerequisite policy

* fix: clarify missing uv runtime error

* docs: document uv runtime prerequisite

* docs: add plan for uv runtime prerequisite contract

* refactor: limit release artifacts to public package runtime

* chore: align release policy with bundled runtime wheel

* docs: describe single public runtime artifact surface

* test: verify single public runtime artifact contract

* docs: add plan for single public runtime artifact cleanup

* fix: align local embeddings smoke with public version

* docs: add plan for local embeddings smoke public version

* release: soft-launch as @kaelio/ktx@0.1.0-rc.0 on next tag

Publish target moves to the pre-release version 0.1.0-rc.0 under the next
dist-tag so npm install @kaelio/ktx (which resolves to latest) does not
pick up the soft-launch build. Users opt in via @kaelio/ktx@next.

* Fix release script boundary checks

* Remove PostHog from public package bundle
2026-05-11 15:50:34 +02:00

1332 lines
39 KiB
Markdown

# 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`.