diff --git a/.github/workflows/npm-release.yml b/.github/workflows/npm-release.yml index 8c005e2..e3c8dcb 100644 --- a/.github/workflows/npm-release.yml +++ b/.github/workflows/npm-release.yml @@ -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 @@ -21,6 +25,7 @@ jobs: release: name: Verify npm release artifact runs-on: ubuntu-latest + environment: npm-release steps: - name: Checkout @@ -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 @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index ad2c177..9755576 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/RELEASE.md b/RELEASE.md index cd7b840..997665f 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -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. @@ -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 diff --git a/tests/release-readiness.test.ts b/tests/release-readiness.test.ts index bfed21d..d35b587 100644 --- a/tests/release-readiness.test.ts +++ b/tests/release-readiness.test.ts @@ -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'); }); @@ -206,7 +214,7 @@ describe('Kernel release-readiness workflow', () => { name?: string; on?: Record; permissions?: Record; - jobs?: Record }>; + jobs?: Record }>; }; expect(workflow.name).toBe('NPM Release'); @@ -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'); @@ -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'); }); });