mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 14:16:39 +01:00
perf(ci): select affected suites and cache task results (#18214)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Pull request CI checks the server, UI, packages, Runner, and release paths. > - An ordinary PR starts 48 jobs even when its diff affects only a small part of the repository. > - Repeated pushes also repeat successful tests with unchanged inputs. > - This PR selects affected suites before runner allocation and caches deterministic task results with Turbo. > - Required checks validate the plan. Unknown inputs and failed comparisons keep full verification. > - The benefit is fewer runner jobs and less repeated work, with full post-merge verification retained. ## Linked Issues or Issue Description **What existing behavior does this improve?** The GitHub Actions PR workflow repeats unrelated suites for each push. **Subsystem affected** Cross-cutting CI routing, test execution, and required-check aggregation. **Current behavior** An ordinary UI component change starts the complete 48-job PR workflow. Existing dependency and compiler caches do not reuse successful test results. **Proposed behavior** A trusted planner reads the tested merge diff and task inputs. A UI component change such as `ui/src/components/Sidebar.tsx` selects 18 jobs, including build and browser coverage. Successful general, serialized, and standalone Rust tasks can reuse unchanged results across pushes in the same PR. **Reason and benefit** Reduce unrelated jobs and repeated test execution. The 48-to-18 comparison measures planned job count. Remote wall-clock savings have not yet been measured. **Breaking changes** No application API changes. Required `verify` and `e2e` names remain stable. `verify` also checks serialized suites and the canary dry run. ## What Changed - Added explicit task inputs and a pinned Turbo version. An execution adapter calls existing package scripts without adding script aliases. - Added trusted diff planning and dynamic matrices with complete fallback matrices. - Added PR-scoped task result caches. External-state and revision-stamped checks remain uncached. - Added and created the `full-ci` override label, plus repository modes `enabled`, `shadow`, and `full`. Forced and shadow runs bypass result caches. - Added candidate-workflow qualification, planner and aggregate tests, and a real Turbo cache fixture. - Kept the repository-wide CLI guidance guard in the uncached typecheck lane so UI-only diffs retain this security check. - Documented rollout controls, input maintenance, and local inspection commands. ## Verification - Passed 537 focused CI policy, routing, matrix, cache, and shard tests, plus 146 existing release workflow tests. - Passed actionlint for all four changed or added workflows and `git diff --check`. - Passed `pnpm -r typecheck` and `pnpm build` on the implementation base. - Checked real repository task plans for existing general-test and native Rust commands. A real server partition passed, then restored from cache: Turbo task time was 20.022 seconds for execution and 151 ms for the cache hit, excluding wrapper/setup overhead. - The real Turbo fixture exercises the execution adapter and proves source, argument, toolchain-scope, and environment invalidation, result restoration, and retry of failed tasks. - Stopped the serial local `pnpm test:run` after over 30 minutes in favor of the complete parallel GitHub suite. A local parallel database-lane attempt had PostgreSQL startup errors and timeouts. The direct migration fixture passed, then all 273 database tests passed with one local worker. This recovery does not establish the exact cause of the earlier startup failures. The full GitHub PR suite and candidate qualification passed on commit `0bf07ae071c9fe7903edc604d44b5a175727fed0`. Greptile is 5/5 with no unresolved review threads. - All 40 CLI guidance safety tests passed with the exact uncached workflow command. - Verified that master rulesets require only `ci / verify` and `ci / e2e`; both names remain unchanged. - Trusted workflows load from master. The added GitHub-hosted qualification workflow exercises this candidate planner before merge. ## Risks - Changes to global inputs, including task definitions and execution helpers, force execution and disable cache restore/save for the full PR diff. Missing a cross-package filesystem read could omit a suite. Inputs include known server reads of UI files and shared tests of UI assets. Unknown paths, malformed or truncated diffs, and definition download failures select all suites. - Task result reuse requires complete inputs. Keys include Git source files, scripts, resolved dependencies, environment, toolchain, checkout path, and unclassified source files. Generated build artifacts are excluded from input hashes. Registry smoke, browser, and revision-stamped tasks remain uncached. - Maintainers must update task inputs when suites start reading new paths. `shadow` reports selection while running all work. `full` and `full-ci` disable selection and result reuse. - The implementation becomes active after merge. Full post-merge verification remains unchanged. ## Model Used OpenAI Codex, GPT-6 family, with reasoning, repository inspection, tool use, and code execution. The session does not expose a verifiable exact runtime model ID or context-window size. ## 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 --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
61bd4ac691
commit
f5dcc505c0
20 files changed
+1400
-284
No files matched your search
@@ -0,0 +1,69 @@
|
||||
name: Incremental CI qualification
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- package.json
|
||||
- turbo.json
|
||||
- .github/ci-task-matrix.json
|
||||
- .github/scripts/ci-plan.mjs
|
||||
- .github/scripts/tests/ci-plan.test.mjs
|
||||
- .github/actions/run-ci-task/**
|
||||
- .github/workflows/ci-routing.yml
|
||||
- .github/workflows/pr-trusted.yml
|
||||
- .github/workflows/ci-incremental-qualification.yml
|
||||
- scripts/ci/**
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: incremental-ci-proof-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
qualify:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
|
||||
with:
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
- name: Install isolated qualification tools
|
||||
run: npm install --prefix "$RUNNER_TEMP/ci-tools" --no-audit --no-fund turbo@2.11.10
|
||||
- name: Prove planning and required checks
|
||||
run: node --test .github/scripts/tests/ci-plan.test.mjs .github/scripts/tests/ci-routing.test.mjs
|
||||
- name: Prove real cache reuse and invalidation
|
||||
env:
|
||||
PAPERCLIP_CI_TEST_TURBO: ${{ runner.temp }}/ci-tools/node_modules/.bin/turbo
|
||||
run: node --test scripts/ci/cache.integration.test.mjs
|
||||
- name: Check the real repository task graph
|
||||
env:
|
||||
CI_TURBO_BIN: ${{ runner.temp }}/ci-tools/node_modules/.bin/turbo
|
||||
run: |
|
||||
node --input-type=module <<'NODE'
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import assert from 'node:assert/strict';
|
||||
import { executionPlan } from './scripts/ci/execution-plan.mjs';
|
||||
const config = JSON.parse(readFileSync('turbo.json', 'utf8'));
|
||||
for (const task of ['ci:server', 'ci:db', 'ci:runner-rust']) {
|
||||
const plan = executionPlan(config, task, []);
|
||||
const file = process.env.RUNNER_TEMP + '/ci-task-config.json';
|
||||
writeFileSync(file, JSON.stringify(plan.config));
|
||||
const result = execFileSync(process.env.CI_TURBO_BIN, ['run', plan.target, '--root-turbo-json', file,
|
||||
'--dry=json', '--cache-dir=.turbo/cache', '--', ...plan.args], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 });
|
||||
assert.ok(JSON.parse(result).tasks.some((task) => task.taskId === plan.target));
|
||||
}
|
||||
NODE
|
||||
- name: Explain this pull request's plan
|
||||
env:
|
||||
CI_INCREMENTAL_MODE: shadow
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
MERGE_SHA: ${{ github.sha }}
|
||||
run: node .github/scripts/ci-plan.mjs "$BASE_SHA" "$MERGE_SHA"
|
||||
@@ -8,6 +8,8 @@ on:
|
||||
value: ${{ jobs.gate.outputs.runner }}
|
||||
full_ci:
|
||||
value: ${{ jobs.gate.outputs.full_ci }}
|
||||
plan:
|
||||
value: ${{ jobs.gate.outputs.plan }}
|
||||
profile_ci:
|
||||
value: ${{ jobs.gate.outputs.profile_ci }}
|
||||
|
||||
@@ -22,6 +24,7 @@ jobs:
|
||||
runs-on: ${{ vars.AWS_CI_ENABLED == 'true' && vars.AWS_CI_ROUTING_USER_IDS_SOURCE != '' && vars.AWS_CI_TRUSTED_USER_IDS == vars.AWS_CI_ROUTING_USER_IDS_SOURCE && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && ((github.event_name == 'pull_request' && contains(vars.AWS_CI_ROUTING_USER_IDS, format('|{0}|', github.event.pull_request.user.id)) && contains(vars.AWS_CI_ROUTING_USER_IDS, format('|{0}|', github.event.sender.id))) || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/master' && contains(vars.AWS_CI_ROUTING_USER_IDS, format('|{0}|', github.actor_id)))) && 'runs-on/fleet=paperclip-ci-routing-x64/env=public-ci' || 'ubuntu-latest' }}
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
plan: ${{ steps.plan.outputs.plan }}
|
||||
runner: ${{ steps.route.outputs.runner }}
|
||||
full_ci: ${{ steps.scope.outputs.full_ci }}
|
||||
profile_ci: ${{ steps.profile.outputs.profile_ci }}
|
||||
@@ -256,6 +259,8 @@ jobs:
|
||||
EVENT_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
EVENT_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
PROFILE_CI: ${{ steps.profile.outputs.profile_ci }}
|
||||
FORCE_FULL_CI: ${{ contains(github.event.pull_request.labels.*.name, 'full-ci') }}
|
||||
CI_INCREMENTAL_MODE: ${{ vars.CI_INCREMENTAL_MODE || 'enabled' }}
|
||||
STACK_JSON: ${{ toJSON(github.event.pull_request.stack) }}
|
||||
PR_BASE_REF: ${{ github.event.pull_request.base.ref }}
|
||||
run: |
|
||||
@@ -326,11 +331,49 @@ jobs:
|
||||
reason='release notes only'
|
||||
fi
|
||||
|
||||
if [[ "${PROFILE_CI:-false}" == 'true' ]]; then
|
||||
if [[ "${PROFILE_CI:-false}" == 'true' || "${FORCE_FULL_CI:-false}" == 'true' || "${CI_INCREMENTAL_MODE:-enabled}" == 'full' ]]; then
|
||||
full_ci='true'
|
||||
reason='profile-ci requests the complete PR suite'
|
||||
reason='profile-ci, full-ci or full mode requests the complete PR suite'
|
||||
fi
|
||||
|
||||
echo "full_ci=$full_ci" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice title=PR CI scope::$reason; full_ci=$full_ci"
|
||||
|
||||
|
||||
- name: Plan affected CI tasks from trusted definitions
|
||||
id: plan
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
FULL_CI: ${{ steps.scope.outputs.full_ci }}
|
||||
PROFILE_CI: ${{ steps.profile.outputs.profile_ci }}
|
||||
FORCE_FULL_CI: ${{ contains(github.event.pull_request.labels.*.name, 'full-ci') }}
|
||||
CI_INCREMENTAL_MODE: ${{ vars.CI_INCREMENTAL_MODE || 'enabled' }}
|
||||
STACK_JSON: ${{ toJSON(github.event.pull_request.stack) }}
|
||||
EVENT_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
EVENT_MERGE_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
fallback() { echo 'plan=' >> "$GITHUB_OUTPUT"; exit 0; }
|
||||
[[ "$FULL_CI" == 'true' ]] || fallback
|
||||
[[ "$EVENT_BASE_SHA" =~ ^[0-9a-f]{40}$ && "$EVENT_MERGE_SHA" =~ ^[0-9a-f]{40}$ ]] || fallback
|
||||
trusted="$(gh api /repos/paperclipai/paperclip/git/ref/heads/master --jq .object.sha)" || fallback
|
||||
[[ "$trusted" =~ ^[0-9a-f]{40}$ ]] || fallback
|
||||
directory="$(mktemp -d)"
|
||||
trap 'rm -rf "$directory"' EXIT
|
||||
for file in .github/scripts/ci-plan.mjs .github/ci-task-matrix.json turbo.json; do
|
||||
gh api -H 'Accept: application/vnd.github.raw+json' \
|
||||
"/repos/paperclipai/paperclip/contents/$file?ref=$trusted" \
|
||||
> "$directory/${file##*/}" || fallback
|
||||
done
|
||||
gh api "/repos/paperclipai/paperclip/compare/$EVENT_BASE_SHA...$EVENT_MERGE_SHA" \
|
||||
> "$directory/comparison.json" || fallback
|
||||
if [[ "$PROFILE_CI" == 'true' || "$FORCE_FULL_CI" == 'true' ]]; then
|
||||
export CI_FORCE_REASON='Full suite requested by label'
|
||||
elif [[ "$STACK_JSON" != 'null' && -n "$STACK_JSON" ]]; then
|
||||
export CI_FORCE_REASON='Stack verification includes lower pull requests'
|
||||
fi
|
||||
export CI_TASK_CONFIG="$directory/turbo.json"
|
||||
export CI_TASK_MATRIX="$directory/ci-task-matrix.json"
|
||||
export CI_COMPARISON="$directory/comparison.json"
|
||||
node "$directory/ci-plan.mjs" || fallback
|
||||
@@ -159,7 +159,7 @@ jobs:
|
||||
typecheck_release_registry:
|
||||
name: Typecheck + Release Registry
|
||||
needs: [gate]
|
||||
if: ${{ needs.gate.outputs.full_ci == 'true' }}
|
||||
if: ${{ needs.gate.outputs.full_ci == 'true' && (needs.gate.outputs.plan == '' || fromJSON(needs.gate.outputs.plan).jobs.typecheck_release_registry.run) }}
|
||||
runs-on: ${{ needs.gate.outputs.runner }}
|
||||
timeout-minutes: 20
|
||||
|
||||
@@ -339,6 +339,9 @@ jobs:
|
||||
- name: Typecheck workspaces whose build scripts skip TypeScript
|
||||
run: pnpm run typecheck:build-gaps
|
||||
|
||||
- name: Verify repository-wide CLI guidance safety
|
||||
run: pnpm exec vitest run --project @paperclipai/server server/src/__tests__/cli-invocation-safety.test.ts
|
||||
|
||||
- name: Verify release registry test coverage
|
||||
run: pnpm run test:release-registry
|
||||
|
||||
@@ -356,100 +359,12 @@ jobs:
|
||||
general_tests:
|
||||
name: General tests (${{ matrix.group_label }})
|
||||
needs: [gate]
|
||||
if: ${{ needs.gate.outputs.full_ci == 'true' }}
|
||||
if: ${{ needs.gate.outputs.full_ci == 'true' && (needs.gate.outputs.plan == '' || fromJSON(needs.gate.outputs.plan).jobs.general_tests.run) }}
|
||||
runs-on: ${{ needs.gate.outputs.runner }}
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
# Keep twelve duration-balanced server shards. Each shard overlaps
|
||||
# at most two isolated fork workers; cases within a file stay serial.
|
||||
# The chat fixture keeps three independent database-backed shards.
|
||||
- group: general-server-without-chat
|
||||
group_label: server (1/12)
|
||||
shard_index: 0
|
||||
shard_count: 12
|
||||
- group: general-server-without-chat
|
||||
group_label: server (2/12)
|
||||
shard_index: 1
|
||||
shard_count: 12
|
||||
- group: general-server-without-chat
|
||||
group_label: server (3/12)
|
||||
shard_index: 2
|
||||
shard_count: 12
|
||||
- group: general-server-without-chat
|
||||
group_label: server (4/12)
|
||||
shard_index: 3
|
||||
shard_count: 12
|
||||
- group: general-server-without-chat
|
||||
group_label: server (5/12)
|
||||
shard_index: 4
|
||||
shard_count: 12
|
||||
- group: general-server-without-chat
|
||||
group_label: server (6/12)
|
||||
shard_index: 5
|
||||
shard_count: 12
|
||||
- group: general-server-without-chat
|
||||
group_label: server (7/12)
|
||||
shard_index: 6
|
||||
shard_count: 12
|
||||
- group: general-server-without-chat
|
||||
group_label: server (8/12)
|
||||
shard_index: 7
|
||||
shard_count: 12
|
||||
- group: general-server-without-chat
|
||||
group_label: server (9/12)
|
||||
shard_index: 8
|
||||
shard_count: 12
|
||||
- group: general-server-without-chat
|
||||
group_label: server (10/12)
|
||||
shard_index: 9
|
||||
shard_count: 12
|
||||
- group: general-server-without-chat
|
||||
group_label: server (11/12)
|
||||
shard_index: 10
|
||||
shard_count: 12
|
||||
- group: general-server-without-chat
|
||||
group_label: server (12/12)
|
||||
shard_index: 11
|
||||
shard_count: 12
|
||||
- group: general-chat
|
||||
group_label: chat (1/3)
|
||||
shard_index: 0
|
||||
shard_count: 3
|
||||
- group: general-chat
|
||||
group_label: chat (2/3)
|
||||
shard_index: 1
|
||||
shard_count: 3
|
||||
- group: general-chat
|
||||
group_label: chat (3/3)
|
||||
shard_index: 2
|
||||
shard_count: 3
|
||||
# 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
|
||||
# workspaces-b became the slowest check (499-593s) once the server
|
||||
# shards were rebalanced: the db project alone was 168-250s of its
|
||||
# 436-513s of vitest time (PR runs 38084171761, 38083084276 and
|
||||
# 38083020834, 2026-10-10). The db project now runs as its own
|
||||
# workspaces-c lane; the rest of workspaces-b is ~265-275s of
|
||||
# vitest time, so both jobs land near 300-350s with setup.
|
||||
- group: general-workspaces-b
|
||||
group_label: workspaces-b
|
||||
- group: general-workspaces-c
|
||||
group_label: workspaces-c
|
||||
matrix: ${{ fromJSON(needs.gate.outputs.plan != '' && toJSON(fromJSON(needs.gate.outputs.plan).jobs.general_tests.matrix) || '{"include":[{"group":"general-server-without-chat","group_label":"server (1/12)","shard_index":0,"shard_count":12,"task":"ci:server"},{"group":"general-server-without-chat","group_label":"server (2/12)","shard_index":1,"shard_count":12,"task":"ci:server"},{"group":"general-server-without-chat","group_label":"server (3/12)","shard_index":2,"shard_count":12,"task":"ci:server"},{"group":"general-server-without-chat","group_label":"server (4/12)","shard_index":3,"shard_count":12,"task":"ci:server"},{"group":"general-server-without-chat","group_label":"server (5/12)","shard_index":4,"shard_count":12,"task":"ci:server"},{"group":"general-server-without-chat","group_label":"server (6/12)","shard_index":5,"shard_count":12,"task":"ci:server"},{"group":"general-server-without-chat","group_label":"server (7/12)","shard_index":6,"shard_count":12,"task":"ci:server"},{"group":"general-server-without-chat","group_label":"server (8/12)","shard_index":7,"shard_count":12,"task":"ci:server"},{"group":"general-server-without-chat","group_label":"server (9/12)","shard_index":8,"shard_count":12,"task":"ci:server"},{"group":"general-server-without-chat","group_label":"server (10/12)","shard_index":9,"shard_count":12,"task":"ci:server"},{"group":"general-server-without-chat","group_label":"server (11/12)","shard_index":10,"shard_count":12,"task":"ci:server"},{"group":"general-server-without-chat","group_label":"server (12/12)","shard_index":11,"shard_count":12,"task":"ci:server"},{"group":"general-chat","group_label":"chat (1/3)","shard_index":0,"shard_count":3,"task":"ci:chat"},{"group":"general-chat","group_label":"chat (2/3)","shard_index":1,"shard_count":3,"task":"ci:chat"},{"group":"general-chat","group_label":"chat (3/3)","shard_index":2,"shard_count":3,"task":"ci:chat"},{"group":"general-workspaces-a","group_label":"workspaces-a (1/2)","shard_index":0,"shard_count":2,"task":"ci:workspaces-a"},{"group":"general-workspaces-a","group_label":"workspaces-a (2/2)","shard_index":1,"shard_count":2,"task":"ci:workspaces-a"},{"group":"general-workspaces-b","group_label":"workspaces-b","task":"ci:workspaces-b"},{"group":"general-workspaces-c","group_label":"workspaces-c","task":"ci:db"}]}') }}
|
||||
|
||||
env:
|
||||
PAPERCLIP_CI_PROFILE: ${{ needs.gate.outputs.profile_ci }}
|
||||
@@ -516,7 +431,17 @@ jobs:
|
||||
pnpm install --frozen-lockfile
|
||||
fi
|
||||
|
||||
- name: Run grouped general test suites through Turbo
|
||||
if: hashFiles('turbo.json', '.github/actions/run-ci-task/action.yml') != ''
|
||||
uses: ./.github/actions/run-ci-task
|
||||
with:
|
||||
task: ${{ matrix.task }}
|
||||
args: ${{ matrix.shard_count && format('["--shard-index","{0}","--shard-count","{1}"]', matrix.shard_index, matrix.shard_count) || '[]' }}
|
||||
partition: ${{ strategy.job-index || 0 }}
|
||||
force: ${{ needs.gate.outputs.profile_ci == 'true' || needs.gate.outputs.plan == '' || fromJSON(needs.gate.outputs.plan).mode != 'enabled' || fromJSON(needs.gate.outputs.plan).forceReason != null }}
|
||||
|
||||
- name: Run grouped general test suites
|
||||
if: hashFiles('turbo.json') == ''
|
||||
run: |
|
||||
if [ -n "${{ matrix.shard_count }}" ]; then
|
||||
pnpm test:run:general -- --group '${{ matrix.group }}' \
|
||||
@@ -539,7 +464,7 @@ jobs:
|
||||
docker_context_integrity:
|
||||
name: Docker context integrity
|
||||
needs: gate
|
||||
if: ${{ needs.gate.outputs.full_ci == 'true' }}
|
||||
if: ${{ needs.gate.outputs.full_ci == 'true' && (needs.gate.outputs.plan == '' || fromJSON(needs.gate.outputs.plan).jobs.docker_context_integrity.run) }}
|
||||
runs-on: ${{ needs.gate.outputs.runner }}
|
||||
timeout-minutes: 15
|
||||
|
||||
@@ -594,77 +519,42 @@ jobs:
|
||||
retention-days: 14
|
||||
|
||||
verify:
|
||||
# Preserve the legacy required-check name while the underlying work runs in parallel.
|
||||
name: verify
|
||||
if: ${{ always() }}
|
||||
needs: [gate, policy, typecheck_release_registry, general_tests, verify_paperclip_runner, build, docker_context_integrity]
|
||||
needs: [gate, policy, typecheck_release_registry, general_tests, verify_paperclip_runner, build, docker_context_integrity, verify_serialized_server, canary_dry_run]
|
||||
runs-on: ${{ needs.gate.outputs.runner }}
|
||||
timeout-minutes: 5
|
||||
|
||||
steps:
|
||||
- name: Fail if any split verify lane failed
|
||||
- name: Validate every planned suite result
|
||||
env:
|
||||
FULL_CI: ${{ needs.gate.outputs.full_ci }}
|
||||
POLICY_RESULT: ${{ needs.policy.result }}
|
||||
TYPECHECK_RELEASE_REGISTRY_RESULT: ${{ needs.typecheck_release_registry.result }}
|
||||
GENERAL_TESTS_RESULT: ${{ needs.general_tests.result }}
|
||||
RUNNER_VERIFICATION_RESULT: ${{ needs.verify_paperclip_runner.result }}
|
||||
BUILD_RESULT: ${{ needs.build.result }}
|
||||
DOCKER_CONTEXT_INTEGRITY_RESULT: ${{ needs.docker_context_integrity.result }}
|
||||
CI_PLAN: ${{ needs.gate.outputs.plan }}
|
||||
CI_RESULTS: ${{ toJSON(needs) }}
|
||||
run: |
|
||||
test "$POLICY_RESULT" = "success"
|
||||
case "$FULL_CI" in
|
||||
true)
|
||||
test "$TYPECHECK_RELEASE_REGISTRY_RESULT" = "success"
|
||||
test "$GENERAL_TESTS_RESULT" = "success"
|
||||
test "$RUNNER_VERIFICATION_RESULT" = "success"
|
||||
test "$BUILD_RESULT" = "success"
|
||||
test "$DOCKER_CONTEXT_INTEGRITY_RESULT" = "success"
|
||||
;;
|
||||
false)
|
||||
test "$TYPECHECK_RELEASE_REGISTRY_RESULT" = "skipped"
|
||||
test "$GENERAL_TESTS_RESULT" = "skipped"
|
||||
test "$RUNNER_VERIFICATION_RESULT" = "skipped"
|
||||
test "$BUILD_RESULT" = "skipped"
|
||||
test "$DOCKER_CONTEXT_INTEGRITY_RESULT" = "skipped"
|
||||
;;
|
||||
*)
|
||||
echo "Invalid full_ci decision: $FULL_CI" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
node --input-type=module <<'NODE'
|
||||
import assert from 'node:assert/strict';
|
||||
const results = JSON.parse(process.env.CI_RESULTS);
|
||||
assert.equal(results.gate.result, 'success', 'CI routing must succeed');
|
||||
assert.equal(results.policy.result, 'success', 'Policy must succeed');
|
||||
assert.ok(['true', 'false'].includes(process.env.FULL_CI), 'Valid scope required');
|
||||
const plan = process.env.CI_PLAN ? JSON.parse(process.env.CI_PLAN) : null;
|
||||
if (plan) assert.equal(plan.version, 1, 'Supported task plan required');
|
||||
for (const job of ["typecheck_release_registry","general_tests","verify_paperclip_runner","build","docker_context_integrity","verify_serialized_server","canary_dry_run"]) {
|
||||
if (plan) assert.equal(typeof plan.jobs[job]?.run, 'boolean', 'Missing suite decision: ' + job);
|
||||
const selected = process.env.FULL_CI === 'true' && (!plan || plan.jobs[job].run);
|
||||
assert.equal(results[job].result, selected ? 'success' : 'skipped', job);
|
||||
}
|
||||
NODE
|
||||
|
||||
verify_paperclip_runner:
|
||||
name: Verify Paperclip Runner (${{ matrix.lane_label }})
|
||||
needs: [gate]
|
||||
if: ${{ needs.gate.outputs.full_ci == 'true' }}
|
||||
if: ${{ needs.gate.outputs.full_ci == 'true' && (needs.gate.outputs.plan == '' || fromJSON(needs.gate.outputs.plan).jobs.verify_paperclip_runner.run) }}
|
||||
runs-on: ${{ needs.gate.outputs.runner }}
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
# check:all spent ~380s of its ~600s cache-warm step inside the
|
||||
# runner package's vitest suite (run 35036001734, 2026-09-15); the
|
||||
# Rust tests took ~160s and every remaining check ~60s combined,
|
||||
# which made this single job the PR critical path once the test
|
||||
# lanes were rebalanced. Split the vitest suite across two runners
|
||||
# with Vitest's native --shard, the ~160s Rust tests into their own
|
||||
# lane, and the remaining static checks into a fourth. The four
|
||||
# lanes union to exactly check:all: check:static covers
|
||||
# check:eval-kernel, check:protocol-without-vitest, and
|
||||
# check:api-authority; check:runner covers the Rust half; and the
|
||||
# two vitest shards cover the package vitest file list.
|
||||
- lane_label: static checks
|
||||
command: check:static
|
||||
- lane_label: rust
|
||||
command: check:runner
|
||||
release_crate: true
|
||||
- lane_label: vitest 1/2
|
||||
command: test:typescript:vitest --shard=1/2
|
||||
- lane_label: vitest 2/2
|
||||
command: test:typescript:vitest --shard=2/2
|
||||
release_crate: true
|
||||
matrix: ${{ fromJSON(needs.gate.outputs.plan != '' && toJSON(fromJSON(needs.gate.outputs.plan).jobs.verify_paperclip_runner.matrix) || '{"include":[{"lane_label":"static checks","command":"check:static","task":"ci:runner-static"},{"lane_label":"rust","command":"check:runner","release_crate":true,"task":"ci:runner-rust"},{"lane_label":"vitest 1/2","command":"test:typescript:vitest --shard=1/2","task":"ci:runner-vitest"},{"lane_label":"vitest 2/2","command":"test:typescript:vitest --shard=2/2","release_crate":true,"task":"ci:runner-vitest"}]}') }}
|
||||
|
||||
env:
|
||||
PAPERCLIP_CI_PROFILE: ${{ needs.gate.outputs.profile_ci }}
|
||||
@@ -849,7 +739,17 @@ jobs:
|
||||
if: ${{ steps.runner_release_crate.outputs.cache-hit == 'true' }}
|
||||
run: node scripts/runner-release-crate-cache.mjs mark-current
|
||||
|
||||
- name: Verify Runner Rust through Turbo
|
||||
if: matrix.command == 'check:runner' && hashFiles('turbo.json') != ''
|
||||
uses: ./.github/actions/run-ci-task
|
||||
with:
|
||||
task: ci:runner-rust
|
||||
args: '[]'
|
||||
partition: ${{ strategy.job-index || 0 }}
|
||||
force: ${{ needs.gate.outputs.profile_ci == 'true' || needs.gate.outputs.plan == '' || fromJSON(needs.gate.outputs.plan).mode != 'enabled' || fromJSON(needs.gate.outputs.plan).forceReason != null }}
|
||||
|
||||
- name: Verify Paperclip Runner
|
||||
if: matrix.command != 'check:runner' || hashFiles('turbo.json') == ''
|
||||
# pnpm appends trailing args to the end of the script's shell chain,
|
||||
# so the vitest lanes' --shard lands on `vitest run`. Do not add a
|
||||
# `--` separator: pnpm forwards it literally and vitest would then
|
||||
@@ -870,7 +770,7 @@ jobs:
|
||||
build:
|
||||
name: Build
|
||||
needs: [gate]
|
||||
if: ${{ needs.gate.outputs.full_ci == 'true' }}
|
||||
if: ${{ needs.gate.outputs.full_ci == 'true' && (needs.gate.outputs.plan == '' || fromJSON(needs.gate.outputs.plan).jobs.build.run) }}
|
||||
runs-on: ${{ needs.gate.outputs.runner }}
|
||||
timeout-minutes: 20
|
||||
|
||||
@@ -1122,48 +1022,12 @@ jobs:
|
||||
verify_serialized_server:
|
||||
name: Verify serialized server suites (${{ matrix.shard_label }})
|
||||
needs: [gate]
|
||||
if: ${{ needs.gate.outputs.full_ci == 'true' }}
|
||||
if: ${{ needs.gate.outputs.full_ci == 'true' && (needs.gate.outputs.plan == '' || fromJSON(needs.gate.outputs.plan).jobs.verify_serialized_server.run) }}
|
||||
runs-on: ${{ needs.gate.outputs.runner }}
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
# Shards are balanced by recorded duration
|
||||
# (scripts/serialized-shard-durations.json, refreshed 2026-09-12);
|
||||
# round-robin used to cluster the heavy suites on one runner (291s
|
||||
# vs 170-201s in run 32012408876, 2026-08-17). The suite total has
|
||||
# since grown to ~1926s recorded: five shards ran ~390s of tests
|
||||
# each while the rebalanced general/e2e lanes run ~210-260s, so
|
||||
# nine shards level this lane to ~214s and keep it off the
|
||||
# critical path.
|
||||
- shard_index: 0
|
||||
shard_count: 9
|
||||
shard_label: 1/9
|
||||
- shard_index: 1
|
||||
shard_count: 9
|
||||
shard_label: 2/9
|
||||
- shard_index: 2
|
||||
shard_count: 9
|
||||
shard_label: 3/9
|
||||
- shard_index: 3
|
||||
shard_count: 9
|
||||
shard_label: 4/9
|
||||
- shard_index: 4
|
||||
shard_count: 9
|
||||
shard_label: 5/9
|
||||
- shard_index: 5
|
||||
shard_count: 9
|
||||
shard_label: 6/9
|
||||
- shard_index: 6
|
||||
shard_count: 9
|
||||
shard_label: 7/9
|
||||
- shard_index: 7
|
||||
shard_count: 9
|
||||
shard_label: 8/9
|
||||
- shard_index: 8
|
||||
shard_count: 9
|
||||
shard_label: 9/9
|
||||
matrix: ${{ fromJSON(needs.gate.outputs.plan != '' && toJSON(fromJSON(needs.gate.outputs.plan).jobs.verify_serialized_server.matrix) || '{"include":[{"shard_index":0,"shard_count":9,"shard_label":"1/9","task":"ci:serialized"},{"shard_index":1,"shard_count":9,"shard_label":"2/9","task":"ci:serialized"},{"shard_index":2,"shard_count":9,"shard_label":"3/9","task":"ci:serialized"},{"shard_index":3,"shard_count":9,"shard_label":"4/9","task":"ci:serialized"},{"shard_index":4,"shard_count":9,"shard_label":"5/9","task":"ci:serialized"},{"shard_index":5,"shard_count":9,"shard_label":"6/9","task":"ci:serialized"},{"shard_index":6,"shard_count":9,"shard_label":"7/9","task":"ci:serialized"},{"shard_index":7,"shard_count":9,"shard_label":"8/9","task":"ci:serialized"},{"shard_index":8,"shard_count":9,"shard_label":"9/9","task":"ci:serialized"}]}') }}
|
||||
|
||||
env:
|
||||
PAPERCLIP_CI_PROFILE: ${{ needs.gate.outputs.profile_ci }}
|
||||
@@ -1230,7 +1094,17 @@ jobs:
|
||||
pnpm install --frozen-lockfile
|
||||
fi
|
||||
|
||||
- name: Run serialized server test shard through Turbo
|
||||
if: hashFiles('turbo.json', '.github/actions/run-ci-task/action.yml') != ''
|
||||
uses: ./.github/actions/run-ci-task
|
||||
with:
|
||||
task: ci:serialized
|
||||
args: ${{ format('["--shard-index","{0}","--shard-count","{1}"]', matrix.shard_index, matrix.shard_count) }}
|
||||
partition: ${{ strategy.job-index || 0 }}
|
||||
force: ${{ needs.gate.outputs.profile_ci == 'true' || needs.gate.outputs.plan == '' || fromJSON(needs.gate.outputs.plan).mode != 'enabled' || fromJSON(needs.gate.outputs.plan).forceReason != null }}
|
||||
|
||||
- name: Run serialized server test shard
|
||||
if: hashFiles('turbo.json') == ''
|
||||
run: pnpm test:run:serialized -- --shard-index ${{ matrix.shard_index }} --shard-count ${{ matrix.shard_count }}
|
||||
|
||||
- name: Upload CI profile timings
|
||||
@@ -1247,7 +1121,7 @@ jobs:
|
||||
canary_dry_run:
|
||||
name: Canary Dry Run
|
||||
needs: [gate]
|
||||
if: ${{ needs.gate.outputs.full_ci == 'true' }}
|
||||
if: ${{ needs.gate.outputs.full_ci == 'true' && (needs.gate.outputs.plan == '' || fromJSON(needs.gate.outputs.plan).jobs.canary_dry_run.run) }}
|
||||
runs-on: ${{ needs.gate.outputs.runner }}
|
||||
timeout-minutes: 20
|
||||
|
||||
@@ -1462,48 +1336,12 @@ jobs:
|
||||
e2e_shards:
|
||||
name: e2e shard (${{ matrix.shard_label }})
|
||||
needs: [gate]
|
||||
if: ${{ needs.gate.outputs.full_ci == 'true' }}
|
||||
if: ${{ needs.gate.outputs.full_ci == 'true' && (needs.gate.outputs.plan == '' || fromJSON(needs.gate.outputs.plan).jobs.e2e_shards.run) }}
|
||||
runs-on: ${{ needs.gate.outputs.runner }}
|
||||
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.
|
||||
# The catalog has grown to ~1694s of serial spec time (mean of runs
|
||||
# 35036001734 and 35024948947, 2026-09-15): with three shards and
|
||||
# stale durations the worst shard ran 745s of specs while the best
|
||||
# ran 277s. The former floors — chat-adapters-ui at ~442s and
|
||||
# agent-chat at ~310s — are each split into two specs, so eight
|
||||
# shards with refreshed durations level to ~212s each; the heaviest
|
||||
# remaining single spec is chat-adapters-ui-messaging at ~242s.
|
||||
- shard_index: 0
|
||||
shard_count: 8
|
||||
shard_label: 1/8
|
||||
- shard_index: 1
|
||||
shard_count: 8
|
||||
shard_label: 2/8
|
||||
- shard_index: 2
|
||||
shard_count: 8
|
||||
shard_label: 3/8
|
||||
- shard_index: 3
|
||||
shard_count: 8
|
||||
shard_label: 4/8
|
||||
- shard_index: 4
|
||||
shard_count: 8
|
||||
shard_label: 5/8
|
||||
- shard_index: 5
|
||||
shard_count: 8
|
||||
shard_label: 6/8
|
||||
- shard_index: 6
|
||||
shard_count: 8
|
||||
shard_label: 7/8
|
||||
- shard_index: 7
|
||||
shard_count: 8
|
||||
shard_label: 8/8
|
||||
matrix: ${{ fromJSON(needs.gate.outputs.plan != '' && toJSON(fromJSON(needs.gate.outputs.plan).jobs.e2e_shards.matrix) || '{"include":[{"shard_index":0,"shard_count":8,"shard_label":"1/8","task":"ci:e2e"},{"shard_index":1,"shard_count":8,"shard_label":"2/8","task":"ci:e2e"},{"shard_index":2,"shard_count":8,"shard_label":"3/8","task":"ci:e2e"},{"shard_index":3,"shard_count":8,"shard_label":"4/8","task":"ci:e2e"},{"shard_index":4,"shard_count":8,"shard_label":"5/8","task":"ci:e2e"},{"shard_index":5,"shard_count":8,"shard_label":"6/8","task":"ci:e2e"},{"shard_index":6,"shard_count":8,"shard_label":"7/8","task":"ci:e2e"},{"shard_index":7,"shard_count":8,"shard_label":"8/8","task":"ci:e2e"}]}') }}
|
||||
|
||||
env:
|
||||
PAPERCLIP_CI_PROFILE: ${{ needs.gate.outputs.profile_ci }}
|
||||
@@ -1628,27 +1466,29 @@ jobs:
|
||||
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: [gate, policy, e2e_shards]
|
||||
runs-on: ${{ needs.gate.outputs.runner }}
|
||||
timeout-minutes: 5
|
||||
|
||||
steps:
|
||||
- name: Fail if any e2e shard failed
|
||||
- name: Validate every planned suite result
|
||||
env:
|
||||
FULL_CI: ${{ needs.gate.outputs.full_ci }}
|
||||
POLICY_RESULT: ${{ needs.policy.result }}
|
||||
E2E_SHARDS_RESULT: ${{ needs.e2e_shards.result }}
|
||||
CI_PLAN: ${{ needs.gate.outputs.plan }}
|
||||
CI_RESULTS: ${{ toJSON(needs) }}
|
||||
run: |
|
||||
test "$POLICY_RESULT" = "success"
|
||||
case "$FULL_CI" in
|
||||
true) test "$E2E_SHARDS_RESULT" = "success" ;;
|
||||
false) test "$E2E_SHARDS_RESULT" = "skipped" ;;
|
||||
*)
|
||||
echo "Invalid full_ci decision: $FULL_CI" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
node --input-type=module <<'NODE'
|
||||
import assert from 'node:assert/strict';
|
||||
const results = JSON.parse(process.env.CI_RESULTS);
|
||||
assert.equal(results.gate.result, 'success', 'CI routing must succeed');
|
||||
assert.equal(results.policy.result, 'success', 'Policy must succeed');
|
||||
assert.ok(['true', 'false'].includes(process.env.FULL_CI), 'Valid scope required');
|
||||
const plan = process.env.CI_PLAN ? JSON.parse(process.env.CI_PLAN) : null;
|
||||
if (plan) assert.equal(plan.version, 1, 'Supported task plan required');
|
||||
for (const job of ["e2e_shards"]) {
|
||||
if (plan) assert.equal(typeof plan.jobs[job]?.run, 'boolean', 'Missing suite decision: ' + job);
|
||||
const selected = process.env.FULL_CI === 'true' && (!plan || plan.jobs[job].run);
|
||||
assert.equal(results[job].result, selected ? 'success' : 'skipped', job);
|
||||
}
|
||||
NODE
|
||||
@@ -13,7 +13,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
if: github.event.action != 'labeled' || github.event.label.name == 'profile-ci'
|
||||
if: github.event.action != 'labeled' || github.event.label.name == 'profile-ci' || github.event.label.name == 'full-ci'
|
||||
# Master requires CODEOWNERS review for .github/**, including this workflow.
|
||||
# The AWS runner group permits pr-trusted.yml@refs/heads/master.
|
||||
uses: paperclipai/paperclip/.github/workflows/pr-trusted.yml@master
|
||||
Reference in new issue
Block a user