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
45 changes: 44 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -178,10 +178,53 @@ 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 "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --draft=false --prerelease=false
env:
RELEASE_TAG: ${{ github.ref_name }}
GH_TOKEN: ${{ secrets.OPENSOURCE_PAT }}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
10 changes: 10 additions & 0 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<version>.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 `@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.
6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
44 changes: 44 additions & 0 deletions packages/cli/src/npm-platform-binary-runtime.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { access } from "node:fs/promises";
import { createRequire } from "node:module";
import { dirname, join } from "node:path";

const fallbackRequire = 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) {
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 = 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 });
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

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";
}
6 changes: 6 additions & 0 deletions scripts/build-all-binaries.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { buildBinary } from "./build-binary.js";
import { supportedBinaryTargets } from "./platform-targets.js";

for (const target of supportedBinaryTargets) {
await buildBinary(target);
}
131 changes: 99 additions & 32 deletions scripts/build-binary.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<void> {
Expand All @@ -54,15 +93,43 @@ async function removeBuildWorkdir(path: string): Promise<void> {
await rm(resolvedPath, { recursive: true, force: true });
}

async function assertNoRootBunBuildArtifacts(root: string): Promise<void> {
export async function cleanupRootBunBuildArtifacts(root: string): Promise<void> {
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<void> {
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),
);
}
10 changes: 10 additions & 0 deletions scripts/check-firefox-architecture-policy.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"/,
);
});
Loading
Loading