Skip to content

Commit 53abba0

Browse files
committed
fix: stabilize npm publish checks
1 parent 9d517aa commit 53abba0

6 files changed

Lines changed: 215 additions & 18 deletions

File tree

package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
"access": "public"
1717
},
1818
"bin": {
19-
"comet": "./bin/comet.js"
19+
"comet": "bin/comet.js"
2020
},
2121
"files": [
2222
"dist",
@@ -45,8 +45,8 @@
4545
"benchmark:execution": "node scripts/benchmark/context-execution-benchmark.mjs",
4646
"benchmark:classic": "node scripts/benchmark/classic-baseline-regression.mjs",
4747
"benchmark:bundle": "pnpm build && node scripts/benchmark/comet-bundle-compatibility-benchmark.mjs",
48-
"prepare": "husky && pnpm run build",
49-
"prepublishOnly": "node scripts/release/prepublish-check.js && pnpm run build",
48+
"prepare": "node scripts/release/prepare.js",
49+
"prepublishOnly": "node scripts/release/prepublish-check.js && node build.js",
5050
"postinstall": "node scripts/install/postinstall.js"
5151
},
5252
"lint-staged": {

scripts/release/prepare.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
#!/usr/bin/env node
2+
3+
import { execFileSync } from 'child_process';
4+
5+
const npmCommand = process.env.npm_command ?? process.env.NPM_COMMAND;
6+
7+
if (npmCommand === 'publish') {
8+
console.log('[PREPARE] skipped during npm publish; prepublishOnly already runs build.');
9+
process.exit(0);
10+
}
11+
12+
execFileSync('husky', { stdio: 'inherit', shell: true });
13+
execFileSync(process.execPath, ['build.js'], { stdio: 'inherit' });

scripts/release/prepublish-check.js

Lines changed: 93 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,40 +5,118 @@
55
* Checks for common secret patterns in files that would be published.
66
*/
77

8-
import { readFileSync, readdirSync, statSync } from 'fs';
9-
import { join, extname } from 'path';
8+
import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
9+
import { extname, join } from 'path';
1010

1111
const SECRET_PATTERNS = [
1212
{ pattern: /(?:api[_-]?key|apikey)\s*[:=]\s*['"][A-Za-z0-9_\-]{20,}['"]/i, name: 'API key' },
13-
{ pattern: /(?:secret|token|password|passwd|pwd)\s*[:=]\s*['"][^\s'"]{8,}['"]/i, name: 'Secret/token' },
13+
{
14+
pattern: /(?:secret|token|password|passwd|pwd)\s*[:=]\s*['"][^\s'"]{8,}['"]/i,
15+
name: 'Secret/token',
16+
},
1417
{ pattern: /-----BEGIN\s+(?:RSA\s+)?PRIVATE\s+KEY-----/, name: 'Private key' },
1518
{ pattern: /ghp_[A-Za-z0-9]{36}/, name: 'GitHub token' },
1619
{ pattern: /sk-[A-Za-z0-9]{20,}/, name: 'OpenAI key' },
1720
{ pattern: /xoxb-[0-9]+-[A-Za-z0-9]+/, name: 'Slack token' },
1821
{ pattern: /AKIA[0-9A-Z]{16}/, name: 'AWS access key' },
1922
];
2023

21-
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist']);
22-
const TEXT_EXTENSIONS = new Set(['.js', '.ts', '.json', '.md', '.txt', '.yml', '.yaml', '.toml']);
24+
const TEXT_EXTENSIONS = new Set([
25+
'.cjs',
26+
'.js',
27+
'.jsx',
28+
'.mjs',
29+
'.ts',
30+
'.tsx',
31+
'.json',
32+
'.md',
33+
'.txt',
34+
'.yml',
35+
'.yaml',
36+
'.toml',
37+
]);
2338
const README_IMAGE_PATTERN = /\b(?:src|srcset)=["'](?:\.\/)?img\//;
2439

25-
function* walkFiles(dir) {
26-
for (const entry of readdirSync(dir)) {
27-
const full = join(dir, entry);
28-
const stat = statSync(full);
29-
if (stat.isDirectory()) {
30-
if (!SKIP_DIRS.has(entry)) {
31-
yield* walkFiles(full);
40+
function normalized(value) {
41+
return value.replaceAll('\\', '/').replace(/^\.\//u, '').replace(/\/+/gu, '/');
42+
}
43+
44+
function* walkIncludedPath(relativePath) {
45+
const stat = statSync(relativePath);
46+
if (stat.isFile()) {
47+
yield normalized(relativePath);
48+
return;
49+
}
50+
if (!stat.isDirectory()) return;
51+
52+
for (const entry of readdirSync(relativePath)) {
53+
yield* walkIncludedPath(join(relativePath, entry));
54+
}
55+
}
56+
57+
function readPackageFileList() {
58+
const packageJson = JSON.parse(readFileSync('package.json', 'utf-8'));
59+
const files = Array.isArray(packageJson.files) ? packageJson.files : [];
60+
const includes = files.filter((entry) => typeof entry === 'string' && !entry.startsWith('!'));
61+
const excludes = files
62+
.filter((entry) => typeof entry === 'string' && entry.startsWith('!'))
63+
.map((entry) => normalized(entry.slice(1)));
64+
return { includes, excludes };
65+
}
66+
67+
function isExcludedFromPackage(filePath, excludes) {
68+
const path = normalized(filePath);
69+
for (const pattern of excludes) {
70+
if (pattern === 'dist/**/*.test.js' && path.startsWith('dist/') && path.endsWith('.test.js')) {
71+
return true;
72+
}
73+
if (
74+
pattern === 'dist/**/__tests__' &&
75+
path.startsWith('dist/') &&
76+
path.split('/').includes('__tests__')
77+
) {
78+
return true;
79+
}
80+
if (path === pattern || path.startsWith(`${pattern}/`)) {
81+
return true;
82+
}
83+
}
84+
return false;
85+
}
86+
87+
function alwaysIncludedPackageFiles() {
88+
const entries = readdirSync('.');
89+
return entries.filter((entry) => {
90+
const lower = entry.toLowerCase();
91+
return (
92+
lower === 'package.json' ||
93+
lower.startsWith('readme') ||
94+
lower.startsWith('license') ||
95+
lower.startsWith('licence')
96+
);
97+
});
98+
}
99+
100+
function publishedFiles() {
101+
const { includes, excludes } = readPackageFileList();
102+
const paths = new Set();
103+
104+
for (const entry of [...alwaysIncludedPackageFiles(), ...includes]) {
105+
const relativePath = normalized(entry);
106+
if (!relativePath || relativePath.startsWith('!') || !existsSync(relativePath)) continue;
107+
for (const filePath of walkIncludedPath(relativePath)) {
108+
if (!isExcludedFromPackage(filePath, excludes)) {
109+
paths.add(filePath);
32110
}
33-
} else if (stat.isFile()) {
34-
yield full;
35111
}
36112
}
113+
114+
return [...paths].sort();
37115
}
38116

39117
let found = 0;
40118

41-
for (const filePath of walkFiles('.')) {
119+
for (const filePath of publishedFiles()) {
42120
const ext = extname(filePath);
43121
if (!TEXT_EXTENSIONS.has(ext)) continue;
44122

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { promises as fs } from 'fs';
2+
import { describe, expect, it } from 'vitest';
3+
4+
describe('package scripts', () => {
5+
it('runs the prepublish build without invoking pnpm from the npm lifecycle', async () => {
6+
const packageJson = JSON.parse(await fs.readFile('package.json', 'utf-8')) as {
7+
scripts?: Record<string, string>;
8+
};
9+
10+
const prepublishOnly = packageJson.scripts?.prepublishOnly;
11+
12+
expect(prepublishOnly).toBe('node scripts/release/prepublish-check.js && node build.js');
13+
expect(prepublishOnly).not.toContain('pnpm');
14+
});
15+
16+
it('routes prepare through the release prepare helper', async () => {
17+
const packageJson = JSON.parse(await fs.readFile('package.json', 'utf-8')) as {
18+
scripts?: Record<string, string>;
19+
};
20+
21+
expect(packageJson.scripts?.prepare).toBe('node scripts/release/prepare.js');
22+
});
23+
});
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { spawnSync } from 'child_process';
2+
import { promises as fs } from 'fs';
3+
import os from 'os';
4+
import path from 'path';
5+
import { afterEach, describe, expect, it } from 'vitest';
6+
7+
const temporary: string[] = [];
8+
const prepublishCheck = path.resolve('scripts/release/prepublish-check.js');
9+
10+
afterEach(async () => {
11+
await Promise.all(temporary.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
12+
});
13+
14+
async function writeFile(root: string, relativePath: string, content: string): Promise<void> {
15+
const target = path.join(root, relativePath);
16+
await fs.mkdir(path.dirname(target), { recursive: true });
17+
await fs.writeFile(target, content, 'utf-8');
18+
}
19+
20+
async function makePackageFixture(): Promise<string> {
21+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'comet-prepublish-check-'));
22+
temporary.push(root);
23+
24+
await writeFile(
25+
root,
26+
'package.json',
27+
JSON.stringify(
28+
{
29+
name: 'comet-prepublish-check-fixture',
30+
version: '1.0.0',
31+
files: ['index.js', 'README.md'],
32+
},
33+
null,
34+
2,
35+
),
36+
);
37+
await writeFile(root, 'README.md', '# Fixture\n');
38+
await writeFile(root, 'index.js', 'export const ok = true;\n');
39+
await writeFile(root, '.gitignore', ['eval/.cache/', 'eval/.pytest-basetemp-*/', ''].join('\n'));
40+
41+
return root;
42+
}
43+
44+
describe('prepublish security check', () => {
45+
it('scans only files that npm would publish', async () => {
46+
const root = await makePackageFixture();
47+
await writeFile(
48+
root,
49+
'eval/.cache/langsmith-cc-plugin/src/langsmith.test.ts',
50+
'const api_key = "abcdefghijklmnopqrstuvwxyz";\n',
51+
);
52+
await writeFile(
53+
root,
54+
'eval/.pytest-basetemp-ci-green/token.txt',
55+
'const api_key = "abcdefghijklmnopqrstuvwxyz";\n',
56+
);
57+
58+
const result = spawnSync(process.execPath, [prepublishCheck], {
59+
cwd: root,
60+
encoding: 'utf-8',
61+
});
62+
63+
expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0);
64+
expect(result.stderr).not.toContain('[SECURITY]');
65+
});
66+
});
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { spawnSync } from 'child_process';
2+
import path from 'path';
3+
import { describe, expect, it } from 'vitest';
4+
5+
const prepareScript = path.resolve('scripts/release/prepare.js');
6+
7+
describe('release prepare script', () => {
8+
it('skips prepare work during npm publish because prepublishOnly already builds', () => {
9+
const result = spawnSync(process.execPath, [prepareScript], {
10+
encoding: 'utf-8',
11+
env: { ...process.env, npm_command: 'publish', NPM_COMMAND: 'publish' },
12+
});
13+
14+
expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0);
15+
expect(result.stdout).toContain('skipped during npm publish');
16+
});
17+
});

0 commit comments

Comments
 (0)