diff --git a/.github/workflows/pr-trusted.yml b/.github/workflows/pr-trusted.yml index 757a6cc562..a2e34b4cd1 100644 --- a/.github/workflows/pr-trusted.yml +++ b/.github/workflows/pr-trusted.yml @@ -220,7 +220,7 @@ jobs: policy: needs: [gate] runs-on: ${{ needs.gate.outputs.runner }} - timeout-minutes: 5 + timeout-minutes: 10 outputs: lockfile_regenerated: ${{ steps.regen_lockfile.outputs.regenerated }} @@ -254,6 +254,7 @@ jobs: uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: 24 + cache: pnpm - name: Validate migration ordering against target branch run: >- @@ -296,22 +297,20 @@ jobs: 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 + - name: Validate dependency resolution and regenerate stale lockfile 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 + cp pnpm-lock.yaml "$RUNNER_TEMP/pnpm-lock.before.yaml" + pnpm install --lockfile-only --ignore-scripts --no-frozen-lockfile + if cmp -s "$RUNNER_TEMP/pnpm-lock.before.yaml" pnpm-lock.yaml; then echo "regenerated=0" >> "$GITHUB_OUTPUT" + else + echo "regenerated=1" >> "$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. + # Manifest-only and stacked PRs keep pnpm-lock.yaml at the default branch. + # Upload a regenerated copy whenever the checked-out merge tree needs one. + # Every downstream job then 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 @@ -339,8 +338,8 @@ jobs: version: 9.15.4 - name: Restore regenerated PR lockfile (if policy uploaded one) + if: needs.policy.outputs.lockfile_regenerated == '1' uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 - continue-on-error: true with: name: pr-lockfile path: . @@ -425,8 +424,8 @@ jobs: version: 9.15.4 - name: Restore regenerated PR lockfile (if policy uploaded one) + if: needs.policy.outputs.lockfile_regenerated == '1' uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 - continue-on-error: true with: name: pr-lockfile path: . @@ -486,8 +485,8 @@ jobs: version: 9.15.4 - name: Restore regenerated PR lockfile (if policy uploaded one) + if: needs.policy.outputs.lockfile_regenerated == '1' uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 - continue-on-error: true with: name: pr-lockfile path: . @@ -550,8 +549,8 @@ jobs: version: 9.15.4 - name: Restore regenerated PR lockfile (if policy uploaded one) + if: needs.policy.outputs.lockfile_regenerated == '1' uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 - continue-on-error: true with: name: pr-lockfile path: . @@ -586,8 +585,8 @@ jobs: version: 9.15.4 - name: Restore regenerated PR lockfile (if policy uploaded one) + if: needs.policy.outputs.lockfile_regenerated == '1' uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 - continue-on-error: true with: name: pr-lockfile path: . @@ -663,8 +662,8 @@ jobs: version: 9.15.4 - name: Restore regenerated PR lockfile (if policy uploaded one) + if: needs.policy.outputs.lockfile_regenerated == '1' uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 - continue-on-error: true with: name: pr-lockfile path: . diff --git a/scripts/__tests__/e2e-shard.test.mjs b/scripts/__tests__/e2e-shard.test.mjs index d87eff0a1d..9b80a99edf 100644 --- a/scripts/__tests__/e2e-shard.test.mjs +++ b/scripts/__tests__/e2e-shard.test.mjs @@ -14,6 +14,7 @@ const durationsManifest = path.join(repoRoot, "scripts", "e2e-shard-durations.js const playwrightConfig = path.join(repoRoot, "tests", "e2e", "playwright.config.ts"); const prCallerWorkflow = path.join(repoRoot, ".github", "workflows", "pr.yml"); const trustedPrWorkflowPath = ".github/workflows/pr-trusted.yml"; +const trustedPrWorkflow = path.join(repoRoot, trustedPrWorkflowPath); const SHARD_COUNT = 3; @@ -181,3 +182,41 @@ test("the trusted PR workflow passes the shard's spec filter to Playwright witho "pr-trusted.yml e2e_shards must invoke `pnpm run test:e2e $specs`", ); }); + +test("the trusted PR workflow regenerates stale stacked lockfiles", () => { + // Implementation PRs validate the workflow under development here. The + // caller remains pinned to the last merged trusted SHA until a separate + // activation PR advances it, so unmerged PR code never runs on trusted + // infrastructure. + const workflow = readFileSync(trustedPrWorkflow, "utf8"); + assert.match( + workflow, + /policy:\n needs: \[gate\][\s\S]{0,160}timeout-minutes: 10/, + "the unconditional resolution step needs the same timeout headroom as the lockfile refresh workflow", + ); + assert.match( + workflow, + /- name: Setup Node\.js\n uses: actions\/setup-node@[0-9a-f]+[^\n]*\n with:\n node-version: 24\n cache: pnpm/, + "the policy job must restore the pnpm cache before dependency resolution", + ); + assert.match( + workflow, + /pnpm install --lockfile-only --ignore-scripts --no-frozen-lockfile/, + "the policy job must validate the complete merge tree instead of only the current PR layer", + ); + assert.match( + workflow, + /cmp -s "\$RUNNER_TEMP\/pnpm-lock\.before\.yaml" pnpm-lock\.yaml/, + "the policy job must upload a lockfile only when regeneration changed it", + ); + + const restoreSteps = workflow.match( + /- name: Restore regenerated PR lockfile \(if policy uploaded one\)\n if: needs\.policy\.outputs\.lockfile_regenerated == '1'/g, + ) ?? []; + assert.equal(restoreSteps.length, 6, "every downstream install job must restore a required regenerated artifact"); + assert.doesNotMatch( + workflow, + /- name: Restore regenerated PR lockfile \(if policy uploaded one\)[\s\S]{0,220}continue-on-error:/, + "a missing artifact must fail after the policy job says it uploaded one", + ); +});