Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 19 additions & 3 deletions .github/workflows/npm-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ on:
required: true
default: false
type: boolean
publish_confirmation:
description: Type the exact package/version, for example @mattbaconz/kernel@0.1.0, before publishing.
required: false
type: string
expected_version:
description: Optional package.json version expected for this release.
required: false
Expand All @@ -21,6 +25,7 @@ jobs:
release:
name: Verify npm release artifact
runs-on: ubuntu-latest
environment: npm-release

steps:
- name: Checkout
Expand All @@ -34,10 +39,15 @@ jobs:
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
node-version: 24
package-manager-cache: false
registry-url: https://registry.npmjs.org

- name: Setup npm for trusted publishing
run: |
npm install -g npm@11.5.1
npm --version

- name: Install
run: pnpm install --frozen-lockfile

Expand Down Expand Up @@ -86,14 +96,20 @@ jobs:

- name: Verify publish gate
if: ${{ inputs.enable_publish == true }}
env:
PUBLISH_CONFIRMATION: ${{ inputs.publish_confirmation }}
run: |
node <<'NODE'
const packageJson = require('./package.json');
const expectedConfirmation = `${packageJson.name}@${packageJson.version}`;
if (packageJson.private) {
throw new Error('Refusing to publish while package.json private=true. Remove private:true in a separate release task.');
}
if (process.env.PUBLISH_CONFIRMATION !== expectedConfirmation) {
throw new Error(`Expected publish_confirmation='@mattbaconz/kernel@${packageJson.version}' before publishing.`);
}
NODE

- name: Publish to npm
if: ${{ inputs.enable_publish == true }}
run: npm publish --access public
run: npm publish --access public --provenance
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ This project follows semantic versioning once public releases begin.
- Release-readiness integration tests and packed CLI verification.
- GitHub CI workflow.
- npm release-readiness checklist and gated manual release workflow skeleton.
- Hardened npm trusted-publishing release workflow constraints and bootstrap documentation.

### Notes

Expand Down
52 changes: 49 additions & 3 deletions RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,44 @@ Review the dry-run package contents and confirm they include only expected files

Preferred release path is npm Trusted Publishing from GitHub Actions, not a long-lived npm token.

Trusted publishing prerequisites:

- Node.js `>=22.14.0`
- npm CLI `>=11.5.1`
- GitHub-hosted runner
- workflow permission `id-token: write`
- no long-lived `NODE_AUTH_TOKEN` for the publish step

Trusted publisher values for Kernel:

- npm package: `@mattbaconz/kernel`
- GitHub organization or user: `mattbaconz`
- GitHub repository: `kernel`
- workflow file: `npm-release.yml`
- GitHub environment: `npm-release`

Check whether the package already exists on npm:

```bash
npm view @mattbaconz/kernel name version --json
```

If npm returns `E404`, the package does not exist yet. The npm trust CLI can only configure trusted publishing for an existing package, so an unpublished package requires a separate one-time bootstrap release decision before normal trusted-publishing releases can begin. Do not treat that bootstrap as part of this checklist.

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.
4. Set the GitHub environment to `npm-release`.
5. Confirm the workflow has `id-token: write`.
6. Confirm npm package ownership and 2FA settings for the `mattbaconz` account.

After the package exists, the trusted publisher can be configured with:

```bash
npm trust github @mattbaconz/kernel --repo mattbaconz/kernel --file npm-release.yml --env npm-release
```

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.

Expand All @@ -68,8 +99,23 @@ Publication behavior:

- requires manual `workflow_dispatch`
- requires `enable_publish: true`
- requires `publish_confirmation` to equal the exact package/version, such as `@mattbaconz/kernel@0.1.0`
- refuses to publish while `package.json` has `"private": true`
- uses `npm publish --access public` only after the gates above pass
- uses `npm publish --access public --provenance` only after the gates above pass

## One-Time Bootstrap

The first npm publication is different because `@mattbaconz/kernel` does not exist on the registry yet.

If the package still returns `E404`, handle the one-time bootstrap in a separate explicit task:

1. Verify the public repository is the source checkout.
2. Verify the package metadata points to `https://github.com/mattbaconz/kernel.git`.
3. Remove `"private": true` only in that task.
4. Run the full verification list and inspect `npm pack --dry-run --json`.
5. Publish once only after the user explicitly approves publication.
6. Configure trusted publishing immediately after the package exists.
7. Use the normal manual workflow for future releases.

## Tag And Release

Expand Down
18 changes: 17 additions & 1 deletion tests/release-readiness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,14 @@ describe('Kernel release-readiness workflow', () => {
expect(releaseChecklist).toContain('Do not publish while `package.json` has `"private": true`.');
expect(releaseChecklist).toContain('Trusted publishing');
expect(releaseChecklist).toContain('provenance');
expect(releaseChecklist).toContain('Node.js `>=22.14.0`');
expect(releaseChecklist).toContain('npm CLI `>=11.5.1`');
expect(releaseChecklist).toContain('npm view @mattbaconz/kernel name version --json');
expect(releaseChecklist).toContain(
'npm trust github @mattbaconz/kernel --repo mattbaconz/kernel --file npm-release.yml --env npm-release'
);
expect(releaseChecklist).toContain('If npm returns `E404`');
expect(releaseChecklist).toContain('one-time bootstrap');
expect(releaseChecklist).toContain('npm publish');
expect(releaseChecklist).toContain('rollback');
});
Expand All @@ -206,7 +214,7 @@ describe('Kernel release-readiness workflow', () => {
name?: string;
on?: Record<string, unknown>;
permissions?: Record<string, string>;
jobs?: Record<string, { steps?: Array<{ if?: string; run?: string }> }>;
jobs?: Record<string, { environment?: string; steps?: Array<{ if?: string; run?: string }> }>;
};

expect(workflow.name).toBe('NPM Release');
Expand All @@ -215,7 +223,13 @@ describe('Kernel release-readiness workflow', () => {
contents: 'read',
'id-token': 'write'
});
expect(workflow.jobs?.release?.environment).toBe('npm-release');
expect(workflowText).toContain('enable_publish');
expect(workflowText).toContain('publish_confirmation');
expect(workflowText).toContain('node-version: 24');
expect(workflowText).toContain('package-manager-cache: false');
expect(workflowText).not.toContain('cache: pnpm');
expect(workflowText).toContain('npm install -g npm@11.5.1');
expect(workflowText).toContain('pnpm install --frozen-lockfile');
expect(workflowText).toContain('pnpm test');
expect(workflowText).toContain('pnpm typecheck');
Expand All @@ -226,6 +240,8 @@ describe('Kernel release-readiness workflow', () => {
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('PUBLISH_CONFIRMATION');
expect(workflowText).toContain("Expected publish_confirmation='@mattbaconz/kernel@");
expect(workflowText).toContain('npm publish --access public');
});
});
Expand Down