diff --git a/.github/actions/install-node-dependencies/action.yml b/.github/actions/install-node-dependencies/action.yml index 646bc34b387..4163150f47e 100644 --- a/.github/actions/install-node-dependencies/action.yml +++ b/.github/actions/install-node-dependencies/action.yml @@ -7,9 +7,9 @@ inputs: required: false default: 'true' cache-pnpm-store-lookup-only: - description: On non-PR producers, refresh existing stores without downloading and save stores on a miss. + description: Auto uses measured hosted Node 24 root installs; true forces lookup, false retains archive restoration. required: false - default: 'false' + default: auto cache-pnpm-verification: description: Restore pnpm's policy-checked lockfile verification record. required: false @@ -67,6 +67,30 @@ outputs: runs: using: composite steps: + - name: Resolve pnpm store mode + id: pnpm-store-mode + if: >- + github.event_name != 'pull_request' && inputs.cache-pnpm-store != 'false' && + (inputs.cache-pnpm-store-lookup-only == 'true' || + (inputs.cache-pnpm-store-lookup-only == 'auto' && + inputs.cache-dependency-path == 'pnpm-lock.yaml' && + runner.environment == 'github-hosted' && job.container.id == '' && + (runner.os == 'Linux' || runner.os == 'macOS' || runner.os == 'Windows') && + (runner.arch == 'X64' || runner.arch == 'ARM64') && + (inputs.node-version == '' || inputs.node-version == '24'))) + shell: bash + env: + LOOKUP_REQUEST: ${{ inputs.cache-pnpm-store-lookup-only }} + run: | + lookup_only=true + case "$LOOKUP_REQUEST" in + [aA][uU][tT][oO]) + # Hosted runners have Node for this manifest-only check before toolchain setup. + lookup_only="$(node -p 'const p = require("./package.json"); p.engines?.node === "24" && typeof p.packageManager === "string" && p.packageManager.split("+")[0] === "pnpm@12.8.1"')" + ;; + esac + printf 'lookup-only=%s\n' "$lookup_only" >> "$GITHUB_OUTPUT" + # setup-node needs pnpm on PATH to locate and restore its store. - name: Setup pnpm uses: pnpm/setup@v2 @@ -80,7 +104,7 @@ runs: uses: actions/setup-node@v6 with: node-version-file: package.json - cache: ${{ github.event_name != 'pull_request' && inputs.cache-pnpm-store != 'false' && inputs.cache-pnpm-store-lookup-only != 'true' && 'pnpm' || '' }} + cache: ${{ github.event_name != 'pull_request' && inputs.cache-pnpm-store != 'false' && steps.pnpm-store-mode.outputs.lookup-only != 'true' && 'pnpm' || '' }} cache-dependency-path: ${{ inputs.cache-dependency-path }} package-manager-cache: false @@ -90,7 +114,7 @@ runs: uses: actions/setup-node@v6 with: node-version: ${{ inputs.node-version }} - cache: ${{ github.event_name != 'pull_request' && inputs.cache-pnpm-store != 'false' && inputs.cache-pnpm-store-lookup-only != 'true' && 'pnpm' || '' }} + cache: ${{ github.event_name != 'pull_request' && inputs.cache-pnpm-store != 'false' && steps.pnpm-store-mode.outputs.lookup-only != 'true' && 'pnpm' || '' }} cache-dependency-path: ${{ inputs.cache-dependency-path }} package-manager-cache: false @@ -104,11 +128,11 @@ runs: !(runner.arch == 'X64' && contains(inputs.cache-dependency-path, 'mobile/pnpm-lock.yaml')) && !((runner.arch == 'X64' || runner.arch == 'ARM64') && inputs.cache-dependency-path == 'pnpm-lock.yaml')) || (github.event_name != 'pull_request' && inputs.cache-pnpm-store != 'false' && - inputs.cache-pnpm-store-lookup-only == 'true') + steps.pnpm-store-mode.outputs.lookup-only == 'true') shell: bash env: LOCKFILE_HASH: ${{ hashFiles(inputs.cache-dependency-path) }} - STORE_LOOKUP_ONLY: ${{ github.event_name != 'pull_request' && inputs.cache-pnpm-store-lookup-only == 'true' }} + STORE_LOOKUP_ONLY: ${{ steps.pnpm-store-mode.outputs.lookup-only == 'true' }} run: | test -n "$LOCKFILE_HASH" cache_path="$(pnpm store path --silent)" @@ -137,12 +161,10 @@ runs: # Producers can refresh access and publish misses without downloading existing archives. - name: Keep pnpm download store without restoring id: pnpm-store-lookup - if: >- - github.event_name != 'pull_request' && inputs.cache-pnpm-store != 'false' && - inputs.cache-pnpm-store-lookup-only == 'true' + if: steps.pnpm-store-mode.outputs.lookup-only == 'true' uses: actions/cache@v5 with: - # Post-job saves cannot resolve the composite's internal step outputs. + # Twice-nested composite cleanup loses internal step outputs. path: ${{ env.ORCA_PNPM_STORE_CACHE_PATH }} key: node-cache-${{ runner.os }}-${{ steps.pnpm-store.outputs.arch }}-pnpm-${{ hashFiles(inputs.cache-dependency-path) }} lookup-only: true diff --git a/config/scripts/ci-dependency-download-cache.test.mjs b/config/scripts/ci-dependency-download-cache.test.mjs index 98a80a9d63f..f04aff20b35 100644 --- a/config/scripts/ci-dependency-download-cache.test.mjs +++ b/config/scripts/ci-dependency-download-cache.test.mjs @@ -12,7 +12,7 @@ describe('CI dependency download caches', () => { expect(action.inputs['cache-dependency-path'].default).toBe('pnpm-lock.yaml') for (const step of action.runs.steps.filter((step) => step.uses === 'actions/setup-node@v6')) { expect(step.with.cache).toBe( - "${{ github.event_name != 'pull_request' && inputs.cache-pnpm-store != 'false' && inputs.cache-pnpm-store-lookup-only != 'true' && 'pnpm' || '' }}" + "${{ github.event_name != 'pull_request' && inputs.cache-pnpm-store != 'false' && steps.pnpm-store-mode.outputs.lookup-only != 'true' && 'pnpm' || '' }}" ) expect(step.with['cache-dependency-path']).toBe('${{ inputs.cache-dependency-path }}') expect(step.with['package-manager-cache']).toBe(false) @@ -41,7 +41,7 @@ describe('CI dependency download caches', () => { "github.event_name == 'pull_request' && inputs.cache-pnpm-store != 'false' && !((runner.os == 'Linux' || runner.os == 'macOS') && (runner.arch == 'X64' || runner.arch == 'ARM64') && inputs.cache-dependency-path == 'pnpm-lock.yaml') && (runner.os != 'Windows' || !(runner.arch == 'X64' && contains(inputs.cache-dependency-path, 'mobile/pnpm-lock.yaml')) && !((runner.arch == 'X64' || runner.arch == 'ARM64') && inputs.cache-dependency-path == 'pnpm-lock.yaml'))" ) expect(resolve.if).toBe( - `${restore.if} || (github.event_name != 'pull_request' && inputs.cache-pnpm-store != 'false' && inputs.cache-pnpm-store-lookup-only == 'true')` + `${restore.if} || (github.event_name != 'pull_request' && inputs.cache-pnpm-store != 'false' && steps.pnpm-store-mode.outputs.lookup-only == 'true')` ) expect(restore.uses).toBe('actions/cache/restore@v5') expect(restore.with.path).toBe('${{ steps.pnpm-store.outputs.path }}') @@ -72,11 +72,9 @@ describe('CI dependency download caches', () => { it('keeps producer lookup optional and compatible with the existing store archive', () => { const lookup = action.runs.steps.find((step) => step.id === 'pnpm-store-lookup') const restore = action.runs.steps.find((step) => step.id === 'pnpm-store-restore') - expect(action.inputs['cache-pnpm-store-lookup-only'].default).toBe('false') + expect(action.inputs['cache-pnpm-store-lookup-only'].default).toBe('auto') expect(lookup.uses).toBe('actions/cache@v5') - expect(lookup.if).toBe( - "github.event_name != 'pull_request' && inputs.cache-pnpm-store != 'false' && inputs.cache-pnpm-store-lookup-only == 'true'" - ) + expect(lookup.if).toBe("steps.pnpm-store-mode.outputs.lookup-only == 'true'") expect(lookup.with).toEqual({ path: '${{ env.ORCA_PNPM_STORE_CACHE_PATH }}', key: restore.with.key, @@ -203,6 +201,16 @@ describe('CI dependency download caches', () => { const context = { github: { event_name: event }, runner: { os, arch }, + steps: { + 'pnpm-store-mode': { + outputs: { + 'lookup-only': + event !== 'pull_request' && storeCache !== 'false' && lookupOnly === 'true' + ? 'true' + : '' + } + } + }, inputs: { 'cache-pnpm-store': storeCache, 'cache-pnpm-store-lookup-only': lookupOnly, @@ -218,6 +226,10 @@ describe('CI dependency download caches', () => { const evaluate = (expression) => runInNewContext( expression + .replaceAll( + 'steps.pnpm-store-mode.outputs.lookup-only', + 'steps["pnpm-store-mode"].outputs["lookup-only"]' + ) .replaceAll( 'inputs.cache-pnpm-store-lookup-only', 'inputs["cache-pnpm-store-lookup-only"]' diff --git a/config/scripts/ci-pnpm-store-mode.test.mjs b/config/scripts/ci-pnpm-store-mode.test.mjs new file mode 100644 index 00000000000..d8402612b04 --- /dev/null +++ b/config/scripts/ci-pnpm-store-mode.test.mjs @@ -0,0 +1,150 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { runInNewContext } from 'node:vm' +import { parse } from 'yaml' +import { describe, expect, it } from 'vitest' +import { runProcessSync } from './script-child-process.mjs' + +const action = parse(readFileSync('.github/actions/install-node-dependencies/action.yml', 'utf8')) +const mode = action.runs.steps.find((step) => step.id === 'pnpm-store-mode') +const defaultContext = { + github: { event_name: 'push' }, + runner: { os: 'Linux', arch: 'X64', environment: 'github-hosted' }, + job: { container: { id: '' } }, + inputs: { + 'cache-pnpm-store': 'true', + 'cache-pnpm-store-lookup-only': 'auto', + 'cache-dependency-path': 'pnpm-lock.yaml', + 'node-version': '' + } +} +const expression = mode.if.replaceAll(/inputs\.([\w-]+)/g, 'inputs["$1"]') + +function eligible(changes) { + const context = structuredClone(defaultContext) + for (const [name, fields] of Object.entries(changes)) { + Object.assign(context[name], fields) + } + return runInNewContext(expression, context) +} + +function resolveMode(request, node, manager) { + const directory = mkdtempSync(join(tmpdir(), 'orca-store-mode-')) + const output = join(directory, 'output') + try { + writeFileSync( + join(directory, 'package.json'), + JSON.stringify({ engines: { node }, packageManager: manager }) + ) + const result = runProcessSync({ + program: 'bash', + args: ['-e', '-o', 'pipefail', '-c', mode.run], + cwd: directory, + env: { ...process.env, LOOKUP_REQUEST: request, GITHUB_OUTPUT: output } + }) + expect(result.code, result.stderr || result.stdout).toBe(0) + return readFileSync(output, 'utf8') + } finally { + rmSync(directory, { recursive: true, force: true }) + } +} + +describe('automatic pnpm store mode', () => { + it.each([ + ['auto', '24', 'pnpm@12.8.1', '', true], + ['auto', '25', 'pnpm@12.8.1', 'pnpm', false], + ['auto', '24', 'pnpm@13.0.0', 'pnpm', false], + ['true', '25', 'pnpm@13.0.0', '', true] + ])( + 'routes resolved %s mode for Node %s / %s into both cache steps', + (request, node, manager, cache, lookup) => { + const context = structuredClone(defaultContext) + context.inputs['cache-pnpm-store-lookup-only'] = request + const resolved = resolveMode(request, node, manager).split('=')[1].trim() + const evaluate = (value) => + runInNewContext( + value + .replaceAll(/inputs\.([\w-]+)/g, 'inputs["$1"]') + .replaceAll( + 'steps.pnpm-store-mode.outputs.lookup-only', + 'steps["pnpm-store-mode"].outputs["lookup-only"]' + ), + { ...context, steps: { 'pnpm-store-mode': { outputs: { 'lookup-only': resolved } } } } + ) + const nodeSetup = action.runs.steps.find((step) => step.id === 'default-node') + expect(evaluate(nodeSetup.with.cache.slice(3, -2))).toBe(cache) + expect(evaluate(action.runs.steps.find((step) => step.id === 'pnpm-store-lookup').if)).toBe( + lookup + ) + } + ) + + it.each( + ['Linux', 'Windows', 'macOS'].flatMap((os) => ['X64', 'ARM64'].map((arch) => [os, arch])) + )('qualifies the measured %s/%s hosted root context', (os, arch) => { + expect(eligible({ runner: { os, arch } })).toBe(true) + }) + + it.each([ + ['PR', { github: { event_name: 'pull_request' } }], + ['opted-out store', { inputs: { 'cache-pnpm-store': 'false' } }], + ['opted-out lookup', { inputs: { 'cache-pnpm-store-lookup-only': 'false' } }], + ['unknown request', { inputs: { 'cache-pnpm-store-lookup-only': 'other' } }], + [ + 'mixed lockfiles', + { inputs: { 'cache-dependency-path': 'pnpm-lock.yaml\nmobile/pnpm-lock.yaml' } } + ], + ['custom lockfile', { inputs: { 'cache-dependency-path': 'cloud/pnpm-lock.yaml' } }], + ['Node 25', { inputs: { 'node-version': '25' } }], + ['job container', { job: { container: { id: 'container-id' } } }], + ['self-hosted runner', { runner: { environment: 'self-hosted' } }], + ['unknown host kind', { runner: { environment: '' } }], + ['unmeasured architecture', { runner: { arch: 'X86' } }], + ['unmeasured OS', { runner: { os: 'other' } }] + ])('retains the legacy policy for %s', (_name, changes) => { + expect(eligible(changes)).toBe(false) + }) + + it('allows an explicit request to preserve the existing force-lookup contract', () => { + expect( + eligible({ + inputs: { + 'cache-pnpm-store-lookup-only': 'true', + 'node-version': '25', + 'cache-dependency-path': 'custom-lock.yaml' + }, + runner: { environment: 'self-hosted' }, + job: { container: { id: 'container-id' } } + }) + ).toBe(true) + expect( + eligible({ + github: { event_name: 'pull_request' }, + inputs: { 'cache-pnpm-store-lookup-only': 'true' } + }) + ).toBe(false) + }) + + it.each([ + ['24', 'pnpm@12.8.1', 'true'], + ['24', 'pnpm@12.8.1+sha512.fixture', 'true'], + ['25', 'pnpm@12.8.1', 'false'], + ['24.x', 'pnpm@12.8.1', 'false'], + ['24', 'pnpm@12.8.2', 'false'], + ['24', 'pnpm@12.8.10', 'false'], + ['24', undefined, 'false'], + [undefined, 'pnpm@12.8.1', 'false'], + ['24', 12, 'false'] + ])('checks manifest Node %s and manager %s before choosing lookup', (node, manager, expected) => { + expect(resolveMode('auto', node, manager)).toBe(`lookup-only=${expected}\n`) + }) + + it('checks uppercase auto requests consistently with GitHub expression comparisons', () => { + expect(resolveMode('AUTO', '25', 'pnpm@12.8.1')).toBe('lookup-only=false\n') + }) + + it('does not constrain an explicit request to the automatic manifest profile', () => { + expect(resolveMode('true', '25', 'pnpm@13.0.0')).toBe('lookup-only=true\n') + }) +}) diff --git a/config/scripts/mobile-release-shell-switch-workflow.test.mjs b/config/scripts/mobile-release-shell-switch-workflow.test.mjs index 2da3b360ad3..5f45d6872ac 100644 --- a/config/scripts/mobile-release-shell-switch-workflow.test.mjs +++ b/config/scripts/mobile-release-shell-switch-workflow.test.mjs @@ -123,7 +123,7 @@ const REVIEWED_COMPUTED_PATHS = [ '${{ env.ORCA_PNPM_STORE_CACHE_PATH }}', // Only pnpm's lockfile-verified.jsonl record, never Metro transforms. '${{ steps.verification-cache.outputs.path }}', - "${{ github.event_name != 'pull_request' && inputs.cache-pnpm-store != 'false' && inputs.cache-pnpm-store-lookup-only != 'true' && 'pnpm' || '' }} store" + "${{ github.event_name != 'pull_request' && inputs.cache-pnpm-store != 'false' && steps.pnpm-store-mode.outputs.lookup-only != 'true' && 'pnpm' || '' }} store" ] /** Every step a workflow runs, descending into the repository's own composite actions. */ diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs index a7bb79ee9ef..1d49c34cc6b 100644 --- a/config/scripts/pr-workflow-parallelism.test.mjs +++ b/config/scripts/pr-workflow-parallelism.test.mjs @@ -290,7 +290,7 @@ describe('PR workflow parallelism', () => { expect(steps[pnpmIndex].with.version).toBeUndefined() expect(steps[pnpmIndex].with.install).toBe(false) const saveOutsidePrs = - "${{ github.event_name != 'pull_request' && inputs.cache-pnpm-store != 'false' && inputs.cache-pnpm-store-lookup-only != 'true' && 'pnpm' || '' }}" + "${{ github.event_name != 'pull_request' && inputs.cache-pnpm-store != 'false' && steps.pnpm-store-mode.outputs.lookup-only != 'true' && 'pnpm' || '' }}" expect(steps[nodeIndex].with.cache).toBe(saveOutsidePrs) expect(steps[nodeIndex].if).toBe("inputs.node-version == ''") expect(steps[requestedNodeIndex].if).toBe("inputs.node-version != ''") diff --git a/docs/reference/ci-runner-efficiency.md b/docs/reference/ci-runner-efficiency.md index 7a3c55754ef..da444138afe 100644 --- a/docs/reference/ci-runner-efficiency.md +++ b/docs/reference/ci-runner-efficiency.md @@ -228,8 +228,8 @@ proved that lookup left the payload absent, refreshed the existing cache's acces time, and published a miss that a fresh job restored. A [nested composite control](https://github.com/stablyai/orca/actions/runs/37084946789) then saved and restored a fresh payload using the actual environment-path pattern. -The installer exports its resolved store path through `GITHUB_ENV`: post-job saves -cannot resolve the composite's internal step outputs. The primary key is captured +The installer exports its resolved store path through `GITHUB_ENV`: twice-nested composite post-job saves +cannot resolve their internal step outputs. The primary key is captured by the cache action before cleanup. Paths, architecture and lockfile keys match `setup-node`, so existing default-branch archives remain reusable. @@ -1814,3 +1814,40 @@ collector stopped its observer before signal routing, and the corrected trial received the signal after both builders finished. The qualifying trial requested normal cancellation earlier in the same preparation sequence to account for observed delivery delay; no workload, wait or proof predicate was shortened. + +## October 3 producer follow-up: automatic selection for the measured profile + +The first producer rollout in [#24927](https://github.com/stablyai/orca/pull/24927) +passed all 46 PR checks, all five manual warmers and all 11 manual Headless +qualifications on `a2c489c0cca5e46d24333a4d40ba910af0de0208`. The same root installer +also serves recurring unit, browser and performance workflows that had not opted +in. The follow-up defaults the existing input to `auto`, reusing lookup mode for +non-PR root-only installs on GitHub-hosted Linux/macOS/Windows x64/ARM64 runners, +with no job container, the manifest's Node 24/pnpm 12.8.1 profile and no conflicting +Node override. Explicit `true` and `false` retain their previous meanings. Mixed +lockfiles, other toolchains, containers and self-hosted runners retain full cache +restoration; PR policies are unchanged. The manifest check runs only when the +context is potentially eligible, before setup-node chooses its cache behavior. + +A second cleanup audit distinguished nesting depth. The +[twice-nested control](https://github.com/stablyai/orca/actions/runs/37087090689) +published the environment-path payload and lost the output-path payload with an +`Input required and not supplied: path` warning. The +[direct control](https://github.com/stablyai/orca/actions/runs/37087211236) published +and restored both payloads. Current Electron archive callers are direct, so they +need no cache-path change. Keeping the producer's exported path also makes its +new lookup mode safe for callers that nest the shared installer. These tiny +controls establish publication behavior, not installer time savings. + +The [actual automatic-mode cold publisher control](https://github.com/stablyai/orca/actions/runs/37097980789) +passed both jobs on `7b8858bdc8f`. A twice-nested wrapper called the installer +without overriding its default input. The writer selected lookup, missed its +unique root-lockfile key, completed the frozen policy-checked install and saved +that key during cleanup. A fresh reader restored the exact key and installed the +same dependency successfully. The fixture retained the manifest toolchain and +applicable workspace policies; its one dependency keeps the publication check +small. Two earlier trials failed fixture assertions (the pnpm multi-document +header placement, then its empty cache-miss output), and are excluded. This proves +automatic selection and cold publication, not a new timing result. Local +verification passed eight suites / 184 tests, the changed-code quality gate and +compiled-composite actionlint.