diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index fabaab1b..fa081796 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -62,7 +62,8 @@ jobs: pnpm install --lockfile-only --ignore-scripts --no-frozen-lockfile fi - verify: + typecheck_release_registry: + name: Typecheck + Release Registry needs: [policy] runs-on: ubuntu-latest timeout-minutes: 20 @@ -88,12 +89,89 @@ jobs: - name: Typecheck workspaces whose build scripts skip TypeScript run: pnpm run typecheck:build-gaps - - name: Run general test suites - run: pnpm test:run:general - - name: Verify release registry test coverage run: pnpm run test:release-registry + general_tests: + name: General tests (${{ matrix.group_label }}) + needs: [policy] + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - group: general-server + group_label: server + - group: general-workspaces-a + group_label: workspaces-a + - group: general-workspaces-b + group_label: workspaces-b + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9.15.4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run grouped general test suites + run: pnpm test:run:general -- --group '${{ matrix.group }}' + + verify: + # Preserve the legacy required-check name while the underlying work runs in parallel. + name: verify + if: ${{ always() }} + needs: [typecheck_release_registry, general_tests, build] + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Fail if any split verify lane failed + env: + TYPECHECK_RELEASE_REGISTRY_RESULT: ${{ needs.typecheck_release_registry.result }} + GENERAL_TESTS_RESULT: ${{ needs.general_tests.result }} + BUILD_RESULT: ${{ needs.build.result }} + run: | + test "$TYPECHECK_RELEASE_REGISTRY_RESULT" = "success" + test "$GENERAL_TESTS_RESULT" = "success" + test "$BUILD_RESULT" = "success" + + build: + name: Build + needs: [policy] + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9.15.4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Build run: pnpm build diff --git a/package.json b/package.json index 152fe471..60b80d5a 100644 --- a/package.json +++ b/package.json @@ -19,8 +19,8 @@ "test": "pnpm run test:run", "test:watch": "pnpm run preflight:workspace-links && vitest", "test:run": "pnpm run preflight:workspace-links && node scripts/run-vitest-stable.mjs", - "test:run:general": "pnpm run preflight:workspace-links && pnpm --filter @paperclipai/plugin-sdk build && node scripts/run-vitest-stable.mjs --mode general", - "test:run:serialized": "pnpm run preflight:workspace-links && pnpm --filter @paperclipai/plugin-sdk build && node scripts/run-vitest-stable.mjs --mode serialized", + "test:run:general": "pnpm run preflight:workspace-links && pnpm --filter @paperclipai/plugin-sdk ensure-build-deps && node scripts/run-vitest-stable.mjs --mode general", + "test:run:serialized": "pnpm run preflight:workspace-links && pnpm --filter @paperclipai/plugin-sdk ensure-build-deps && node scripts/run-vitest-stable.mjs --mode serialized", "db:generate": "pnpm --filter @paperclipai/db generate", "db:migrate": "pnpm --filter @paperclipai/db migrate", "issue-references:backfill": "pnpm run preflight:workspace-links && tsx scripts/backfill-issue-reference-mentions.ts", diff --git a/scripts/prepare-server-ui-dist.sh b/scripts/prepare-server-ui-dist.sh index d43807b3..0c785d4a 100755 --- a/scripts/prepare-server-ui-dist.sh +++ b/scripts/prepare-server-ui-dist.sh @@ -3,13 +3,26 @@ set -euo pipefail # prepare-server-ui-dist.sh — Build the UI and copy it into server/ui-dist. # This keeps @paperclipai/server publish artifacts self-contained for static UI serving. +# When PAPERCLIP_RELEASE_REUSE_UI_DIST=1 and ui/dist already exists, reuse that +# output instead of rebuilding it again inside the release packaging flow. REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" UI_DIST="$REPO_ROOT/ui/dist" SERVER_UI_DIST="$REPO_ROOT/server/ui-dist" -echo " -> Building @paperclipai/ui..." -pnpm --dir "$REPO_ROOT" --filter @paperclipai/ui build +should_reuse_existing_ui_dist=false +case "${PAPERCLIP_RELEASE_REUSE_UI_DIST:-}" in + 1|true|TRUE|yes|YES) + should_reuse_existing_ui_dist=true + ;; +esac + +if [ "$should_reuse_existing_ui_dist" = true ] && [ -f "$UI_DIST/index.html" ]; then + echo " -> Reusing existing @paperclipai/ui dist output" +else + echo " -> Building @paperclipai/ui..." + pnpm --dir "$REPO_ROOT" --filter @paperclipai/ui build +fi if [ ! -f "$UI_DIST/index.html" ]; then echo "Error: UI build output missing at $UI_DIST/index.html" diff --git a/scripts/release.sh b/scripts/release.sh index 9d27c9cf..35da947d 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -198,6 +198,10 @@ fi set_cleanup_trap +# The release flow already prepares ui/dist before packaging. Reuse that output +# so server prepack does not rebuild the UI a second time during preview/publish. +export PAPERCLIP_RELEASE_REUSE_UI_DIST=1 + if [ "$skip_verify" = false ]; then release_info "" release_info "==> Step 1/7: Verification gate..." diff --git a/scripts/run-typecheck-build-gaps.mjs b/scripts/run-typecheck-build-gaps.mjs index 6210ab2a..e149990b 100644 --- a/scripts/run-typecheck-build-gaps.mjs +++ b/scripts/run-typecheck-build-gaps.mjs @@ -86,7 +86,7 @@ if (buildGapPackages.length === 0) { process.exit(0); } -run("pnpm", ["--filter", "@paperclipai/plugin-sdk", "build"]); +run("pnpm", ["--filter", "@paperclipai/plugin-sdk", "ensure-build-deps"]); for (const workspacePkg of buildGapPackages) { run("pnpm", ["--filter", workspacePkg.name, "typecheck"]); diff --git a/scripts/run-vitest-stable.mjs b/scripts/run-vitest-stable.mjs index c85c48aa..a3ef4ba4 100644 --- a/scripts/run-vitest-stable.mjs +++ b/scripts/run-vitest-stable.mjs @@ -49,6 +49,12 @@ let invocationIndex = 0; const serializedModeName = "serialized"; const generalModeName = "general"; const allModeName = "all"; +const generalServerGroupName = "general-server"; +const generalWorkspacesAGroupName = "general-workspaces-a"; +const generalWorkspacesBGroupName = "general-workspaces-b"; +const generalWorkspacesAProjects = ["@paperclipai/ui", "paperclipai"]; +const generalWorkspacesBProjects = nonServerProjects.filter((project) => !generalWorkspacesAProjects.includes(project)); +const generalGroupNames = [generalServerGroupName, generalWorkspacesAGroupName, generalWorkspacesBGroupName]; function walk(dir) { const entries = readdirSync(dir); @@ -117,6 +123,7 @@ function parseCliOptions(argv) { let mode = allModeName; let shardIndex = null; let shardCount = null; + let group = null; let dryRun = false; for (let index = 0; index < argv.length; index += 1) { @@ -163,6 +170,17 @@ function parseCliOptions(argv) { continue; } + if (arg === "--group") { + group = readOptionValue(argv, index, arg); + index += 1; + continue; + } + + if (arg.startsWith("--group=")) { + group = arg.slice("--group=".length); + continue; + } + fail(`Unknown argument "${arg}".`); } @@ -178,6 +196,14 @@ function parseCliOptions(argv) { fail("--shard-index/--shard-count are only valid with --mode serialized."); } + if (group !== null && mode !== generalModeName) { + fail("--group is only valid with --mode general."); + } + + if (group !== null && !generalGroupNames.includes(group)) { + fail(`Unknown group "${group}". Expected one of: ${generalGroupNames.join(", ")}.`); + } + if (mode === serializedModeName) { const resolvedShardCount = shardCount ?? 1; const resolvedShardIndex = shardIndex ?? 0; @@ -189,6 +215,7 @@ function parseCliOptions(argv) { mode, shardIndex: resolvedShardIndex, shardCount: resolvedShardCount, + group: null, dryRun, }; } @@ -197,6 +224,7 @@ function parseCliOptions(argv) { mode, shardIndex: null, shardCount: null, + group, dryRun, }; } @@ -208,12 +236,14 @@ function selectSerializedSuites(routeTests, shardIndex, shardCount) { function runVitest(args, label) { console.log(`\n[test:run] ${label}`); invocationIndex += 1; - const testRoot = mkdtempSync(path.join(os.tmpdir(), `paperclip-vitest-${process.pid}-${invocationIndex}-`)); + const tempRootParent = process.platform === "win32" ? os.tmpdir() : "/tmp"; + const testRoot = mkdtempSync(path.join(tempRootParent, `pcvt-${process.pid}-${invocationIndex}-`)); + // Keep per-run paths compact so Unix socket fixtures stay under macOS path limits. const env = { ...process.env, - PAPERCLIP_HOME: path.join(testRoot, "home"), - PAPERCLIP_INSTANCE_ID: `vitest-${process.pid}-${invocationIndex}`, - TMPDIR: path.join(testRoot, "tmp"), + PAPERCLIP_HOME: path.join(testRoot, "h"), + PAPERCLIP_INSTANCE_ID: `vt-${process.pid}-${invocationIndex}`, + TMPDIR: path.join(testRoot, "t"), }; mkdirSync(env.PAPERCLIP_HOME, { recursive: true }); mkdirSync(env.TMPDIR, { recursive: true }); @@ -232,15 +262,38 @@ function runVitest(args, label) { } function runGeneralSuites(routeTests) { - const excludeRouteArgs = routeTests.flatMap((file) => ["--exclude", file.serverPath]); - for (const project of nonServerProjects) { - runVitest(["--project", project], `non-server project ${project}`); + for (const groupName of generalGroupNames) { + runGeneralGroup(routeTests, groupName); + } +} + +function runProjectGroup(projects, groupName) { + for (const project of projects) { + runVitest(["--project", project], `${groupName} project ${project}`); + } +} + +function runGeneralGroup(routeTests, groupName) { + if (groupName === generalServerGroupName) { + const excludeRouteArgs = routeTests.flatMap((file) => ["--exclude", file.serverPath]); + runVitest( + ["--project", "@paperclipai/server", ...excludeRouteArgs], + `${groupName} server suites excluding ${routeTests.length} serialized suites`, + ); + return; } - runVitest( - ["--project", "@paperclipai/server", ...excludeRouteArgs], - `server suites excluding ${routeTests.length} serialized suites`, - ); + if (groupName === generalWorkspacesAGroupName) { + runProjectGroup(generalWorkspacesAProjects, groupName); + return; + } + + if (groupName === generalWorkspacesBGroupName) { + runProjectGroup(generalWorkspacesBProjects, groupName); + return; + } + + fail(`Unknown group "${groupName}".`); } function runSerializedSuites(routeTests, shardIndex, shardCount) { @@ -283,6 +336,8 @@ if (options.dryRun) { mode: options.mode, shardIndex: options.shardIndex, shardCount: options.shardCount, + group: options.group, + availableGeneralGroups: generalGroupNames, serializedSuiteCount: routeTests.length, selectedSerializedSuites: serializedSuites.map((routeTest) => routeTest.repoPath), }, @@ -294,7 +349,11 @@ if (options.dryRun) { } if (options.mode === generalModeName || options.mode === allModeName) { - runGeneralSuites(routeTests); + if (options.group) { + runGeneralGroup(routeTests, options.group); + } else { + runGeneralSuites(routeTests); + } } if (options.mode === serializedModeName || options.mode === allModeName) {