Files
paperclip/scripts/build-standalone-public-packages.mjs
Devin FoleyandPaperclip ef37203a48 perf(ci): build standalone public packages concurrently (#8567)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - CI runs a Canary Dry Run job that exercises `release.sh`, which
builds the standalone sandbox-provider packages for publish
> - That step (`scripts/build-standalone-public-packages.mjs`) built the
7 provider plugins serially — each doing `rm -rf dist && tsc` — making
it the dominant cost (~49s) inside the slowest PR check (~4.9m wall)
after the general-server lane was already sharded
> - The packages are independent (their own `node_modules` via
`--ignore-workspace`, their own `dist`), so the serial build is pure
latency with no correctness benefit
> - This pull request builds them with a bounded-concurrency pool sized
to the runner CPU count (overridable via
`STANDALONE_BUILD_CONCURRENCY`), buffering each package's output and
flushing it as one block so parallel logs stay readable, and aggregating
failures by original index
> - The benefit is a faster Canary Dry Run / PR feedback loop without
changing what gets built or published

## Linked Issues or Issue Description

No public GitHub issue exists. Inline feature/perf description:

### Problem or motivation

`build-standalone-public-packages.mjs` builds standalone provider
packages serially, making it the largest single cost inside the slowest
PR check.

### Proposed solution

Run independent per-package builds through a bounded-concurrency worker
pool sized to runner CPU count, with an env override and readable
buffered logs.

### Alternatives considered

Keep the serial build for simpler logs, but that preserves the avoidable
CI latency.

### Roadmap alignment

This is CI maintenance and does not overlap planned core roadmap work.

## What Changed

- `scripts/build-standalone-public-packages.mjs`: replaced the serial
per-package build loop with a bounded-concurrency pool (default = runner
CPU count, override via `STANDALONE_BUILD_CONCURRENCY`); per-package
stdout/stderr is buffered and flushed as a single block; failures are
aggregated by original package index so one failure neither aborts the
others mid-flight nor obscures which package broke.
- `scripts/__tests__/build-standalone-concurrency.test.mjs`: new
`node:test` unit suite covering the pool (limit respected, all items
run, ordered failure aggregation, env-override resolution).
- `.github/workflows/pr.yml`: wired the new unit test into the policy
job.

## Verification

- `node --test
./scripts/__tests__/build-standalone-concurrency.test.mjs` → 6/6 pass
- `node ./scripts/release-package-map.mjs check` → OK (29 enabled for CI
publish)
- `git diff --check origin/master..HEAD` → clean

## Risks

- Low risk. Build inputs/outputs are unchanged; only scheduling differs.
The concurrency is bounded by CPU count and overridable; output is
buffered per package so logs remain attributable. If a package fails,
all failures are still reported with their package index.

## Model Used

- Claude (Anthropic), `claude-opus-4-8`, extended thinking with tool
use.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change and contains no internal
Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-06-23 17:52:12 -07:00

265 lines
7.7 KiB
JavaScript

#!/usr/bin/env node
import { execFile, execFileSync } from "node:child_process";
import { existsSync, readFileSync, rmSync } from "node:fs";
import { availableParallelism } from "node:os";
import path, { dirname } from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { linkSdkInto } from "./link-plugin-dev-sdk.mjs";
const execFileAsync = promisify(execFile);
const scriptDir = dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(scriptDir, "..");
const workspacePath = path.join(repoRoot, "pnpm-workspace.yaml");
const releasePackageMapPath = path.join(repoRoot, "scripts", "release-package-map.mjs");
function parseWorkspaceEntries(workspaceText) {
// Keep this aligned with the repo's block-sequence `packages:` format in
// pnpm-workspace.yaml. If that file moves to a more complex YAML shape,
// switch this parser to a real YAML parser instead of line matching.
return workspaceText
.split("\n")
.map((line) => line.match(/^\s*-\s+(.+)\s*$/)?.[1]?.trim() ?? null)
.map((entry) => {
if (!entry) return entry;
return entry.replace(/^(['"])(.*)\1$/, "$2");
})
.filter(Boolean)
.map((entry) => ({
pattern: entry.startsWith("!") ? entry.slice(1) : entry,
negated: entry.startsWith("!"),
}));
}
function globToRegExp(pattern) {
let regex = "";
for (let index = 0; index < pattern.length; index += 1) {
const char = pattern[index];
const next = pattern[index + 1];
if (char === "*" && next === "*") {
regex += ".*";
index += 1;
continue;
}
if (char === "*") {
regex += "[^/]*";
continue;
}
if (char === "?") {
regex += "[^/]";
continue;
}
regex += /[|\\{}()[\]^$+?.]/.test(char) ? `\\${char}` : char;
}
return new RegExp(`^${regex}$`);
}
function isWorkspacePackage(pkgDir, workspaceEntries) {
let included = false;
for (const entry of workspaceEntries) {
if (globToRegExp(entry.pattern).test(pkgDir)) {
included = !entry.negated;
}
}
return included;
}
function listPublicPackages() {
const output = execFileSync(
process.execPath,
[releasePackageMapPath, "list"],
{ cwd: repoRoot, encoding: "utf8" },
);
return output
.trim()
.split("\n")
.filter(Boolean)
.map((line) => {
const [dir, name] = line.split("\t");
return { dir, name };
});
}
function readPackageJson(pkgDir) {
return JSON.parse(
readFileSync(path.join(repoRoot, pkgDir, "package.json"), "utf8"),
);
}
async function runCaptured(command, args, cwd, log) {
try {
const { stdout, stderr } = await execFileAsync(command, args, {
cwd,
env: {
...process.env,
CI: "true",
},
maxBuffer: 64 * 1024 * 1024,
});
if (stdout?.trim()) log(stdout.trimEnd());
if (stderr?.trim()) log(stderr.trimEnd());
} catch (error) {
if (error.stdout?.toString().trim()) log(error.stdout.toString().trimEnd());
if (error.stderr?.toString().trim()) log(error.stderr.toString().trimEnd());
throw error;
}
}
// Each standalone package installs into its own directory (`--ignore-workspace`)
// and builds into its own `dist`, so there is no shared mutable state between
// packages beyond pnpm's content-addressable global store, which is safe for
// concurrent access. Buffer each package's output and flush it as one block so
// interleaved parallel logs stay readable.
async function prepareAndBuildPackage(pkg) {
const logs = [];
const log = (line) => logs.push(line);
const pkgDir = path.join(repoRoot, pkg.dir);
const pkgJson = readPackageJson(pkg.dir);
const nodeModulesDir = path.join(pkgDir, "node_modules");
const packageLockfilePath = path.join(pkgDir, "pnpm-lock.yaml");
log(` Preparing standalone package ${pkg.name} (${pkg.dir})`);
try {
if (existsSync(nodeModulesDir)) {
rmSync(nodeModulesDir, { force: true, recursive: true });
}
const installArgs = existsSync(packageLockfilePath)
? ["install", "--ignore-workspace", "--frozen-lockfile"]
: [
"install",
"--ignore-workspace",
"--no-lockfile",
// Standalone packages intentionally avoid committed lockfile churn in the repo.
];
await runCaptured("pnpm", installArgs, pkgDir, log);
// The fresh install above wipes node_modules and no longer fires a
// per-plugin postinstall (removed for supply-chain safety), so link the
// in-repo @paperclipai/plugin-sdk that the build's tsc resolves against.
linkSdkInto(pkgDir);
if (pkgJson.scripts?.build) {
await runCaptured("pnpm", ["run", "build"], pkgDir, log);
} else {
log(" i No build script; skipped build");
}
} finally {
if (logs.length > 0) {
console.log(logs.join("\n"));
}
}
}
export function resolveConcurrency(packageCount) {
const raw = process.env.STANDALONE_BUILD_CONCURRENCY;
if (raw !== undefined && raw !== "") {
const parsed = Number.parseInt(raw, 10);
if (Number.isFinite(parsed) && parsed > 0) {
return Math.max(1, Math.min(parsed, packageCount));
}
}
let cpus = 4;
try {
cpus = availableParallelism();
} catch {
// Fall back to the default when parallelism cannot be determined.
}
return Math.max(1, Math.min(cpus, packageCount));
}
// Bounded-concurrency task pool. Workers pull from a shared cursor so no more
// than `limit` tasks run at once. Every task is awaited even if an earlier one
// fails, and failures are aggregated (sorted by original index) so a single bad
// package neither aborts the others mid-flight nor hides which one broke.
export async function runWithConcurrency(items, limit, worker) {
const results = new Array(items.length);
const failures = [];
let nextIndex = 0;
async function runWorker() {
while (true) {
const current = nextIndex;
nextIndex += 1;
if (current >= items.length) {
return;
}
try {
results[current] = await worker(items[current], current);
} catch (error) {
failures.push({ index: current, error });
}
}
}
const poolSize = Math.max(1, Math.min(limit, items.length));
await Promise.all(Array.from({ length: poolSize }, () => runWorker()));
if (failures.length > 0) {
failures.sort((a, b) => a.index - b.index);
const aggregate = new Error(
`${failures.length} standalone package build(s) failed`,
);
aggregate.failures = failures;
throw aggregate;
}
return results;
}
async function main() {
const workspaceEntries = parseWorkspaceEntries(readFileSync(workspacePath, "utf8"));
const standalonePackages = listPublicPackages()
.filter(({ dir }) => !isWorkspacePackage(dir, workspaceEntries));
if (standalonePackages.length === 0) {
console.log(" i No standalone public packages detected outside the pnpm workspace");
return;
}
const concurrency = resolveConcurrency(standalonePackages.length);
console.log(
` Building ${standalonePackages.length} standalone package(s) with concurrency ${concurrency}`,
);
try {
await runWithConcurrency(
standalonePackages,
concurrency,
prepareAndBuildPackage,
);
} catch (error) {
if (Array.isArray(error.failures)) {
const names = error.failures
.map(({ index }) => standalonePackages[index]?.name)
.filter(Boolean)
.join(", ");
console.error(` ✗ Failed standalone package build(s): ${names}`);
}
throw error;
}
}
const invokedDirectly =
process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (invokedDirectly) {
main().catch((error) => {
console.error(error);
process.exit(1);
});
}