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
10 changes: 10 additions & 0 deletions docs/publishing.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@ 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.

`npm login` authenticates the CLI but does not satisfy the publish security requirement by itself. Enable two-factor authentication on the npm account before the first publish:

```bash
npm profile enable-2fa auth-and-writes
```

npm will open the browser and ask you to register a security key or platform authenticator. A granular access token with write access and **Bypass 2FA** can also publish, but interactive 2FA avoids storing a long-lived publishing credential.

## 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:
Expand Down Expand Up @@ -70,6 +78,8 @@ 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.

If npm returns `E403` with “Two-factor authentication ... is required,” finish the account 2FA setup and run the same publish command again. npm prompts for the second factor during the publish. Do not place a one-time code or access token in the repository, shell scripts or documentation.

Create and push the Git tag only after npm confirms the release:

```bash
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
"node": ">=20"
},
"bin": {
"loopeng": "./dist/index.js"
"loopeng": "dist/index.js"
},
"main": "./dist/index.js",
"files": [
Expand Down
37 changes: 32 additions & 5 deletions scripts/verify-package.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#!/usr/bin/env node
/* global process */

import { execFileSync } from 'node:child_process';
import { execFileSync, spawnSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
Expand Down Expand Up @@ -56,8 +56,25 @@ export function validatePackManifest(manifest) {
);
}

export function validatePackageMetadata(packageJson) {
assert(packageJson.name === 'loop-engineer', 'package.json must name loop-engineer.');
assert(typeof packageJson.version === 'string', 'package.json must define a version.');
assert(
packageJson.bin?.loopeng === 'dist/index.js',
'package.json bin.loopeng must use dist/index.js without a leading ./ for npm publish.',
);
}

export function npmPublishNeedsPackFallback(result) {
const output = `${result.stdout ?? ''}\n${result.stderr ?? ''}`;
return (
result.status !== 0 && /cannot publish over the previously published versions/i.test(output)
);
}

export function verifyEntrypoint(root = process.cwd()) {
const packageJson = JSON.parse(readFileSync(path.join(root, 'package.json'), 'utf8'));
validatePackageMetadata(packageJson);
const relativeEntrypoint = packageJson.bin?.loopeng;
assert(typeof relativeEntrypoint === 'string', 'package.json must define the loopeng binary.');

Expand All @@ -81,13 +98,23 @@ export function verifyEntrypoint(root = process.cwd()) {
}

function main() {
const output = execFileSync('npm', ['pack', '--dry-run', '--json', '--ignore-scripts'], {
const publishResult = spawnSync('npm', ['publish', '--dry-run', '--json', '--ignore-scripts'], {
encoding: 'utf8',
});
const manifests = JSON.parse(output);
assert(manifests.length === 1, `Expected one package manifest, received ${manifests.length}.`);
const result = npmPublishNeedsPackFallback(publishResult)
? spawnSync('npm', ['pack', '--dry-run', '--json', '--ignore-scripts'], {
encoding: 'utf8',
})
: publishResult;
const npmWarnings = `${publishResult.stderr ?? ''}\n${result.stderr ?? ''}`;
assert(result.status === 0, `npm publish --dry-run failed:\n${result.stderr.trim()}`);
assert(
!npmWarnings.includes('auto-corrected') && !npmWarnings.includes('invalid and removed'),
`npm would rewrite package.json during publish:\n${npmWarnings.trim()}`,
);

const manifest = manifests[0];
const parsedManifest = JSON.parse(result.stdout);
const manifest = Array.isArray(parsedManifest) ? parsedManifest[0] : parsedManifest;
validatePackManifest(manifest);
const { version } = verifyEntrypoint();
const size = new Intl.NumberFormat('en', { maximumFractionDigits: 1 }).format(
Expand Down
32 changes: 31 additions & 1 deletion tests/unit/package-verifier.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { describe, expect, it } from 'vitest';
// Build tooling stays in scripts/ and does not ship as a TypeScript library.
// @ts-expect-error The verifier is an ESM build script with no declaration file.
import { validatePackManifest } from '../../scripts/verify-package.mjs';
import * as packageVerifier from '../../scripts/verify-package.mjs';

const { npmPublishNeedsPackFallback, validatePackageMetadata, validatePackManifest } =
packageVerifier;

const validManifest = {
name: 'loop-engineer',
Expand All @@ -19,6 +22,33 @@ const validManifest = {
};

describe('npm package verifier', () => {
it('falls back to a pack dry run when the version already exists on npm', () => {
expect(
npmPublishNeedsPackFallback({
status: 1,
stdout: '',
stderr: 'npm error You cannot publish over the previously published versions: 0.1.0.',
}),
).toBe(true);
expect(
npmPublishNeedsPackFallback({
status: 1,
stdout: '',
stderr: 'npm error code E403\nnpm error Two-factor authentication is required.',
}),
).toBe(false);
});

it('requires the canonical npm binary path', () => {
expect(() =>
validatePackageMetadata({
name: 'loop-engineer',
version: '0.1.0',
bin: { loopeng: './dist/index.js' },
}),
).toThrow(/dist\/index\.js without a leading/);
});

it('accepts the publishable package contract', () => {
expect(() => validatePackManifest(validManifest)).not.toThrow();
});
Expand Down
Loading