diff --git a/CHANGELOG.md b/CHANGELOG.md index 568342a..8ee4da4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes will appear in this file. The project follows Keep a Changel ## [Unreleased] +### Added + +- Repeatable npm package verification and a maintainer publishing guide. + ## [0.1.0] - 2026-07-15 First public release. diff --git a/README.md b/README.md index 38212f8..da077c4 100644 --- a/README.md +++ b/README.md @@ -238,6 +238,7 @@ Each real run starts from the current commit and creates: | [Configuration](docs/configuration.md) | Schema, commands and quality gates | | [Security](docs/security.md) | Process, prompt, credential and Git controls | | [Development](docs/development.md) | Build, test and contribution workflow | +| [Publishing](docs/publishing.md) | npm package checks and release procedure | | [Roadmap](docs/roadmap.md) | Planned scope and exclusions | ## Limitations diff --git a/docs/development.md b/docs/development.md index 2f23e48..98b19b7 100644 --- a/docs/development.md +++ b/docs/development.md @@ -9,6 +9,7 @@ npm run typecheck npm test npm run test:coverage npm run build +npm run pack:check npm run format:check ``` @@ -23,3 +24,5 @@ node dist/index.js --help node dist/index.js doctor node dist/index.js run --dry-run --task "Smoke test" ``` + +Maintainers should follow the [npm publishing guide](publishing.md) before creating a release tag. diff --git a/docs/publishing.md b/docs/publishing.md new file mode 100644 index 0000000..0789cf7 --- /dev/null +++ b/docs/publishing.md @@ -0,0 +1,92 @@ +# Publishing to npm + +This guide is for maintainers publishing `loop-engineer`. Users do not need npm publisher access. + +## Requirements + +- An npm account with two-factor authentication +- Publish access to the `loop-engineer` package +- Node.js 20 or newer +- A clean checkout of the release commit + +Check the account and package before changing a version: + +```bash +npm login +npm whoami +npm view loop-engineer name version dist-tags --json +``` + +An npm `E404` response means the package has no public release at the time of the check. It does not reserve the name. + +## Prepare the release + +Update `package.json`, `package-lock.json` and `CHANGELOG.md` in the same pull request. The CLI version comes from `package.json`, so these commands must print the same version: + +```bash +npm run build +head -1 dist/index.js +node dist/index.js --version +node -p "require('./package.json').version" +``` + +Run the full validation suite and inspect the package contract: + +```bash +npm ci +npm run format:check +npm run lint +npm run typecheck +npm test +npm run test:coverage +npm run build +node scripts/verify-package.mjs +npm pack +``` + +The verifier requires the executable `dist/index.js`, its Node shebang, matching CLI and package versions, the GUI runtime assets and a tarball smaller than 2 MB. It rejects source files, tests and repository-only assets. + +## Test the tarball + +Install the generated file before publishing it: + +```bash +npm install --global ./loop-engineer-0.1.0.tgz +loopeng --version +loopeng --help +``` + +Replace `0.1.0` with the version from `package.json`. Remove the test installation with `npm uninstall --global loop-engineer` if you plan to keep using `npm link` from a checkout. + +## Publish + +Publish from the reviewed release commit: + +```bash +git status --short +npm publish --access public +npm view loop-engineer name version dist-tags --json +``` + +`git status --short` must print nothing. npm does not allow a publisher to replace an existing version. Fix a failed release with a new patch version instead of reusing the old version number. + +Create and push the Git tag only after npm confirms the release: + +```bash +git tag -a v0.1.0 -m "Loop Engineer v0.1.0" +git push origin v0.1.0 +``` + +Create the matching GitHub release from that tag and copy the release section from `CHANGELOG.md` into the release notes. + +## After publishing + +Install from the registry in a clean environment: + +```bash +npm install --global loop-engineer@0.1.0 +loopeng --version +loopeng doctor +``` + +Then replace source-install instructions in the README and website with `npm install --global loop-engineer`. diff --git a/package.json b/package.json index d1cb1d5..6d4a99a 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,9 @@ "bugs": { "url": "https://github.com/BotondCsereklye/LoopEngineer/issues" }, + "publishConfig": { + "access": "public" + }, "engines": { "node": ">=20" }, @@ -41,6 +44,9 @@ ], "scripts": { "build": "tsc -p tsconfig.build.json && node scripts/copy-gui-assets.mjs", + "pack:check": "npm run build && node scripts/verify-package.mjs", + "prepack": "npm run build", + "prepublishOnly": "npm run format:check && npm run lint && npm run typecheck && npm test && npm run test:coverage && npm run pack:check", "test": "vitest run", "test:coverage": "vitest run --coverage", "test:watch": "vitest", diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs new file mode 100644 index 0000000..6a59d4a --- /dev/null +++ b/scripts/verify-package.mjs @@ -0,0 +1,104 @@ +#!/usr/bin/env node +/* global process */ + +import { execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const MAX_PACKAGE_SIZE_BYTES = 2_000_000; +const REQUIRED_PATHS = [ + 'LICENSE', + 'README.md', + 'dist/index.js', + 'dist/gui/public/index.html', + 'dist/gui/public/app.js', + 'dist/gui/public/styles.css', + 'package.json', +]; + +function assert(condition, message) { + if (!condition) { + throw new Error(message); + } +} + +export function validatePackManifest(manifest) { + assert(manifest && typeof manifest === 'object', 'npm pack returned no package manifest.'); + assert(manifest.name === 'loop-engineer', `Unexpected package name: ${manifest.name ?? 'none'}`); + assert(Array.isArray(manifest.files), 'npm pack returned no file list.'); + + const files = new Map(manifest.files.map((file) => [file.path, file])); + for (const requiredPath of REQUIRED_PATHS) { + assert(files.has(requiredPath), `Package is missing required file: ${requiredPath}`); + } + + const unexpected = [...files.keys()].filter( + (filePath) => + filePath !== 'LICENSE' && + filePath !== 'README.md' && + filePath !== 'package.json' && + !filePath.startsWith('dist/'), + ); + assert( + unexpected.length === 0, + `Package contains files outside the public contract: ${unexpected.join(', ')}`, + ); + assert( + Number(manifest.size) <= MAX_PACKAGE_SIZE_BYTES, + `Packed tarball exceeds the 2 MB safety limit: ${manifest.size} bytes`, + ); + + const entrypoint = files.get('dist/index.js'); + assert( + (Number(entrypoint.mode) & 0o111) !== 0, + 'dist/index.js must be executable for the loopeng binary.', + ); +} + +export function verifyEntrypoint(root = process.cwd()) { + const packageJson = JSON.parse(readFileSync(path.join(root, 'package.json'), 'utf8')); + const relativeEntrypoint = packageJson.bin?.loopeng; + assert(typeof relativeEntrypoint === 'string', 'package.json must define the loopeng binary.'); + + const entrypoint = path.resolve(root, relativeEntrypoint); + const source = readFileSync(entrypoint, 'utf8'); + assert( + source.startsWith('#!/usr/bin/env node\n'), + 'dist/index.js must start with the Node shebang.', + ); + + const version = execFileSync(process.execPath, [entrypoint, '--version'], { + cwd: root, + encoding: 'utf8', + }).trim(); + assert( + version === packageJson.version, + `CLI version ${version} does not match package version ${packageJson.version}.`, + ); + + return { entrypoint, version }; +} + +function main() { + const output = execFileSync('npm', ['pack', '--dry-run', '--json', '--ignore-scripts'], { + encoding: 'utf8', + }); + const manifests = JSON.parse(output); + assert(manifests.length === 1, `Expected one package manifest, received ${manifests.length}.`); + + const manifest = manifests[0]; + validatePackManifest(manifest); + const { version } = verifyEntrypoint(); + const size = new Intl.NumberFormat('en', { maximumFractionDigits: 1 }).format( + manifest.size / 1000, + ); + process.stdout.write( + `Verified ${manifest.name}@${version}: ${manifest.entryCount} files, ${size} kB packed.\n`, + ); +} + +const invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : ''; +if (fileURLToPath(import.meta.url) === invokedPath) { + main(); +} diff --git a/site/documentation.html b/site/documentation.html index dcac5ad..62feca3 100644 --- a/site/documentation.html +++ b/site/documentation.html @@ -121,6 +121,15 @@

Documentation

Build, test, and contribution workflow + + + Publishing + + npm package checks and release procedure + { + it('accepts the publishable package contract', () => { + expect(() => validatePackManifest(validManifest)).not.toThrow(); + }); + + it('rejects source files and missing runtime assets', () => { + expect(() => + validatePackManifest({ + ...validManifest, + files: validManifest.files + .filter((file) => file.path !== 'dist/gui/public/index.html') + .concat({ path: 'src/index.ts', mode: 0o644 }), + }), + ).toThrow(/dist\/gui\/public\/index\.html/); + }); + + it('rejects a non-executable CLI entrypoint', () => { + expect(() => + validatePackManifest({ + ...validManifest, + files: validManifest.files.map((file) => + file.path === 'dist/index.js' ? { ...file, mode: 0o644 } : file, + ), + }), + ).toThrow(/executable/); + }); + + it('rejects an unexpectedly large tarball', () => { + expect(() => validatePackManifest({ ...validManifest, size: 2_000_001 })).toThrow(/2 MB/); + }); +});