diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..f5ceed7 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,51 @@ +name: Publish verified package + +on: + workflow_dispatch: + inputs: + version: + description: npm version argument, such as patch, minor, or 1.2.3 + required: true + type: string + +permissions: + contents: write + id-token: write + +concurrency: + group: npm-release + cancel-in-progress: false + +jobs: + publish: + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + environment: npm-publish + steps: + - name: Check out main + uses: actions/checkout@v6 + with: + fetch-depth: 0 + - name: Set up trusted publishing runtime + uses: actions/setup-node@v6 + with: + node-version: 24.x + registry-url: https://registry.npmjs.org + - name: Enable the pinned npm version + run: corepack enable + - name: Install lockfile dependencies + run: corepack npm ci + - name: Set release author + run: | + git config user.name github-actions[bot] + git config user.email 41898282+github-actions[bot]@users.noreply.github.com + - name: Confirm the reviewed main commit is still current + run: | + git fetch --no-tags origin main + test "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)" + - name: Publish verified package + env: + RELEASE_VERSION: ${{ inputs.version }} + run: corepack npm@11.5.1 run release -- "$RELEASE_VERSION" + - name: Push release commit and tag + run: git push origin HEAD:main --follow-tags diff --git a/docs/content/development.mdx b/docs/content/development.mdx index 9ab4c6b..b66476f 100644 --- a/docs/content/development.mdx +++ b/docs/content/development.mdx @@ -43,23 +43,32 @@ and `corepack npm test` builds the app and runs the Node test suite. ## Publish a release -Commit all release changes, then pass a version and any extra `npm version` -options to the release script: +Publish from the protected **npm-publish** GitHub environment. It runs the +product gate before versioning, verifies the exact tarball in a temporary +consumer, then uses npm trusted publishing and provenance. Start the +**Publish verified package** workflow on `main` with a version argument such +as `patch`, `minor`, or `1.0.0`. + +Configure npmjs.com for trusted publishing from `itsjling/diffsplain`, +workflow file `release.yml`, and environment `npm-publish`. In GitHub, require +approval for that environment and protect `main` and release tags. Do not add +an npm token to this repo or to the workflow. + +The workflow output names the tested commit, version, and registry result. If +publication succeeds but pushing fails, inspect the release commit and tag, +then push them without publishing again. If a check fails, fix it and restart +the workflow before versioning. ```sh -npm run release -- patch -npm run release -- minor -npm run release -- 1.0.0 -npm run release -- prerelease --preid beta +corepack npm run package:verify ``` -The script creates the npm version commit and tag, then publishes the public -package. It stops when either command fails. It does not run the project -checks. After a successful publish, push the commit and tag: - -```sh -git push origin main --follow-tags -``` +This local command checks the built tarball without publishing it. The +production audit has no findings. The full audit has 12 high and 5 low findings +below the Blume docs tool. +`npm audit fix --package-lock-only --dry-run` makes no change. We accept this +limited risk while docs builds use only committed docs and do not handle user +content. ## Refresh the demo diff --git a/package.json b/package.json index add653e..d971f05 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "doctor": "node scripts/present.mjs doctor", "fallow:audit": "fallow audit", "present": "node scripts/present.mjs", + "package:verify": "node scripts/check.mjs --package-only", "release": "node scripts/release.mjs", "summarize": "node scripts/generate-summaries.mjs", "snapshot": "node scripts/build-diff-data.mjs", diff --git a/scripts/check.mjs b/scripts/check.mjs index c43270d..055f0a9 100644 --- a/scripts/check.mjs +++ b/scripts/check.mjs @@ -1,7 +1,15 @@ import { execFile, spawn } from 'node:child_process'; -import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { + chmod, + copyFile, + mkdtemp, + mkdir, + readFile, + rm, + writeFile, +} from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { dirname, join, resolve } from 'node:path'; +import { delimiter, dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { promisify } from 'node:util'; @@ -44,6 +52,56 @@ const executeStage = proofMode if (proofFailure === id) throw new Error('proof failure'); } : (_id, run) => run(); +const packageOnly = process.argv.includes('--package-only'); +const releaseTarballIndex = process.argv.indexOf('--release-tarball'); +const releaseTarball = + releaseTarballIndex === -1 + ? undefined + : resolve(root, process.argv[releaseTarballIndex + 1]); +const requiredPackageFiles = [ + 'README.md', + 'package.json', + 'dist/index.html', + 'scripts/build-diff-data.mjs', + 'scripts/cli-args.mjs', + 'scripts/coding-agents.mjs', + 'scripts/doctor.mjs', + 'scripts/generate-summaries.mjs', + 'scripts/present.mjs', + 'scripts/serve-built.mjs', + 'scripts/summary-path.mjs', +]; +const allowedPackageFile = /^(README(?:\.md)?|LICENSE(?:\.md)?|NOTICE(?:\.md)?|package\.json|dist\/.+|scripts\/(?:build-diff-data|cli-args|coding-agents|doctor|generate-summaries|present|serve-built|summary-path)\.mjs)$/; +const privatePackageFile = /(^|\/)(?:\.env|\.npmrc|\.git|\.github|\.agents|\.codex)(?:\/|$)|\.(?:pem|key)$/i; + +export function validatePackageManifest(pack) { + const files = pack.files ?? []; + const paths = new Set(files.map((file) => file.path)); + const missing = requiredPackageFiles.filter((path) => !paths.has(path)); + const unexpected = files.filter((file) => !allowedPackageFile.test(file.path)); + const privateFiles = files.filter((file) => privatePackageFile.test(file.path)); + const oversizedPackage = pack.unpackedSize > 12_000_000; + const oversizedFile = files.some((file) => file.size > 1_000_000); + const problems = [ + { present: missing.length > 0, text: `missing ${missing.join(', ')}` }, + { + present: unexpected.length > 0, + text: `unexpected ${unexpected.map((file) => file.path).join(', ')}`, + }, + { + present: privateFiles.length > 0, + text: `private ${privateFiles.map((file) => file.path).join(', ')}`, + }, + { present: oversizedPackage, text: 'package exceeds 12 MB' }, + { present: oversizedFile, text: 'file exceeds 1 MB' }, + ] + .filter((problem) => problem.present) + .map((problem) => problem.text); + + if (problems.length) { + throw new Error(`Package manifest failed: ${problems.join('; ')}`); + } +} async function runStage(id, name, run) { console.log(`\n==> ${name}`); @@ -57,6 +115,67 @@ async function runStage(id, name, run) { console.log(`✓ ${name}`); } +async function makeSmokeCommandFixtures(consumerRoot) { + const bin = join(consumerRoot, 'bin'); + const windows = process.platform === 'win32'; + const extension = windows ? '.cmd' : ''; + const contents = windows + ? '@echo off\r\necho test version\r\n' + : '#!/bin/sh\nprintf "%s\\n" "test version"\n'; + await mkdir(bin); + for (const command of ['git', 'gh', 'codex']) { + const path = join(bin, `${command}${extension}`); + await writeFile(path, contents); + await chmod(path, 0o755); + } + return bin; +} + +async function makeSmokeRuntimeFixture(consumerRoot) { + const fixture = join(consumerRoot, 'fixture'); + await mkdir(fixture); + await execFileAsync('git', ['init', '-q'], { cwd: fixture }); + await execFileAsync('git', ['config', 'user.email', 'release@example.test'], { + cwd: fixture, + }); + await execFileAsync('git', ['config', 'user.name', 'Release test'], { + cwd: fixture, + }); + await writeFile(join(fixture, 'changed.txt'), 'before\n'); + await execFileAsync('git', ['add', 'changed.txt'], { cwd: fixture }); + await execFileAsync('git', ['commit', '-qm', 'base'], { cwd: fixture }); + await writeFile(join(fixture, 'changed.txt'), 'after\n'); + const runtimeOutput = join(consumerRoot, 'runtime.json'); + await execFileAsync( + process.execPath, + [ + resolve( + consumerRoot, + 'node_modules/diffsplain/scripts/build-diff-data.mjs', + ), + '--repo', + fixture, + '--output', + runtimeOutput, + ], + { cwd: consumerRoot }, + ); + return JSON.parse(await readFile(runtimeOutput, 'utf8')); +} + +function verifySmokeResults({ packageJson, version, help, doctor, runtime }) { + const checks = [ + packageJson.name === 'diffsplain', + version.stdout.includes(packageJson.version), + help.stdout.includes('Usage:'), + doctor.stdout.includes('Diffsplain doctor'), + runtime.files?.[0]?.path === 'changed.txt', + ]; + if (checks.includes(false)) { + throw new Error('packed package has the wrong name'); + } +} + async function smokeTestPackage() { const packageRoot = await mkdtemp(join(tmpdir(), 'diffsplain-package-')); const consumerRoot = join(packageRoot, 'consumer'); @@ -68,6 +187,11 @@ async function smokeTestPackage() { ); const [pack] = JSON.parse(stdout); const tarball = join(packageRoot, pack.filename); + validatePackageManifest(pack); + if (releaseTarball) { + await mkdir(dirname(releaseTarball), { recursive: true }); + await copyFile(tarball, releaseTarball); + } await mkdir(consumerRoot); await writeFile( @@ -87,13 +211,20 @@ async function smokeTestPackage() { 'node_modules/diffsplain', packageJson.bin.diffsplain, ); - await execFileAsync(process.execPath, [executable, '--version'], { + const version = await execFileAsync(process.execPath, [executable, '--version'], { + cwd: consumerRoot, + }); + const help = await execFileAsync(process.execPath, [executable, '--help'], { cwd: consumerRoot, }); - if (packageJson.name !== 'diffsplain') { - throw new Error('packed package has the wrong name'); - } + const bin = await makeSmokeCommandFixtures(consumerRoot); + const doctor = await execFileAsync(process.execPath, [executable, 'doctor'], { + cwd: consumerRoot, + env: { ...process.env, PATH: `${bin}${delimiter}${process.env.PATH}` }, + }); + const runtime = await makeSmokeRuntimeFixture(consumerRoot); + verifySmokeResults({ packageJson, version, help, doctor, runtime }); } finally { await rm(packageRoot, { force: true, recursive: true }); } @@ -107,12 +238,21 @@ const stages = [ ['docs', 'Production docs build', () => runNpm(['run', 'docs:build'])], ]; -try { - for (const [id, name, run] of stages) { +export async function runCheck() { + const selectedStages = packageOnly + ? stages.filter(([id]) => id === 'build') + : stages; + for (const [id, name, run] of selectedStages) { await runStage(id, name, run); } await runStage('package', 'Packed-package smoke test', smokeTestPackage); -} catch (error) { - console.error(`\nCheck stopped: ${error.message}`); - process.exitCode = 1; +} + +if (resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + await runCheck(); + } catch (error) { + console.error(`\nCheck stopped: ${error.message}`); + process.exitCode = 1; + } } diff --git a/scripts/release.mjs b/scripts/release.mjs index fb0b7c8..bd8585f 100644 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -6,9 +6,17 @@ import { fileURLToPath } from 'node:url'; const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm'; +const verifiedTarball = '.cache/diffsplain-release.tgz'; + +function npmCommand(args) { + return process.env.npm_execpath + ? [process.execPath, [process.env.npm_execpath, ...args]] + : [npm, args]; +} function runNpm(args) { - const result = spawnSync(npm, args, { + const [command, commandArgs] = npmCommand(args); + const result = spawnSync(command, commandArgs, { cwd: root, stdio: 'inherit', }); @@ -16,6 +24,32 @@ function runNpm(args) { return result.status ?? 1; } +function releaseSteps(versionArgs) { + const prerelease = + versionArgs[0].startsWith('pre') || + /^[v=]?\d+\.\d+\.\d+-/.test(versionArgs[0]); + const publishArgs = [ + 'publish', + verifiedTarball, + '--access', + 'public', + '--provenance', + ...(prerelease ? ['--tag', 'next'] : []), + ]; + return [ + ['run', 'check'], + ['version', ...versionArgs], + [ + 'run', + 'package:verify', + '--', + '--release-tarball', + verifiedTarball, + ], + publishArgs, + ]; +} + export function runRelease(versionArgs, run = runNpm) { if (!versionArgs[0] || versionArgs[0].startsWith('-')) { throw new Error( @@ -23,11 +57,7 @@ export function runRelease(versionArgs, run = runNpm) { ); } - const steps = [ - ['version', ...versionArgs], - ['publish', '--access', 'public'], - ]; - for (const args of steps) { + for (const args of releaseSteps(versionArgs)) { const status = run(args); if (status !== 0) return status; } @@ -36,11 +66,19 @@ export function runRelease(versionArgs, run = runNpm) { if (resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { try { + if (process.env.GITHUB_ACTIONS !== 'true') { + throw new Error('Releases run only in the protected GitHub Actions workflow.'); + } + console.log(`Tested commit: ${spawnSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8' }).stdout.trim()}`); process.exitCode = runRelease(process.argv.slice(2)); if (process.exitCode === 0) { + const [command, commandArgs] = npmCommand(['pkg', 'get', 'version']); + const version = spawnSync(command, commandArgs, { cwd: root, encoding: 'utf8' }).stdout.trim(); console.log( - 'Published. Push the version commit and tag with: git push origin main --follow-tags', + `Registry result: published diffsplain ${version} with provenance. Recovery: if the later Git push fails, inspect the release commit and tag, then push them without republishing.`, ); + } else { + console.error('Registry result: not published. Recovery: fix the named stage, then restart the protected workflow. If versioning already ran, inspect the local commit and tag before retrying.'); } } catch (error) { console.error(error instanceof Error ? error.message : String(error)); diff --git a/tests/package-manifest.test.mjs b/tests/package-manifest.test.mjs new file mode 100644 index 0000000..e474678 --- /dev/null +++ b/tests/package-manifest.test.mjs @@ -0,0 +1,48 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { validatePackageManifest } from '../scripts/check.mjs'; + +const requiredFiles = [ + 'README.md', + 'package.json', + 'dist/index.html', + 'scripts/build-diff-data.mjs', + 'scripts/cli-args.mjs', + 'scripts/coding-agents.mjs', + 'scripts/doctor.mjs', + 'scripts/generate-summaries.mjs', + 'scripts/present.mjs', + 'scripts/serve-built.mjs', + 'scripts/summary-path.mjs', +]; + +function manifest(files = requiredFiles.map((path) => ({ path, size: 1 }))) { + return { files, unpackedSize: 1 }; +} + +test('accepts the required package manifest', () => { + assert.doesNotThrow(() => validatePackageManifest(manifest())); +}); + +test('rejects missing, private, unexpected, and oversized package files', () => { + assert.throws( + () => validatePackageManifest(manifest(requiredFiles.slice(1).map((path) => ({ path, size: 1 })))), + /missing README\.md/, + ); + assert.throws( + () => validatePackageManifest(manifest([...requiredFiles.map((path) => ({ path, size: 1 })), { path: '.env', size: 1 }])), + /private .env/, + ); + assert.throws( + () => validatePackageManifest(manifest([...requiredFiles.map((path) => ({ path, size: 1 })), { path: 'notes.txt', size: 1 }])), + /unexpected notes\.txt/, + ); + assert.throws( + () => validatePackageManifest(manifest([...requiredFiles.map((path) => ({ path, size: 1 })), { path: 'dist/large.js', size: 1_000_001 }])), + /file exceeds 1 MB/, + ); + assert.throws( + () => validatePackageManifest({ ...manifest(), unpackedSize: 12_000_001 }), + /package exceeds 12 MB/, + ); +}); diff --git a/tests/product-gate.test.mjs b/tests/product-gate.test.mjs index 9f9cbb7..5b41141 100644 --- a/tests/product-gate.test.mjs +++ b/tests/product-gate.test.mjs @@ -36,3 +36,18 @@ for (const stage of ['test', 'lint', 'docs', 'build', 'package']) { assert.match(result.stderr, /Check stopped: .* failed: proof failure/); }); } + +test('builds fresh assets before standalone package verification', () => { + const result = spawnSync(process.execPath, [check, '--package-only'], { + cwd: root, + encoding: 'utf8', + env: { + ...process.env, + DIFFSPLAIN_CHECK_PROOF_MODE: '1', + DIFFSPLAIN_CHECK_PROOF_FAIL_STAGE: 'build', + }, + }); + + assert.equal(result.status, 1); + assert.match(result.stderr, /Production app build failed: proof failure/); +}); diff --git a/tests/release.test.mjs b/tests/release.test.mjs index e370102..01e0c8c 100644 --- a/tests/release.test.mjs +++ b/tests/release.test.mjs @@ -1,8 +1,12 @@ import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { readFile } from 'node:fs/promises'; import test from 'node:test'; import { runRelease } from '../scripts/release.mjs'; -test('bumps and publishes while passing version arguments through', () => { +const releaseScript = new URL('../scripts/release.mjs', import.meta.url).pathname; + +test('checks, versions, verifies, and publishes with provenance', () => { const calls = []; const status = runRelease( ['prerelease', '--preid', 'beta'], @@ -14,20 +18,53 @@ test('bumps and publishes while passing version arguments through', () => { assert.equal(status, 0); assert.deepEqual(calls, [ + ['run', 'check'], ['version', 'prerelease', '--preid', 'beta'], - ['publish', '--access', 'public'], + [ + 'run', + 'package:verify', + '--', + '--release-tarball', + '.cache/diffsplain-release.tgz', + ], + [ + 'publish', + '.cache/diffsplain-release.tgz', + '--access', + 'public', + '--provenance', + '--tag', + 'next', + ], + ]); +}); + +test('publishes stable versions under the default dist-tag', () => { + const calls = []; + const status = runRelease(['1.2.3'], (args) => { + calls.push(args); + return 0; + }); + + assert.equal(status, 0); + assert.deepEqual(calls.at(-1), [ + 'publish', + '.cache/diffsplain-release.tgz', + '--access', + 'public', + '--provenance', ]); }); -test('stops before publishing when the version bump fails', () => { +test('stops before versioning when the product gate fails', () => { const calls = []; const status = runRelease(['patch'], (args) => { calls.push(args); - return args[0] === 'version' ? 1 : 0; + return args[0] === 'run' && args[1] === 'check' ? 1 : 0; }); assert.equal(status, 1); - assert.deepEqual(calls, [['version', 'patch']]); + assert.deepEqual(calls, [['run', 'check']]); }); test('requires the version before any npm version options', () => { @@ -40,3 +77,33 @@ test('requires the version before any npm version options', () => { /npm run release -- /, ); }); + +test('rejects a local release before it can version or publish', () => { + const result = spawnSync(process.execPath, [releaseScript, 'patch'], { + encoding: 'utf8', + env: { ...process.env, GITHUB_ACTIONS: 'false' }, + }); + + assert.equal(result.status, 2); + assert.match(result.stderr, /Releases run only in the protected GitHub Actions workflow/); +}); + +test('uses a protected trusted-publishing workflow', async () => { + const workflow = await readFile(new URL('../.github/workflows/release.yml', import.meta.url), 'utf8'); + + assert.match(workflow, /workflow_dispatch:/); + assert.match(workflow, /github\.ref == 'refs\/heads\/main'/); + assert.match(workflow, /environment: npm-publish/); + assert.match(workflow, /group: npm-release/); + assert.match(workflow, /cancel-in-progress: false/); + assert.match(workflow, /id-token: write/); + assert.match(workflow, /git fetch --no-tags origin main/); + assert.match( + workflow, + /git rev-parse HEAD.*git rev-parse origin\/main/, + ); + assert.match(workflow, /RELEASE_VERSION: \$\{\{ inputs\.version \}\}/); + assert.match(workflow, /run: corepack npm@11\.5\.1 run release -- "\$RELEASE_VERSION"/); + assert.match(workflow, /corepack npm@11\.5\.1 run release/); + assert.match(workflow, /git push origin HEAD:main --follow-tags/); +});