Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
97 changes: 97 additions & 0 deletions __test__/composal-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {'refs/heads/main': second}
) {
Expand Down
29 changes: 23 additions & 6 deletions dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 &&
Expand All @@ -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. */
}
}
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
45 changes: 37 additions & 8 deletions src/composal-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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(
Expand All @@ -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<string, string> = {}
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. */
}
}
Expand Down
Loading