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-android-release.yml b/.github/workflows/mobile-android-release.yml index 17100c788b8..6983226eb55 100644 --- a/.github/workflows/mobile-android-release.yml +++ b/.github/workflows/mobile-android-release.yml @@ -38,16 +38,18 @@ jobs: - name: Checkout uses: actions/checkout@v6 - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: 24 - - name: Setup pnpm uses: pnpm/setup@v2 with: install: false + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 24 + cache: pnpm + cache-dependency-path: mobile/pnpm-lock.yaml + - name: Install dependencies run: pnpm install --frozen-lockfile @@ -67,6 +69,22 @@ jobs: - name: Expo prebuild run: npx expo prebuild --platform android --no-install + # setup-gradle also extracts compiler caches independently of its include paths. + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + with: + cache-disabled: true + + - name: Cache Gradle dependency downloads + uses: actions/cache@v5 + with: + path: | + ~/.gradle/caches/modules-2 + ~/.gradle/wrapper/dists + key: gradle-downloads-v1-${{ runner.os }}-${{ runner.arch }}-jdk17-${{ hashFiles('mobile/android/gradle/wrapper/gradle-wrapper.properties') }}-${{ hashFiles('mobile/pnpm-lock.yaml', 'mobile/pnpm-workspace.yaml', 'mobile/patches/**', 'mobile/android/**/*.gradle', 'mobile/android/**/*.gradle.kts', 'mobile/android/**/gradle.properties', 'mobile/android/gradle/wrapper/gradle-wrapper.properties', 'mobile/android/gradle/libs.versions.toml') }} + restore-keys: | + gradle-downloads-v1-${{ runner.os }}-${{ runner.arch }}-jdk17-${{ hashFiles('mobile/android/gradle/wrapper/gradle-wrapper.properties') }}- + - name: Build Android release APK run: cd android && ./gradlew assembleRelease diff --git a/.github/workflows/mobile-ios-release.yml b/.github/workflows/mobile-ios-release.yml index dd9a265d0c8..87a881ac9ab 100644 --- a/.github/workflows/mobile-ios-release.yml +++ b/.github/workflows/mobile-ios-release.yml @@ -58,16 +58,18 @@ jobs: # Xcode 26.x ships the Swift 6.x toolchain Expo SDK 55 requires. xcode-version: '26.5' - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: 24 - - name: Setup pnpm uses: pnpm/setup@v2 with: install: false + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 24 + cache: pnpm + cache-dependency-path: mobile/pnpm-lock.yaml + - name: Install dependencies run: pnpm install --frozen-lockfile @@ -98,6 +100,15 @@ jobs: ORCA_IOS_APS_ENVIRONMENT: production run: npx expo prebuild --platform ios --no-install + # Cache downloaded pod sources only; prebuild and pod install still run on every build. + - name: Cache CocoaPods downloads + uses: actions/cache@v5 + with: + path: ~/Library/Caches/CocoaPods + key: cocoapods-downloads-v1-${{ runner.os }}-${{ runner.arch }}-xcode26.5-${{ hashFiles('mobile/Gemfile.lock') }}-${{ hashFiles('mobile/pnpm-lock.yaml', 'mobile/pnpm-workspace.yaml', 'mobile/patches/**', 'mobile/Gemfile.lock', 'mobile/ios/Podfile', 'mobile/ios/Podfile.properties.json', 'mobile/ios/Podfile.lock') }} + restore-keys: | + cocoapods-downloads-v1-${{ runner.os }}-${{ runner.arch }}-xcode26.5-${{ hashFiles('mobile/Gemfile.lock') }}- + - name: Install CocoaPods run: npx pod-install ios 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 c49091ab148..34c0a65030a 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/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index c2124d12990..7f34b1b2fa2 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -1251,12 +1251,13 @@ jobs: # Cache the Electron binary + electron-builder tool downloads # (winCodeSign, nsis, squirrel, AppImage). Saves ~30-90s per job. - name: Cache electron-builder downloads - uses: actions/cache@v5 + id: electron-builder-downloads + uses: actions/cache/restore@v5 with: path: ${{ matrix.eb_cache_path }} - key: electron-builder-${{ matrix.platform }}-${{ hashFiles('pnpm-lock.yaml') }} + key: electron-builder-downloads-v2-${{ runner.os }}-${{ runner.arch }}-${{ matrix.platform }}-${{ hashFiles('pnpm-lock.yaml') }} restore-keys: | - electron-builder-${{ matrix.platform }}- + electron-builder-downloads-v2-${{ runner.os }}-${{ runner.arch }}-${{ matrix.platform }}- # Why: pnpm install triggers electron's postinstall, which downloads the # Electron binary from GitHub release assets. GitHub's download CDN @@ -1454,6 +1455,14 @@ jobs: if: matrix.platform == 'win' && github.run_attempt == 1 uses: ./.github/actions/install-signpath-module + # Save before SignPath replaces cached elevate.exe with this run's signed bytes. + - name: Save electron-builder downloads before signing + if: steps.electron-builder-downloads.outputs.cache-hit != 'true' && (matrix.platform != 'win' || github.run_attempt == 1) + uses: actions/cache/save@v5 + with: + path: ${{ matrix.eb_cache_path }} + key: ${{ steps.electron-builder-downloads.outputs.cache-primary-key }} + # ── Windows inner-binary signing (issue #7785) ───────────────────── # Why: SignPath cannot deep-sign inside NSIS installers, so inner PE # files (Orca.exe, node-pty *.node, DLLs) are signed via a separate zip @@ -1713,10 +1722,7 @@ jobs: # clobbered the SignPath signature in v1.4.129-rc.4. There is no supported # way to disable just the copy, so we overwrite the cache's copy with our # signed one (identical bytes plus signature) so the clobber becomes a - # no-op. Known quirk: the cache persists across releases via actions/cache, - # so later runs may see elevate.exe as already signed and skip staging it — - # that is fine (the signature is timestamped) and the evidence gate checks - # elevate.exe in the shipped installer unconditionally. + # no-op. The download cache is saved before signing, so this swap stays local. # # The cache lookup lives in a script because the inline path this step used # (`\nsis`) matches no app-builder-lib layout, and `SilentlyContinue` @@ -1733,9 +1739,7 @@ jobs: Write-Host '::warning::No elevate.exe in win-unpacked resources; nothing to protect from the rebuild clobber.' exit 0 } - # Why this guard stays: windows-signing-rehearsal.yml shares the - # electron-builder-win- cache key with this workflow, so a - # test-certificate elevate.exe must never be staged into a release cache. + # Only this run's production SignPath signature may enter the installer rebuild. $signature = Get-AuthenticodeSignature -FilePath $signed $subject = if ($null -eq $signature.SignerCertificate) { '' } else { $signature.SignerCertificate.Subject } if ($signature.Status -ne 'Valid' -or $subject -notlike '*CN=SignPath Foundation*') { 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..475837a92c3 --- /dev/null +++ b/config/scripts/ci-dependency-download-cache.test.mjs @@ -0,0 +1,125 @@ +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' + ]) + }) + + it.each(['mobile-android-release', 'mobile-ios-release'])( + '%s caches its own lockfile and always performs a frozen install and native generation', + (name) => { + const steps = Object.values(workflow(name).jobs)[0].steps + const node = steps.findIndex((step) => step.uses === 'actions/setup-node@v6') + expect(steps.findIndex((step) => step.uses === 'pnpm/setup@v2')).toBeLessThan(node) + expect(steps[node].with.cache).toBe('pnpm') + expect(steps[node].with['cache-dependency-path']).toBe('mobile/pnpm-lock.yaml') + const install = steps.find((step) => step.name === 'Install dependencies') + expect(install.if).toBeUndefined() + expect(install.run).toBe('pnpm install --frozen-lockfile') + const prebuild = steps.findIndex((step) => step.name === 'Expo prebuild') + expect(steps[prebuild].if).toBeUndefined() + expect(prebuild).toBeLessThan(steps.findIndex((step) => step.uses === 'actions/cache@v5')) + } + ) + + it('caches only Gradle downloads, with generated native and toolchain inputs', () => { + const steps = workflow('mobile-android-release').jobs['android-build'].steps + const setup = steps.find((step) => step.uses === 'gradle/actions/setup-gradle@v4') + expect(setup.with['cache-disabled']).toBe(true) + const cache = steps.find((step) => step.name === 'Cache Gradle dependency downloads') + expect(cache.with.path.trim().split('\n')).toEqual([ + '~/.gradle/caches/modules-2', + '~/.gradle/wrapper/dists' + ]) + for (const input of [ + 'runner.os', + 'runner.arch', + 'jdk17', + 'mobile/pnpm-lock.yaml', + 'mobile/patches/**', + 'mobile/android/**/*.gradle', + 'gradle-wrapper.properties' + ]) { + expect(cache.with.key).toContain(input) + } + expect(cache.with['restore-keys'].trim()).toBe( + "gradle-downloads-v1-${{ runner.os }}-${{ runner.arch }}-jdk17-${{ hashFiles('mobile/android/gradle/wrapper/gradle-wrapper.properties') }}-" + ) + const build = steps.find((step) => step.name === 'Build Android release APK') + expect(build.if).toBeUndefined() + expect(build.run).toBe('cd android && ./gradlew assembleRelease') + }) + + it('caches CocoaPods downloads without Pods, signing state, or build products', () => { + const steps = workflow('mobile-ios-release').jobs['ios-build'].steps + const cache = steps.find((step) => step.name === 'Cache CocoaPods downloads') + expect(cache.with.path).toBe('~/Library/Caches/CocoaPods') + for (const input of [ + 'runner.os', + 'runner.arch', + 'xcode26.5', + 'mobile/Gemfile.lock', + 'mobile/pnpm-lock.yaml', + 'mobile/patches/**', + 'mobile/ios/Podfile', + 'Podfile.properties.json' + ]) { + expect(cache.with.key).toContain(input) + } + expect(cache.with['restore-keys'].trim()).toBe( + "cocoapods-downloads-v1-${{ runner.os }}-${{ runner.arch }}-xcode26.5-${{ hashFiles('mobile/Gemfile.lock') }}-" + ) + const install = steps.find((step) => step.name === 'Install CocoaPods') + expect(install.if).toBeUndefined() + expect(install.run).toBe('npx pod-install ios') + expect(steps.indexOf(cache)).toBeLessThan(steps.indexOf(install)) + }) + + it('saves release tool downloads before signing can mutate them', () => { + const steps = Object.values(workflow('release-cut').jobs).find((job) => + job.steps?.some((step) => step.id === 'electron-builder-downloads') + ).steps + const restore = steps.find((step) => step.id === 'electron-builder-downloads') + const save = steps.find( + (step) => step.name === 'Save electron-builder downloads before signing' + ) + expect(restore.uses).toBe('actions/cache/restore@v5') + expect(restore.with.key).toContain( + 'electron-builder-downloads-v2-${{ runner.os }}-${{ runner.arch }}' + ) + expect(restore.with['restore-keys']).toContain('electron-builder-downloads-v2-') + expect(save.uses).toBe('actions/cache/save@v5') + expect(save.with.path).toBe(restore.with.path) + expect(save.with.key).toBe('${{ steps.electron-builder-downloads.outputs.cache-primary-key }}') + expect(steps.indexOf(save)).toBeGreaterThan( + steps.findIndex((step) => step.name === 'Build Windows release artifacts') + ) + expect(steps.indexOf(save)).toBeLessThan( + steps.findIndex((step) => step.id === 'sign-elevate-cache') + ) + expect(save.if).toContain("matrix.platform != 'win' || github.run_attempt == 1") + }) +}) 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('') }) })