diff --git a/.github/workflows/npm-release.yml b/.github/workflows/npm-release.yml new file mode 100644 index 0000000..8c005e2 --- /dev/null +++ b/.github/workflows/npm-release.yml @@ -0,0 +1,99 @@ +name: NPM Release + +on: + workflow_dispatch: + inputs: + enable_publish: + description: Set to true only after RELEASE.md is complete and package privacy has been intentionally changed. + required: true + default: false + type: boolean + expected_version: + description: Optional package.json version expected for this release. + required: false + type: string + +permissions: + contents: read + id-token: write + +jobs: + release: + name: Verify npm release artifact + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup pnpm + uses: pnpm/action-setup@v6 + with: + version: 10.24.0 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: 22 + cache: pnpm + registry-url: https://registry.npmjs.org + + - name: Install + run: pnpm install --frozen-lockfile + + - name: Test + run: pnpm test + + - name: Typecheck + run: pnpm typecheck + + - name: Lint + run: pnpm lint + + - name: Build + run: pnpm build + + - name: Verify packed CLI + run: pnpm verify:packed + + - name: Verify release metadata + env: + EXPECTED_VERSION: ${{ inputs.expected_version }} + run: | + node <<'NODE' + const packageJson = require('./package.json'); + if (packageJson.name !== '@mattbaconz/kernel') { + throw new Error(`Unexpected package name: ${packageJson.name}`); + } + if (packageJson.publishConfig?.access !== 'public') { + throw new Error('Expected publishConfig.access to be public.'); + } + if (process.env.EXPECTED_VERSION && packageJson.version !== process.env.EXPECTED_VERSION) { + throw new Error(`Expected version ${process.env.EXPECTED_VERSION}, got ${packageJson.version}.`); + } + console.log(`package: ${packageJson.name}@${packageJson.version}`); + console.log(`private: ${String(packageJson.private)}`); + NODE + + - name: Pack dry run + run: npm pack --dry-run --json + + - name: Refuse publish by default + if: ${{ inputs.enable_publish != true }} + run: | + echo "Publication disabled. Set enable_publish=true only after RELEASE.md is complete." + echo "No npm publish command was run." + + - name: Verify publish gate + if: ${{ inputs.enable_publish == true }} + run: | + node <<'NODE' + const packageJson = require('./package.json'); + if (packageJson.private) { + throw new Error('Refusing to publish while package.json private=true. Remove private:true in a separate release task.'); + } + NODE + + - name: Publish to npm + if: ${{ inputs.enable_publish == true }} + run: npm publish --access public diff --git a/CHANGELOG.md b/CHANGELOG.md index f646f8d..ad2c177 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ This project follows semantic versioning once public releases begin. - Schema discovery commands. - Release-readiness integration tests and packed CLI verification. - GitHub CI workflow. +- npm release-readiness checklist and gated manual release workflow skeleton. ### Notes diff --git a/README.md b/README.md index 0a8eff7..8381231 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,10 @@ pnpm verify:packed CI runs the same core checks on `main` and pull requests. +## Release Readiness + +Kernel is not published to npm yet. Release gates, trusted-publishing setup notes, provenance expectations, and rollback checks are documented in `RELEASE.md`. + ## JSON Outputs And Schemas Machine-readable command outputs include `schemaVersion: 1`. diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000..cd7b840 --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,96 @@ +# Release Checklist + +Kernel is not published to npm yet. This checklist prepares `@mattbaconz/kernel` for a future public npm release without enabling publication now. + +## Current Gate + +- Do not publish while `package.json` has `"private": true`. +- Do not remove `"private": true` except in a dedicated npm publication task. +- Do not publish from the private `mattbaconz/kernel-skills` repository. +- Use the public `mattbaconz/kernel` repository as the trusted publishing source. + +## Package Metadata + +- Package name: `@mattbaconz/kernel` +- License: Apache-2.0 +- Copyright: `Copyright 2026 mattbaconz` +- Package files: `dist/` and `schemas/` +- Public package access: configured with `publishConfig.access: public` +- npm package remains unpublished until a separate explicit release task. + +## Required Verification + +Run these checks before any release tag or npm publication: + +```bash +pnpm install --frozen-lockfile +pnpm test +pnpm typecheck +pnpm lint +pnpm build +pnpm verify:packed +npm pack --dry-run --json +``` + +Review the dry-run package contents and confirm they include only expected files: + +- `LICENSE` +- `README.md` +- `package.json` +- `dist/` +- `schemas/` + +## Trusted Publishing + +Preferred release path is npm Trusted Publishing from GitHub Actions, not a long-lived npm token. + +Before enabling publication: + +1. Configure npm trusted publishing for `@mattbaconz/kernel`. +2. Set the trusted publisher to the public GitHub repository `mattbaconz/kernel`. +3. Set the workflow file to `.github/workflows/npm-release.yml`. +4. Confirm the workflow has `id-token: write`. +5. Confirm npm package ownership and 2FA settings for the `mattbaconz` account. + +Trusted publishing uses OIDC. With trusted publishing, npm generates provenance attestations automatically. If trusted publishing is not available, do not fall back to a broad token without a separate release security review. + +## Manual Workflow + +The manual workflow `.github/workflows/npm-release.yml` is intentionally gated. + +Default behavior: + +- verifies the release artifact +- runs `npm pack --dry-run --json` +- refuses to publish because `enable_publish` defaults to `false` + +Publication behavior: + +- requires manual `workflow_dispatch` +- requires `enable_publish: true` +- refuses to publish while `package.json` has `"private": true` +- uses `npm publish --access public` only after the gates above pass + +## Tag And Release + +Before npm publication: + +1. Confirm `CHANGELOG.md` has the intended version notes. +2. Confirm `package.json` version matches the release tag. +3. Create or verify the release tag, for example `v0.1.0`. +4. Confirm public CI is green on the tagged commit. +5. Create a GitHub Release after the tag is final. + +## Rollback + +npm packages cannot be treated like normal mutable deploys. + +Use this rollback section for containment and follow-up when a release is wrong. + +If a bad package is published: + +1. Stop further publication. +2. Open a security or release issue if user impact is possible. +3. Deprecate the bad version on npm if appropriate. +4. Publish a fixed patch version instead of replacing the bad version. +5. Record evidence in the private source repository. diff --git a/package.json b/package.json index 258ac4e..a551410 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,9 @@ "dist", "schemas" ], + "publishConfig": { + "access": "public" + }, "scripts": { "build": "tsc -p tsconfig.build.json", "typecheck": "tsc --noEmit -p tsconfig.json", diff --git a/tests/release-readiness.test.ts b/tests/release-readiness.test.ts index fc2b3e9..bfed21d 100644 --- a/tests/release-readiness.test.ts +++ b/tests/release-readiness.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import type { Command } from 'commander'; +import { parse } from 'yaml'; import { afterEach, describe, expect, test, vi } from 'vitest'; import { createKernelProgram } from '../src/cli/index.js'; @@ -159,6 +160,7 @@ describe('Kernel release-readiness workflow', () => { private?: boolean; bin?: Record; files?: string[]; + publishConfig?: Record; scripts?: Record; }; @@ -169,6 +171,9 @@ describe('Kernel release-readiness workflow', () => { kernel: './dist/cli/index.js' }); expect(packageJson.files).toEqual(expect.arrayContaining(['dist', 'schemas'])); + expect(packageJson.publishConfig).toEqual({ + access: 'public' + }); expect(packageJson.scripts).toEqual( expect.objectContaining({ build: 'tsc -p tsconfig.build.json', @@ -183,6 +188,46 @@ describe('Kernel release-readiness workflow', () => { ); await expect(readFile(join(repoRoot, 'LICENSE'), 'utf8')).resolves.toContain('Copyright 2026 mattbaconz'); }); + + test('documents npm release readiness without enabling publication', async () => { + const releaseChecklist = await readFile(join(repoRoot, 'RELEASE.md'), 'utf8'); + + expect(releaseChecklist).toContain('@mattbaconz/kernel'); + expect(releaseChecklist).toContain('Do not publish while `package.json` has `"private": true`.'); + expect(releaseChecklist).toContain('Trusted publishing'); + expect(releaseChecklist).toContain('provenance'); + expect(releaseChecklist).toContain('npm publish'); + expect(releaseChecklist).toContain('rollback'); + }); + + test('keeps npm release workflow manual and publish-gated', async () => { + const workflowText = await readFile(join(repoRoot, '.github', 'workflows', 'npm-release.yml'), 'utf8'); + const workflow = parse(workflowText) as { + name?: string; + on?: Record; + permissions?: Record; + jobs?: Record }>; + }; + + expect(workflow.name).toBe('NPM Release'); + expect(Object.keys(workflow.on ?? {})).toEqual(['workflow_dispatch']); + expect(workflow.permissions).toEqual({ + contents: 'read', + 'id-token': 'write' + }); + expect(workflowText).toContain('enable_publish'); + expect(workflowText).toContain('pnpm install --frozen-lockfile'); + expect(workflowText).toContain('pnpm test'); + expect(workflowText).toContain('pnpm typecheck'); + expect(workflowText).toContain('pnpm lint'); + expect(workflowText).toContain('pnpm build'); + expect(workflowText).toContain('pnpm verify:packed'); + expect(workflowText).toContain('npm pack --dry-run --json'); + expect(workflowText).toContain("if: ${{ inputs.enable_publish != true }}"); + expect(workflowText).toContain("if: ${{ inputs.enable_publish == true }}"); + expect(workflowText).toContain('package.json private=true'); + expect(workflowText).toContain('npm publish --access public'); + }); }); async function createReleaseFixture(): Promise {