From 8b823d0d2dcfc83224d61beec5d627398070df70 Mon Sep 17 00:00:00 2001 From: Cayman Date: Fri, 30 Oct 2026 06:11:28 -0400 Subject: [PATCH 1/2] feat: support workspace-managed native publishing --- README.md | 17 ++- examples/publishing/mod.test.ts | 193 ++++++++++++++++++++++++++++++++ ts/cli.ts | 4 + ts/prepublish.ts | 32 ++++-- ts/publish.ts | 105 +++++++++++------ 5 files changed, 307 insertions(+), 44 deletions(-) create mode 100644 examples/publishing/mod.test.ts diff --git a/README.md b/README.md index d04f91a..45ac958 100644 --- a/README.md +++ b/README.md @@ -848,6 +848,10 @@ zapi prepublish [options] |--------|-------------|---------| | `--artifacts-dir` | Directory containing built artifacts | `artifacts` | | `--npm-dir` | Directory for npm packages | `npm` | +| `--platforms-only` | Prepare target packages without changing the main manifest | `false` | +| `--main-only` | Update only the main manifest’s `optionalDependencies` | `false` | + +`--platforms-only` and `--main-only` are mutually exclusive. Neither option changes the default behavior when omitted. **Example output:** ``` @@ -856,8 +860,9 @@ zapi prepublish [options] → x86_64-unknown-linux-gnu → npm/x86_64-unknown-linux-gnu/my-addon.node ▶ Generating target package.json files... → Created npm/x86_64-unknown-linux-gnu/package.json -▶ Updating package.json with optionalDependencies... ✓ Prepared 6 target package(s) in npm/ +▶ Updating package.json with optionalDependencies... +✓ Prepared main package ``` #### `zapi publish` @@ -872,20 +877,26 @@ zapi publish [options] [-- ] |--------|-------------|---------| | `--npm-dir` | Directory containing npm packages | `npm` | | `--dry-run` | Preview what would be published without publishing | `false` | +| `--platforms-only` | Leave the main package to another publisher, such as Lerna | `false` | +| `--skip-existing` | Skip exact versions already published; registry errors abort the publish | `false` | Any arguments after `--` are passed directly to `npm publish` (e.g., `--access public`, `--tag beta`). +For workspace releases managed by Lerna, build the artifacts, then run `zapi prepublish --platforms-only` and `zapi publish --platforms-only --skip-existing -- --access public`. These commands leave the main manifest unchanged. Set the main package's `prepack` script to `zapi prepublish --main-only` so Lerna generates its exact platform dependencies during packaging, after checking the working tree. Lerna then publishes the main package alongside the workspace. + +With `--skip-existing`, dry runs query the registry but never publish. An explicit npm `--registry` also applies to these queries. + **Example dry-run:** ```bash zapi publish --dry-run ``` ``` -▶ [DRY RUN] Would publish 6 target package(s) + main package +▶ [DRY RUN] Publishing 6 target package(s) + main package with concurrency 1... → Extra npm args: (none) [1/7] Would publish x86_64-unknown-linux-gnu → Directory: /path/to/npm/x86_64-unknown-linux-gnu ... -✓ [DRY RUN] 7 package(s) would be published +✓ [DRY RUN] Would publish 7 package(s); skipped 0 ``` ### Release Workflow diff --git a/examples/publishing/mod.test.ts b/examples/publishing/mod.test.ts new file mode 100644 index 0000000..5ee68d5 --- /dev/null +++ b/examples/publishing/mod.test.ts @@ -0,0 +1,193 @@ +import {spawnSync} from "node:child_process"; +import {existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync} from "node:fs"; +import {tmpdir} from "node:os"; +import {delimiter, join} from "node:path"; +import {fileURLToPath} from "node:url"; +import {afterEach, beforeEach, describe, expect, it} from "vitest"; + +const cli = fileURLToPath(new URL("../../lib/cli.js", import.meta.url)); +const targets = ["aarch64-apple-darwin", "x86_64-unknown-linux-gnu"]; +const name = "@zapi-test/addon"; +const version = "1.2.3"; + +describe("package preparation", () => { + let directory: string; + const manifest = {name, version, zapi: {binaryName: "bindings", targets}}; + + beforeEach(() => { + directory = mkdtempSync(join(tmpdir(), "zapi-preparing-")); + writeFileSync(join(directory, "package.json"), JSON.stringify(manifest)); + for (const target of targets) { + mkdirSync(join(directory, "artifacts", target), {recursive: true}); + writeFileSync(join(directory, "artifacts", target, "bindings.node"), target); + } + }); + + afterEach(() => rmSync(directory, {recursive: true, force: true})); + + function run(...args: string[]) { + return spawnSync(process.execPath, [cli, "prepublish", ...args], {cwd: directory, encoding: "utf8"}); + } + + it("prepares platforms without modifying the tracked manifest", () => { + const result = run("--platforms-only"); + expect(result.status, result.stderr).toBe(0); + expect(readFileSync(join(directory, "package.json"), "utf8")).toBe(JSON.stringify(manifest)); + for (const target of targets) { + const pkg = JSON.parse(readFileSync(join(directory, "npm", target, "package.json"), "utf8")); + expect(pkg.name, target).toBe(`${name}-${target}`); + expect(pkg.version, target).toBe(version); + expect(readFileSync(join(directory, "npm", target, "bindings.node"), "utf8"), target).toBe(target); + } + }); + + it("prepares only the main manifest without requiring or touching artifacts", () => { + rmSync(join(directory, "artifacts"), {recursive: true}); + const result = run("--main-only"); + expect(result.status, result.stderr).toBe(0); + const pkg = JSON.parse(readFileSync(join(directory, "package.json"), "utf8")); + expect(pkg.optionalDependencies).toEqual( + Object.fromEntries(targets.map((target) => [`${name}-${target}`, version])) + ); + expect(existsSync(join(directory, "npm"))).toBe(false); + expect(result.stdout).not.toContain("Artifact not found"); + }); + + it("still prepares platforms and the main manifest by default", () => { + const result = run(); + expect(result.status, result.stderr).toBe(0); + const pkg = JSON.parse(readFileSync(join(directory, "package.json"), "utf8")); + expect(pkg.optionalDependencies).toEqual( + Object.fromEntries(targets.map((target) => [`${name}-${target}`, version])) + ); + for (const target of targets) { + expect(existsSync(join(directory, "npm", target, "bindings.node")), target).toBe(true); + } + }); + + it("rejects conflicting preparation options before changing files", () => { + const result = run("--main-only", "--platforms-only"); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("mutually exclusive"); + expect(readFileSync(join(directory, "package.json"), "utf8")).toBe(JSON.stringify(manifest)); + expect(existsSync(join(directory, "npm"))).toBe(false); + }); +}); + +describe("publishing", () => { + let directory: string; + let env: NodeJS.ProcessEnv; + + beforeEach(() => { + directory = mkdtempSync(join(tmpdir(), "zapi-publishing-")); + mkdirSync(join(directory, "bin")); + writeFileSync( + join(directory, "package.json"), + JSON.stringify({name, version, zapi: {binaryName: "bindings", targets}}) + ); + for (const target of targets) { + const path = join(directory, "npm", target); + mkdirSync(path, {recursive: true}); + writeFileSync(join(path, "package.json"), JSON.stringify({name: `${name}-${target}`, version})); + } + writeFileSync(join(directory, "published.json"), "[]"); + writeFileSync(join(directory, "calls.jsonl"), ""); + writeFileSync( + join(directory, "bin", "npm"), + `#!/usr/bin/env node +const fs = require("node:fs"); +const path = require("node:path"); +const root = process.env.TEST_ROOT; +const args = process.argv.slice(2); +const pkg = JSON.parse(fs.readFileSync("package.json")); +const spec = pkg.name + "@" + pkg.version; +const state = path.join(root, "published.json"); +const published = JSON.parse(fs.readFileSync(state)); +fs.appendFileSync(path.join(root, "calls.jsonl"), JSON.stringify({args, spec}) + "\\n"); +if (args[0] === "view") { + if (process.env.VIEW_ERROR) { + console.log(JSON.stringify({error: {code: process.env.VIEW_ERROR}})); + process.exit(1); + } + if (published.includes(spec)) { console.log(JSON.stringify(pkg.version)); process.exit(0); } + console.log(JSON.stringify({error: {code: "E404"}})); + process.exit(1); +} +if (args[0] !== "publish") process.exit(99); +if (pkg.name === process.env.FAIL_PACKAGE || published.includes(spec)) process.exit(1); +published.push(spec); +fs.writeFileSync(state, JSON.stringify(published)); +`, + {mode: 0o755} + ); + env = {...process.env, TEST_ROOT: directory, PATH: `${join(directory, "bin")}${delimiter}${process.env.PATH}`}; + }); + + afterEach(() => rmSync(directory, {recursive: true, force: true})); + + function run(args: string[]) { + return spawnSync(process.execPath, [cli, "publish", ...args], {cwd: directory, env, encoding: "utf8"}); + } + + function published(): string[] { + return JSON.parse(readFileSync(join(directory, "published.json"), "utf8")); + } + + it("preserves the default platform-first, main-last publish order", () => { + const result = run([]); + expect(result.status, result.stderr).toBe(0); + expect(published()).toEqual([...targets.map((target) => `${name}-${target}@${version}`), `${name}@${version}`]); + }); + + it("resumes a partial platform publish without touching the main package", () => { + env.FAIL_PACKAGE = `${name}-${targets[1]}`; + expect(run(["--platforms-only", "--skip-existing"]).status).not.toBe(0); + expect(published()).toEqual([`${name}-${targets[0]}@${version}`]); + delete env.FAIL_PACKAGE; + const result = run([ + "--platforms-only", + "--skip-existing", + "--", + "--registry=https://registry.example", + "--tag", + "next", + ]); + expect(result.status, result.stderr).toBe(0); + expect(published()).toEqual(targets.map((target) => `${name}-${target}@${version}`)); + const calls: {args: string[]; spec: string}[] = readFileSync(join(directory, "calls.jsonl"), "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + expect( + calls.filter((call) => call.args[0] === "publish" && call.spec === `${name}-${targets[0]}@${version}`) + ).toHaveLength(1); + expect( + calls + .filter((call) => call.args[0] === "view") + .slice(-2) + .every((call) => call.args.includes("https://registry.example")) + ).toBe(true); + expect(calls.at(-1)?.args).toEqual(["publish", "--registry=https://registry.example", "--tag", "next"]); + }); + + it.each(["E401", "E500", "ENOTFOUND"])("aborts on %s instead of treating it as a missing version", (code) => { + env.VIEW_ERROR = code; + const result = run(["--platforms-only", "--skip-existing"]); + expect(result.status).not.toBe(0); + expect(published()).toEqual([]); + }); + + it("skips an already published main package when requested", () => { + expect(run([]).status).toBe(0); + const result = run(["--skip-existing"]); + expect(result.status, result.stderr).toBe(0); + expect(published()).toHaveLength(3); + }); + + it.each([false, true])("never publishes in a dry run (skip existing: %s)", (skip) => { + const result = run(["--platforms-only", "--dry-run", ...(skip ? ["--skip-existing"] : [])]); + expect(result.status, result.stderr).toBe(0); + expect(published()).toEqual([]); + expect(result.stdout).not.toContain("main package"); + }); +}); diff --git a/ts/cli.ts b/ts/cli.ts index 975ebbb..08e8896 100644 --- a/ts/cli.ts +++ b/ts/cli.ts @@ -34,11 +34,15 @@ Commands: --artifacts-dir Directory containing built artifacts (default: artifacts) --npm-dir Directory for npm packages (default: npm) --concurrency Parallel filesystem prep jobs (default: min(cpu, 4)) + --platforms-only Prepare target packages without changing the main manifest + --main-only Update only the main manifest's optionalDependencies publish Publish all packages to npm --npm-dir Directory containing npm packages (default: npm) --dry-run Preview what would be published without publishing --concurrency Parallel target publishes (default: 1) + --platforms-only Publish target packages without the main package + --skip-existing Skip package versions already present in the registry [-- ] Additional arguments passed to npm publish Options: diff --git a/ts/prepublish.ts b/ts/prepublish.ts index ae60fae..6b897eb 100644 --- a/ts/prepublish.ts +++ b/ts/prepublish.ts @@ -91,10 +91,18 @@ const prepublishOptions = { default: defaultConcurrency, type: "string", }, + "main-only": { + default: false, + type: "boolean", + }, "npm-dir": { default: "npm", type: "string", }, + "platforms-only": { + default: false, + type: "boolean", + }, } satisfies ParseArgsOptionsConfig; export async function prepublishCli(): Promise { @@ -103,6 +111,10 @@ export async function prepublishCli(): Promise { options: prepublishOptions, }); + if (values["platforms-only"] && values["main-only"]) { + throw new Error("--platforms-only and --main-only are mutually exclusive"); + } + const {pkgJson, config} = await loadConfig(); const concurrency = parsePositiveIntOption( "concurrency", @@ -118,13 +130,17 @@ export async function prepublishCli(): Promise { "npm-dir": values["npm-dir"], }; - await createNpmDirs(config, opts); - await moveArtifacts(pkgJson, config, opts); - await updateTargetPkgJsons(pkgJson, config, opts); - - logInfo("Updating package.json with optionalDependencies..."); - const updatedPkgJson = await updateOptionalDependencies(pkgJson, config); - await fs.writeFile("package.json", JSON.stringify(updatedPkgJson, null, 2)); + if (!values["main-only"]) { + await createNpmDirs(config, opts); + await moveArtifacts(pkgJson, config, opts); + await updateTargetPkgJsons(pkgJson, config, opts); + logSuccess(`Prepared ${config.targets.length} target package(s) in ${values["npm-dir"]}/`); + } - logSuccess(`Prepared ${config.targets.length} target package(s) in ${values["npm-dir"]}/`); + if (!values["platforms-only"]) { + logInfo("Updating package.json with optionalDependencies..."); + const updatedPkgJson = updateOptionalDependencies(pkgJson, config); + await fs.writeFile("package.json", JSON.stringify(updatedPkgJson, null, 2)); + logSuccess("Prepared main package"); + } } diff --git a/ts/publish.ts b/ts/publish.ts index 120e81c..2d4c285 100644 --- a/ts/publish.ts +++ b/ts/publish.ts @@ -1,6 +1,7 @@ -import {spawn} from "node:child_process"; +import {execFile, spawn} from "node:child_process"; +import {readFile} from "node:fs/promises"; import {join} from "node:path"; -import {type ParseArgsOptionsConfig, parseArgs} from "node:util"; +import {type ParseArgsOptionsConfig, parseArgs, promisify} from "node:util"; import {loadConfig} from "./config.js"; import {parsePositiveIntOption, runWithConcurrency} from "./lib.js"; import {logDetail, logInfo, logStep, logSuccess} from "./log.js"; @@ -18,12 +19,22 @@ const publishOptions = { default: "npm", type: "string", }, + "platforms-only": { + default: false, + type: "boolean", + }, + "skip-existing": { + default: false, + type: "boolean", + }, } satisfies ParseArgsOptionsConfig; export type PublishOpts = { "npm-dir": string; "dry-run": boolean; concurrency: number; + "platforms-only"?: boolean; + "skip-existing"?: boolean; }; function extraPublishArgs(): string[] { @@ -58,48 +69,74 @@ async function runNpm(args: string[], cwd: string): Promise { } } +async function isPublished(cwd: string, extraArgs: string[]): Promise { + const manifest = JSON.parse(await readFile(join(cwd, "package.json"), "utf8")) as {name: string; version: string}; + const spec = `${manifest.name}@${manifest.version}`; + const {values} = parseArgs({ + allowPositionals: true, + args: extraArgs, + options: {registry: {type: "string"}}, + strict: false, + }); + const registryArgs = typeof values.registry === "string" ? ["--registry", values.registry] : []; + let stdout: string; + try { + ({stdout} = await promisify(execFile)( + "npm", + ["view", spec, "version", "--json", "--prefer-online", ...registryArgs], + {cwd} + )); + } catch (error) { + if (error instanceof Error && "stdout" in error && typeof error.stdout === "string") { + let response: {error?: {code?: string}} | undefined; + try { + response = JSON.parse(error.stdout) as typeof response; + } catch { + // Non-JSON failures must not be mistaken for an unpublished package. + } + if (response?.error?.code === "E404") return false; + } + throw new Error(`Unable to check whether ${spec} is published`, {cause: error}); + } + if (JSON.parse(stdout) !== manifest.version) throw new Error(`Unexpected npm version response for ${spec}`); + return true; +} + export async function publish(opts: PublishOpts): Promise { const extraArgs = extraPublishArgs(); const publishArgv = ["publish", ...extraArgs]; - const {config} = await loadConfig(); - const total = config.targets.length + 1; // +1 for main package + const total = config.targets.length + (opts["platforms-only"] ? 0 : 1); + const description = `${config.targets.length} target package(s)${opts["platforms-only"] ? "" : " + main package"}`; - if (opts["dry-run"]) { - logInfo( - `[DRY RUN] Would publish ${config.targets.length} target package(s) + main package with concurrency ${opts.concurrency}` - ); - logDetail(`Extra npm args: ${extraArgs.length > 0 ? extraArgs.join(" ") : "(none)"}`); + logInfo(`${opts["dry-run"] ? "[DRY RUN] " : ""}Publishing ${description} with concurrency ${opts.concurrency}...`); + logDetail(`Extra npm args: ${extraArgs.length > 0 ? extraArgs.join(" ") : "(none)"}`); - for (let i = 0; i < config.targets.length; i++) { - const target = config.targets[i]; - const cwd = join(process.cwd(), opts["npm-dir"], target); - logStep(i + 1, total, `Would publish ${target}`); + let started = 0; + let skipped = 0; + const publishPackage = async (label: string, cwd: string): Promise => { + const step = ++started; + if (opts["skip-existing"] && (await isPublished(cwd, extraArgs))) { + skipped += 1; + logStep(step, total, `Skipping ${label}: version already published`); + return; + } + logStep(step, total, `${opts["dry-run"] ? "Would publish" : "Publishing"} ${label}`); + if (opts["dry-run"]) { logDetail(`Directory: ${cwd}`); + } else { + await runNpm(publishArgv, cwd); } + }; - logStep(total, total, "Would publish main package"); - logDetail(`Directory: ${process.cwd()}`); - - logSuccess(`[DRY RUN] ${total} package(s) would be published`); - return; - } - - logInfo( - `Publishing ${config.targets.length} target package(s) + main package with concurrency ${opts.concurrency}...` - ); - - let started = 0; await runWithConcurrency(config.targets, opts.concurrency, async (target) => { - started += 1; - logStep(started, total, `Publishing ${target}...`); - await runNpm(publishArgv, join(process.cwd(), opts["npm-dir"], target)); + await publishPackage(target, join(process.cwd(), opts["npm-dir"], target)); }); + if (!opts["platforms-only"]) await publishPackage("main package", process.cwd()); - logStep(total, total, "Publishing main package..."); - await runNpm(publishArgv, process.cwd()); - - logSuccess(`Published ${total} package(s) successfully!`); + logSuccess( + `${opts["dry-run"] ? "[DRY RUN] Would publish" : "Published"} ${total - skipped} package(s); skipped ${skipped}` + ); } export async function publishCli(): Promise { @@ -110,8 +147,10 @@ export async function publishCli(): Promise { }); await publish({ - concurrency: parsePositiveIntOption("concurrency", values["concurrency"] as string | undefined, 1), + concurrency: parsePositiveIntOption("concurrency", values.concurrency as string | undefined, 1), "dry-run": values["dry-run"] as boolean, "npm-dir": values["npm-dir"] as string, + "platforms-only": values["platforms-only"] as boolean, + "skip-existing": values["skip-existing"] as boolean, }); } From 0d68d43fa146423553267006dd8bb9623cc9f331 Mon Sep 17 00:00:00 2001 From: Cayman Date: Fri, 30 Oct 2026 06:28:38 -0400 Subject: [PATCH 2/2] fix: enforce native preparation and publishing contracts --- README.md | 15 +-- examples/publishing/mod.test.ts | 198 +++++++++++++++++++++++++++++++- ts/config.ts | 2 + ts/prepublish.ts | 24 ++-- ts/publish.ts | 48 ++++---- 5 files changed, 244 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index 45ac958..3de47b1 100644 --- a/README.md +++ b/README.md @@ -836,9 +836,9 @@ zapi build-artifacts [options] Prepare npm packages for publishing: - Creates `npm//` directories for each target -- Moves compiled `.node` binaries from artifacts into target packages +- Copies compiled `.node` binaries from artifacts into target packages - Generates `package.json` for each target package (with correct `os`, `cpu`, `libc`) -- Updates the main `package.json` with `optionalDependencies` +- Updates native entries in the main `package.json`'s `optionalDependencies`, preserving unrelated entries ```bash zapi prepublish [options] @@ -851,12 +851,12 @@ zapi prepublish [options] | `--platforms-only` | Prepare target packages without changing the main manifest | `false` | | `--main-only` | Update only the main manifest’s `optionalDependencies` | `false` | -`--platforms-only` and `--main-only` are mutually exclusive. Neither option changes the default behavior when omitted. +`--platforms-only` and `--main-only` are mutually exclusive. Without either option, both package types are prepared. Preparation is local and makes no registry requests. Artifacts remain available for retries; a missing artifact fails preparation. Generated platform manifests inherit the wrapper's `publishConfig`. **Example output:** ``` ▶ Preparing my-addon@1.0.0 for publishing... -▶ Moving artifacts to npm packages... +▶ Copying artifacts to npm packages... → x86_64-unknown-linux-gnu → npm/x86_64-unknown-linux-gnu/my-addon.node ▶ Generating target package.json files... → Created npm/x86_64-unknown-linux-gnu/package.json @@ -882,9 +882,11 @@ zapi publish [options] [-- ] Any arguments after `--` are passed directly to `npm publish` (e.g., `--access public`, `--tag beta`). -For workspace releases managed by Lerna, build the artifacts, then run `zapi prepublish --platforms-only` and `zapi publish --platforms-only --skip-existing -- --access public`. These commands leave the main manifest unchanged. Set the main package's `prepack` script to `zapi prepublish --main-only` so Lerna generates its exact platform dependencies during packaging, after checking the working tree. Lerna then publishes the main package alongside the workspace. +For workspace releases managed by Lerna, build the artifacts, then run `zapi prepublish --platforms-only` and `zapi publish --platforms-only --skip-existing -- --access public`. These commands leave the main manifest unchanged. Set the main package's `prepack` script to `zapi prepublish --main-only` so its exact platform dependencies are generated during packaging, after Lerna checks the working tree. Lifecycle scripts must be enabled for that step. Lerna then publishes the main package alongside the workspace. -With `--skip-existing`, dry runs query the registry but never publish. An explicit npm `--registry` also applies to these queries. +Publishing requires platform manifests matching the wrapper's current name and version, with their binaries present. zapi validates every platform before making registry requests. The default publishes platforms first and the wrapper last; `--platforms-only` assigns the wrapper to an external publisher. To publish just the wrapper, use `npm publish`. + +With `--skip-existing`, version queries use the destination registry, including `publishConfig` and explicit npm CLI overrides. Existing versions are left untouched, including their dist-tags. This supports retrying a partial release; changing an existing version's dist-tags is a separate `npm dist-tag` operation. Dry runs perform local validation and, with `--skip-existing`, registry queries, but never publish. **Example dry-run:** ```bash @@ -892,7 +894,6 @@ zapi publish --dry-run ``` ``` ▶ [DRY RUN] Publishing 6 target package(s) + main package with concurrency 1... - → Extra npm args: (none) [1/7] Would publish x86_64-unknown-linux-gnu → Directory: /path/to/npm/x86_64-unknown-linux-gnu ... diff --git a/examples/publishing/mod.test.ts b/examples/publishing/mod.test.ts index 5ee68d5..8b90ac0 100644 --- a/examples/publishing/mod.test.ts +++ b/examples/publishing/mod.test.ts @@ -1,8 +1,10 @@ -import {spawnSync} from "node:child_process"; +import {execFile, spawnSync} from "node:child_process"; import {existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync} from "node:fs"; +import {createServer} from "node:http"; import {tmpdir} from "node:os"; import {delimiter, join} from "node:path"; import {fileURLToPath} from "node:url"; +import {promisify} from "node:util"; import {afterEach, beforeEach, describe, expect, it} from "vitest"; const cli = fileURLToPath(new URL("../../lib/cli.js", import.meta.url)); @@ -72,6 +74,56 @@ describe("package preparation", () => { expect(readFileSync(join(directory, "package.json"), "utf8")).toBe(JSON.stringify(manifest)); expect(existsSync(join(directory, "npm"))).toBe(false); }); + + it("preserves unrelated optional dependencies and removes retired native targets", () => { + writeFileSync( + join(directory, "package.json"), + JSON.stringify({ + ...manifest, + optionalDependencies: {other: "^2.0.0", [`${name}-x86_64-apple-darwin`]: "1.0.0"}, + }) + ); + const result = run("--main-only"); + expect(result.status, result.stderr).toBe(0); + const pkg = JSON.parse(readFileSync(join(directory, "package.json"), "utf8")); + expect(pkg.optionalDependencies).toEqual({ + other: "^2.0.0", + ...Object.fromEntries(targets.map((target) => [`${name}-${target}`, version])), + }); + }); + + it("can prepare platforms repeatedly from the same build artifacts", () => { + expect(run("--platforms-only").status).toBe(0); + rmSync(join(directory, "npm"), {recursive: true}); + const result = run("--platforms-only"); + expect(result.status, result.stderr).toBe(0); + for (const target of targets) { + expect(readFileSync(join(directory, "npm", target, "bindings.node"), "utf8"), target).toBe(target); + } + }); + + it("fails on missing artifacts instead of repackaging an old binary", () => { + expect(run("--platforms-only").status).toBe(0); + rmSync(join(directory, "artifacts"), {recursive: true, force: true}); + writeFileSync(join(directory, "package.json"), JSON.stringify({...manifest, version: "2.0.0"})); + const result = run("--platforms-only"); + expect(result.status).not.toBe(0); + for (const target of targets) { + const pkg = JSON.parse(readFileSync(join(directory, "npm", target, "package.json"), "utf8")); + expect(pkg.version, target).toBe(version); + } + }); + + it("passes the wrapper's publish configuration to generated platforms", () => { + const publishConfig = {registry: "https://registry.example", access: "public"}; + writeFileSync(join(directory, "package.json"), JSON.stringify({...manifest, publishConfig})); + const result = run("--platforms-only"); + expect(result.status, result.stderr).toBe(0); + for (const target of targets) { + const pkg = JSON.parse(readFileSync(join(directory, "npm", target, "package.json"), "utf8")); + expect(pkg.publishConfig, target).toEqual(publishConfig); + } + }); }); describe("publishing", () => { @@ -89,6 +141,7 @@ describe("publishing", () => { const path = join(directory, "npm", target); mkdirSync(path, {recursive: true}); writeFileSync(join(path, "package.json"), JSON.stringify({name: `${name}-${target}`, version})); + writeFileSync(join(path, "bindings.node"), target); } writeFileSync(join(directory, "published.json"), "[]"); writeFileSync(join(directory, "calls.jsonl"), ""); @@ -133,12 +186,26 @@ fs.writeFileSync(state, JSON.stringify(published)); return JSON.parse(readFileSync(join(directory, "published.json"), "utf8")); } + function calls(): {args: string[]; spec: string}[] { + return readFileSync(join(directory, "calls.jsonl"), "utf8") + .trim() + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line)); + } + it("preserves the default platform-first, main-last publish order", () => { const result = run([]); expect(result.status, result.stderr).toBe(0); expect(published()).toEqual([...targets.map((target) => `${name}-${target}@${version}`), `${name}@${version}`]); }); + it("accepts an absolute path to the prepared platform packages", () => { + const result = run(["--platforms-only", "--npm-dir", join(directory, "npm")]); + expect(result.status, result.stderr).toBe(0); + expect(published()).toEqual(targets.map((target) => `${name}-${target}@${version}`)); + }); + it("resumes a partial platform publish without touching the main package", () => { env.FAIL_PACKAGE = `${name}-${targets[1]}`; expect(run(["--platforms-only", "--skip-existing"]).status).not.toBe(0); @@ -165,11 +232,138 @@ fs.writeFileSync(state, JSON.stringify(published)); calls .filter((call) => call.args[0] === "view") .slice(-2) - .every((call) => call.args.includes("https://registry.example")) + .every((call) => call.args.includes("--registry=https://registry.example")) ).toBe(true); expect(calls.at(-1)?.args).toEqual(["publish", "--registry=https://registry.example", "--tag", "next"]); }); + it.each([{extraArgs: []}, {extraArgs: ["--registry=https://override.example", "--userconfig=/tmp/custom.npmrc"]}])( + "queries the publish registry while preserving explicit npm overrides: $extraArgs", + ({extraArgs}) => { + for (const target of targets) { + writeFileSync( + join(directory, "npm", target, "package.json"), + JSON.stringify({ + name: `${name}-${target}`, + version, + publishConfig: {registry: "https://registry.example"}, + }) + ); + } + const result = run(["--platforms-only", "--skip-existing", "--", ...extraArgs]); + expect(result.status, result.stderr).toBe(0); + for (const call of calls().filter((call) => call.args[0] === "view")) { + expect(call.args, call.spec).toEqual([ + "view", + call.spec, + "version", + "--registry=https://registry.example", + ...extraArgs, + "--json", + "--prefer-online", + ]); + } + } + ); + + it.each(["name", "version"])("refuses stale platform %s before publishing any package", (field) => { + const target = targets[1]; + writeFileSync( + join(directory, "npm", target, "package.json"), + JSON.stringify({ + name: `${name}-${target}`, + version, + [field]: "stale", + }) + ); + const result = run(["--platforms-only", "--skip-existing"]); + expect(result.status).not.toBe(0); + expect(calls()).toEqual([]); + }); + + it.each(["manifest", "cli", "scoped"])( + "uses the correct registry with real npm (%s)", + async (mode) => { + const requests: number[] = []; + const servers = [0, 1].map((index) => + createServer((request, response) => { + requests.push(index); + const packageName = decodeURIComponent(request.url?.slice(1) ?? ""); + response.setHeader("content-type", "application/json"); + response.end(JSON.stringify({name: packageName, versions: {[version]: {name: packageName, version}}})); + }) + ); + try { + const registries = await Promise.all( + servers.map(async (server) => { + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Expected a TCP address"); + return `http://127.0.0.1:${address.port}`; + }) + ); + const registryKey = mode === "scoped" ? "@zapi-test:registry" : "registry"; + for (const target of targets) { + writeFileSync( + join(directory, "npm", target, "package.json"), + JSON.stringify({ + name: `${name}-${target}`, + version, + publishConfig: {[registryKey]: registries[1]}, + }) + ); + } + writeFileSync(join(directory, "user.npmrc"), ""); + writeFileSync(join(directory, "global.npmrc"), ""); + await promisify(execFile)( + process.execPath, + [ + cli, + "publish", + "--platforms-only", + "--skip-existing", + "--dry-run", + "--", + "--access", + "public", + "--ignore-scripts", + "--provenance", + "--tag", + "next", + ...(mode === "cli" ? ["--registry", registries[0]] : []), + ], + { + cwd: directory, + timeout: 10000, + env: { + ...process.env, + npm_config_registry: registries[0], + npm_config_userconfig: join(directory, "user.npmrc"), + npm_config_globalconfig: join(directory, "global.npmrc"), + npm_config_cache: join(directory, "cache"), + npm_config_update_notifier: "false", + }, + } + ); + expect(requests).toEqual(targets.map(() => (mode === "cli" ? 0 : 1))); + } finally { + for (const server of servers) server.close(); + } + }, + 15000 + ); + + it("refuses a missing platform binary before publishing any package", () => { + rmSync(join(directory, "npm", targets[1], "bindings.node")); + expect(run(["--platforms-only"]).status).not.toBe(0); + expect(calls()).toEqual([]); + }); + + it("rejects unsupported zapi options instead of silently publishing every package", () => { + expect(run(["--main-only"]).status).not.toBe(0); + expect(calls()).toEqual([]); + }); + it.each(["E401", "E500", "ENOTFOUND"])("aborts on %s instead of treating it as a missing version", (code) => { env.VIEW_ERROR = code; const result = run(["--platforms-only", "--skip-existing"]); diff --git a/ts/config.ts b/ts/config.ts index dfc29ae..051e575 100644 --- a/ts/config.ts +++ b/ts/config.ts @@ -15,6 +15,8 @@ export type PkgJson = { version: string; license?: string; repository?: string | {type?: string; url?: string}; + optionalDependencies?: Record; + publishConfig?: Record; zapi?: { binaryName: string; targets: string[]; diff --git a/ts/prepublish.ts b/ts/prepublish.ts index 6b897eb..3238e28 100644 --- a/ts/prepublish.ts +++ b/ts/prepublish.ts @@ -3,8 +3,8 @@ import {availableParallelism} from "node:os"; import {join} from "node:path"; import {type ParseArgsOptionsConfig, parseArgs} from "node:util"; import {type Config, type PkgJson, getTargetParts, loadConfig} from "./config.js"; -import {parsePositiveIntOption, runWithConcurrency} from "./lib.js"; -import {logDetail, logInfo, logSuccess, logWarn} from "./log.js"; +import {TARGETS, parsePositiveIntOption, runWithConcurrency} from "./lib.js"; +import {logDetail, logInfo, logSuccess} from "./log.js"; const defaultConcurrency = String(Math.max(1, Math.min(availableParallelism(), 4))); @@ -19,19 +19,13 @@ export async function createNpmDirs(config: Config, opts: CreateNpmDirsOpts): Pr }); } -export async function moveArtifacts(_pkgJson: PkgJson, config: Config, opts: PrepublishOpts): Promise { - logInfo("Moving artifacts to npm packages..."); +export async function copyArtifacts(config: Config, opts: PrepublishOpts): Promise { + logInfo("Copying artifacts to npm packages..."); await runWithConcurrency(config.targets, opts.concurrency, async (target) => { const artifactPath = join(opts["artifacts-dir"], target, `${config.binaryName}.node`); const destPath = join(opts["npm-dir"], target, `${config.binaryName}.node`); - await fs.rename(artifactPath, destPath).catch((err) => { - if (err.code === "ENOENT") { - logWarn(`Artifact not found: ${artifactPath}`); - return; - } - throw err; - }); + await fs.copyFile(artifactPath, destPath); logDetail(`${target} → ${destPath}`); }); } @@ -51,6 +45,7 @@ export async function updateTargetPkgJsons(pkgJson: PkgJson, config: Config, opt main: `${config.binaryName}.node`, name: `${pkgJson.name}-${target}`, os: [platform], + publishConfig: pkgJson.publishConfig, repository: pkgJson.repository, version: pkgJson.version, ...(libc ? {libc: [libc]} : {}), @@ -68,7 +63,10 @@ This is the ${target} target package for ${pkgJson.name}. } export function updateOptionalDependencies(pkgJson: PkgJson, config: Config): PkgJson { - const optionalDependencies: Record = {}; + const optionalDependencies = {...pkgJson.optionalDependencies}; + for (const target of TARGETS) { + delete optionalDependencies[`${pkgJson.name}-${target}`]; + } for (const target of config.targets) { optionalDependencies[`${pkgJson.name}-${target}`] = pkgJson.version; } @@ -132,7 +130,7 @@ export async function prepublishCli(): Promise { if (!values["main-only"]) { await createNpmDirs(config, opts); - await moveArtifacts(pkgJson, config, opts); + await copyArtifacts(config, opts); await updateTargetPkgJsons(pkgJson, config, opts); logSuccess(`Prepared ${config.targets.length} target package(s) in ${values["npm-dir"]}/`); } diff --git a/ts/publish.ts b/ts/publish.ts index 2d4c285..fbb90ab 100644 --- a/ts/publish.ts +++ b/ts/publish.ts @@ -1,8 +1,8 @@ import {execFile, spawn} from "node:child_process"; -import {readFile} from "node:fs/promises"; -import {join} from "node:path"; +import {access, readFile} from "node:fs/promises"; +import {join, resolve} from "node:path"; import {type ParseArgsOptionsConfig, parseArgs, promisify} from "node:util"; -import {loadConfig} from "./config.js"; +import {type PkgJson, loadConfig} from "./config.js"; import {parsePositiveIntOption, runWithConcurrency} from "./lib.js"; import {logDetail, logInfo, logStep, logSuccess} from "./log.js"; @@ -69,21 +69,19 @@ async function runNpm(args: string[], cwd: string): Promise { } } -async function isPublished(cwd: string, extraArgs: string[]): Promise { - const manifest = JSON.parse(await readFile(join(cwd, "package.json"), "utf8")) as {name: string; version: string}; +async function isPublished(manifest: PkgJson, cwd: string, extraArgs: string[]): Promise { const spec = `${manifest.name}@${manifest.version}`; - const {values} = parseArgs({ - allowPositionals: true, - args: extraArgs, - options: {registry: {type: "string"}}, - strict: false, - }); - const registryArgs = typeof values.registry === "string" ? ["--registry", values.registry] : []; + const registryArgs = Object.entries(manifest.publishConfig ?? {}) + .filter(([key]) => key === "registry" || key.endsWith(":registry")) + .map(([key, value]) => { + if (typeof value !== "string") throw new Error(`publishConfig.${key} must be a string`); + return `--${key}=${value}`; + }); let stdout: string; try { ({stdout} = await promisify(execFile)( "npm", - ["view", spec, "version", "--json", "--prefer-online", ...registryArgs], + ["view", spec, "version", ...registryArgs, ...extraArgs, "--json", "--prefer-online"], {cwd} )); } catch (error) { @@ -105,18 +103,27 @@ async function isPublished(cwd: string, extraArgs: string[]): Promise { export async function publish(opts: PublishOpts): Promise { const extraArgs = extraPublishArgs(); const publishArgv = ["publish", ...extraArgs]; - const {config} = await loadConfig(); + const {config, pkgJson} = await loadConfig(); + const platforms = []; + for (const target of config.targets) { + const cwd = resolve(opts["npm-dir"], target); + const manifest = JSON.parse(await readFile(join(cwd, "package.json"), "utf8")) as PkgJson; + if (manifest.name !== `${pkgJson.name}-${target}` || manifest.version !== pkgJson.version) { + throw new Error(`Stale platform package ${target}; run zapi prepublish before publishing`); + } + await access(join(cwd, `${config.binaryName}.node`)); + platforms.push({cwd, manifest, target}); + } const total = config.targets.length + (opts["platforms-only"] ? 0 : 1); const description = `${config.targets.length} target package(s)${opts["platforms-only"] ? "" : " + main package"}`; logInfo(`${opts["dry-run"] ? "[DRY RUN] " : ""}Publishing ${description} with concurrency ${opts.concurrency}...`); - logDetail(`Extra npm args: ${extraArgs.length > 0 ? extraArgs.join(" ") : "(none)"}`); let started = 0; let skipped = 0; - const publishPackage = async (label: string, cwd: string): Promise => { + const publishPackage = async (label: string, cwd: string, manifest: PkgJson): Promise => { const step = ++started; - if (opts["skip-existing"] && (await isPublished(cwd, extraArgs))) { + if (opts["skip-existing"] && (await isPublished(manifest, cwd, extraArgs))) { skipped += 1; logStep(step, total, `Skipping ${label}: version already published`); return; @@ -129,10 +136,10 @@ export async function publish(opts: PublishOpts): Promise { } }; - await runWithConcurrency(config.targets, opts.concurrency, async (target) => { - await publishPackage(target, join(process.cwd(), opts["npm-dir"], target)); + await runWithConcurrency(platforms, opts.concurrency, async ({target, cwd, manifest}) => { + await publishPackage(target, cwd, manifest); }); - if (!opts["platforms-only"]) await publishPackage("main package", process.cwd()); + if (!opts["platforms-only"]) await publishPackage("main package", process.cwd(), pkgJson); logSuccess( `${opts["dry-run"] ? "[DRY RUN] Would publish" : "Published"} ${total - skipped} package(s); skipped ${skipped}` @@ -143,7 +150,6 @@ export async function publishCli(): Promise { const {values} = parseArgs({ allowPositionals: true, options: publishOptions, - strict: false, }); await publish({