From 605c98fd319c400335782dbdab0738742e031e3c Mon Sep 17 00:00:00 2001 From: Pandelis Zembashis
Date: Fri, 16 Oct 2026 16:45:31 -0700 Subject: [PATCH] Use dependency cache volumes in place --- .github/workflows/volume-canary.yml | 17 +++--- README.md | 30 ++++++----- action.yml | 2 +- main.cjs | 19 ++++--- main.test.cjs | 81 +++++++++++++++++++++++++++++ post.cjs | 25 ++------- post.test.cjs | 41 +++------------ 7 files changed, 130 insertions(+), 85 deletions(-) create mode 100644 main.test.cjs diff --git a/.github/workflows/volume-canary.yml b/.github/workflows/volume-canary.yml index 7db136a..73254ec 100644 --- a/.github/workflows/volume-canary.yml +++ b/.github/workflows/volume-canary.yml @@ -1,4 +1,4 @@ -name: Verify Modal dependency volumes +name: Verify dependency volumes on: workflow_dispatch: inputs: @@ -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 || '' }} @@ -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" diff --git a/README.md b/README.md index 2859f51..752cd72 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. diff --git a/action.yml b/action.yml index 477c731..538f742 100644 --- a/action.yml +++ b/action.yml @@ -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: diff --git a/main.cjs b/main.cjs index bb8ebb0..960a21b 100644 --- a/main.cjs +++ b/main.cjs @@ -5,15 +5,15 @@ 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'); @@ -21,15 +21,22 @@ function mount(env = process.env) { 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}; diff --git a/main.test.cjs b/main.test.cjs new file mode 100644 index 0000000..e2323e8 --- /dev/null +++ b/main.test.cjs @@ -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});} +}); diff --git a/post.cjs b/post.cjs index fbce0f3..ba65651 100644 --- a/post.cjs +++ b/post.cjs @@ -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; } diff --git a/post.test.cjs b/post.test.cjs index 568368e..8d79278 100644 --- a/post.test.cjs +++ b/post.test.cjs @@ -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/); +});