diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3a82fe8..6cfa5f5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -235,15 +235,23 @@ jobs: id: publish_npm if: steps.changesets.outputs.hasChangesets == 'false' && steps.publish_check.outputs.needs_publish == 'true' env: - NODE_AUTH_TOKEN: "" + NODE_AUTH_TOKEN: '' NPM_CONFIG_USERCONFIG: /home/runner/work/_temp/trusted-npmrc - NPM_CONFIG_PROVENANCE: "true" + NPM_CONFIG_PROVENANCE: 'true' run: | npm install -g npm@^11 npm --version printf "registry=https://registry.npmjs.org/\n" > "$NPM_CONFIG_USERCONFIG" 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 id: publish_metadata run: | @@ -294,7 +302,7 @@ jobs: run: | VERSION=$(node -p "require('./package.json').version") TAG="v$VERSION" - + # Check if release already exists if gh release view "$TAG" &>/dev/null; then echo "Release $TAG already exists, uploading binaries..." diff --git a/package.json b/package.json index 6062ae9..bda4170 100644 --- a/package.json +++ b/package.json @@ -42,12 +42,15 @@ "postinstall": "node scripts/postinstall.js", "verify:native-version": "node scripts/verify-native-version.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", "sync:upstream": "bash scripts/sync-upstream.sh", "sync:upstream:push": "bash scripts/sync-upstream.sh --push", "changeset": "changeset", "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": [ "browser", diff --git a/scripts/postinstall.js b/scripts/postinstall.js index 2d461cb..b7a4b01 100644 --- a/scripts/postinstall.js +++ b/scripts/postinstall.js @@ -2,19 +2,29 @@ /** * Postinstall script for agent-browser - * + * * Downloads the platform-specific native binary if not present. * On global installs, patches npm's bin entry to use the native binary directly: * - Windows: Overwrites .cmd/.ps1 shims * - 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 { fileURLToPath } from 'url'; import { platform, arch } from 'os'; import { get } from 'https'; -import { execSync } from 'child_process'; +import { execFileSync, execSync } from 'child_process'; const __dirname = dirname(fileURLToPath(import.meta.url)); const projectRoot = join(__dirname, '..'); @@ -65,7 +75,7 @@ function getBinCommands(pkg) { async function downloadFile(url, dest) { return new Promise((resolve, reject) => { const file = createWriteStream(dest); - + const request = (url) => { get(url, (response) => { // Handle redirects @@ -73,12 +83,12 @@ async function downloadFile(url, dest) { request(response.headers.location); return; } - + if (response.statusCode !== 200) { reject(new Error(`Failed to download: HTTP ${response.statusCode}`)); return; } - + response.pipe(file); file.on('finish', () => { file.close(); @@ -89,7 +99,7 @@ async function downloadFile(url, dest) { reject(err); }); }; - + request(url); }); } @@ -101,13 +111,21 @@ async function main() { if (platform() !== 'win32') { chmodSync(binaryPath, 0o755); } - console.log(`✓ Native binary ready: ${binaryName}`); - - // On global installs, fix npm's bin entry to use native binary directly - await fixGlobalInstallBin(); - - showPlaywrightReminder(); - return; + const installedVersion = readBinaryVersion(binaryPath); + if (installedVersion.includes(version)) { + console.log(`✓ Native binary ready: ${binaryName}`); + + // On global installs, fix npm's bin entry to use native binary directly + await fixGlobalInstallBin(); + + 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 @@ -120,12 +138,19 @@ async function main() { try { await downloadFile(DOWNLOAD_URL, binaryPath); - + // Make executable on Unix if (platform() !== 'win32') { 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}`); } catch (err) { console.log(`⚠ Could not download native binary: ${err.message}`); @@ -143,6 +168,17 @@ async function main() { showPlaywrightReminder(); } +function readBinaryVersion(path) { + try { + return execFileSync(path, ['--version'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }).trim(); + } catch { + return ''; + } +} + function showPlaywrightReminder() { console.log(''); console.log('╔═══════════════════════════════════════════════════════════════════════════╗'); diff --git a/scripts/verify-packed-host-binary.js b/scripts/verify-packed-host-binary.js new file mode 100644 index 0000000..4951a06 --- /dev/null +++ b/scripts/verify-packed-host-binary.js @@ -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); +} diff --git a/scripts/verify-registry-host-binary.js b/scripts/verify-registry-host-binary.js new file mode 100644 index 0000000..a160e17 --- /dev/null +++ b/scripts/verify-registry-host-binary.js @@ -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 }); +}