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
17 changes: 7 additions & 10 deletions .github/workflows/volume-canary.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: Verify Modal dependency volumes
name: Verify dependency volumes
on:
workflow_dispatch:
inputs:
Expand All @@ -20,13 +20,13 @@ jobs:
runs-on: ["composal-x64-${{ inputs.size || '4' }}x"]
steps:
- uses: actions/checkout@v4
- run: node --test post.test.cjs
- run: node --test main.test.cjs post.test.cjs
- uses: ./
id: disk
with:
key: volume-canary-v1
key: direct-volume-canary-v2
path: /tmp/dependency-store
- name: Verify restore and isolation
- name: Verify mounted cache persistence
env:
CACHE_HIT: ${{ steps.disk.outputs.cache-hit }}
EXPECTED_HIT: ${{ inputs.expected_hit || '' }}
Expand All @@ -36,14 +36,11 @@ jobs:
if [ -n "$EXPECTED_HIT" ]; then test "$CACHE_HIT" = "$EXPECTED_HIT"; fi
findmnt -T /tmp/dependency-store
if [ "$CACHE_HIT" = true ]; then
test "$(cat /tmp/dependency-store/proof)" = trusted
test "$(cat /tmp/dependency-store/proof)" = persisted
npm cache add is-number@7.0.0 --offline --cache /tmp/dependency-store
else
npm cache add is-number@7.0.0 --cache /tmp/dependency-store
fi
if [ "$EVENT" = pull_request ]; then
printf untrusted-pr > /tmp/dependency-store/proof
else
printf trusted > /tmp/dependency-store/proof
fi
printf persisted > /tmp/dependency-store/proof
case "$(realpath "$GITHUB_WORKSPACE")" in "$COMPOSAL_DISK_ROOT"/*) exit 1;; esac
echo "Verified dependency cache: hit=$CACHE_HIT event=$EVENT"
30 changes: 17 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ jobs:
runs-on: [composal-x64-4x]
steps:
- uses: actions/checkout@v4
- uses: composalai/disk-volume@v1
- uses: composalai/disk-volume@v1.2.0
id: npm-disk
with:
key: npm-store-v1
Expand All @@ -26,19 +26,23 @@ keys for package download stores (`~/.npm`, a pnpm store, Cargo registry/git
caches); package managers check the package versions and integrity. Use distinct
keys for incompatible platforms or toolchains.

Each job restores its own copy of a trusted snapshot. Concurrent jobs never
share a live directory. Only successful `push`, `schedule`, or
`workflow_dispatch` jobs on the repository's default branch publish an updated
snapshot. Pull requests and other branches can restore and modify their copy,
but their changes are discarded. When trusted jobs publish the same key
concurrently, the last completed publication becomes the snapshot for future
jobs; changes are not merged.
The directory is backed by persistent storage mounted directly on the runner.
Package managers read and update it in place. The post step flushes writes;
there is no archive, restore copy, snapshot publication, or automatic deletion.
The repository checkout and `.git` stay on local runner disk.

Limits: five disk mounts per job, 32 keys and 10 GiB of published data per
repository. Snapshots unused for seven days are not restored. Superseded
snapshots are removed after seven days and abandoned job clones after a day.
Current snapshot metadata remains reserved until the key is reused; this first
release has no user-facing disk deletion interface.
Jobs with the same key share a writable cache across branches and runner sizes,
including pull requests and failed jobs. Use it for rebuildable package download
stores with integrity checks. Choose separate keys for tools that require an
exclusive writable directory. Do not store credentials or use these directories
as build outputs or workspaces. Committed writes are visible to subsequent
runners; an already running job does not automatically refresh its view.

Up to five keys can be mounted per job. `cache-hit` means the directory already
exists, not that every dependency is present. Let the package manager install
missing packages. VM runners use bind mounts; sandboxes link the requested path
to the mounted storage. The first run after the direct-storage update starts
cold; archive snapshots from older runners are not copied or extracted.

Dependency disks are opt-in. Ordinary `actions/cache` and language setup actions
continue using GitHub's cache backend.
2 changes: 1 addition & 1 deletion action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ inputs:
required: true
outputs:
cache-hit:
description: Whether a trusted published snapshot was available when the runner started.
description: Whether the persistent cache directory already existed.
path:
description: Absolute path of the mounted dependency directory.
runs:
Expand Down
19 changes: 13 additions & 6 deletions main.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,31 +5,38 @@ const os = require('node:os');
const crypto = require('node:crypto');
const {execFileSync} = require('node:child_process');

function mount(env = process.env) {
if (process.platform !== 'linux' || !env.COMPOSAL_DISK_ROOT) throw new Error('disk-volume requires a Composal Modal GitHub runner');
function mount(env = process.env, run = execFileSync, platform = process.platform) {
if (platform !== 'linux' || !env.COMPOSAL_DISK_ROOT) throw new Error('disk-volume requires a Composal GitHub runner');
const key = env.INPUT_KEY;
const input = env.INPUT_PATH;
if (!key || key.length > 512 || !input || /[\r\n\0]/.test(key + input)) throw new Error('A key (1–512 characters) and a path are required');
const root = fs.realpathSync(env.COMPOSAL_DISK_ROOT);
const digest = crypto.createHash('sha256').update(key).digest('hex');
const source = path.join(root, 'data', digest);
const manifests = path.join(root, 'manifests');
const manifests = env.COMPOSAL_DISK_MANIFEST_ROOT || path.join(root, 'manifests');
const stateFile = path.join(manifests, digest + '.json');
if (fs.existsSync(stateFile)) throw new Error('Each disk key can be mounted once per job');
if (fs.readdirSync(manifests).filter(name => name.endsWith('.json')).length >= 5) throw new Error('At most five disks can be mounted per job');
const hit = fs.existsSync(source);
fs.mkdirSync(source, {recursive:true});
if (fs.realpathSync(source) !== source) throw new Error('Disk source must not be a symbolic link');
const expanded = input === '~' ? os.homedir() : input.startsWith('~/') ? path.join(os.homedir(), input.slice(2)) : input;
const target = path.resolve(env.GITHUB_WORKSPACE || process.cwd(), expanded);
const workspace = fs.realpathSync(env.GITHUB_WORKSPACE || process.cwd());
const target = path.resolve(workspace, expanded);
if (target === workspace || workspace.startsWith(target + '/') || target.split(path.sep).includes('.git')) throw new Error('Dependency cache paths must not include the workspace or .git');
if (target === root || target.startsWith(root + '/') || root.startsWith(target + '/') || target === '/') throw new Error('Mount path overlaps runner storage');
fs.mkdirSync(target, {recursive:true});
if (fs.realpathSync(target) !== target || fs.readdirSync(target).length) throw new Error('Mount path must be an empty directory without symlinks');
execFileSync('mount', ['--bind', source, target], {stdio:'inherit'});
if (env.COMPOSAL_RUNNER_RUNTIME === 'sandbox') {
fs.rmdirSync(target);
fs.symlinkSync(source, target, 'dir');
} else {
run('mount', ['--bind', source, target], {stdio:'inherit'});
}
fs.writeFileSync(stateFile, JSON.stringify({key}), {flag:'wx'});
fs.appendFileSync(env.GITHUB_STATE, `target=${target}\nroot=${root}\ndigest=${digest}\n`);
fs.appendFileSync(env.GITHUB_OUTPUT, `cache-hit=${hit}\npath=${target}\n`);
console.log(`Mounted ${key}: ${hit ? 'trusted snapshot restored' : 'empty volume'} at ${target}`);
console.log(`Mounted ${key}: ${hit ? 'existing cache' : 'new cache'} at ${target}`);
}
if (require.main === module) { try { mount(); } catch (error) { console.error(error.message); process.exitCode = 1; } }
module.exports = {mount};
81 changes: 81 additions & 0 deletions main.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
'use strict';
const {test} = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const {mount} = require('./main.cjs');

function fixture() {
const temp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'disk-volume-')));
const root = path.join(temp, 'volume');
fs.mkdirSync(path.join(root, 'data'), {recursive:true});
return {temp, root, job(name, runtime = 'sandbox') {
const job = path.join(temp, name);
fs.mkdirSync(path.join(job, 'manifests'), {recursive:true});
return {INPUT_KEY:'npm-store', INPUT_PATH:'cache', COMPOSAL_DISK_ROOT:root,
COMPOSAL_DISK_MANIFEST_ROOT:path.join(job, 'manifests'), COMPOSAL_RUNNER_RUNTIME:runtime,
GITHUB_WORKSPACE:job, GITHUB_STATE:path.join(job, 'state'), GITHUB_OUTPUT:path.join(job, 'output')};
}};
}

test('fresh jobs reuse cache bytes in place with independent mount bookkeeping', () => {
const f = fixture();
try {
const first = f.job('first');
mount(first, () => assert.fail('sandbox must not invoke mount'), 'linux');
fs.writeFileSync(path.join(first.GITHUB_WORKSPACE, 'cache', 'dependency'), 'cached bytes');
const persistFirst = fs.realpathSync(path.join(first.GITHUB_WORKSPACE, 'cache'));
const second = f.job('second');
mount(second, () => assert.fail('sandbox must not invoke mount'), 'linux');
assert.equal(fs.readFileSync(path.join(second.GITHUB_WORKSPACE, 'cache', 'dependency'), 'utf8'), 'cached bytes');
assert.equal(fs.realpathSync(path.join(second.GITHUB_WORKSPACE, 'cache')), persistFirst);
assert.match(fs.readFileSync(first.GITHUB_OUTPUT, 'utf8'), /cache-hit=false/);
assert.match(fs.readFileSync(second.GITHUB_OUTPUT, 'utf8'), /cache-hit=true/);
assert.throws(() => mount(first, () => {}, 'linux'), /once per job/);
} finally {fs.rmSync(f.temp, {recursive:true, force:true});}
});

test('VM cache paths bind directly to the persistent filesystem', () => {
const f = fixture();
try {
const env = f.job('vm', 'vm'), calls = [];
mount(env, (command, args) => calls.push([command, args]), 'linux');
assert.equal(calls.length, 1);
assert.equal(calls[0][0], 'mount');
assert.equal(calls[0][1][0], '--bind');
assert.ok(calls[0][1][1].startsWith(path.join(f.root, 'data') + '/'));
assert.equal(calls[0][1][2], path.join(env.GITHUB_WORKSPACE, 'cache'));
} finally {fs.rmSync(f.temp, {recursive:true, force:true});}
});

test('mounting refuses to cover an existing checkout or populated directory', () => {
const f = fixture();
try {
const env = f.job('populated');
fs.mkdirSync(path.join(env.GITHUB_WORKSPACE, 'cache'));
fs.writeFileSync(path.join(env.GITHUB_WORKSPACE, 'cache', 'source.txt'), 'keep source');
assert.throws(() => mount(env, () => assert.fail('must not mount'), 'linux'), /empty directory/);
assert.equal(fs.readFileSync(path.join(env.GITHUB_WORKSPACE, 'cache', 'source.txt'), 'utf8'), 'keep source');
} finally {fs.rmSync(f.temp, {recursive:true, force:true});}
});

test('empty workspaces and Git metadata remain local before checkout starts', () => {
const f = fixture();
try {
for (const runtime of ['vm', 'sandbox']) {
for (const input of ['.', '..', '.git', '.git/objects', 'nested/.git']) {
const env = f.job(runtime + '-' + input.replaceAll('/', '-').replaceAll('.', 'dot'), runtime);
env.INPUT_PATH = input;
// Keep bookkeeping outside an otherwise empty workspace, as it is
// when the action runs before the first checkout step.
env.COMPOSAL_DISK_MANIFEST_ROOT = path.join(f.temp, 'manifests-' + path.basename(env.GITHUB_WORKSPACE));
fs.renameSync(path.join(env.GITHUB_WORKSPACE, 'manifests'), env.COMPOSAL_DISK_MANIFEST_ROOT);
assert.equal(fs.readdirSync(env.GITHUB_WORKSPACE).length, 0);
assert.throws(() => mount(env, () => assert.fail('must not mount workspace or .git'), 'linux'), /workspace or .git/);
assert.equal(fs.lstatSync(env.GITHUB_WORKSPACE).isSymbolicLink(), false);
assert.equal(fs.existsSync(path.join(env.GITHUB_WORKSPACE, '.git')), false);
}
}
} finally {fs.rmSync(f.temp, {recursive:true, force:true});}
});
25 changes: 3 additions & 22 deletions post.cjs
Original file line number Diff line number Diff line change
@@ -1,29 +1,10 @@
'use strict';
const fs = require('node:fs');
const path = require('node:path');
const {execFileSync} = require('node:child_process');

function persist(env = process.env, run = execFileSync) {
if (!env.STATE_target) return;
if (!env.COMPOSAL_DISK_PERSIST_ROOT) run('sync', [env.STATE_root], {stdio:'inherit'});
run('umount', [env.STATE_target], {stdio:'inherit'});
if (env.COMPOSAL_DISK_PERSIST_ROOT) {
const digest = env.STATE_digest;
if (!/^[a-f0-9]{64}$/.test(digest || '')) throw new Error('Invalid disk identity');
const root = fs.realpathSync(env.STATE_root);
const backing = fs.realpathSync(env.COMPOSAL_DISK_PERSIST_ROOT);
const source = path.join(root, 'data', digest);
if (fs.realpathSync(source) !== source) throw new Error('Disk source must not be a symbolic link');
const archive = path.join(backing, 'archives', digest + '.tar.gz');
fs.mkdirSync(path.dirname(archive), {recursive:true});
fs.mkdirSync(path.join(backing, 'manifests'), {recursive:true});
run('tar', ['-czf', archive + '.tmp', '-C', source, '.'], {stdio:'inherit'});
fs.renameSync(archive + '.tmp', archive);
fs.copyFileSync(path.join(root, 'manifests', digest + '.json'), path.join(backing, 'manifests', digest + '.json'));
run('sync', [backing], {stdio:'inherit'});
console.log('Dependency snapshot archived to the private Modal disk.');
}
console.log('Disk writes persisted to the job clone. Composal publishes successful default-branch jobs after cleanup.');
if (!env.STATE_root) return;
run('sync', [env.STATE_root], {stdio:'inherit'});
console.log('Dependency cache writes persisted.');
}
if (require.main === module) {
try { persist(); } catch (error) { console.error('Could not persist the disk: ' + error.message); process.exitCode = 1; }
Expand Down
41 changes: 8 additions & 33 deletions post.test.cjs
Original file line number Diff line number Diff line change
@@ -1,43 +1,18 @@
'use strict';
const {test} = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const crypto = require('node:crypto');
const {execFileSync} = require('node:child_process');
const {persist} = require('./post.cjs');

test('post step preserves dependency bytes in the Modal archive', () => {
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'composal-disk-post-'));
try {
const root = path.join(temp, 'working');
const backing = path.join(temp, 'persisted');
const digest = crypto.createHash('sha256').update('test-key').digest('hex');
const source = path.join(root, 'data', digest);
fs.mkdirSync(source, {recursive:true});
fs.mkdirSync(path.join(root, 'manifests'), {recursive:true});
fs.mkdirSync(backing);
fs.writeFileSync(path.join(source, 'dependency.txt'), 'cached dependency bytes');
fs.writeFileSync(path.join(root, 'manifests', digest + '.json'), JSON.stringify({key:'test-key'}));
persist({STATE_target:source, STATE_root:root, STATE_digest:digest, COMPOSAL_DISK_PERSIST_ROOT:backing}, (command, args, options) => {
if (command === 'tar') execFileSync(command, args, options);
});
const restored = path.join(temp, 'restored');
fs.mkdirSync(restored);
execFileSync('tar', ['-xzf', path.join(backing, 'archives', digest + '.tar.gz'), '-C', restored]);
assert.equal(fs.readFileSync(path.join(restored, 'dependency.txt'), 'utf8'), 'cached dependency bytes');
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(backing, 'manifests', digest + '.json'))), {key:'test-key'});
assert.equal(fs.existsSync(path.join(backing, 'archives', digest + '.tar.gz.tmp')), false);
} finally { fs.rmSync(temp, {recursive:true, force:true}); }
});

test('original runner disks still flush before unmounting', () => {
test('cache writes are committed in place without copying or unmounting', () => {
const calls = [];
persist({STATE_target:'/target',STATE_root:'/disk'}, (...args) => calls.push(args.slice(0,2)));
assert.deepEqual(calls, [['sync',['/disk']],['umount',['/target']]]);
persist({STATE_target:'/cache', STATE_root:'/disk'}, (...args) => calls.push(args.slice(0,2)));
assert.deepEqual(calls, [['sync', ['/disk']]]);
});

test('unmounted actions do not attempt publication', () => {
test('an action that did not mount a disk does not flush storage', () => {
persist({}, () => assert.fail('unmounted action must not run commands'));
});

test('flush errors fail the post step instead of claiming persistence', () => {
assert.throws(() => persist({STATE_root:'/disk'}, () => {throw new Error('sync failed');}), /sync failed/);
});
Loading