diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 0950a8615a..72ae92b98e 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -5,515 +5,11 @@ on: branches: - master -concurrency: - group: pr-${{ github.event.pull_request.number }} - cancel-in-progress: true +permissions: + actions: read + contents: read + pull-requests: read 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 migration ordering against target branch - run: >- - node .github/scripts/check-pr-migration-order.mjs - "${{ github.event.pull_request.base.sha }}" - "${{ github.event.pull_request.head.sha }}" - - - name: Validate Dockerfile deps stage - run: node ./scripts/check-docker-deps-stage.mjs - - - name: Validate Node version policy - run: pnpm check:node-version - - - 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 PR quality-gate scripts - run: node --test '.github/scripts/tests/*.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: Verify Paperclip Runner - run: pnpm --filter @paperclipai/paperclip-runner check:all - - - 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" - # specs is an intentional argument list. - # shellcheck disable=SC2086 - 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" + ci: + uses: paperclipai/paperclip/.github/workflows/pr-trusted.yml@d9fc93d8383ece6fba721881a7aba638867f4996 diff --git a/scripts/__tests__/e2e-shard.test.mjs b/scripts/__tests__/e2e-shard.test.mjs index cdd3c7cd80..d87eff0a1d 100644 --- a/scripts/__tests__/e2e-shard.test.mjs +++ b/scripts/__tests__/e2e-shard.test.mjs @@ -12,7 +12,8 @@ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".." const script = path.join(repoRoot, "scripts", "e2e-shard.mjs"); const durationsManifest = path.join(repoRoot, "scripts", "e2e-shard-durations.json"); const playwrightConfig = path.join(repoRoot, "tests", "e2e", "playwright.config.ts"); -const prWorkflow = path.join(repoRoot, ".github", "workflows", "pr.yml"); +const prCallerWorkflow = path.join(repoRoot, ".github", "workflows", "pr.yml"); +const trustedPrWorkflowPath = ".github/workflows/pr-trusted.yml"; const SHARD_COUNT = 3; @@ -22,6 +23,21 @@ function runShard(args) { return result.stdout.trim().split(/\s+/).filter(Boolean); } +function readPinnedTrustedPrWorkflow() { + const caller = readFileSync(prCallerWorkflow, "utf8"); + const pin = caller.match( + /uses: paperclipai\/paperclip\/\.github\/workflows\/pr-trusted\.yml@([0-9a-f]{40})/, + ); + assert.ok(pin, "pr.yml must call the trusted workflow at a full commit SHA"); + + const result = spawnSync("git", ["show", `${pin[1]}:${trustedPrWorkflowPath}`], { + cwd: repoRoot, + encoding: "utf8", + }); + assert.equal(result.status, 0, `cannot read the pinned trusted workflow: ${result.stderr}`); + return result.stdout; +} + test("the e2e shards form a complete, non-overlapping partition", () => { const specs = listE2eSpecs(); assert.ok(specs.length > 0, "expected a non-empty e2e spec set"); @@ -89,11 +105,15 @@ test("shard arguments are validated", () => { } }); -test("pr.yml keeps a stable aggregate check named e2e over the shard matrix", () => { +test("pr.yml calls the trusted PR workflow at an immutable SHA", () => { + assert.ok(readPinnedTrustedPrWorkflow().length > 0); +}); + +test("the trusted PR workflow keeps a stable aggregate check named e2e over the shard matrix", () => { // Branch protection requires a check literally named `e2e`. The shards run // as `e2e shard (n/3)`, so the aggregate job below is what keeps the // required-check contract intact — same pattern as the `verify` aggregate. - const workflow = readFileSync(prWorkflow, "utf8"); + const workflow = readPinnedTrustedPrWorkflow(); const jobs = new Map(); let current = null; for (const line of workflow.split("\n")) { @@ -109,10 +129,14 @@ test("pr.yml keeps a stable aggregate check named e2e over the shard matrix", () for (const [id, lines] of jobs) jobs.set(id, lines.join("\n")); const aggregate = jobs.get("e2e"); - assert.ok(aggregate, "pr.yml must define an `e2e` job to satisfy branch protection"); + assert.ok(aggregate, "pr-trusted.yml must define an `e2e` job to satisfy branch protection"); assert.match(aggregate, /^ {4}name: e2e$/m, "the aggregate job must be named exactly `e2e`"); assert.match(aggregate, /^ {4}if: \$\{\{ always\(\) \}\}$/m, "the aggregate must run even when a shard fails"); - assert.match(aggregate, /^ {4}needs: \[e2e_shards\]$/m, "the aggregate must depend on the shard matrix"); + assert.match( + aggregate, + /^ {4}needs: \[gate, e2e_shards\]$/m, + "the aggregate must depend on the runner gate and shard matrix", + ); assert.match( aggregate, /test "\$E2E_SHARDS_RESULT" = "success"/, @@ -120,7 +144,7 @@ test("pr.yml keeps a stable aggregate check named e2e over the shard matrix", () ); const shards = jobs.get("e2e_shards"); - assert.ok(shards, "pr.yml must define the `e2e_shards` matrix job"); + assert.ok(shards, "pr-trusted.yml must define the `e2e_shards` matrix job"); const matrixEntries = [ ...shards.matchAll( /^ {10}- shard_index: (?\d+)\n {12}shard_count: (?\d+)\n {12}shard_label: (?\d+\/\d+)$/gm, @@ -143,17 +167,17 @@ test("pr.yml keeps a stable aggregate check named e2e over the shard matrix", () } }); -test("pr.yml passes the shard's spec filter to Playwright without a literal --", () => { +test("the trusted PR workflow passes the shard's spec filter to Playwright without a literal --", () => { // `pnpm run test:e2e -- $specs` forwards the literal separator to Playwright, // so the specs after it are not applied as file filters. - const workflow = readFileSync(prWorkflow, "utf8"); + const workflow = readPinnedTrustedPrWorkflow(); assert.ok( !/pnpm run test:e2e --\s/.test(workflow), - "pr.yml must not insert a literal `--` between `pnpm run test:e2e` and the spec filter", + "pr-trusted.yml must not insert a literal `--` between `pnpm run test:e2e` and the spec filter", ); assert.match( workflow, /pnpm run test:e2e \$specs/, - "pr.yml e2e_shards must invoke `pnpm run test:e2e $specs`", + "pr-trusted.yml e2e_shards must invoke `pnpm run test:e2e $specs`", ); });