Files
PaperClipAI/.github/workflows/pr.yml
T
49217aadf0 refactor: balance serialized server shards by recorded suite duration (#11528)
<!-- Write all pull request text in Simplified Technical English
(ASD-STE100). -->

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The PR verify workflow gates every pull request; its wall-clock time
sets the feedback loop for all contributors
> - In a recent successful PR run (actions run 32012408876), the slowest
check was "Verify serialized server suites (1/5)" at 337s, while its
four sibling shards finished in 212-238s
> - The serialized lane assigns suites to shards round-robin over an
alphabetical list, so the heavy heartbeat and issues suites cluster on
one runner
> - The general-server lane already solves this with a duration-aware
LPT partition backed by a recorded manifest
> - This pull request reuses that partitioner for the serialized lane
with a fresh per-suite duration manifest
> - The benefit is a balanced serialized matrix: the measured 968s suite
total levels to about 194s per shard, which removes about 80-100s from
the run's slowest check

## Linked Issues or Issue Description

**What existing behavior does this improve?**

The `Verify serialized server suites` shard matrix in
`.github/workflows/pr.yml` distributes route/authz test suites across
five runners.

**Subsystem affected**

CI / test infrastructure (`scripts/run-vitest-stable.mjs`).

**Current behavior**

`selectSerializedSuites` assigns suites round-robin (`index %
shardCount`) over the alphabetically sorted file list. The heavy suites
cluster on shard 1/5. In actions run 32012408876, shard 1/5 spent 291s
in its test step while the other shards spent 170-201s, which made that
job (337s total) the slowest check of the whole PR run.

**Proposed behavior**

Partition the serialized suites with the same duration-aware LPT
algorithm the general-server lane already uses
(`scripts/general-server-shard.mjs`), backed by a new per-suite duration
manifest. All five shards then carry about 194s of measured test time.

**Reason and benefit**

The slowest check bounds PR feedback time. Balancing the serialized
matrix removes about 80-100s from that bound without adding runners.

**Breaking changes**

None. The partition remains deterministic, complete, and
non-overlapping; suites missing from the manifest get the median weight.

## What Changed

- Added `scripts/serialized-shard-durations.json`: per-suite wall-clock
durations (ms) for all 134 serialized suites, sampled from actions run
32012408876 by diffing consecutive per-suite label timestamps in the
shard logs (captures vitest spawn overhead, not just reported test time)
- `scripts/run-vitest-stable.mjs`: `selectSerializedSuites` now uses the
existing LPT partitioner (`selectGeneralServerShard`) with the new
manifest instead of round-robin
- `scripts/__tests__/run-vitest-stable-shard.test.mjs`: added a
manifest-freshness test and a shard-balance test for the serialized
lane, mirroring the general-server ones
- `.github/workflows/pr.yml`: updated the serialized matrix comment with
the new measurement and mechanism

## Verification

- `node --test ./scripts/__tests__/run-vitest-stable-shard.test.mjs`
passes (13 tests), including the existing test that the serialized
shards form a complete, non-overlapping partition
- Dry-run of all five shards shows estimated totals of
194/194/194/194/193s (round-robin was 276/175/160/172/187s):
`node scripts/run-vitest-stable.mjs --mode serialized --shard-index N
--shard-count 5 --dry-run`
- The `Verify serialized server suites` jobs on this PR run the real
partition end to end

## Risks

- Low risk. Selection logic only; the vitest invocation per suite is
unchanged
- A stale manifest degrades gracefully: unknown suites get the median
weight, and a dedicated test fails if fewer than half the current suites
have recorded durations

## Model Used

- Claude (Anthropic), model ID `claude-fable-5`, agentic coding session
with tool use (Claude Code / Claude Agent SDK); no extended-thinking
mode

## 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 (e.g. `docs/...`, `fix/...`)
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
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

Related prior work: #10923 (split serialized tests into five shards),
#10925 (general-server duration manifest), #11156 (workspaces-a native
shards).

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-08-17 20:30:44 -07:00

504 lines
17 KiB
YAML

name: PR
on:
pull_request:
branches:
- master
concurrency:
group: pr-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
policy:
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
lockfile_regenerated: ${{ steps.regen_lockfile.outputs.regenerated }}
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Block manual lockfile edits
if: >-
github.head_ref != 'chore/refresh-lockfile' &&
github.event.pull_request.user.login != 'dependabot[bot]'
run: |
# Diff the PR branch against its merge base so recent base-branch commits
# do not masquerade as changes made by the PR itself.
changed="$(git diff --name-only "${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }}")"
if printf '%s\n' "$changed" | grep -qx 'pnpm-lock.yaml'; then
echo "Do not commit pnpm-lock.yaml in pull requests. CI owns lockfile updates."
exit 1
fi
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
version: 9.15.4
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: 24
- name: Validate Dockerfile deps stage
run: node ./scripts/check-docker-deps-stage.mjs
- name: Reject git push in adapter/runtime code
run: node ./scripts/check-no-git-push.mjs
- name: Test no-git-push check
run: node --test ./scripts/check-no-git-push.test.mjs
- name: Test general-server shard partition
run: node --test ./scripts/__tests__/run-vitest-stable-shard.test.mjs
- name: Test e2e shard partition
run: node --test ./scripts/__tests__/e2e-shard.test.mjs
- name: Test release verify workflow wiring
run: node --test ./scripts/__tests__/release-verify-workflow.test.mjs
- name: Test standalone package build concurrency
run: node --test ./scripts/__tests__/build-standalone-concurrency.test.mjs
- name: Validate release package manifest
run: node ./scripts/release-package-map.mjs check
- name: Verify release package bootstrap for changed manifests
run: |
mapfile -t changed_paths < <(git diff --name-only "${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }}")
PAPERCLIP_RELEASE_BOOTSTRAP_BASE_SHA="${{ github.event.pull_request.base.sha }}" \
node ./scripts/check-release-package-bootstrap.mjs "${changed_paths[@]}"
- name: Validate dependency resolution when manifests change
id: regen_lockfile
run: |
changed="$(git diff --name-only "${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }}")"
manifest_pattern='(^|/)package\.json$|^pnpm-workspace\.yaml$|^\.npmrc$|^pnpmfile\.(cjs|js|mjs)$|^patches/'
if printf '%s\n' "$changed" | grep -Eq "$manifest_pattern"; then
pnpm install --lockfile-only --ignore-scripts --no-frozen-lockfile
echo "regenerated=1" >> "$GITHUB_OUTPUT"
else
echo "regenerated=0" >> "$GITHUB_OUTPUT"
fi
# Manifest-only PRs (where pnpm-lock.yaml stays at base because the policy
# job above blocks committing it) need the regenerated lockfile for the
# downstream `pnpm install --frozen-lockfile` steps. Upload it here so
# every job consumes the same hash without recomputing.
- name: Upload regenerated lockfile for downstream jobs
if: steps.regen_lockfile.outputs.regenerated == '1'
uses: actions/upload-artifact@v7
with:
name: pr-lockfile
path: pnpm-lock.yaml
retention-days: 1
if-no-files-found: error
typecheck_release_registry:
name: Typecheck + Release Registry
needs: [policy]
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
version: 9.15.4
- name: Restore regenerated PR lockfile (if policy uploaded one)
uses: actions/download-artifact@v8
continue-on-error: true
with:
name: pr-lockfile
path: .
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: 24
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Typecheck workspaces whose build scripts skip TypeScript
run: pnpm run typecheck:build-gaps
- 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:
# The server suite is pinned to maxWorkers=1 (server/vitest.config.ts),
# so it can only be parallelized across runners. Shard it to keep this
# lane off the PR critical path. Five shards because the suite has
# grown to ~946s of serial vitest wall time (run 30930345729,
# 2026-08-04): at four shards the worst shard ran 311s and was the
# slowest check in the whole PR run; five brings each shard to ~196s
# of suite time (~240s job), level with the other ~250-300s lanes.
- group: general-server
group_label: server (1/5)
shard_index: 0
shard_count: 5
- group: general-server
group_label: server (2/5)
shard_index: 1
shard_count: 5
- group: general-server
group_label: server (3/5)
shard_index: 2
shard_count: 5
- group: general-server
group_label: server (4/5)
shard_index: 3
shard_count: 5
- group: general-server
group_label: server (5/5)
shard_index: 4
shard_count: 5
# workspaces-a was the slowest check in the fully-green PR run
# 31371439296 (2026-08-10) at 319s, with the ui project's single
# vitest invocation accounting for ~224s and the paperclipai CLI
# ~37s. Two shards use Vitest's native --shard on each project's
# file list (ui: 439 files, cli: 54), bringing each job to roughly
# half the suite time (~130s + setup) without a duration manifest.
- group: general-workspaces-a
group_label: workspaces-a (1/2)
shard_index: 0
shard_count: 2
- group: general-workspaces-a
group_label: workspaces-a (2/2)
shard_index: 1
shard_count: 2
- group: general-workspaces-b
group_label: workspaces-b
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
version: 9.15.4
- name: Restore regenerated PR lockfile (if policy uploaded one)
uses: actions/download-artifact@v8
continue-on-error: true
with:
name: pr-lockfile
path: .
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: 24
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run grouped general test suites
run: |
if [ -n "${{ matrix.shard_count }}" ]; then
pnpm test:run:general -- --group '${{ matrix.group }}' \
--shard-index ${{ matrix.shard_index }} --shard-count ${{ matrix.shard_count }}
else
pnpm test:run:general -- --group '${{ matrix.group }}'
fi
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@v7
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
version: 9.15.4
- name: Restore regenerated PR lockfile (if policy uploaded one)
uses: actions/download-artifact@v8
continue-on-error: true
with:
name: pr-lockfile
path: .
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: 24
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm build
verify_serialized_server:
name: Verify serialized server suites (${{ matrix.shard_label }})
needs: [policy]
runs-on: ubuntu-latest
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
include:
# A successful PR run on 2026-08-17 (32012408876) spent 291s in
# serialized shard 1/5 while its siblings ran 170-201s: round-robin
# clustered the heavy suites on one runner. Shards are now balanced
# by recorded duration (scripts/serialized-shard-durations.json),
# which levels the measured 968s suite total to about 194s per
# runner before setup overhead.
- shard_index: 0
shard_count: 5
shard_label: 1/5
- shard_index: 1
shard_count: 5
shard_label: 2/5
- shard_index: 2
shard_count: 5
shard_label: 3/5
- shard_index: 3
shard_count: 5
shard_label: 4/5
- shard_index: 4
shard_count: 5
shard_label: 5/5
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
version: 9.15.4
- name: Restore regenerated PR lockfile (if policy uploaded one)
uses: actions/download-artifact@v8
continue-on-error: true
with:
name: pr-lockfile
path: .
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: 24
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run serialized server test shard
run: pnpm test:run:serialized -- --shard-index ${{ matrix.shard_index }} --shard-count ${{ matrix.shard_count }}
canary_dry_run:
name: Canary Dry Run
needs: [policy]
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
version: 9.15.4
- name: Restore regenerated PR lockfile (if policy uploaded one)
uses: actions/download-artifact@v8
continue-on-error: true
with:
name: pr-lockfile
path: .
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: 24
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
# `release.sh` always executes its Step 2/7 workspace build, even when
# `--skip-verify` bypasses the initial verification gate. release.sh
# also requires a clean working tree, so any in-place lockfile churn
# from `pnpm install --frozen-lockfile` must be reverted first — unless
# the policy job uploaded a regenerated lockfile (manifest-changing
# PRs), in which case we stage the artifact-restored copy into an
# ephemeral local commit so release.sh sees a clean tree and its
# workspace build sees a lockfile that matches the manifest.
- name: Release canary dry run via release.sh internal build
env:
USED_ARTIFACT_LOCKFILE: ${{ needs.policy.outputs.lockfile_regenerated || '0' }}
run: |
git checkout -B master HEAD
if [ "$USED_ARTIFACT_LOCKFILE" = "1" ]; then
git add pnpm-lock.yaml
if ! git diff --cached --quiet; then
git -c user.email=ci@paperclip.local -c user.name=CI \
commit --no-verify -m "ci(canary): stage regenerated lockfile"
fi
else
git checkout -- pnpm-lock.yaml
fi
./scripts/release.sh canary --skip-verify --dry-run
e2e_shards:
name: e2e shard (${{ matrix.shard_label }})
needs: [policy]
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
include:
# The Playwright lane is pinned to workers=1 (tests/e2e/playwright.config.ts)
# because every spec shares one throwaway server and some toggle
# instance-level flags, so it can only be parallelized across runners.
# Each shard boots its own server, which keeps that isolation intact.
# Three shards let the ~3min smoke-lab spec ride alone while the rest
# of the catalog splits evenly, pulling this lane off the PR critical
# path (it was the slowest check at ~8min20s with two shards).
- shard_index: 0
shard_count: 3
shard_label: 1/3
- shard_index: 1
shard_count: 3
shard_label: 2/3
- shard_index: 2
shard_count: 3
shard_label: 3/3
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
version: 9.15.4
- name: Restore regenerated PR lockfile (if policy uploaded one)
uses: actions/download-artifact@v8
continue-on-error: true
with:
name: pr-lockfile
path: .
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: 24
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Verify runner Chrome
# GitHub's Ubuntu runner image already ships Google Chrome, so use that
# directly for the headless e2e lane instead of downloading Playwright
# browser bundles inside the 30 minute job budget.
run: google-chrome --version
- name: Generate Paperclip config
run: |
mkdir -p ~/.paperclip/instances/default
cat > ~/.paperclip/instances/default/config.json << 'CONF'
{
"$meta": { "version": 1, "updatedAt": "2026-01-01T00:00:00.000Z", "source": "onboard" },
"database": { "mode": "embedded-postgres" },
"logging": { "mode": "file" },
"server": { "deploymentMode": "local_trusted", "host": "127.0.0.1", "port": 3100 },
"auth": { "baseUrlMode": "auto" },
"storage": { "provider": "local_disk" },
"secrets": { "provider": "local_encrypted", "strictMode": false }
}
CONF
- name: Run e2e tests
env:
PAPERCLIP_E2E_SKIP_LLM: "true"
PAPERCLIP_PLAYWRIGHT_CHANNEL: "chrome"
run: |
# Playwright's own --shard balances by test count, and one spec
# (smoke-lab) is ~40% of the lane's wall clock. Partition by recorded
# spec duration instead so both runners finish together.
specs="$(node ./scripts/e2e-shard.mjs \
--shard-index ${{ matrix.shard_index }} --shard-count ${{ matrix.shard_count }})"
echo "shard ${{ matrix.shard_label }} specs: $specs"
pnpm run test:e2e $specs
- name: Upload Playwright report
uses: actions/upload-artifact@v7
if: always()
with:
name: playwright-report-${{ matrix.shard_index }}
path: |
tests/e2e/playwright-report/
tests/e2e/test-results/
retention-days: 14
e2e:
# Preserve the legacy required-check name while the specs run sharded
# across the matrix above (same pattern as the `verify` aggregate).
name: e2e
if: ${{ always() }}
needs: [e2e_shards]
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Fail if any e2e shard failed
env:
E2E_SHARDS_RESULT: ${{ needs.e2e_shards.result }}
run: test "$E2E_SHARDS_RESULT" = "success"