diff --git a/README.md b/README.md index aa381c7..e1ba54b 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,24 @@ builds something different its own `key`: key: ${{ matrix.target }} ``` +### Share an existing job's cache + +Compatible jobs in different workflows can reuse an existing cache by naming +its owning workflow and job. Use the same `key` and `working-directory`, and +keep the target, toolchain and build flags compatible: + +```yaml + - uses: tensorlakeai/github-runners@v1 + with: + languages: rust + cache-owner: build_platform_dataplane_on_merge.yaml/build_binary + key: dataplane-platform-dataplane-release-glibc-2.34-x86_64-gnu-and-musl +``` + +The owner job needs no configuration change. Existing entries are reused; +branches and pull requests still save only to their own ref. Builds restore +archives to local disk and do not share a writable build directory. + ## What is cached | Language | Detected from | Cached | @@ -195,6 +213,7 @@ of many small files. | `languages` | `auto` | `auto`, `none`, or a comma list of `rust`, `node`, `go`, `python`. | | `paths` | | Extra files or directories to cache, one per line, relative to `working-directory` or starting with `~/`. | | `key` | | Separates caches for jobs that build different things, such as matrix entries. | +| `cache-owner` | | Reuse another job's cache in this repository, as `workflow.yml/job-id`; use its `key` and `working-directory`. | | `working-directory` | `.` | Where to search for lockfiles. | | `save` | `auto` | `auto` saves to the job's own branch or pull request when dependencies changed, except in merge queue runs; `true` also saves in merge queue runs; `false` never saves. | | `default-branch` | the repository's | The fallback cache for branches, and never expired. | diff --git a/action.yml b/action.yml index 00fe9f6..00be134 100644 --- a/action.yml +++ b/action.yml @@ -27,6 +27,13 @@ inputs: build different things, for example the target triple. required: false default: "" + cache-owner: + description: >- + Share an existing job's cache in this repository. Use workflow.yml/job-id + and the same key and working-directory as that job. Branch isolation is + preserved. Leave empty to use this workflow and job. + required: false + default: "" working-directory: description: Directory to search for lockfiles. required: false diff --git a/src/lib/config.js b/src/lib/config.js index e91caf1..b3b191f 100644 --- a/src/lib/config.js +++ b/src/lib/config.js @@ -48,6 +48,16 @@ function workflowIdentity() { return ref.replace(/@.*$/, ''); } +function cacheIdentity() { + const owner = core.getInput('cache-owner', ''); + if (!owner) return [workflowIdentity(), process.env.GITHUB_JOB || '']; + const match = /^([A-Za-z0-9_.-]+\.ya?ml)\/([A-Za-z_][A-Za-z0-9_-]*)$/.exec(owner); + if (!match) throw new Error('Input cache-owner must be workflow.yml/job-id in this repository.'); + const repository = process.env.GITHUB_REPOSITORY || ''; + if (!repository) throw new Error('Input cache-owner requires GITHUB_REPOSITORY.'); + return [`${repository}/.github/workflows/${match[1]}`, match[2]]; +} + function readEvent() { try { return JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH, 'utf8')); @@ -83,10 +93,11 @@ function load() { const workingDirectory = path.resolve(process.env.GITHUB_WORKSPACE || process.cwd(), core.getInput('working-directory', '.')); const key = core.getInput('key', ''); const languages = parseLanguages(core.getInput('languages', 'auto')); + const [workflow, job] = cacheIdentity(); const scope = sha256(JSON.stringify([ process.env.GITHUB_REPOSITORY || '', - workflowIdentity(), - process.env.GITHUB_JOB || '', + workflow, + job, path.relative(process.env.GITHUB_WORKSPACE || process.cwd(), workingDirectory), key, ])).slice(0, 20); diff --git a/test/cache.test.js b/test/cache.test.js index b87fbd8..fc2eebc 100644 --- a/test/cache.test.js +++ b/test/cache.test.js @@ -167,6 +167,60 @@ test('looks up the own ref, then the base branch, then the default branch', () = assert.deepEqual(config.refs(''), ['refs/heads/feature']); }); +test('cache-owner reuses the existing scope and keeps repository and build keys isolated', () => { + job({ inputs: { key: 'release-linux' } }); + const existing = config.load().scope; + const legacy = config.sha256(JSON.stringify(['acme/app', 'acme/app/.github/workflows/ci.yml', 'build', '', 'release-linux'])).slice(0, 20); + assert.equal(existing, legacy, 'default scopes remain compatible with existing entries'); + + job({ inputs: { 'cache-owner': 'ci.yml/build', key: 'release-linux' } }); + process.env.GITHUB_WORKFLOW_REF = 'acme/app/.github/workflows/publish.yml@refs/heads/main'; + process.env.GITHUB_JOB = 'helper'; + assert.equal(config.load().scope, existing); + + process.env.INPUT_KEY = 'release-musl'; + assert.notEqual(config.load().scope, existing, 'different build keys stay isolated'); + process.env.INPUT_KEY = 'release-linux'; + process.env.GITHUB_REPOSITORY = 'acme/other'; + assert.notEqual(config.load().scope, existing, 'cache-owner cannot cross repositories'); +}); + +test('rejects malformed cache owners', () => { + for (const owner of ['../ci.yml/build', 'acme/app/ci.yml/build', 'ci.yml', 'ci.yml/../build', 'ci.yml/1build']) { + job({ inputs: { 'cache-owner': owner } }); + assert.throws(() => config.load(), /cache-owner must be/); + } + job({ inputs: { 'cache-owner': 'ci.yml/build' } }); + delete process.env.GITHUB_REPOSITORY; + assert.throws(() => config.load(), /requires GITHUB_REPOSITORY/); +}); + +test('another workflow restores the owner cache and saves only to its own branch', async () => { + const main = job({ inputs: { key: 'release-linux' } }); + rustProject(main); + const dependency = path.join('target', 'debug', 'deps', 'libserde.rlib'); + write(path.join(main.workspace, dependency), 'built by the owner workflow'); + await main.restore(); + await main.save(); + const mainEntries = entries('rust'); + + // Fresh runners use the same absolute repository/home paths. Remove the + // build output and switch workflow identity without changing those paths. + fs.rmSync(path.join(main.workspace, 'target'), { recursive: true }); + process.env.GITHUB_WORKFLOW_REF = 'acme/app/.github/workflows/publish.yml@refs/pull/9/merge'; + process.env.GITHUB_JOB = 'helper'; + process.env.GITHUB_EVENT_NAME = 'pull_request'; + process.env.GITHUB_REF = 'refs/pull/9/merge'; + process.env.GITHUB_BASE_REF = 'main'; + process.env['INPUT_CACHE-OWNER'] = 'ci.yml/build'; + assert.equal((await main.restore()).restored, 'rust'); + assert.equal(fs.readFileSync(path.join(main.workspace, dependency), 'utf8'), 'built by the owner workflow'); + write(path.join(main.workspace, 'Cargo.lock'), 'version = 4\n# helper dependency change\n'); + await main.save(); + assert.equal(entries('rust', 'refs/pull/9/merge').length, 1); + assert.deepEqual(entries('rust'), mainEntries, 'the shared owner cache keeps default-branch entries intact'); +}); + test('balances shards by size', () => { const files = [900, 500, 400, 300, 200, 100].map((size, index) => ({ path: `f${index}`, size })); const shards = archive.plan(files, 1000);