From 579920382a8742df3c3e14027b49db39a7f22069 Mon Sep 17 00:00:00 2001 From: Andrew Klotz Date: Thu, 6 Aug 2026 22:49:29 +0000 Subject: [PATCH] Materialize LFS objects with an explicit git lfs checkout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem On persistent/self-hosted runners that reuse a working directory across jobs, LFS-tracked files can be left as pointer files instead of real content when a lfs: false checkout precedes a lfs: true checkout of the same repo. Sequence that triggers it: 1. A lfs: false run sets GIT_LFS_SKIP_SMUDGE=1, so git checkout writes LFS files to the working tree as pointers. 2. A later lfs: true run on the same worktree fetches the objects into .git/lfs, but git checkout sees the tree already at the target commit and does not re-smudge the already-present pointer files. Result: the objects are in the local cache, but the working tree still contains pointers. The action relies on the smudge filter firing during git checkout to materialize content — which holds on a clean tree (GitHub-hosted runners) but not on a reused one. Related: actions/checkout#270 --- .github/workflows/test.yml | 10 ++++++++++ __test__/git-auth-helper.test.ts | 1 + __test__/git-directory-helper.test.ts | 1 + __test__/verify-lfs.sh | 5 +++++ dist/index.js | 8 ++++++++ src/git-command-manager.ts | 5 +++++ src/git-source-provider.ts | 6 ++++++ 7 files changed, 36 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4740daf6..b2effcec 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -136,6 +136,16 @@ jobs: run: __test__/verify-sparse-checkout-non-cone-mode.sh # LFS + # First check out without LFS so the reused worktree is left holding LFS + # pointer files; the `lfs: true` checkout below must then materialize them + # rather than leave the stale pointers in place (#270). + - name: Checkout LFS pointers (reused worktree setup) + uses: ./ + with: + repository: actions/checkout # hardcoded, otherwise doesn't work from a fork + ref: test-data/v2/lfs + path: lfs + lfs: false - name: Checkout LFS uses: ./ with: diff --git a/__test__/git-auth-helper.test.ts b/__test__/git-auth-helper.test.ts index 2c963b90..acbb0d21 100644 --- a/__test__/git-auth-helper.test.ts +++ b/__test__/git-auth-helper.test.ts @@ -1065,6 +1065,7 @@ async function setup(testName: string): Promise { getWorkingDirectory: jest.fn(() => workspace), init: jest.fn(), isDetached: jest.fn(), + lfsCheckout: jest.fn(), lfsFetch: jest.fn(), lfsInstall: jest.fn(), log1: jest.fn(), diff --git a/__test__/git-directory-helper.test.ts b/__test__/git-directory-helper.test.ts index 4dd5fda6..10c5efd0 100644 --- a/__test__/git-directory-helper.test.ts +++ b/__test__/git-directory-helper.test.ts @@ -497,6 +497,7 @@ async function setup(testName: string): Promise { getWorkingDirectory: jest.fn(() => repositoryPath), init: jest.fn(), isDetached: jest.fn(), + lfsCheckout: jest.fn(), lfsFetch: jest.fn(), lfsInstall: jest.fn(), log1: jest.fn(), diff --git a/__test__/verify-lfs.sh b/__test__/verify-lfs.sh index b0463f1b..60111872 100755 --- a/__test__/verify-lfs.sh +++ b/__test__/verify-lfs.sh @@ -9,3 +9,8 @@ if [ ! -f "./lfs/lfs-file.bin" ]; then echo "Expected lfs file does not exist" exit 1 fi + +if head -n 1 "./lfs/lfs-file.bin" | grep -q "git-lfs.github.com/spec"; then + echo "Expected lfs file to be materialized, but it is still an LFS pointer" + exit 1 +fi diff --git a/dist/index.js b/dist/index.js index 06ae5d22..d1662876 100644 --- a/dist/index.js +++ b/dist/index.js @@ -35816,6 +35816,9 @@ class GitCommandManager { const output = await this.execGit(['rev-parse', '--symbolic-full-name', '--verify', '--quiet', 'HEAD'], true); return !output.stdout.trim().startsWith('refs/heads/'); } + async lfsCheckout() { + await this.execGit(['lfs', 'checkout']); + } async lfsFetch(ref) { const args = ['lfs', 'fetch', 'origin', ref]; const that = this; @@ -41879,6 +41882,11 @@ async function getSource(settings) { startGroup('Checking out the ref'); await git.checkout(checkoutInfo.ref, checkoutInfo.startPoint); endGroup(); + if (settings.lfs && !settings.sparseCheckout) { + startGroup('Checking out LFS objects'); + await git.lfsCheckout(); + endGroup(); + } // Submodules if (settings.submodules) { // Temporarily override global config diff --git a/src/git-command-manager.ts b/src/git-command-manager.ts index 84316589..51e81fe1 100644 --- a/src/git-command-manager.ts +++ b/src/git-command-manager.ts @@ -45,6 +45,7 @@ export interface IGitCommandManager { getWorkingDirectory(): string init(objectFormat?: string): Promise isDetached(): Promise + lfsCheckout(): Promise lfsFetch(ref: string): Promise lfsInstall(): Promise log1(format?: string): Promise @@ -383,6 +384,10 @@ class GitCommandManager { return !output.stdout.trim().startsWith('refs/heads/') } + async lfsCheckout(): Promise { + await this.execGit(['lfs', 'checkout']) + } + async lfsFetch(ref: string): Promise { const args = ['lfs', 'fetch', 'origin', ref] diff --git a/src/git-source-provider.ts b/src/git-source-provider.ts index b9c1d357..36f7f067 100644 --- a/src/git-source-provider.ts +++ b/src/git-source-provider.ts @@ -271,6 +271,12 @@ export async function getSource(settings: IGitSourceSettings): Promise { await git.checkout(checkoutInfo.ref, checkoutInfo.startPoint) core.endGroup() + if (settings.lfs && !settings.sparseCheckout) { + core.startGroup('Checking out LFS objects') + await git.lfsCheckout() + core.endGroup() + } + // Submodules if (settings.submodules) { // Temporarily override global config