diff --git a/.github/actions/install-node-dependencies/action.yml b/.github/actions/install-node-dependencies/action.yml index 7695d2bec9b..95ef7e96cc9 100644 --- a/.github/actions/install-node-dependencies/action.yml +++ b/.github/actions/install-node-dependencies/action.yml @@ -10,6 +10,10 @@ inputs: description: Node.js version override; defaults to the version declared in package.json. required: false default: '' + cache-dependency-path: + description: Lockfiles for the pnpm download store; include mobile/pnpm-lock.yaml only when the job installs mobile dependencies. + required: false + default: pnpm-lock.yaml persist-native-cache: description: Save restored native modules at job end. Set false when a later step overwrites the same path with a different ABI. required: false @@ -39,9 +43,7 @@ runs: with: install: false - # Why both lockfiles: setup-node keys the pnpm store on the root lockfile alone, so - # jobs that also install mobile restored a store with none of the React Native tree - # in it and re-downloaded the lot on every run. + # Desktop-only jobs should not miss their download cache when mobile dependencies change. - name: Setup Node.js id: default-node if: inputs.node-version == '' @@ -49,9 +51,7 @@ runs: with: node-version-file: package.json cache: pnpm - cache-dependency-path: | - pnpm-lock.yaml - mobile/pnpm-lock.yaml + cache-dependency-path: ${{ inputs.cache-dependency-path }} - name: Setup requested Node.js id: requested-node @@ -60,9 +60,7 @@ runs: with: node-version: ${{ inputs.node-version }} cache: pnpm - cache-dependency-path: | - pnpm-lock.yaml - mobile/pnpm-lock.yaml + cache-dependency-path: ${{ inputs.cache-dependency-path }} - name: Validate native runtime shell: bash diff --git a/.github/workflows/adhoc-mac-build.yml b/.github/workflows/adhoc-mac-build.yml index 3e17eee9b68..ac49acdbb83 100644 --- a/.github/workflows/adhoc-mac-build.yml +++ b/.github/workflows/adhoc-mac-build.yml @@ -160,16 +160,14 @@ jobs: - name: Checkout the requested ref uses: actions/checkout@v6 - env: - # Full-history checkout must also preserve case-twin branch and tag names. - GIT_DEFAULT_REF_FORMAT: reftable with: # Why an input at all rather than just github.ref: the whole point is to # build code that has not landed, and the workflow definition itself # always comes from the dispatch ref — naming the branch here instead # applies main's current copy of this file to an arbitrary branch. ref: ${{ steps.vetted.outputs.sha }} - fetch-depth: 0 + # Version helpers only read HEAD; published versions come from the release API. + fetch-depth: 1 # This job only reads stablyai/orca and never pushes; every write goes # to the adhoc repo through a minted App token passed by env. Not # persisting the checkout credential shrinks the blast radius if a build diff --git a/.github/workflows/daily-mac-build.yml b/.github/workflows/daily-mac-build.yml index 45130e932d8..e70fc928295 100644 --- a/.github/workflows/daily-mac-build.yml +++ b/.github/workflows/daily-mac-build.yml @@ -90,7 +90,8 @@ jobs: uses: actions/checkout@v6 with: ref: main - fetch-depth: 0 + # Version helpers only read HEAD; published versions come from the release API. + fetch-depth: 1 # Why: this job only reads stablyai/orca and never pushes; every write # goes to the daily repo through a minted App token passed by env. # Not persisting the checkout credential shrinks the blast radius if a diff --git a/.github/workflows/hourly-mac-build.yml b/.github/workflows/hourly-mac-build.yml index c300b2543b8..e55adc54995 100644 --- a/.github/workflows/hourly-mac-build.yml +++ b/.github/workflows/hourly-mac-build.yml @@ -137,7 +137,8 @@ jobs: uses: actions/checkout@v6 with: ref: ${{ needs.preflight.outputs.head_sha }} - fetch-depth: 0 + # Version helpers only read HEAD; published versions come from the release API. + fetch-depth: 1 # Why: this job only reads stablyai/orca and never pushes; every write # goes to the hourly repo through a minted App token passed by env. # Not persisting the checkout credential shrinks the blast radius if a diff --git a/.github/workflows/mobile.yml b/.github/workflows/mobile.yml index 6dbfc02aa3c..9afc865f87e 100644 --- a/.github/workflows/mobile.yml +++ b/.github/workflows/mobile.yml @@ -41,6 +41,10 @@ jobs: uses: actions/checkout@v6 - uses: ./.github/actions/install-node-dependencies + with: + cache-dependency-path: | + pnpm-lock.yaml + mobile/pnpm-lock.yaml # bundler-cache installs mobile/Gemfile.lock, so this job is also what # proves the pinned fastlane the release workflow depends on still diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index a9e45ef116e..45a3d12c2d8 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -126,6 +126,9 @@ jobs: - uses: ./.github/actions/install-node-dependencies with: native-runtime: node + cache-dependency-path: | + pnpm-lock.yaml + mobile/pnpm-lock.yaml - name: Lint run: pnpm exec oxlint --format github diff --git a/config/scripts/ci-dependency-download-cache.test.mjs b/config/scripts/ci-dependency-download-cache.test.mjs new file mode 100644 index 00000000000..9cce5bb0f6f --- /dev/null +++ b/config/scripts/ci-dependency-download-cache.test.mjs @@ -0,0 +1,30 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import { parse } from 'yaml' + +const read = (path) => parse(readFileSync(path, 'utf8')) +const workflow = (name) => read(`.github/workflows/${name}.yml`) +const action = read('.github/actions/install-node-dependencies/action.yml') + +describe('CI dependency download caches', () => { + it('scopes desktop stores to the root lockfile and lets mixed installs opt in', () => { + 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('pnpm') + expect(step.with['cache-dependency-path']).toBe('${{ inputs.cache-dependency-path }}') + } + const install = action.runs.steps.find((step) => step.name === 'Install dependencies') + expect(install.if).toBeUndefined() + expect(install.run).toContain('pnpm install --frozen-lockfile --ignore-scripts') + expect(install.run).toContain( + 'diff --exit-code -- package.json pnpm-lock.yaml pnpm-workspace.yaml' + ) + const mobile = workflow('mobile').jobs.verify.steps.find((step) => + step.uses?.includes('install-node-dependencies') + ) + expect(mobile.with['cache-dependency-path'].trim().split('\n')).toEqual([ + 'pnpm-lock.yaml', + 'mobile/pnpm-lock.yaml' + ]) + }) +}) diff --git a/config/scripts/workflow-ref-mirror-case-safety.test.mjs b/config/scripts/workflow-ref-mirror-case-safety.test.mjs index 31366f5e489..497a58653a0 100644 --- a/config/scripts/workflow-ref-mirror-case-safety.test.mjs +++ b/config/scripts/workflow-ref-mirror-case-safety.test.mjs @@ -1,7 +1,10 @@ -import { readFileSync } from 'node:fs' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' import { describe, expect, it } from 'vitest' import { parse } from 'yaml' +import { runProcessSync } from '../../src/shared/child-process/run-process' const projectDir = resolve(import.meta.dirname, '../..') @@ -15,13 +18,74 @@ const REF_MIRRORS = [ ] describe('ref-mirroring vet steps', () => { - it('keeps the full-history adhoc checkout on the same case-safe backend', () => { + it.each(['daily', 'hourly', 'adhoc'])('%s builds only need the current commit', (channel) => { + const job = readWorkflow(`.github/workflows/${channel}-mac-build.yml`).jobs[ + `build-${channel}-mac` + ] + const checkout = job.steps.find((step) => step.uses === 'actions/checkout@v6') + expect(checkout.with['fetch-depth']).toBe(1) + expect(job.steps.some((step) => step.run?.includes('gh release list'))).toBe(true) + expect( + job.steps.some((step) => step.run?.includes('ORCA_PUBLISHED_VERSIONS="$published"')) + ).toBe(true) + }) + + it('retains release-cut history for version reservation and retry ancestry', () => { + const checkout = readWorkflow('.github/workflows/release-cut.yml').jobs.cut.steps.find( + (step) => step.uses === 'actions/checkout@v6' + ) + expect(checkout.with['fetch-depth']).toBe(0) + }) + + it('resolves identical dev identities in full and depth-one checkouts without local tags', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-checkout-identity-')) + const source = join(directory, 'source') + const shallow = join(directory, 'shallow') + const run = (program, args, cwd) => { + const result = runProcessSync({ program, args, cwd }) + expect(result.code, result.stderr).toBe(0) + return result.stdout.trim() + } + const git = (args, cwd = directory) => run('git', args, cwd) + try { + git(['init', source]) + git(['config', 'user.name', 'CI test'], source) + git(['config', 'user.email', 'ci@example.invalid'], source) + writeFileSync(join(source, 'package.json'), JSON.stringify({ version: '1.4.165-rc.0' })) + git(['add', 'package.json'], source) + git(['-c', 'commit.gpgsign=false', 'commit', '-m', 'initial'], source) + git(['tag', 'v1.4.167'], source) + git(['-c', 'commit.gpgsign=false', 'commit', '--allow-empty', '-m', 'head'], source) + git(['clone', '--depth=1', '--no-tags', pathToFileURL(source).href, shallow]) + expect(git(['rev-list', '--count', 'HEAD'], shallow)).toBe('1') + expect(git(['tag', '--list'], shallow)).toBe('') + const script = ` + const result = []; + for (const [channel, exported] of [['daily', 'Daily'], ['hourly', 'Hourly'], ['adhoc', 'Adhoc']]) { + const module = await import(${JSON.stringify(pathToFileURL(join(projectDir, 'config/scripts/')).href)} + channel + '-build-version.mjs'); + const date = new Date('2026-09-12T00:00:00Z'); + result.push(channel === 'adhoc' + ? module.getAdhocBuildIdentity(date, 'branch', ['v1.4.167']) + : module['get' + exported + 'BuildIdentity'](date, { publishedVersions: ['v1.4.167'], releaseNames: [] })); + } + process.stdout.write(JSON.stringify(result)); + ` + const identities = (cwd) => run(process.execPath, ['--input-type=module', '-e', script], cwd) + expect(identities(shallow)).toBe(identities(source)) + expect( + JSON.parse(identities(shallow)).every((identity) => identity.version.startsWith('1.4.168-')) + ).toBe(true) + } finally { + rmSync(directory, { recursive: true, force: true }) + } + }) + + it('checks out only the vetted commit without remirroring refs', () => { const steps = readWorkflow('.github/workflows/adhoc-mac-build.yml').jobs['build-adhoc-mac'] .steps const checkout = steps.find((step) => step.name === 'Checkout the requested ref') - expect(checkout.env.GIT_DEFAULT_REF_FORMAT).toBe('reftable') expect(checkout.with.ref).toBe('${{ steps.vetted.outputs.sha }}') - expect(checkout.with['fetch-depth']).toBe(0) + expect(checkout.with['fetch-depth']).toBe(1) expect(checkout.with['persist-credentials']).toBe(false) }) diff --git a/config/scripts/workflow-ref-reachability.test.mjs b/config/scripts/workflow-ref-reachability.test.mjs index d71c3094c56..84a0b167986 100644 --- a/config/scripts/workflow-ref-reachability.test.mjs +++ b/config/scripts/workflow-ref-reachability.test.mjs @@ -95,7 +95,7 @@ describe('release ref trust with case-twin names', () => { expect(result.stdout).toContain('Refusing to build PR ref') }) - it('preserves both case variants in the subsequent full-history checkout', async () => { + it('checks out the vetted SHA shallowly without mirroring case-twin refs again', async () => { const checkout = join(directory, 'checkout') const env = { ...identity, ...macCheckout.env } await git(['init', checkout], env) @@ -105,21 +105,15 @@ describe('release ref trust with case-twin names', () => { checkout, 'fetch', '--no-tags', + `--depth=${macCheckout.with['fetch-depth']}`, repository, - '+refs/heads/*:refs/remotes/origin/*', - '+refs/tags/*:refs/tags/*' + upper ], env ) await git(['-C', checkout, 'checkout', '--detach', upper], env) - for (const [ref, sha] of [ - ['refs/remotes/origin/Fix', upper], - ['refs/remotes/origin/fix', lower], - ['refs/tags/Release', upper], - ['refs/tags/release', lower] - ]) { - expect(await git(['-C', checkout, 'rev-parse', `${ref}^{commit}`], env)).toBe(sha) - } expect(await git(['-C', checkout, 'rev-parse', 'HEAD'], env)).toBe(upper) + expect(await git(['-C', checkout, 'rev-list', '--count', 'HEAD'], env)).toBe('1') + expect(await git(['-C', checkout, 'for-each-ref', '--format=%(refname)'], env)).toBe('') }) })