fix(release): enforce binary version integrity pre/post publish
This commit is contained in:
@@ -235,15 +235,23 @@ jobs:
|
|||||||
id: publish_npm
|
id: publish_npm
|
||||||
if: steps.changesets.outputs.hasChangesets == 'false' && steps.publish_check.outputs.needs_publish == 'true'
|
if: steps.changesets.outputs.hasChangesets == 'false' && steps.publish_check.outputs.needs_publish == 'true'
|
||||||
env:
|
env:
|
||||||
NODE_AUTH_TOKEN: ""
|
NODE_AUTH_TOKEN: ''
|
||||||
NPM_CONFIG_USERCONFIG: /home/runner/work/_temp/trusted-npmrc
|
NPM_CONFIG_USERCONFIG: /home/runner/work/_temp/trusted-npmrc
|
||||||
NPM_CONFIG_PROVENANCE: "true"
|
NPM_CONFIG_PROVENANCE: 'true'
|
||||||
run: |
|
run: |
|
||||||
npm install -g npm@^11
|
npm install -g npm@^11
|
||||||
npm --version
|
npm --version
|
||||||
printf "registry=https://registry.npmjs.org/\n" > "$NPM_CONFIG_USERCONFIG"
|
printf "registry=https://registry.npmjs.org/\n" > "$NPM_CONFIG_USERCONFIG"
|
||||||
pnpm ci:publish
|
pnpm ci:publish
|
||||||
|
|
||||||
|
- name: Verify published npm tarball
|
||||||
|
if: steps.publish_npm.outcome == 'success'
|
||||||
|
env:
|
||||||
|
PACKAGE_NAME: agent-browser-stealth
|
||||||
|
EXPECTED_VERSION: ${{ steps.publish_check.outputs.local_version }}
|
||||||
|
run: |
|
||||||
|
pnpm run verify:registry-host-binary
|
||||||
|
|
||||||
- name: Set release outputs
|
- name: Set release outputs
|
||||||
id: publish_metadata
|
id: publish_metadata
|
||||||
run: |
|
run: |
|
||||||
@@ -294,7 +302,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
VERSION=$(node -p "require('./package.json').version")
|
VERSION=$(node -p "require('./package.json').version")
|
||||||
TAG="v$VERSION"
|
TAG="v$VERSION"
|
||||||
|
|
||||||
# Check if release already exists
|
# Check if release already exists
|
||||||
if gh release view "$TAG" &>/dev/null; then
|
if gh release view "$TAG" &>/dev/null; then
|
||||||
echo "Release $TAG already exists, uploading binaries..."
|
echo "Release $TAG already exists, uploading binaries..."
|
||||||
|
|||||||
+4
-1
@@ -42,12 +42,15 @@
|
|||||||
"postinstall": "node scripts/postinstall.js",
|
"postinstall": "node scripts/postinstall.js",
|
||||||
"verify:native-version": "node scripts/verify-native-version.js",
|
"verify:native-version": "node scripts/verify-native-version.js",
|
||||||
"verify:bundled-binaries": "node scripts/verify-bundled-binaries.js",
|
"verify:bundled-binaries": "node scripts/verify-bundled-binaries.js",
|
||||||
|
"verify:packed-host-binary": "node scripts/verify-packed-host-binary.js",
|
||||||
|
"verify:registry-host-binary": "node scripts/verify-registry-host-binary.js",
|
||||||
|
"prepublishOnly": "pnpm run verify:bundled-binaries && pnpm run verify:native-version && pnpm run verify:packed-host-binary",
|
||||||
"clawhub:sync": "bash scripts/clawhub-sync.sh",
|
"clawhub:sync": "bash scripts/clawhub-sync.sh",
|
||||||
"sync:upstream": "bash scripts/sync-upstream.sh",
|
"sync:upstream": "bash scripts/sync-upstream.sh",
|
||||||
"sync:upstream:push": "bash scripts/sync-upstream.sh --push",
|
"sync:upstream:push": "bash scripts/sync-upstream.sh --push",
|
||||||
"changeset": "changeset",
|
"changeset": "changeset",
|
||||||
"ci:version": "changeset version && pnpm run version:sync && pnpm install --no-frozen-lockfile",
|
"ci:version": "changeset version && pnpm run version:sync && pnpm install --no-frozen-lockfile",
|
||||||
"ci:publish": "pnpm run version:sync && pnpm run build && pnpm run build:native && pnpm run verify:bundled-binaries && pnpm run verify:native-version && changeset publish"
|
"ci:publish": "pnpm run version:sync && pnpm run build && pnpm run build:native && pnpm run verify:bundled-binaries && pnpm run verify:native-version && pnpm run verify:packed-host-binary && changeset publish"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"browser",
|
"browser",
|
||||||
|
|||||||
+52
-16
@@ -2,19 +2,29 @@
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Postinstall script for agent-browser
|
* Postinstall script for agent-browser
|
||||||
*
|
*
|
||||||
* Downloads the platform-specific native binary if not present.
|
* Downloads the platform-specific native binary if not present.
|
||||||
* On global installs, patches npm's bin entry to use the native binary directly:
|
* On global installs, patches npm's bin entry to use the native binary directly:
|
||||||
* - Windows: Overwrites .cmd/.ps1 shims
|
* - Windows: Overwrites .cmd/.ps1 shims
|
||||||
* - Mac/Linux: Replaces symlink to point to native binary
|
* - Mac/Linux: Replaces symlink to point to native binary
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { existsSync, mkdirSync, chmodSync, createWriteStream, unlinkSync, writeFileSync, symlinkSync, lstatSync, readFileSync } from 'fs';
|
import {
|
||||||
|
existsSync,
|
||||||
|
mkdirSync,
|
||||||
|
chmodSync,
|
||||||
|
createWriteStream,
|
||||||
|
unlinkSync,
|
||||||
|
writeFileSync,
|
||||||
|
symlinkSync,
|
||||||
|
lstatSync,
|
||||||
|
readFileSync,
|
||||||
|
} from 'fs';
|
||||||
import { dirname, join } from 'path';
|
import { dirname, join } from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import { platform, arch } from 'os';
|
import { platform, arch } from 'os';
|
||||||
import { get } from 'https';
|
import { get } from 'https';
|
||||||
import { execSync } from 'child_process';
|
import { execFileSync, execSync } from 'child_process';
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const projectRoot = join(__dirname, '..');
|
const projectRoot = join(__dirname, '..');
|
||||||
@@ -65,7 +75,7 @@ function getBinCommands(pkg) {
|
|||||||
async function downloadFile(url, dest) {
|
async function downloadFile(url, dest) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const file = createWriteStream(dest);
|
const file = createWriteStream(dest);
|
||||||
|
|
||||||
const request = (url) => {
|
const request = (url) => {
|
||||||
get(url, (response) => {
|
get(url, (response) => {
|
||||||
// Handle redirects
|
// Handle redirects
|
||||||
@@ -73,12 +83,12 @@ async function downloadFile(url, dest) {
|
|||||||
request(response.headers.location);
|
request(response.headers.location);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.statusCode !== 200) {
|
if (response.statusCode !== 200) {
|
||||||
reject(new Error(`Failed to download: HTTP ${response.statusCode}`));
|
reject(new Error(`Failed to download: HTTP ${response.statusCode}`));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
response.pipe(file);
|
response.pipe(file);
|
||||||
file.on('finish', () => {
|
file.on('finish', () => {
|
||||||
file.close();
|
file.close();
|
||||||
@@ -89,7 +99,7 @@ async function downloadFile(url, dest) {
|
|||||||
reject(err);
|
reject(err);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
request(url);
|
request(url);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -101,13 +111,21 @@ async function main() {
|
|||||||
if (platform() !== 'win32') {
|
if (platform() !== 'win32') {
|
||||||
chmodSync(binaryPath, 0o755);
|
chmodSync(binaryPath, 0o755);
|
||||||
}
|
}
|
||||||
console.log(`✓ Native binary ready: ${binaryName}`);
|
const installedVersion = readBinaryVersion(binaryPath);
|
||||||
|
if (installedVersion.includes(version)) {
|
||||||
// On global installs, fix npm's bin entry to use native binary directly
|
console.log(`✓ Native binary ready: ${binaryName}`);
|
||||||
await fixGlobalInstallBin();
|
|
||||||
|
// On global installs, fix npm's bin entry to use native binary directly
|
||||||
showPlaywrightReminder();
|
await fixGlobalInstallBin();
|
||||||
return;
|
|
||||||
|
showPlaywrightReminder();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`⚠ Binary version mismatch detected: expected ${version}, got "${installedVersion || 'unknown'}"`
|
||||||
|
);
|
||||||
|
console.log(` Re-downloading ${binaryName} from release assets...`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure bin directory exists
|
// Ensure bin directory exists
|
||||||
@@ -120,12 +138,19 @@ async function main() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await downloadFile(DOWNLOAD_URL, binaryPath);
|
await downloadFile(DOWNLOAD_URL, binaryPath);
|
||||||
|
|
||||||
// Make executable on Unix
|
// Make executable on Unix
|
||||||
if (platform() !== 'win32') {
|
if (platform() !== 'win32') {
|
||||||
chmodSync(binaryPath, 0o755);
|
chmodSync(binaryPath, 0o755);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const downloadedVersion = readBinaryVersion(binaryPath);
|
||||||
|
if (!downloadedVersion.includes(version)) {
|
||||||
|
throw new Error(
|
||||||
|
`downloaded binary version mismatch: expected ${version}, got "${downloadedVersion || 'unknown'}"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`✓ Downloaded native binary: ${binaryName}`);
|
console.log(`✓ Downloaded native binary: ${binaryName}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log(`⚠ Could not download native binary: ${err.message}`);
|
console.log(`⚠ Could not download native binary: ${err.message}`);
|
||||||
@@ -143,6 +168,17 @@ async function main() {
|
|||||||
showPlaywrightReminder();
|
showPlaywrightReminder();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readBinaryVersion(path) {
|
||||||
|
try {
|
||||||
|
return execFileSync(path, ['--version'], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
}).trim();
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function showPlaywrightReminder() {
|
function showPlaywrightReminder() {
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log('╔═══════════════════════════════════════════════════════════════════════════╗');
|
console.log('╔═══════════════════════════════════════════════════════════════════════════╗');
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build an npm tarball locally and verify that the bundled host-platform native
|
||||||
|
* binary reports the same version as package.json.
|
||||||
|
*
|
||||||
|
* This catches publish drifts where package.json is bumped but the embedded
|
||||||
|
* binary still points to an older fork version.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { existsSync, mkdtempSync, readFileSync, rmSync, unlinkSync } from 'fs';
|
||||||
|
import { tmpdir } from 'os';
|
||||||
|
import { dirname, join } from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { execFileSync } from 'child_process';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const rootDir = join(__dirname, '..');
|
||||||
|
|
||||||
|
const pkg = JSON.parse(readFileSync(join(rootDir, 'package.json'), 'utf8'));
|
||||||
|
const expectedVersion = String(pkg.version || '').trim();
|
||||||
|
|
||||||
|
if (!expectedVersion) {
|
||||||
|
console.error('Error: package.json version is empty');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = process.platform === 'win32' ? '.exe' : '';
|
||||||
|
const hostBinaryName = `agent-browser-${process.platform}-${process.arch}${ext}`;
|
||||||
|
|
||||||
|
let tempDir = '';
|
||||||
|
let packedTarball = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const packRaw = execFileSync('npm', ['pack', '--json'], {
|
||||||
|
cwd: rootDir,
|
||||||
|
encoding: 'utf8',
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
const parsed = JSON.parse(packRaw);
|
||||||
|
const filename = parsed?.[0]?.filename;
|
||||||
|
if (!filename || typeof filename !== 'string') {
|
||||||
|
throw new Error(`unexpected npm pack output: ${packRaw}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
packedTarball = join(rootDir, filename);
|
||||||
|
if (!existsSync(packedTarball)) {
|
||||||
|
throw new Error(`tarball not found: ${packedTarball}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
tempDir = mkdtempSync(join(tmpdir(), 'ab-pack-verify-'));
|
||||||
|
execFileSync('tar', ['-xzf', packedTarball, '-C', tempDir], {
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const packedRoot = join(tempDir, 'package');
|
||||||
|
const packedPkg = JSON.parse(readFileSync(join(packedRoot, 'package.json'), 'utf8'));
|
||||||
|
if (String(packedPkg.version || '').trim() !== expectedVersion) {
|
||||||
|
throw new Error(
|
||||||
|
`packed package.json version mismatch: expected ${expectedVersion}, got ${packedPkg.version}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const packedBinary = join(packedRoot, 'bin', hostBinaryName);
|
||||||
|
if (!existsSync(packedBinary)) {
|
||||||
|
throw new Error(`host binary missing in tarball: bin/${hostBinaryName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const binaryVersion = execFileSync(packedBinary, ['--version'], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
}).trim();
|
||||||
|
|
||||||
|
if (!binaryVersion.includes(expectedVersion)) {
|
||||||
|
throw new Error(
|
||||||
|
`tarball host binary version mismatch: expected ${expectedVersion}, got "${binaryVersion}"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✓ Packed tarball host binary matches package.json (${expectedVersion})`);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error(`Error: ${message}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
} finally {
|
||||||
|
if (tempDir) rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
if (packedTarball && existsSync(packedTarball)) unlinkSync(packedTarball);
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download the published npm tarball and verify that the bundled host-platform
|
||||||
|
* native binary reports the expected package version.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createWriteStream, existsSync, mkdtempSync, readFileSync, rmSync, unlinkSync } from 'fs';
|
||||||
|
import { tmpdir } from 'os';
|
||||||
|
import { dirname, join } from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { execFileSync } from 'child_process';
|
||||||
|
import { get } from 'https';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const rootDir = join(__dirname, '..');
|
||||||
|
|
||||||
|
const pkg = JSON.parse(readFileSync(join(rootDir, 'package.json'), 'utf8'));
|
||||||
|
const packageName = process.env.PACKAGE_NAME || pkg.name || 'agent-browser-stealth';
|
||||||
|
const expectedVersion = process.env.EXPECTED_VERSION || pkg.version;
|
||||||
|
|
||||||
|
if (!expectedVersion) {
|
||||||
|
console.error('Error: expected version is empty');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = process.platform === 'win32' ? '.exe' : '';
|
||||||
|
const hostBinaryName = `agent-browser-${process.platform}-${process.arch}${ext}`;
|
||||||
|
|
||||||
|
function downloadFile(url, dest) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const file = createWriteStream(dest);
|
||||||
|
|
||||||
|
const request = (currentUrl) => {
|
||||||
|
get(currentUrl, (response) => {
|
||||||
|
if (response.statusCode === 301 || response.statusCode === 302) {
|
||||||
|
request(response.headers.location);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.statusCode !== 200) {
|
||||||
|
reject(new Error(`Failed to download tarball: HTTP ${response.statusCode}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
response.pipe(file);
|
||||||
|
file.on('finish', () => {
|
||||||
|
file.close();
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
}).on('error', (err) => {
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
request(url);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let tempDir = '';
|
||||||
|
let tarballPath = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tarballUrl = execFileSync(
|
||||||
|
'npm',
|
||||||
|
['view', `${packageName}@${expectedVersion}`, 'dist.tarball'],
|
||||||
|
{
|
||||||
|
cwd: rootDir,
|
||||||
|
encoding: 'utf8',
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
}
|
||||||
|
).trim();
|
||||||
|
|
||||||
|
if (!tarballUrl) {
|
||||||
|
throw new Error(`could not resolve dist.tarball for ${packageName}@${expectedVersion}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
tempDir = mkdtempSync(join(tmpdir(), 'ab-registry-verify-'));
|
||||||
|
tarballPath = join(tempDir, 'package.tgz');
|
||||||
|
await downloadFile(tarballUrl, tarballPath);
|
||||||
|
|
||||||
|
execFileSync('tar', ['-xzf', tarballPath, '-C', tempDir], {
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const packedRoot = join(tempDir, 'package');
|
||||||
|
const packedPkg = JSON.parse(readFileSync(join(packedRoot, 'package.json'), 'utf8'));
|
||||||
|
if (String(packedPkg.version || '').trim() !== expectedVersion) {
|
||||||
|
throw new Error(
|
||||||
|
`registry package.json version mismatch: expected ${expectedVersion}, got ${packedPkg.version}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const packedBinary = join(packedRoot, 'bin', hostBinaryName);
|
||||||
|
if (!existsSync(packedBinary)) {
|
||||||
|
throw new Error(`host binary missing in registry tarball: bin/${hostBinaryName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const binaryVersion = execFileSync(packedBinary, ['--version'], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
}).trim();
|
||||||
|
|
||||||
|
if (!binaryVersion.includes(expectedVersion)) {
|
||||||
|
throw new Error(
|
||||||
|
`registry host binary version mismatch: expected ${expectedVersion}, got "${binaryVersion}"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`✓ Registry tarball host binary matches package.json (${packageName}@${expectedVersion})`
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error(`Error: ${message}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
} finally {
|
||||||
|
if (tarballPath && existsSync(tarballPath)) unlinkSync(tarballPath);
|
||||||
|
if (tempDir) rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user