diff --git a/.github/actions/prepare-native-runtime/action.yml b/.github/actions/prepare-native-runtime/action.yml index fe60ef1f656..e487d7beef5 100644 --- a/.github/actions/prepare-native-runtime/action.yml +++ b/.github/actions/prepare-native-runtime/action.yml @@ -77,12 +77,13 @@ runs: ORCA_NATIVE_BUILD_DIRECTORIES ORCA_NATIVE_CACHE_PATHS - # The warmers seed every Windows image and runtime on main; PR copies were single-use 8-14 MB uploads. + # The warmers seed every Windows image and runtime, and hosted Linux Electron, on main; PR copies were single-use uploads. - name: Restore compiled native modules id: native-cache-restore if: >- inputs.native-runtime != 'none' && inputs.persist-native-cache != 'false' && - !(github.event_name == 'pull_request' && runner.os == 'Windows') + !(github.event_name == 'pull_request' && (runner.os == 'Windows' || + runner.os == 'Linux' && inputs.native-runtime == 'electron' && runner.environment == 'github-hosted')) uses: actions/cache@v5 with: # Post-job saving cannot resolve nested composite step outputs. @@ -97,7 +98,8 @@ runs: id: native-cache-restore-only if: >- inputs.native-runtime != 'none' && (inputs.persist-native-cache == 'false' || - github.event_name == 'pull_request' && runner.os == 'Windows') + github.event_name == 'pull_request' && (runner.os == 'Windows' || + runner.os == 'Linux' && inputs.native-runtime == 'electron' && runner.environment == 'github-hosted')) uses: actions/cache/restore@v5 with: path: | diff --git a/.github/workflows/ci-cache-warmup-platform-jobs.yml b/.github/workflows/ci-cache-warmup-platform-jobs.yml index 7b24b8f6be8..91fa1eac53b 100644 --- a/.github/workflows/ci-cache-warmup-platform-jobs.yml +++ b/.github/workflows/ci-cache-warmup-platform-jobs.yml @@ -180,6 +180,91 @@ jobs: native-runtime: electron cache-pnpm-store: 'false' + # Hosted Linux packaging and packaged-browser E2E restore these; PRs no longer save their own copies. + warm-linux-electron: + concurrency: + group: ci-cache-warmup-linux-electron-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name != 'schedule' }} + runs-on: ubuntu-24.04 + timeout-minutes: 15 + env: + ORCA_BACKGROUND_LAUNCH: '1' + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: ./.github/actions/install-node-dependencies + with: + native-runtime: electron + cache-pnpm-store: 'false' + + - name: Resolve electron-builder download versions + id: eb-versions + if: github.event_name != 'pull_request' + shell: bash + run: node -p "const d = require('./package.json').devDependencies; 'versions=electron' + d.electron + '-builder' + d['electron-builder']" >> "$GITHUB_OUTPUT" + # pr.yml package restores this exact path and key. + - name: Seed shared Linux packaging downloads + id: eb-cache + if: github.event_name != 'pull_request' + uses: actions/cache@v5 + with: + path: ~/.cache/electron-builder + key: electron-builder-linux-${{ steps.eb-versions.outputs.versions }} + lookup-only: true + # The same toolsets electron-builder fetches for AppImage, deb and rpm. + - name: Download Linux packaging tools + if: github.event_name != 'pull_request' && steps.eb-cache.outputs.cache-hit != 'true' + run: >- + node -e "const linux = require('app-builder-lib/out/toolsets/linux.js'); + const { getIconsToolsetPath } = require('app-builder-lib/out/toolsets/icons.js'); + const { Arch } = require('builder-util'); + const { toolsets } = require('./config/electron-builder-pr-linux.config.cjs'); + (async () => { await getIconsToolsetPath(); await linux.getFpmPath(); + await linux.getAppImageTools(toolsets.appimage, Arch.x64) })().catch((error) => { console.error(error); process.exit(1) })" + + # Mobile verify restores this bundle on pull requests without saving. + warm-mobile-bundle: + if: github.event_name != 'pull_request' + concurrency: + group: ci-cache-warmup-mobile-bundle-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name != 'schedule' }} + runs-on: ubuntu-24.04-arm + timeout-minutes: 15 + env: + BUNDLE_FROZEN: 'true' + defaults: + run: + working-directory: mobile + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.3' + working-directory: mobile + - name: Resolve fastlane bundle cache + id: ruby-bundle + env: + GEMFILE_HASH: ${{ hashFiles('mobile/Gemfile', 'mobile/Gemfile.lock') }} + run: | + . /etc/os-release + printf 'key=mobile-bundle-v1-%s-%s-%s-%s\n' "$ID-$VERSION_ID" "$(ruby -e 'print RUBY_PLATFORM')" "$(ruby -e 'print RbConfig::CONFIG["ruby_version"]')" "$GEMFILE_HASH" >> "$GITHUB_OUTPUT" + - name: Seed fastlane bundle + id: ruby-bundle-cache + uses: actions/cache@v5 + with: + path: mobile/vendor/bundle + key: ${{ steps.ruby-bundle.outputs.key }} + lookup-only: true + - name: Install fastlane + if: steps.ruby-bundle-cache.outputs.cache-hit != 'true' + run: | + bundle config set --local path "$PWD/vendor/bundle" + bundle config set --local deployment true + bundle install --jobs 4 + warm-linux-package-fixtures: if: github.event_name != 'pull_request' concurrency: diff --git a/.github/workflows/ci-cache-warmup-platforms.yml b/.github/workflows/ci-cache-warmup-platforms.yml index 440a0976af8..b1d6a7c726b 100644 --- a/.github/workflows/ci-cache-warmup-platforms.yml +++ b/.github/workflows/ci-cache-warmup-platforms.yml @@ -62,6 +62,8 @@ on: - 'config/scripts/build-windows-agent-hook.mjs' - 'config/scripts/windows-rust-executable-build.mjs' - 'resources/build/icon.ico' + - 'mobile/Gemfile' + - 'mobile/Gemfile.lock' pull_request: types: [opened, synchronize, reopened, ready_for_review, converted_to_draft] paths: diff --git a/.github/workflows/mobile.yml b/.github/workflows/mobile.yml index 0d0237a102d..f4ea49d9571 100644 --- a/.github/workflows/mobile.yml +++ b/.github/workflows/mobile.yml @@ -141,20 +141,47 @@ jobs: echo 'should_run=true' >> "$GITHUB_OUTPUT" fi - # Bundler-cache proves the release’s pinned fastlane still resolves before a release needs it. - - name: Setup Ruby and fastlane - id: ruby-tools + - name: Setup Ruby + id: ruby-setup background: true if: steps.ruby-scope.outputs.should_run != 'false' uses: ruby/setup-ruby@v1 with: ruby-version: '3.3' - bundler-cache: true working-directory: mobile - name: Install dependencies run: pnpm install --frozen-lockfile + - wait: ruby-setup + + # ruby/setup-ruby's bundler-cache saves on every miss; the platform warmup saves this key on main. + - name: Resolve fastlane bundle cache + id: ruby-bundle + if: steps.ruby-scope.outputs.should_run != 'false' + env: + GEMFILE_HASH: ${{ hashFiles('mobile/Gemfile', 'mobile/Gemfile.lock') }} + run: | + . /etc/os-release + printf 'key=mobile-bundle-v1-%s-%s-%s-%s\n' "$ID-$VERSION_ID" "$(ruby -e 'print RUBY_PLATFORM')" "$(ruby -e 'print RbConfig::CONFIG["ruby_version"]')" "$GEMFILE_HASH" >> "$GITHUB_OUTPUT" + + - name: Restore fastlane bundle + if: steps.ruby-scope.outputs.should_run != 'false' + uses: actions/cache/restore@v5 + with: + path: mobile/vendor/bundle + key: ${{ steps.ruby-bundle.outputs.key }} + + # Installing proves the release’s pinned fastlane still resolves before a release needs it. + - name: Install fastlane + id: ruby-tools + background: true + if: steps.ruby-scope.outputs.should_run != 'false' + run: | + bundle config set --local path "$PWD/vendor/bundle" + bundle config set --local deployment true + bundle install --jobs 4 + # Check mobile import cycles after Expo deps install; pre-install pr.yml cannot load its tsconfig. - name: Lint mobile imports with mobile dependencies installed working-directory: . @@ -298,8 +325,10 @@ jobs: with: persist-credentials: false + # Restoring the 650 MB store took longer than downloading these packages. - uses: ./.github/actions/install-node-dependencies with: + cache-pnpm-store: 'false' cache-dependency-path: | pnpm-lock.yaml mobile/pnpm-lock.yaml diff --git a/.github/workflows/node-server-tests.yml b/.github/workflows/node-server-tests.yml index cab510df828..7fd95253d27 100644 --- a/.github/workflows/node-server-tests.yml +++ b/.github/workflows/node-server-tests.yml @@ -409,7 +409,10 @@ jobs: persist-credentials: false - name: Trust the checked-out workspace run: git config --global --add safe.directory "$GITHUB_WORKSPACE" + # A direct install (6-9s) beats restoring the 417 MB store plus a linking install. - uses: ./.github/actions/install-node-dependencies + with: + cache-pnpm-store: 'false' - name: Build and smoke this runner's node-pty prebuild slot under the pinned Node run: | pnpm build:orcad-prebuilds diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index a527fa75c25..da43aa890a5 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -1042,11 +1042,18 @@ jobs: with: persist-credentials: false + # Electron and builder releases fix what this downloads; the lockfile churns far more often. + - name: Resolve electron-builder download versions + id: eb-versions + shell: bash + run: node -p "const d = require('./package.json').devDependencies; 'versions=electron' + d.electron + '-builder' + d['electron-builder']" >> "$GITHUB_OUTPUT" + + # The platform warmup seeds this on main. - name: Cache electron-builder downloads uses: actions/cache/restore@v5 with: path: ~/.cache/electron-builder - key: electron-builder-linux-${{ hashFiles('pnpm-lock.yaml') }} + key: electron-builder-linux-${{ steps.eb-versions.outputs.versions }} restore-keys: | electron-builder-linux- diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index 47ddd30176f..57b0316ca88 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -1217,7 +1217,7 @@ jobs: uses: actions/cache@v5 with: path: ~/.cache/electron-builder - key: electron-builder-linux-${{ hashFiles('pnpm-lock.yaml') }} + key: electron-builder-linux-${{ steps.eb-versions.outputs.versions }} # The release cache above restores the same tools, so this only needs to save on a miss. lookup-only: true diff --git a/config/scripts/ci-dependency-download-cache.test.mjs b/config/scripts/ci-dependency-download-cache.test.mjs index 14427ddf15f..495c8ae6680 100644 --- a/config/scripts/ci-dependency-download-cache.test.mjs +++ b/config/scripts/ci-dependency-download-cache.test.mjs @@ -32,6 +32,24 @@ describe('CI dependency download caches', () => { ]) }) + it('restores the fastlane bundle on pull requests from the key only the warmup saves', () => { + const verify = workflow('mobile').jobs.verify.steps + const warm = workflow('ci-cache-warmup-platform-jobs').jobs['warm-mobile-bundle'] + const key = (steps) => steps.find((step) => step.name === 'Resolve fastlane bundle cache') + expect(key(warm.steps).run).toBe(key(verify).run) + expect(key(warm.steps).env).toEqual(key(verify).env) + const restore = verify.find((step) => step.name === 'Restore fastlane bundle') + const seed = warm.steps.find((step) => step.name === 'Seed fastlane bundle') + expect(restore.uses).toBe('actions/cache/restore@v5') + expect(restore.with).toEqual({ path: seed.with.path, key: seed.with.key }) + expect(warm['runs-on']).toBe(workflow('mobile').jobs.verify['runs-on']) + for (const step of [...verify, ...warm.steps].filter((step) => + step.uses?.startsWith('ruby/') + )) { + expect(step.with['bundler-cache']).toBeUndefined() + } + }) + it('builds internal packages before native preparation after a script-free install', () => { const steps = action.runs.steps const buildIndex = steps.findIndex((step) => step.name === 'Build internal packages') @@ -509,6 +527,16 @@ describe('CI dependency download caches', () => { expect(release.steps.indexOf(writer)).toBeGreaterThan(release.steps.indexOf(combined)) expect(consumer.uses).toBe('actions/cache/restore@v5') expect(consumer.with['restore-keys'].trim()).toBe('electron-builder-linux-') + // Hosted PRs read GitHub's store, which the release alone leaves empty between cuts. + const warm = workflow('ci-cache-warmup-platform-jobs').jobs['warm-linux-electron'] + const seed = warm.steps.find((step) => step.name === 'Seed shared Linux packaging downloads') + expect(warm['runs-on']).toBe(packaging['runs-on']) + expect(seed.with).toEqual(writer.with) + for (const steps of [packaging.steps, release.steps, warm.steps]) { + expect(steps.find((step) => step.id === 'eb-versions').run).toBe( + packaging.steps.find((step) => step.id === 'eb-versions').run + ) + } expect(combined.uses).toBe('actions/cache@v5') expect(linux.eb_cache_path.trim().split('\n')).toEqual([ '~/.cache/electron', diff --git a/config/scripts/ci-native-cache.test.mjs b/config/scripts/ci-native-cache.test.mjs index 9045fe926ea..419dd70b3eb 100644 --- a/config/scripts/ci-native-cache.test.mjs +++ b/config/scripts/ci-native-cache.test.mjs @@ -151,10 +151,23 @@ describe('CI native cache ownership', () => { expect(install.with['persist-native-cache']).toBe('false') expect(save).toBeUndefined() const saving = native.runs.steps.find((step) => step.id === 'native-cache-restore') - expect(saving.if).toContain("!(github.event_name == 'pull_request' && runner.os == 'Windows')") + expect(saving.if).toContain( + "!(github.event_name == 'pull_request' && (runner.os == 'Windows' || " + + "runner.os == 'Linux' && inputs.native-runtime == 'electron' && runner.environment == 'github-hosted'))" + ) const warm = parse(readFileSync('.github/workflows/ci-cache-warmup-platform-jobs.yml', 'utf8')) expect(warm.jobs['warm-windows'].strategy.matrix.os).toContain(job['runs-on']) expect(warm.jobs['warm-windows-electron']['runs-on']).toBe(job['runs-on']) + const linuxPackage = parse(readFileSync('.github/workflows/pr.yml', 'utf8')).jobs.package + const linuxWarm = warm.jobs['warm-linux-electron'] + expect(linuxWarm['runs-on']).toBe(linuxPackage['runs-on']) + for (const consumer of [linuxPackage, linuxWarm]) { + const install = consumer.steps.find((step) => + step.uses?.includes('install-node-dependencies') + ) + expect(install.with['native-runtime']).toBe('electron') + expect(install.with['node-version']).toBeUndefined() + } expect(job.steps.indexOf(build)).toBeLessThan(job.steps.indexOf(electron)) expect(electron.uses).toBe('./.github/actions/prepare-native-runtime') expect(electron.with).toEqual({ diff --git a/config/scripts/mobile-release-check-scope.test.mjs b/config/scripts/mobile-release-check-scope.test.mjs index 7195be14d45..169ff1255d1 100644 --- a/config/scripts/mobile-release-check-scope.test.mjs +++ b/config/scripts/mobile-release-check-scope.test.mjs @@ -87,7 +87,10 @@ it('gates Ruby independently and retains mobile static validation', () => { step.name !== 'Summarize RPC recording changes' ) expect(gated.map((step) => step.name)).toEqual([ - 'Setup Ruby and fastlane', + 'Setup Ruby', + 'Resolve fastlane bundle cache', + 'Restore fastlane bundle', + 'Install fastlane', 'Test iOS release version resolution', 'Test TestFlight lane arguments', 'Smoke-check the Fastfile', @@ -115,9 +118,7 @@ it('gates Ruby independently and retains mobile static validation', () => { expect(workflow.on.pull_request.paths.some((pattern) => matchesGlob(file, pattern))).toBe(true) } expect(workflow.jobs.verify.env.BUNDLE_FROZEN).toBe('true') - expect(steps.find((step) => step.name === 'Setup Ruby and fastlane').with['bundler-cache']).toBe( - true - ) + expect(steps.find((step) => step.name === 'Install fastlane').run).toContain('bundle install') expect(steps.find((step) => step.name === 'Smoke-check the Fastfile').run).toBe( 'bundle exec fastlane lanes' ) diff --git a/config/scripts/mobile-test-change-scope.test.mjs b/config/scripts/mobile-test-change-scope.test.mjs index 5e8deff2e25..2d4f6b30628 100644 --- a/config/scripts/mobile-test-change-scope.test.mjs +++ b/config/scripts/mobile-test-change-scope.test.mjs @@ -165,9 +165,11 @@ it('overlaps both typechecks with tests and collects their failures before the s const test = steps.findIndex((step) => step.name === 'Test') const summary = steps.findIndex((step) => step.name === 'Summarize RPC recording changes') const waits = steps.filter((step) => step.wait !== undefined) - expect(waits).toHaveLength(1) - const [wait] = waits - expect(wait.wait).toEqual(['production-types', 'test-types', 'ruby-tools']) + expect(waits.map((step) => step.wait)).toEqual([ + 'ruby-setup', + ['production-types', 'test-types', 'ruby-tools'] + ]) + const wait = waits.at(-1) expect(wait.if).toBeUndefined() expect(steps.indexOf(wait)).toBeGreaterThan(test) expect(steps.indexOf(wait)).toBeLessThan(summary) @@ -180,10 +182,14 @@ it('overlaps both typechecks with tests and collects their failures before the s expect(steps.indexOf(step)).toBeLessThan(test) expect(step.run).toMatch(/^node /) } + const setup = steps.find((step) => step.id === 'ruby-setup') + expect(setup.uses).toBe('ruby/setup-ruby@v1') + expect(steps.indexOf(setup)).toBeLessThan(install) const ruby = steps.find((step) => step.id === 'ruby-tools') - expect(ruby.uses).toBe('ruby/setup-ruby@v1') - expect(ruby.background).toBe(true) - expect(ruby.if).toBe("steps.ruby-scope.outputs.should_run != 'false'") - expect(ruby['continue-on-error']).toBeUndefined() - expect(steps.indexOf(ruby)).toBeLessThan(install) + expect(steps.indexOf(ruby)).toBeLessThan(test) + for (const step of [setup, ruby]) { + expect(step.background).toBe(true) + expect(step.if).toBe("steps.ruby-scope.outputs.should_run != 'false'") + expect(step['continue-on-error']).toBeUndefined() + } }) diff --git a/config/scripts/mobile-typecheck-cache.test.mjs b/config/scripts/mobile-typecheck-cache.test.mjs index 10afb469029..6ab327d14e4 100644 --- a/config/scripts/mobile-typecheck-cache.test.mjs +++ b/config/scripts/mobile-typecheck-cache.test.mjs @@ -14,7 +14,7 @@ it('runs uncached Bun checks like root CI and collects failures on pull requests ).toBe(false) const install = steps.findIndex((step) => step.name === 'Install dependencies') expect(install).toBeGreaterThanOrEqual(0) - const wait = steps.find((step) => step.wait) + const wait = steps.findLast((step) => step.wait) for (const id of ['production-types', 'test-types']) { const check = steps.find((step) => step.id === id) expect(check.if).toBeUndefined()