Files
copilotkit__copilotkit/scripts/release/lib/npm-cli.ts
Benjamin Taylor 6a35e3cdbf perf(release): cut canary publish wall-clock roughly in half
The canary flow took ~11.5 min steady-state (and 20 min in an observed
run). Measured from run 30473499191, the time went to five avoidable
places rather than to real work.

1. `npx --yes npm@11.15.0 publish` ran per package, and npx re-resolves
   the spec against the registry on EVERY invocation: ~16s of each
   package's ~21s. A 9-package channels canary paid ~2.4 min of pure npx
   overhead; a 16-package monorepo release paid over 4 min. Hoist the
   pinned npm into lib/npm-cli.ts, install it once into a throwaway
   prefix, and reuse the binary.

2. publish-release.yml was the only workflow in the repo with no pnpm
   store cache, so all three jobs installed 4608 packages cold every
   time. Usually ~45s each, but registry-bandwidth bound and heavy
   tailed: the observed run spent 9m08s here on tarballs arriving at
   2-49 KiB/s. Add the same node-version-keyed cache the rest of CI uses.

3. The notify job ran for canaries only to compute "post nothing" — the
   builder already returns should_post=false for mode=prerelease and the
   self-watchdog is already gated off. ~85s of dead work on the critical
   path, since canary.yml waits for the whole run. Skip the job, keeping
   it reachable for a python_publish dispatch.

4. The build job fetched full history for canaries, which need none (no
   tag, no GH Release, no release-note commit range, and `nx run-many`
   resolves no merge base). That rode along in the 837 MiB workspace
   artifact too. Shallow-fetch prereleases; stable keeps depth 0 because
   its publish job pushes tags out of that artifact's .git.

5. Two smaller ones: the artifact was gzipped and then re-deflated into
   the artifact zip (compression-level: 0), and the orchestrator's
   run-discovery loop slept 6s before its first poll.

Verified: 143 release-script tests pass (6 new for the npm-cli helper),
actionlint + shellcheck + the scope-dropdown guard are clean, the
prerelease dry-run path still enumerates all 9 channels packages, and a
live probe confirms the helper installs npm 11.15.0 once (3.2s) and
memoizes thereafter (0ms).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 19:29:38 -05:00

75 lines
2.9 KiB
TypeScript

/**
* Resolve the pinned npm CLI used to publish to the registry.
*
* WHY A PINNED npm AT ALL: npm >= 11.5.1 authenticates via GitHub Actions OIDC
* trusted publishers, which is what the @copilotkit trusted-publisher records
* are bound to (see publish-release.yml's header). The runner's bundled npm 10.x
* cannot publish under that binding, so the publish scripts must invoke a newer
* npm than the one on PATH. The version pin lives HERE as the single source of
* truth for both prerelease.ts and publish-release.ts.
*
* WHY INSTALL ONCE instead of `npx --yes npm@<version>` per package: npx
* re-resolves the spec against the registry on EVERY invocation. Measured on a
* channels canary, that was ~16s of the ~21s spent per package — 9 packages paid
* ~2.4 minutes of pure npx overhead, and a 16-package monorepo release paid over
* 4 minutes. Installing into one throwaway prefix collapses all of it into a
* single ~15s install, after which each publish is just the ~5s of real work.
*
* A throwaway prefix (rather than `npm i -g npm@<version>`) keeps this hermetic:
* it never mutates the ambient npm, so running these scripts locally does not
* downgrade/upgrade a developer's global npm.
*/
import { spawnSync } from "child_process";
import fs from "fs";
import os from "os";
import path from "path";
/**
* The npm version used for every registry publish. Must stay >= 11.5.1 for OIDC
* trusted publishing; bumping it here updates both publish scripts at once.
*/
export const NPM_PUBLISH_VERSION = "11.15.0";
let cachedNpmBin: string | null = null;
/**
* Install the pinned npm into a temp prefix (once per process) and return the
* path to its CLI entrypoint. Memoized, so callers may invoke it per package
* without repaying the install.
*/
export function resolvePublishNpm(): string {
if (cachedNpmBin) return cachedNpmBin;
const prefix = fs.mkdtempSync(path.join(os.tmpdir(), "cpk-publish-npm-"));
console.log(`Installing npm@${NPM_PUBLISH_VERSION} into ${prefix}...`);
const result = spawnSync(
"npm",
["install", "--global", "--prefix", prefix, `npm@${NPM_PUBLISH_VERSION}`],
{ stdio: "inherit", encoding: "utf8" },
);
if (result.status !== 0) {
throw new Error(
`Failed to install npm@${NPM_PUBLISH_VERSION} (exit ${result.status}). Refusing to fall back to the ambient npm, which is too old for OIDC trusted publishing.`,
);
}
// Assert the binary exists rather than trusting exit 0: publishing with a
// missing/!executable path would fail deep inside the per-package loop, after
// earlier packages had already shipped.
const bin = path.join(prefix, "bin", "npm");
if (!fs.existsSync(bin)) {
throw new Error(
`Installed npm@${NPM_PUBLISH_VERSION} but ${bin} does not exist.`,
);
}
cachedNpmBin = bin;
return bin;
}
/** Test-only: drop the memoized binary so each test observes a fresh install. */
export function resetPublishNpmCache(): void {
cachedNpmBin = null;
}