From d586cec1ba3e93cd1e5d2dc4e76361dd0e2805f6 Mon Sep 17 00:00:00 2001 From: Nek-12 Date: Thu, 11 Jun 2026 18:39:53 +0200 Subject: [PATCH 1/4] Add npm release publishing flow --- .github/workflows/release.yml | 44 +++++- docs/development.md | 10 ++ package.json | 6 + .../cli/src/npm-platform-binary-runtime.js | 44 ++++++ scripts/build-all-binaries.ts | 6 + scripts/build-binary.ts | 131 +++++++++++++----- ...check-firefox-architecture-policy.test.mjs | 10 ++ scripts/npm-package-check.ts | 93 +++++++++++++ scripts/npm-package.ts | 67 +++++++++ scripts/npm-publish.ts | 101 ++++++++++++++ scripts/package-check.ts | 4 +- scripts/package.ts | 3 +- scripts/platform-targets.ts | 48 +++++++ scripts/test/npm-publish.test.ts | 32 +++++ scripts/test/package-check-test-utils.ts | 2 +- 15 files changed, 563 insertions(+), 38 deletions(-) create mode 100644 packages/cli/src/npm-platform-binary-runtime.js create mode 100644 scripts/build-all-binaries.ts create mode 100644 scripts/npm-package-check.ts create mode 100644 scripts/npm-package.ts create mode 100644 scripts/npm-publish.ts create mode 100644 scripts/platform-targets.ts create mode 100644 scripts/test/npm-publish.test.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ad4a9a8..e8adb0a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -178,10 +178,52 @@ jobs: - uses: ncipollo/release-action@v1.21.0 with: artifactErrorsFailBuild: true - artifacts: "release-artifacts/**/*" + artifacts: "release-artifacts/**/*.tar.gz,release-artifacts/**/*.zip,release-artifacts/signed-extension/*.xpi,release-artifacts/signed-extension/*.xpi.provenance.json" body: ${{ steps.build_changelog.outputs.changelog }} draft: true prerelease: false tag: ${{ github.ref_name }} env: GITHUB_TOKEN: ${{ secrets.OPENSOURCE_PAT }} + + npm-publish: + name: Publish npm + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + needs: + - package + - github-release + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + environment: npm + steps: + - uses: actions/checkout@v6 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.14 + - uses: actions/setup-node@v6 + with: + node-version: 22.14.0 + registry-url: https://registry.npmjs.org + - run: npm install -g npm@^11.5.1 + - run: npm --version + - run: bun install --frozen-lockfile + - uses: actions/download-artifact@v8 + with: + name: firefox-cli-signed-extension + path: dist/extension-artifacts + - run: bun run npm:publish:ci + + publish-github-release: + name: Publish GitHub Release + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + needs: + - npm-publish + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - run: gh release edit "${{ github.ref_name }}" --repo "${{ github.repository }}" --draft=false --prerelease=false + env: + GH_TOKEN: ${{ secrets.OPENSOURCE_PAT }} diff --git a/docs/development.md b/docs/development.md index e2be66a..55e98e3 100644 --- a/docs/development.md +++ b/docs/development.md @@ -39,3 +39,13 @@ Release signing: - `bun run extension:sign` signs `dist/extension` with `web-ext sign`. - Set `WEB_EXT_JWT_ISSUER` to the Mozilla Add-ons JWT issuer and `WEB_EXT_JWT_SECRET` to the corresponding JWT secret. - `bun run release:check:signed` requires a signed `dist/extension-artifacts/firefox-cli-.xpi` artifact and matching provenance. + +Npm publishing: + +- `bun run npm:publish:dry-run` cross-compiles all supported platform binaries, assembles `dist/npm`, verifies the npm package layout, and runs `npm publish --dry-run` for every package. +- `bun run npm:publish:local` runs the same local cross-compiled package flow and publishes `firefox-cli` to npm. +- `bun run npm:publish:ci` is the release workflow entrypoint. It requires the signed XPI artifact under `dist/extension-artifacts`, verifies the signed release package, and publishes through npm trusted publishing. + +The published CLI package depends on platform-specific native packages through `optionalDependencies`; npm installs only the package that matches the user's `os` and `cpu`. + +Configure npm trusted publishing for `firefox-cli` and each `@firefox-cli/native-*` package with this GitHub repository, workflow `release.yml`, and the `npm` environment. The workflow grants `id-token: write` only to the npm publish job. diff --git a/package.json b/package.json index ae9aedb..185e41d 100644 --- a/package.json +++ b/package.json @@ -38,13 +38,19 @@ "extension:run": "bun run extension:build && web-ext run --source-dir dist/extension", "extension:sign": "bun run extension:build && bun scripts/sign-extension.ts", "build:binary": "bun scripts/build-binary.ts", + "build:binary:all": "bun scripts/build-all-binaries.ts", "build:artifacts": "bun run build:binary && bun run extension:build && bun run package", "build:artifacts:check": "bun run build:artifacts && bun run package:verify", "build:packages": "bun run typecheck", "build": "bun run version:check && bun run deps:policy && bun run ts:policy && bun run build:packages && bun run build:artifacts", "package": "bun scripts/package.ts", "package:verify": "bun scripts/package-check.ts", + "npm:package": "bun scripts/npm-package.ts", + "npm:package:verify": "bun scripts/npm-package-check.ts", "package:check": "bun run build && bun run package:verify", + "npm:publish:local": "bun scripts/npm-publish.ts --build-all", + "npm:publish:ci": "bun scripts/npm-publish.ts --build-all --require-signed-xpi --provenance", + "npm:publish:dry-run": "bun scripts/npm-publish.ts --build-all --dry-run", "deps:check": "bun scripts/dependency-upgrade-check.ts", "release:check": "bun run build && bun scripts/release-check.ts", "release:check:local": "bun run build && bun scripts/release-check.ts --allow-unsigned-local", diff --git a/packages/cli/src/npm-platform-binary-runtime.js b/packages/cli/src/npm-platform-binary-runtime.js new file mode 100644 index 0000000..c986f15 --- /dev/null +++ b/packages/cli/src/npm-platform-binary-runtime.js @@ -0,0 +1,44 @@ +import { access } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; + +const require = createRequire(import.meta.url); + +export function getPlatformKey(input = process) { + if (!isSupportedPlatform(input.platform)) { + throw new Error(`Unsupported platform: ${input.platform}`); + } + if (!isSupportedArch(input.arch)) { + throw new Error(`Unsupported architecture: ${input.arch}`); + } + + return `${input.platform}-${input.arch}`; +} + +export function getBinaryName(input = process) { + return input.platform === "win32" ? "firefox-cli.exe" : "firefox-cli"; +} + +export async function resolvePackagedBinary(packageRoot, input = process) { + void packageRoot; + const platformKey = getPlatformKey(input); + const packageName = `@firefox-cli/native-${platformKey}`; + let packageJsonPath; + try { + packageJsonPath = require.resolve(`${packageName}/package.json`); + } catch (error) { + throw new Error(`Missing firefox-cli native package for ${platformKey}. Reinstall firefox-cli with optional dependencies enabled.`, { cause: error }); + } + + const binaryPath = join(dirname(packageJsonPath), "bin", getBinaryName(input)); + await access(binaryPath); + return binaryPath; +} + +function isSupportedPlatform(platform) { + return platform === "darwin" || platform === "linux" || platform === "win32"; +} + +function isSupportedArch(arch) { + return arch === "arm64" || arch === "x64"; +} diff --git a/scripts/build-all-binaries.ts b/scripts/build-all-binaries.ts new file mode 100644 index 0000000..166f9fb --- /dev/null +++ b/scripts/build-all-binaries.ts @@ -0,0 +1,6 @@ +import { buildBinary } from "./build-binary.js"; +import { supportedBinaryTargets } from "./platform-targets.js"; + +for (const target of supportedBinaryTargets) { + await buildBinary(target); +} diff --git a/scripts/build-binary.ts b/scripts/build-binary.ts index eebbeb4..aef5dd8 100644 --- a/scripts/build-binary.ts +++ b/scripts/build-binary.ts @@ -1,47 +1,86 @@ -import { chmod, mkdir, mkdtemp, readdir, rm } from "node:fs/promises"; +import { chmod, lstat, mkdir, mkdtemp, open, readdir, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join, relative, resolve, sep } from "node:path"; -import { getBinaryName, getPlatformKey } from "@firefox-cli/native-host"; import { copyPackagedBinary } from "./packaged-binary.js"; +import { + resolveBinaryTargetByBunTarget, + resolveBinaryTargetByPlatformKey, + resolveCurrentBinaryTarget, + type SupportedBinaryTarget, +} from "./platform-targets.js"; import { runProcess } from "./process-runner.js"; -const platformKey = getPlatformKey(); -const binaryName = getBinaryName(); const rootDir = process.cwd(); -const outputPath = resolve("dist/bin", platformKey, binaryName); const entrypointPath = resolve("packages/cli/src/entrypoint.ts"); const packageRoot = resolve("dist/package"); -await mkdir(dirname(outputPath), { recursive: true }); +if (import.meta.main) { + const target = resolveRequestedTarget(process.argv.slice(2)); + await buildBinary(target); +} + +export async function buildBinary(target: SupportedBinaryTarget): Promise { + const outputPath = resolve("dist/bin", target.platformKey, target.binaryName); + await mkdir(dirname(outputPath), { recursive: true }); + + const buildWorkdir = await mkdtemp(join(tmpdir(), "firefox-cli-bun-build-")); + + try { + await runProcess("bun", ["build", entrypointPath, "--compile", `--target=${target.bunTarget}`, "--outfile", outputPath], { + cwd: buildWorkdir, + stderr: "inherit", + stdout: "inherit", + }); + } finally { + await removeBuildWorkdir(buildWorkdir); + } -const buildWorkdir = await mkdtemp(join(tmpdir(), "firefox-cli-bun-build-")); + await cleanupRootBunBuildArtifacts(rootDir); -try { - await runProcess("bun", ["build", entrypointPath, "--compile", "--outfile", outputPath], { - cwd: buildWorkdir, - stderr: "inherit", - stdout: "inherit", + if (target.platform !== "win32") { + await chmod(outputPath, 0o755); + } + + console.log(`Built ${outputPath}`); + const syncedPackageBinaryPath = await copyPackagedBinary({ + sourcePath: outputPath, + packageRoot, + platformKey: target.platformKey, + binaryName: target.binaryName, + skipWhenPackageBinMissing: true, }); -} finally { - await removeBuildWorkdir(buildWorkdir); + if (syncedPackageBinaryPath !== undefined) { + console.log(`Updated packaged binary ${syncedPackageBinaryPath}`); + } + return outputPath; } -await assertNoRootBunBuildArtifacts(rootDir); - -if (process.platform !== "win32") { - await chmod(outputPath, 0o755); +function resolveRequestedTarget(args: readonly string[]): SupportedBinaryTarget { + const platformKey = readOption(args, "--platform-key"); + if (platformKey !== undefined) { + return resolveBinaryTargetByPlatformKey(platformKey); + } + const bunTarget = readOption(args, "--target"); + if (bunTarget !== undefined) { + return resolveBinaryTargetByBunTarget(bunTarget); + } + return resolveCurrentBinaryTarget(); } -console.log(`Built ${outputPath}`); -const syncedPackageBinaryPath = await copyPackagedBinary({ - sourcePath: outputPath, - packageRoot, - platformKey, - binaryName, - skipWhenPackageBinMissing: true, -}); -if (syncedPackageBinaryPath !== undefined) { - console.log(`Updated packaged binary ${syncedPackageBinaryPath}`); +function readOption(args: readonly string[], name: string): string | undefined { + const prefixed = args.find((arg) => arg.startsWith(`${name}=`)); + if (prefixed !== undefined) { + return prefixed.slice(name.length + 1); + } + const index = args.indexOf(name); + if (index === -1) { + return undefined; + } + const value = args[index + 1]; + if (value === undefined || value.startsWith("--")) { + throw new Error(`Expected value after ${name}`); + } + return value; } async function removeBuildWorkdir(path: string): Promise { @@ -54,15 +93,43 @@ async function removeBuildWorkdir(path: string): Promise { await rm(resolvedPath, { recursive: true, force: true }); } -async function assertNoRootBunBuildArtifacts(root: string): Promise { +export async function cleanupRootBunBuildArtifacts(root: string): Promise { const artifacts = (await readdir(root)).filter(isRootBunBuildArtifact); + await Promise.all( + artifacts.map(async (artifact) => { + const artifactPath = resolve(root, artifact); + const info = await lstat(artifactPath); + if (info.isSymbolicLink()) { + throw new Error(`Refusing to clean symlinked Bun build artifact: ${artifactPath}`); + } + if (!info.isFile()) { + throw new Error(`Refusing to clean unsupported Bun build artifact file type: ${artifactPath}`); + } + await assertBunBuildArtifactSignature(artifactPath); + await rm(artifactPath, { recursive: false, force: false }); + }), + ); if (artifacts.length > 0) { - throw new Error( - `Bun compile left root build artifacts: ${artifacts.slice(0, 5).join(", ")}${artifacts.length > 5 ? `, and ${String(artifacts.length - 5)} more` : ""}`, - ); + console.log(`Cleaned Bun root build artifacts: ${artifacts.join(", ")}`); } } function isRootBunBuildArtifact(name: string): boolean { return name.startsWith(".") && name.endsWith(".bun-build"); } + +async function assertBunBuildArtifactSignature(path: string): Promise { + const file = await open(path, "r"); + const prefix = Buffer.alloc(4); + const { bytesRead } = await file.read(prefix, 0, prefix.length, 0).finally(async () => file.close()); + const data = prefix.subarray(0, bytesRead); + if (!hasKnownExecutableSignature(data)) { + throw new Error(`Refusing to clean Bun build artifact without executable signature: ${path}`); + } +} + +function hasKnownExecutableSignature(data: Buffer): boolean { + return [Buffer.from([0xcf, 0xfa, 0xed, 0xfe]), Buffer.from([0x7f, 0x45, 0x4c, 0x46]), Buffer.from([0x4d, 0x5a])].some((signature) => + data.subarray(0, signature.length).equals(signature), + ); +} diff --git a/scripts/check-firefox-architecture-policy.test.mjs b/scripts/check-firefox-architecture-policy.test.mjs index 7c7561e..25060bd 100644 --- a/scripts/check-firefox-architecture-policy.test.mjs +++ b/scripts/check-firefox-architecture-policy.test.mjs @@ -27,3 +27,13 @@ test("native messaging manifest defaults stay aligned with the stable extension assert.match(nativeManifest, /type: "stdio"/); assert.match(nativeManifest, /allowed_extensions: options\.allowedExtensions \?\? \[FIREFOX_CLI_EXTENSION_ID\]/); }); + +test("release workflow uploads only concrete release artifact files", async () => { + const workflow = await readFile(new URL("../.github/workflows/release.yml", import.meta.url), "utf8"); + + assert.doesNotMatch(workflow, /artifacts:\s*"release-artifacts\/\*\*\/\*"/); + assert.match( + workflow, + /artifacts: "release-artifacts\/\*\*\/\*\.tar\.gz,release-artifacts\/\*\*\/\*\.zip,release-artifacts\/signed-extension\/\*\.xpi,release-artifacts\/signed-extension\/\*\.xpi\.provenance\.json"/, + ); +}); diff --git a/scripts/npm-package-check.ts b/scripts/npm-package-check.ts new file mode 100644 index 0000000..b15f0b3 --- /dev/null +++ b/scripts/npm-package-check.ts @@ -0,0 +1,93 @@ +import { cp, lstat, mkdtemp, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { z } from "zod"; +import rootPackage from "../package.json" with { type: "json" }; +import { parseJsonManifestContent } from "./manifest-validation.js"; +import { resolveCurrentBinaryTarget, supportedBinaryTargets } from "./platform-targets.js"; +import { runProcess } from "./process-runner.js"; +import { readRegularFileUnder } from "./safe-extension-files.js"; + +const npmRoot = resolve("dist/npm"); +const cliPackageRoot = resolve(npmRoot, "firefox-cli"); + +const packageJsonSchema = z + .object({ + name: z.string().min(1), + version: z.string().min(1), + bin: z.record(z.string(), z.string()).optional(), + optionalDependencies: z.record(z.string(), z.string()).optional(), + os: z.array(z.string().min(1)).optional(), + cpu: z.array(z.string().min(1)).optional(), + }) + .loose(); + +await verifyCliPackage(); +await Promise.all(supportedBinaryTargets.map(verifyNativePackage)); +await verifyTempInstall(); + +console.log("Npm package layout check passed."); + +async function verifyCliPackage(): Promise { + await Promise.all( + ["README.md", "LICENSE", "bin/firefox-cli.js", "lib/platform-binary.js"].map(async (path) => readRegularFileUnder(cliPackageRoot, path, path)), + ); + const packageJson = await readPackageJson(resolve(cliPackageRoot, "package.json")); + if (packageJson.name !== "firefox-cli") { + throw new Error(`Expected CLI npm package name firefox-cli, received ${packageJson.name}`); + } + if (packageJson.version !== rootPackage.version) { + throw new Error(`Expected CLI npm package version ${rootPackage.version}, received ${packageJson.version}`); + } + if (packageJson.bin?.["firefox-cli"] !== "bin/firefox-cli.js") { + throw new Error("Expected CLI npm package bin to point at bin/firefox-cli.js"); + } + const optionalDependencies = packageJson.optionalDependencies ?? {}; + for (const target of supportedBinaryTargets) { + if (optionalDependencies[target.npmPackageName] !== rootPackage.version) { + throw new Error(`Expected optional dependency ${target.npmPackageName}@${rootPackage.version}`); + } + } +} + +async function verifyNativePackage(target: (typeof supportedBinaryTargets)[number]): Promise { + const nativeRoot = resolve(npmRoot, target.npmPackageName); + const binaryPath = resolve(nativeRoot, "bin", target.binaryName); + await readRegularFileUnder(nativeRoot, `bin/${target.binaryName}`, `${target.platformKey} binary`); + const binaryInfo = await lstat(binaryPath); + if (binaryInfo.size === 0) { + throw new Error(`Expected ${target.npmPackageName} binary to be non-empty`); + } + const packageJson = await readPackageJson(resolve(nativeRoot, "package.json")); + if (packageJson.name !== target.npmPackageName) { + throw new Error(`Expected native package name ${target.npmPackageName}, received ${packageJson.name}`); + } + if (packageJson.version !== rootPackage.version) { + throw new Error(`Expected native package version ${rootPackage.version}, received ${packageJson.version}`); + } + if (packageJson.os?.length !== 1 || packageJson.os[0] !== target.platform) { + throw new Error(`Expected ${target.npmPackageName} os selector ${target.platform}`); + } + if (packageJson.cpu?.length !== 1 || packageJson.cpu[0] !== target.arch) { + throw new Error(`Expected ${target.npmPackageName} cpu selector ${target.arch}`); + } +} + +async function verifyTempInstall(): Promise { + const target = resolveCurrentBinaryTarget(); + const tempRoot = await mkdtemp(join(tmpdir(), "firefox-cli-npm-package-check-")); + const installRoot = join(tempRoot, "node_modules"); + await cp(cliPackageRoot, join(installRoot, "firefox-cli"), { recursive: true }); + await cp(resolve(npmRoot, target.npmPackageName), join(installRoot, target.npmPackageName), { recursive: true }); + const result = await runProcess(process.execPath, [join(installRoot, "firefox-cli/bin/firefox-cli.js"), "--version"], { + timeoutMs: 30_000, + label: "npm temp install", + }); + if (result.stdout.trim() !== rootPackage.version) { + throw new Error(`Expected npm temp install version ${rootPackage.version}, received ${result.stdout.trim()}`); + } +} + +async function readPackageJson(path: string): Promise> { + return parseJsonManifestContent(await readFile(path, "utf8"), "npm package manifest", path, packageJsonSchema); +} diff --git a/scripts/npm-package.ts b/scripts/npm-package.ts new file mode 100644 index 0000000..4463f87 --- /dev/null +++ b/scripts/npm-package.ts @@ -0,0 +1,67 @@ +import { chmod, cp, mkdir, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import rootPackage from "../package.json" with { type: "json" }; +import { resetGeneratedArtifact } from "./generated-artifacts.js"; +import { copyPackagedBinary } from "./packaged-binary.js"; +import { supportedBinaryTargets, type SupportedBinaryTarget } from "./platform-targets.js"; + +const packageRoot = resolve("dist/npm"); +const cliPackageRoot = resolve(packageRoot, "firefox-cli"); +const repositoryMetadata = { + type: "git", + url: "git+https://github.com/respawn-llc/firefox-cli.git", +}; + +await resetGeneratedArtifact(packageRoot); +await writeCliPackage(); +await Promise.all(supportedBinaryTargets.map(writeNativePackage)); + +console.log(`Assembled npm packages at ${packageRoot}`); + +async function writeCliPackage(): Promise { + await mkdir(resolve(cliPackageRoot, "bin"), { recursive: true }); + await mkdir(resolve(cliPackageRoot, "lib"), { recursive: true }); + await writePackageJson(resolve(cliPackageRoot, "package.json"), { + name: "firefox-cli", + version: rootPackage.version, + description: "Firefox automation CLI for AI agents", + type: "module", + bin: { + "firefox-cli": "bin/firefox-cli.js", + }, + files: ["bin", "lib", "README.md", "LICENSE"], + optionalDependencies: Object.fromEntries(supportedBinaryTargets.map((target) => [target.npmPackageName, rootPackage.version])), + license: "AGPL-3.0-only", + repository: repositoryMetadata, + }); + await cp("README.md", resolve(cliPackageRoot, "README.md")); + await cp("LICENSE", resolve(cliPackageRoot, "LICENSE")); + await cp("packages/cli/src/launcher-template.js", resolve(cliPackageRoot, "bin/firefox-cli.js")); + await cp("packages/cli/src/npm-platform-binary-runtime.js", resolve(cliPackageRoot, "lib/platform-binary.js")); + await chmod(resolve(cliPackageRoot, "bin/firefox-cli.js"), 0o755); +} + +async function writeNativePackage(target: SupportedBinaryTarget): Promise { + const nativePackageRoot = resolve(packageRoot, target.npmPackageName); + await mkdir(nativePackageRoot, { recursive: true }); + await writePackageJson(resolve(nativePackageRoot, "package.json"), { + name: target.npmPackageName, + version: rootPackage.version, + description: `Firefox CLI native executable for ${target.platformKey}`, + files: ["bin"], + os: [target.platform], + cpu: [target.arch], + license: "AGPL-3.0-only", + repository: repositoryMetadata, + }); + await copyPackagedBinary({ + sourcePath: resolve("dist/bin", target.platformKey, target.binaryName), + packageRoot: nativePackageRoot, + platformKey: "", + binaryName: target.binaryName, + }); +} + +async function writePackageJson(path: string, value: Record): Promise { + await writeFile(path, `${JSON.stringify(value, null, 2)}\n`); +} diff --git a/scripts/npm-publish.ts b/scripts/npm-publish.ts new file mode 100644 index 0000000..141d8cf --- /dev/null +++ b/scripts/npm-publish.ts @@ -0,0 +1,101 @@ +import { resolve } from "node:path"; +import { supportedBinaryTargets } from "./platform-targets.js"; +import { runProcess } from "./process-runner.js"; + +const npmRoot = resolve("dist/npm"); + +export interface NpmPublishPlan { + readonly buildAll: boolean; + readonly dryRun: boolean; + readonly provenance: boolean; + readonly requireSignedXpi: boolean; + readonly registry: string; + readonly publishArgs: readonly string[]; + readonly packageRoots: readonly string[]; +} + +export function resolveNpmPublishPlan(args: readonly string[] = process.argv.slice(2)): NpmPublishPlan { + const separatorIndex = args.indexOf("--"); + const scriptArgs = separatorIndex === -1 ? args : args.slice(0, separatorIndex); + const extraNpmArgs = separatorIndex === -1 ? [] : args.slice(separatorIndex + 1); + const dryRun = scriptArgs.includes("--dry-run"); + const buildAll = scriptArgs.includes("--build-all"); + const provenance = scriptArgs.includes("--provenance"); + const requireSignedXpi = scriptArgs.includes("--require-signed-xpi"); + const registry = readOption(scriptArgs, "--registry") ?? "https://registry.npmjs.org"; + const otp = readOption(scriptArgs, "--otp"); + const publishArgs = [ + "publish", + "--access", + "public", + "--registry", + registry, + ...(provenance ? ["--provenance"] : []), + ...(dryRun ? ["--dry-run"] : []), + ...(otp === undefined ? [] : [`--otp=${otp}`]), + ...extraNpmArgs, + ]; + const packageRoots = [...supportedBinaryTargets.map((target) => resolve(npmRoot, target.npmPackageName)), resolve(npmRoot, "firefox-cli")]; + return { + buildAll, + dryRun, + provenance, + requireSignedXpi, + registry, + publishArgs, + packageRoots, + }; +} + +export async function publishNpmPackage(plan: NpmPublishPlan = resolveNpmPublishPlan()): Promise { + await runProcess("bun", ["run", "version:check"], { stdout: "inherit", stderr: "inherit" }); + await runProcess("bun", ["run", "deps:policy"], { stdout: "inherit", stderr: "inherit" }); + await runProcess("bun", ["run", "ts:policy"], { stdout: "inherit", stderr: "inherit" }); + await runProcess("bun", ["run", "build:packages"], { stdout: "inherit", stderr: "inherit" }); + + if (plan.buildAll) { + await runProcess("bun", ["scripts/build-all-binaries.ts"], { stdout: "inherit", stderr: "inherit" }); + } + + await runProcess("bun", ["run", "extension:build"], { stdout: "inherit", stderr: "inherit" }); + await runProcess("bun", ["scripts/npm-package.ts"], { stdout: "inherit", stderr: "inherit" }); + await runProcess("bun", ["scripts/npm-package-check.ts"], { stdout: "inherit", stderr: "inherit" }); + + if (plan.requireSignedXpi) { + await runProcess("bun", ["scripts/package.ts"], { stdout: "inherit", stderr: "inherit" }); + await runProcess("bun", ["scripts/release-check.ts", "--require-signed-xpi"], { stdout: "inherit", stderr: "inherit" }); + } + + for (const publishRoot of plan.packageRoots) { + await runProcess("npm", plan.publishArgs, { + cwd: publishRoot, + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + label: `npm publish ${publishRoot}`, + }); + } +} + +function readOption(args: readonly string[], name: string): string | undefined { + const prefixed = args.find((arg) => arg.startsWith(`${name}=`)); + if (prefixed !== undefined) { + return prefixed.slice(name.length + 1); + } + const index = args.indexOf(name); + if (index === -1) { + return undefined; + } + const value = args[index + 1]; + if (value === undefined || value.startsWith("--")) { + throw new Error(`Expected value after ${name}`); + } + return value; +} + +if (import.meta.main) { + publishNpmPackage().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + }); +} diff --git a/scripts/package-check.ts b/scripts/package-check.ts index dcc56ad..2f18dce 100644 --- a/scripts/package-check.ts +++ b/scripts/package-check.ts @@ -59,8 +59,8 @@ async function verifyPackageJson(packageRoot: string): Promise { if (packageJson.version !== rootPackage.version) { throw new Error(`Expected package version ${rootPackage.version}, received ${packageJson.version}`); } - if (packageJson.bin?.["firefox-cli"] !== "./bin/firefox-cli.js") { - throw new Error("Expected firefox-cli bin to point at ./bin/firefox-cli.js"); + if (packageJson.bin?.["firefox-cli"] !== "bin/firefox-cli.js") { + throw new Error("Expected firefox-cli bin to point at bin/firefox-cli.js"); } } diff --git a/scripts/package.ts b/scripts/package.ts index 62910d8..1391425 100644 --- a/scripts/package.ts +++ b/scripts/package.ts @@ -12,7 +12,6 @@ const binaryName = getBinaryName(); await resetGeneratedPackage(packageRoot); await mkdir(resolve(packageRoot, "bin", platformKey), { recursive: true }); - await writePackageJson(packageRoot); await cp("README.md", resolve(packageRoot, "README.md")); await cp("LICENSE", resolve(packageRoot, "LICENSE")); @@ -36,7 +35,7 @@ async function writePackageJson(path: string): Promise { description: "Firefox automation CLI for AI agents", type: "module", bin: { - "firefox-cli": "./bin/firefox-cli.js", + "firefox-cli": "bin/firefox-cli.js", }, files: ["bin", "lib", "README.md", "LICENSE"], license: "AGPL-3.0-only", diff --git a/scripts/platform-targets.ts b/scripts/platform-targets.ts new file mode 100644 index 0000000..e360fd5 --- /dev/null +++ b/scripts/platform-targets.ts @@ -0,0 +1,48 @@ +import { getBinaryName, getPlatformKey, type PlatformInput } from "@firefox-cli/native-host"; + +export interface SupportedBinaryTarget extends PlatformInput { + readonly bunTarget: string; + readonly platformKey: string; + readonly binaryName: string; + readonly npmPackageName: string; +} + +const targetInputs = [ + { platform: "darwin", arch: "arm64", bunTarget: "bun-darwin-arm64" }, + { platform: "darwin", arch: "x64", bunTarget: "bun-darwin-x64" }, + { platform: "linux", arch: "arm64", bunTarget: "bun-linux-arm64" }, + { platform: "linux", arch: "x64", bunTarget: "bun-linux-x64" }, + { platform: "win32", arch: "arm64", bunTarget: "bun-windows-arm64" }, + { platform: "win32", arch: "x64", bunTarget: "bun-windows-x64" }, +] satisfies readonly (PlatformInput & { readonly bunTarget: string })[]; + +export const supportedBinaryTargets: readonly SupportedBinaryTarget[] = targetInputs.map((target) => ({ + ...target, + platformKey: getPlatformKey(target), + binaryName: getBinaryName(target), + npmPackageName: `@firefox-cli/native-${getPlatformKey(target)}`, +})); + +export function resolveCurrentBinaryTarget(input: PlatformInput = process): SupportedBinaryTarget { + const target = supportedBinaryTargets.find((candidate) => candidate.platform === input.platform && candidate.arch === input.arch); + if (target === undefined) { + throw new Error(`Unsupported binary target: ${input.platform}-${input.arch}`); + } + return target; +} + +export function resolveBinaryTargetByPlatformKey(platformKey: string): SupportedBinaryTarget { + const target = supportedBinaryTargets.find((candidate) => candidate.platformKey === platformKey); + if (target === undefined) { + throw new Error(`Unsupported platform key: ${platformKey}`); + } + return target; +} + +export function resolveBinaryTargetByBunTarget(bunTarget: string): SupportedBinaryTarget { + const target = supportedBinaryTargets.find((candidate) => candidate.bunTarget === bunTarget); + if (target === undefined) { + throw new Error(`Unsupported Bun executable target: ${bunTarget}`); + } + return target; +} diff --git a/scripts/test/npm-publish.test.ts b/scripts/test/npm-publish.test.ts new file mode 100644 index 0000000..f3d1f54 --- /dev/null +++ b/scripts/test/npm-publish.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { resolveNpmPublishPlan } from "../npm-publish.js"; + +describe("resolveNpmPublishPlan", () => { + it("plans a local cross-compiled publish", () => { + const plan = resolveNpmPublishPlan(["--build-all"]); + + expect(plan.buildAll).toBe(true); + expect(plan.dryRun).toBe(false); + expect(plan.provenance).toBe(false); + expect(plan.requireSignedXpi).toBe(false); + expect(plan.registry).toBe("https://registry.npmjs.org"); + expect(plan.publishArgs).toEqual(["publish", "--access", "public", "--registry", "https://registry.npmjs.org"]); + expect(plan.packageRoots.at(-1)).toMatch(/dist\/npm\/firefox-cli$/); + }); + + it("plans CI trusted-publishing release verification", () => { + const plan = resolveNpmPublishPlan(["--require-signed-xpi", "--provenance", "--dry-run"]); + + expect(plan.buildAll).toBe(false); + expect(plan.dryRun).toBe(true); + expect(plan.provenance).toBe(true); + expect(plan.requireSignedXpi).toBe(true); + expect(plan.publishArgs).toEqual(["publish", "--access", "public", "--registry", "https://registry.npmjs.org", "--provenance", "--dry-run"]); + }); + + it("forwards local npm publish credentials and extra npm args", () => { + const plan = resolveNpmPublishPlan(["--otp", "123456", "--", "--tag", "next"]); + + expect(plan.publishArgs).toEqual(["publish", "--access", "public", "--registry", "https://registry.npmjs.org", "--otp=123456", "--tag", "next"]); + }); +}); diff --git a/scripts/test/package-check-test-utils.ts b/scripts/test/package-check-test-utils.ts index 2b8751b..2fe8ce5 100644 --- a/scripts/test/package-check-test-utils.ts +++ b/scripts/test/package-check-test-utils.ts @@ -48,7 +48,7 @@ export async function createPackageRoot( version: rootPackage.version, type: "module", bin: { - "firefox-cli": "./bin/firefox-cli.js", + "firefox-cli": "bin/firefox-cli.js", }, }, null, From 3b0d71ce420c8903b3f5e893da493da26d15adc4 Mon Sep 17 00:00:00 2001 From: Nek-12 Date: Thu, 11 Jun 2026 18:44:33 +0200 Subject: [PATCH 2/4] Fix npm publish test on Windows --- scripts/test/npm-publish.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/test/npm-publish.test.ts b/scripts/test/npm-publish.test.ts index f3d1f54..c8d7e69 100644 --- a/scripts/test/npm-publish.test.ts +++ b/scripts/test/npm-publish.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { sep } from "node:path"; import { resolveNpmPublishPlan } from "../npm-publish.js"; describe("resolveNpmPublishPlan", () => { @@ -11,7 +12,7 @@ describe("resolveNpmPublishPlan", () => { expect(plan.requireSignedXpi).toBe(false); expect(plan.registry).toBe("https://registry.npmjs.org"); expect(plan.publishArgs).toEqual(["publish", "--access", "public", "--registry", "https://registry.npmjs.org"]); - expect(plan.packageRoots.at(-1)).toMatch(/dist\/npm\/firefox-cli$/); + expect(plan.packageRoots.at(-1)?.split(sep).slice(-3)).toEqual(["dist", "npm", "firefox-cli"]); }); it("plans CI trusted-publishing release verification", () => { From 7d19f3724eab5dc33f180863412583b054482fc0 Mon Sep 17 00:00:00 2001 From: Nek-12 Date: Thu, 11 Jun 2026 19:14:02 +0200 Subject: [PATCH 3/4] Use respawn npm native package scope --- docs/development.md | 2 +- packages/cli/src/npm-platform-binary-runtime.js | 2 +- scripts/platform-targets.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/development.md b/docs/development.md index 55e98e3..8c8b0c1 100644 --- a/docs/development.md +++ b/docs/development.md @@ -48,4 +48,4 @@ Npm publishing: The published CLI package depends on platform-specific native packages through `optionalDependencies`; npm installs only the package that matches the user's `os` and `cpu`. -Configure npm trusted publishing for `firefox-cli` and each `@firefox-cli/native-*` package with this GitHub repository, workflow `release.yml`, and the `npm` environment. The workflow grants `id-token: write` only to the npm publish job. +Configure npm trusted publishing for `firefox-cli` and each `@respawn-app/firefox-cli-native-*` package with this GitHub repository, workflow `release.yml`, and the `npm` environment. The workflow grants `id-token: write` only to the npm publish job. diff --git a/packages/cli/src/npm-platform-binary-runtime.js b/packages/cli/src/npm-platform-binary-runtime.js index c986f15..f1185e0 100644 --- a/packages/cli/src/npm-platform-binary-runtime.js +++ b/packages/cli/src/npm-platform-binary-runtime.js @@ -22,7 +22,7 @@ export function getBinaryName(input = process) { export async function resolvePackagedBinary(packageRoot, input = process) { void packageRoot; const platformKey = getPlatformKey(input); - const packageName = `@firefox-cli/native-${platformKey}`; + const packageName = `@respawn-app/firefox-cli-native-${platformKey}`; let packageJsonPath; try { packageJsonPath = require.resolve(`${packageName}/package.json`); diff --git a/scripts/platform-targets.ts b/scripts/platform-targets.ts index e360fd5..9d3aeae 100644 --- a/scripts/platform-targets.ts +++ b/scripts/platform-targets.ts @@ -20,7 +20,7 @@ export const supportedBinaryTargets: readonly SupportedBinaryTarget[] = targetIn ...target, platformKey: getPlatformKey(target), binaryName: getBinaryName(target), - npmPackageName: `@firefox-cli/native-${getPlatformKey(target)}`, + npmPackageName: `@respawn-app/firefox-cli-native-${getPlatformKey(target)}`, })); export function resolveCurrentBinaryTarget(input: PlatformInput = process): SupportedBinaryTarget { From 9a96c22b0feb483403bbe18a4b36c505a2cd01bf Mon Sep 17 00:00:00 2001 From: Nek-12 Date: Thu, 11 Jun 2026 19:26:01 +0200 Subject: [PATCH 4/4] Fix release review findings --- .github/workflows/release.yml | 3 ++- packages/cli/src/npm-platform-binary-runtime.js | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e8adb0a..a4296d9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -224,6 +224,7 @@ jobs: permissions: contents: write steps: - - run: gh release edit "${{ github.ref_name }}" --repo "${{ github.repository }}" --draft=false --prerelease=false + - run: gh release edit "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --draft=false --prerelease=false env: + RELEASE_TAG: ${{ github.ref_name }} GH_TOKEN: ${{ secrets.OPENSOURCE_PAT }} diff --git a/packages/cli/src/npm-platform-binary-runtime.js b/packages/cli/src/npm-platform-binary-runtime.js index f1185e0..187f34b 100644 --- a/packages/cli/src/npm-platform-binary-runtime.js +++ b/packages/cli/src/npm-platform-binary-runtime.js @@ -2,7 +2,7 @@ import { access } from "node:fs/promises"; import { createRequire } from "node:module"; import { dirname, join } from "node:path"; -const require = createRequire(import.meta.url); +const fallbackRequire = createRequire(import.meta.url); export function getPlatformKey(input = process) { if (!isSupportedPlatform(input.platform)) { @@ -20,12 +20,12 @@ export function getBinaryName(input = process) { } export async function resolvePackagedBinary(packageRoot, input = process) { - void packageRoot; + const packageRequire = packageRoot === undefined ? fallbackRequire : createRequire(join(packageRoot, "package.json")); const platformKey = getPlatformKey(input); const packageName = `@respawn-app/firefox-cli-native-${platformKey}`; let packageJsonPath; try { - packageJsonPath = require.resolve(`${packageName}/package.json`); + packageJsonPath = packageRequire.resolve(`${packageName}/package.json`); } catch (error) { throw new Error(`Missing firefox-cli native package for ${platformKey}. Reinstall firefox-cli with optional dependencies enabled.`, { cause: error }); }