From 04f404cb0ee5a640e6d903d746f79adda0fbd1bd Mon Sep 17 00:00:00 2001 From: Pandelis Zembashis Date: Sat, 17 Oct 2026 21:38:51 -0700 Subject: [PATCH] Use mounted full-history cache without a preparation task --- README.md | 3 + __test__/composal-cache.test.ts | 97 +++++++++++++++++++++++++++++++++ dist/index.js | 29 ++++++++-- package-lock.json | 4 +- package.json | 2 +- src/composal-cache.ts | 45 ++++++++++++--- 6 files changed, 163 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index ecfb527..b6b0748 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,9 @@ scope can read the cached source. Workflow code cannot update the cache. Only objects confirmed as readable from the GitHub repository enter it; unpublished platform refs are excluded. Credentials are never stored in the shared cache. +Full-history checkouts confirm the current branches and tags before using the +mounted objects. When every advertised tip is cached, they skip preparation. + The first checkout of a commit can wait up to 10 seconds for preparation, then fetch from GitHub while preparation continues for later jobs. Cache preparation and cache-fetch failures fall back to the standard transport. For large cold diff --git a/__test__/composal-cache.test.ts b/__test__/composal-cache.test.ts index 5ed2080..179a148 100644 --- a/__test__/composal-cache.test.ts +++ b/__test__/composal-cache.test.ts @@ -184,6 +184,103 @@ test('a warm exact-commit checkout makes no cache API call', async () => { } }) +test('warm full history confirms live refs and uses mounted objects without preparation', async () => { + const previous = process.env.GITHUB_REPOSITORY + process.env.GITHUB_REPOSITORY = 'example/repo' + const tag = git(['rev-parse', 'refs/tags/v1'], origin) + git(['remote', 'set-url', 'origin', origin]) + await fs.mkdir(path.join(cacheRoot, 'commits')) + await fs.mkdir(path.join(cacheRoot, 'complete')) + await fs.writeFile( + path.join(cacheRoot, 'commits', second), + JSON.stringify({commit: second, object_oid: second}) + ) + for (const value of [second, tag]) + await fs.writeFile(path.join(cacheRoot, 'complete', value), '{}') + const request = jest + .spyOn(globalThis, 'fetch') + .mockRejectedValue(new Error('Preparation must not be called')) + try { + const result = await prepare( + { + repositoryOwner: 'example', + repositoryName: 'repo', + repositoryPath: workspace, + ref: 'refs/heads/main', + commit: second, + fetchDepth: 0 + } as IGitSourceSettings, + cacheRoot + ) + expect(result).toBeInstanceOf(CheckoutCache) + expect(request).not.toHaveBeenCalled() + expect( + await result!.fetch( + ['+refs/heads/*:refs/remotes/origin/*', '+refs/tags/*:refs/tags/*'], + {} + ) + ).toBe(true) + git(['checkout', '--detach', 'refs/remotes/origin/main']) + expect(git(['rev-list', '--count', 'HEAD'])).toBe('2') + expect(git(['rev-parse', 'refs/tags/v1'])).toBe(tag) + } finally { + if (previous === undefined) delete process.env.GITHUB_REPOSITORY + else process.env.GITHUB_REPOSITORY = previous + } +}) + +test('full history prepares newly advertised tips even when the target commit is warm', async () => { + const previous = {...process.env} + Object.assign(process.env, { + GITHUB_REPOSITORY: 'example/repo', + COMPOSAL_CHECKOUT_API_URL: 'https://composal.example/api/checkout', + COMPOSAL_CHECKOUT_TOKEN: 'private-fixture-token' + }) + git(['branch', 'new-branch', first], origin) + git(['remote', 'set-url', 'origin', origin]) + const tag = git(['rev-parse', 'refs/tags/v1'], origin) + await fs.mkdir(path.join(cacheRoot, 'commits')) + await fs.mkdir(path.join(cacheRoot, 'complete')) + await fs.writeFile( + path.join(cacheRoot, 'commits', second), + JSON.stringify({commit: second, object_oid: second}) + ) + for (const value of [second, tag]) + await fs.writeFile(path.join(cacheRoot, 'complete', value), '{}') + const request = jest.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + json: async () => ({ + status: 'ready', + commit: second, + object_oid: second, + refs: {'refs/heads/main': second, 'refs/heads/new-branch': first} + }) + } as Response) + try { + const result = await prepare( + { + repositoryOwner: 'example', + repositoryName: 'repo', + repositoryPath: workspace, + ref: 'refs/heads/main', + commit: second, + fetchDepth: 0 + } as IGitSourceSettings, + cacheRoot + ) + expect(result).toBeInstanceOf(CheckoutCache) + expect(request).toHaveBeenCalledTimes(1) + expect(JSON.parse(request.mock.calls[0][1]!.body as string)).toMatchObject({ + operation: 'prepare', + all_refs: true + }) + } finally { + for (const key of Object.keys(process.env)) + if (!(key in previous)) delete process.env[key] + Object.assign(process.env, previous) + } +}) + async function cache( refs: Record = {'refs/heads/main': second} ) { diff --git a/dist/index.js b/dist/index.js index e399124..341db54 100644 --- a/dist/index.js +++ b/dist/index.js @@ -42202,12 +42202,12 @@ var external_node_url_ = __nccwpck_require__(3136); const root = '/opt/composal/git-cache'; const oid = /^[0-9a-f]{40}$/; -function run(args, cwd, input) { +function run(args, cwd, input, timeout = 60_000) { const result = (0,external_node_child_process_namespaceObject.spawnSync)('git', ['-c', `safe.directory=${cwd}`, ...args], { cwd, input, encoding: 'utf8', - timeout: 60_000, + timeout, maxBuffer: 16 * 1024 * 1024 }); if (result.status !== 0) @@ -42242,10 +42242,7 @@ async function prepare(settings, cacheRoot = root) { try { await promises_namespaceObject.access(cacheRoot); let manifest; - if (oid.test(settings.commit) && - settings.fetchDepth > 0 && - !settings.fetchTags && - !settings.ref.startsWith('refs/tags/')) { + if (oid.test(settings.commit) && !settings.ref.startsWith('refs/tags/')) { try { const marker = JSON.parse(await promises_namespaceObject.readFile(external_node_path_namespaceObject.join(cacheRoot, 'commits', settings.commit), 'utf8')); if (marker.commit === settings.commit && @@ -42258,9 +42255,29 @@ async function prepare(settings, cacheRoot = root) { : {}, cache_hit: true }; + if (settings.fetchDepth <= 0 || settings.fetchTags) { + // A fresh advertisement preserves branch/tag freshness. The mounted + // completion markers prove their entire object graphs are present. + // No preparation task is needed for an already populated cache. + const advertised = run(['ls-remote', '--refs', 'origin', 'refs/heads/*', 'refs/tags/*'], settings.repositoryPath, undefined, 5000); + const refs = {}; + for (const line of advertised.split('\n').filter(Boolean)) { + const [value, name] = line.split('\t'); + if (!oid.test(value) || + !name?.startsWith('refs/') || + /[\x00-\x20\x7f]/.test(name)) + throw new Error('Invalid advertised checkout ref'); + refs[name] = value; + } + const completed = new Set(await promises_namespaceObject.readdir(external_node_path_namespaceObject.join(cacheRoot, 'complete'))); + if (!Object.values(refs).every(value => completed.has(value))) + throw new Error('Advertised refs need cache preparation'); + manifest.refs = { ...refs, ...manifest.refs }; + } } } catch { + manifest = undefined; /* A cold cache is prepared outside the workflow container. */ } } diff --git a/package-lock.json b/package-lock.json index f13d01f..be3ed96 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "composal-checkout", - "version": "1.0.1", + "version": "1.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "composal-checkout", - "version": "1.0.1", + "version": "1.0.2", "license": "MIT", "dependencies": { "@actions/core": "^3.0.1", diff --git a/package.json b/package.json index a2df9f2..4d48dcc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "composal-checkout", - "version": "1.0.1", + "version": "1.0.2", "description": "Composal Git checkout action", "type": "module", "main": "lib/main.js", diff --git a/src/composal-cache.ts b/src/composal-cache.ts index e63522a..146e377 100644 --- a/src/composal-cache.ts +++ b/src/composal-cache.ts @@ -19,12 +19,17 @@ type FetchOptions = { showProgress?: boolean } -function run(args: string[], cwd: string, input?: string): string { +function run( + args: string[], + cwd: string, + input?: string, + timeout = 60_000 +): string { const result = spawnSync('git', ['-c', `safe.directory=${cwd}`, ...args], { cwd, input, encoding: 'utf8', - timeout: 60_000, + timeout, maxBuffer: 16 * 1024 * 1024 }) if (result.status !== 0) @@ -67,12 +72,7 @@ export async function prepare( try { await fs.access(cacheRoot) let manifest: Manifest | undefined - if ( - oid.test(settings.commit) && - settings.fetchDepth > 0 && - !settings.fetchTags && - !settings.ref.startsWith('refs/tags/') - ) { + if (oid.test(settings.commit) && !settings.ref.startsWith('refs/tags/')) { try { const marker = JSON.parse( await fs.readFile( @@ -92,8 +92,37 @@ export async function prepare( : {}, cache_hit: true } + if (settings.fetchDepth <= 0 || settings.fetchTags) { + // A fresh advertisement preserves branch/tag freshness. The mounted + // completion markers prove their entire object graphs are present. + // No preparation task is needed for an already populated cache. + const advertised = run( + ['ls-remote', '--refs', 'origin', 'refs/heads/*', 'refs/tags/*'], + settings.repositoryPath, + undefined, + 5000 + ) + const refs: Record = {} + for (const line of advertised.split('\n').filter(Boolean)) { + const [value, name] = line.split('\t') + if ( + !oid.test(value) || + !name?.startsWith('refs/') || + /[\x00-\x20\x7f]/.test(name) + ) + throw new Error('Invalid advertised checkout ref') + refs[name] = value + } + const completed = new Set( + await fs.readdir(path.join(cacheRoot, 'complete')) + ) + if (!Object.values(refs).every(value => completed.has(value))) + throw new Error('Advertised refs need cache preparation') + manifest.refs = {...refs, ...manifest.refs} + } } } catch { + manifest = undefined /* A cold cache is prepared outside the workflow container. */ } }