From 07b6816829dde458eaabc26ca096a83c4dc4ac96 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:34:12 -0500 Subject: [PATCH] ci: add trusted reusable PR workflow (#12436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Pull request checks protect the quality of the control plane > - The current checks depend only on the shared GitHub-hosted runner limit > - Busy periods leave many pull request jobs queued even when external capacity is available > - Public pull request code must not select or directly access private runner infrastructure > - This pull request adds an inactive reusable workflow with a fail-closed identity gate > - A later pull request can pin this workflow by its full master commit SHA > - The benefit is automatic, controlled access to isolated runner capacity without changing current CI during bootstrap ## Linked Issues or Issue Description **What existing behavior does this improve?** This improves the pull request CI workflow. It prepares the existing checks to use an administrator-controlled runner selection. **Subsystem affected** Cross-cutting GitHub Actions CI configuration. **Current behavior** Every pull request job uses `ubuntu-latest`. Jobs wait when the GitHub-hosted concurrency limit is full. **Proposed behavior** Add a reusable copy of the current PR workflow. A GitHub-hosted gate validates durable numeric user IDs and current GitHub API state. The gate emits one runner label. The default and every validation failure use `ubuntu-latest`. The AWS label is possible only when an administrator enables it and every identity check passes. This bootstrap pull request does not change the active `.github/workflows/pr.yml` caller. A follow-up change will call this workflow by the full master commit SHA. **Reason and benefit** The split bootstrap creates an immutable trust boundary before external runners are reachable. It also keeps CI automatic for contributors. Contributors do not select a runner. **Breaking changes** None in this bootstrap pull request. The active PR workflow does not change. ## What Changed - Added an inactive `workflow_call` copy of the current PR checks. - Added a GitHub-hosted routing gate that checks the repository ID, pull request author ID, event sender ID, rerun actor ID, base branch, head SHA, merge SHA, and current pull request state. - Made every validation failure select `ubuntu-latest`. - Pinned every third-party action to a full commit SHA. - Disabled persistent checkout credentials for all jobs. - Limited the workflow token to Actions read, contents read, and pull request read access. ## Verification - `actionlint .github/workflows/pr-trusted.yml` - Ran the dedicated workflow routing test harness against `.github/workflows/pr-trusted.yml`. - Compared the job keys with `.github/workflows/pr.yml`. The new workflow contains every existing job plus the gate. - Verified each pinned action commit against its current GitHub major-version tag. ## Risks The gate could route a trusted pull request to the wrong runner if an identity check is incomplete. The gate checks durable numeric IDs from the event and current GitHub API state. It checks the rerun actor separately. It defaults to GitHub-hosted capacity before any validation runs. This file is inactive in this pull request. The follow-up caller and runner-group restriction must use the exact commit that reaches `master`. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI Codex based on GPT-5. The exact serving model ID and context-window size are not exposed in this environment. The model used high-reasoning, terminal, GitHub API, browser, and web-research capabilities. ## 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 - [ ] 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 --- .github/workflows/pr-trusted.yml | 646 +++++++++++++++++++++++++++++++ 1 file changed, 646 insertions(+) create mode 100644 .github/workflows/pr-trusted.yml diff --git a/.github/workflows/pr-trusted.yml b/.github/workflows/pr-trusted.yml new file mode 100644 index 0000000000..247b668acf --- /dev/null +++ b/.github/workflows/pr-trusted.yml @@ -0,0 +1,646 @@ +name: Trusted PR CI + +# Rollout is intentionally two-step: merge this reusable workflow first, then +# replace pr.yml with an immutable-SHA caller so the active CI definition cannot +# drift from this file. +on: + workflow_call: + +permissions: + actions: read + contents: read + pull-requests: read + +concurrency: + group: pr-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + gate: + name: Select trusted runner + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + runner: ${{ steps.route.outputs.runner }} + + steps: + - name: Validate PR identity and select runner + id: route + shell: bash + env: + GH_TOKEN: ${{ github.token }} + AWS_CI_ENABLED: ${{ vars.AWS_CI_ENABLED }} + TRUSTED_USER_IDS: ${{ vars.AWS_CI_TRUSTED_USER_IDS }} + EVENT_NAME: ${{ github.event_name }} + EVENT_REPOSITORY: ${{ github.repository }} + EVENT_REPOSITORY_ID: ${{ github.repository_id }} + EVENT_ACTION: ${{ github.event.action }} + EVENT_PR_NUMBER: ${{ github.event.pull_request.number }} + EVENT_PR_AUTHOR_ID: ${{ github.event.pull_request.user.id }} + EVENT_SENDER_ID: ${{ github.event.sender.id }} + EVENT_BASE_REPOSITORY_ID: ${{ github.event.pull_request.base.repo.id }} + EVENT_BASE_REF: ${{ github.event.pull_request.base.ref }} + EVENT_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + EVENT_MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }} + RUN_ID: ${{ github.run_id }} + run: | + set -u + + github_runner='ubuntu-latest' + aws_runner='runs-on/fleet=paperclip-public-pr-x64/env=public-ci' + echo "runner=$github_runner" >> "$GITHUB_OUTPUT" + + fail_closed() { + echo "::notice title=AWS CI routing::Using GitHub-hosted runner: $1" + exit 0 + } + + is_positive_integer() { + [[ "$1" =~ ^[1-9][0-9]*$ ]] + } + + is_allowed() { + local user_id="$1" + jq -e --argjson user_id "$user_id" 'index($user_id) != null' \ + <<< "$TRUSTED_USER_IDS" >/dev/null + } + + [[ "$AWS_CI_ENABLED" == 'true' ]] || fail_closed 'AWS_CI_ENABLED is not true' + # Reusable workflows retain the caller's github context and event + # payload, so a pull_request caller must still report pull_request. + [[ "$EVENT_NAME" == 'pull_request' ]] || fail_closed 'event is not pull_request' + [[ "$EVENT_REPOSITORY" == 'paperclipai/paperclip' ]] || fail_closed 'unexpected repository' + [[ "$EVENT_REPOSITORY_ID" == '1170821064' ]] || fail_closed 'unexpected repository ID' + [[ "$EVENT_BASE_REPOSITORY_ID" == '1170821064' ]] || fail_closed 'unexpected base repository ID' + [[ "$EVENT_BASE_REF" == 'master' ]] || fail_closed 'unexpected base branch' + [[ "$EVENT_ACTION" =~ ^(opened|reopened|synchronize)$ ]] || fail_closed 'unsupported pull_request action' + + jq -e ' + type == "array" and + length > 0 and + all(.[]; type == "number" and . > 0 and floor == .) + ' <<< "$TRUSTED_USER_IDS" >/dev/null 2>&1 || fail_closed 'trusted user ID list is malformed' + + for user_id in "$EVENT_PR_AUTHOR_ID" "$EVENT_SENDER_ID"; do + is_positive_integer "$user_id" || fail_closed 'event contains a malformed user ID' + is_allowed "$user_id" || fail_closed "GitHub user ID $user_id is not allowlisted" + done + + pr_json="$(gh api \ + -H 'Accept: application/vnd.github+json' \ + -H 'X-GitHub-Api-Version: 2022-11-28' \ + "/repos/paperclipai/paperclip/pulls/$EVENT_PR_NUMBER" 2>/dev/null)" \ + || fail_closed 'could not refresh pull request state' + + jq -e \ + --argjson repository_id 1170821064 \ + --argjson author_id "$EVENT_PR_AUTHOR_ID" \ + --arg base_ref "$EVENT_BASE_REF" \ + --arg head_sha "$EVENT_HEAD_SHA" \ + --arg merge_sha "$EVENT_MERGE_SHA" ' + .state == "open" and + .user.id == $author_id and + .base.repo.id == $repository_id and + .base.ref == $base_ref and + .head.sha == $head_sha and + ($merge_sha != "") and + .merge_commit_sha == $merge_sha + ' <<< "$pr_json" >/dev/null 2>&1 \ + || fail_closed 'current pull request state does not match the triggering event' + + run_json="$(gh api \ + -H 'Accept: application/vnd.github+json' \ + -H 'X-GitHub-Api-Version: 2022-11-28' \ + "/repos/paperclipai/paperclip/actions/runs/$RUN_ID" 2>/dev/null)" \ + || fail_closed 'could not refresh workflow run state' + + triggering_actor_id="$(jq -r '.triggering_actor.id // empty' <<< "$run_json")" + is_positive_integer "$triggering_actor_id" || fail_closed 'workflow run has no valid triggering actor ID' + is_allowed "$triggering_actor_id" || fail_closed "triggering GitHub user ID $triggering_actor_id is not allowlisted" + + jq -e \ + --argjson repository_id 1170821064 \ + --arg event_name "$EVENT_NAME" ' + .repository.id == $repository_id and + .event == $event_name + ' <<< "$run_json" >/dev/null 2>&1 \ + || fail_closed 'current workflow run does not match the expected repository event' + + echo "runner=$aws_runner" >> "$GITHUB_OUTPUT" + echo '::notice title=AWS CI routing::Using an ephemeral RunsOn Fleet runner' + + policy: + needs: [gate] + runs-on: ${{ needs.gate.outputs.runner }} + timeout-minutes: 5 + outputs: + lockfile_regenerated: ${{ steps.regen_lockfile.outputs.regenerated }} + + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + 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@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 + with: + version: 9.15.4 + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: 24 + + - 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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # 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: [gate, policy] + runs-on: ${{ needs.gate.outputs.runner }} + timeout-minutes: 20 + + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Setup pnpm + uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 + with: + version: 9.15.4 + + - name: Restore regenerated PR lockfile (if policy uploaded one) + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + continue-on-error: true + with: + name: pr-lockfile + path: . + + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # 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: [gate, policy] + runs-on: ${{ needs.gate.outputs.runner }} + 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Setup pnpm + uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 + with: + version: 9.15.4 + + - name: Restore regenerated PR lockfile (if policy uploaded one) + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + continue-on-error: true + with: + name: pr-lockfile + path: . + + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # 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: [gate, typecheck_release_registry, general_tests, build] + runs-on: ${{ needs.gate.outputs.runner }} + 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: [gate, policy] + runs-on: ${{ needs.gate.outputs.runner }} + timeout-minutes: 20 + + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Setup pnpm + uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 + with: + version: 9.15.4 + + - name: Restore regenerated PR lockfile (if policy uploaded one) + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + continue-on-error: true + with: + name: pr-lockfile + path: . + + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # 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: [gate, policy] + runs-on: ${{ needs.gate.outputs.runner }} + 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Setup pnpm + uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 + with: + version: 9.15.4 + + - name: Restore regenerated PR lockfile (if policy uploaded one) + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + continue-on-error: true + with: + name: pr-lockfile + path: . + + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # 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: [gate, policy] + runs-on: ${{ needs.gate.outputs.runner }} + timeout-minutes: 20 + + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Setup pnpm + uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 + with: + version: 9.15.4 + + - name: Restore regenerated PR lockfile (if policy uploaded one) + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + continue-on-error: true + with: + name: pr-lockfile + path: . + + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # 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: [gate, policy] + 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. + # 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Setup pnpm + uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 + with: + version: 9.15.4 + + - name: Restore regenerated PR lockfile (if policy uploaded one) + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + continue-on-error: true + with: + name: pr-lockfile + path: . + + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # 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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # 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: [gate, e2e_shards] + runs-on: ${{ needs.gate.outputs.runner }} + 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"