diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2e22d686..aacac2da 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -79,6 +79,41 @@ jobs: - name: Check binary exists run: deno_foo -V + test-setup-cache: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + steps: + - uses: actions/checkout@v4 + + - name: Setup Deno + uses: ./ + with: + cache: true + cache-hash: ${{ hashFiles('**/package-lock.json') }} + + - name: Download dependencies for cache + run: deno install --global npm:cowsay@1.6.0 + + test-cache: + needs: test-setup-cache + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + steps: + - uses: actions/checkout@v4 + + - name: Setup Deno + uses: ./ + with: + cache: true + cache-hash: ${{ hashFiles('**/package-lock.json') }} + + - name: Run with cached dependencies + run: deno run --cached-only -RE npm:cowsay@1.6.0 "It works!" + lint: runs-on: ubuntu-latest steps: diff --git a/README.md b/README.md index 385aa209..45db988b 100644 --- a/README.md +++ b/README.md @@ -133,3 +133,23 @@ number. - run: echo "Deno version is ${{ steps.deno.outputs.deno-version }}" ``` + +### Caching dependencies downloaded by Deno automatically + +Dependencies installed by Deno can be cached automatically, which is similar to +the [`cache` option in `setup-node`](https://github.com/actions/setup-node). + +To enable the cache, use `cache: true`. It's recommended to also add the +`cache-hash` property, to scope caches based on lockfile changes. + +```yaml +- uses: denoland/setup-deno@v2 + with: + cache: true + cache-hash: ${{ hashFiles('**/deno.lock') }} +``` + +> [!WARNING] +> If an environment variable `DENO_DIR` is set for steps that run/download +> dependencies, then `DENO_DIR` must also be set for the `denoland/setup-deno` +> Action, for the caching to work as intended. diff --git a/action.yml b/action.yml index 831ef21e..8d5bf9e2 100644 --- a/action.yml +++ b/action.yml @@ -13,7 +13,14 @@ inputs: deno-binary-name: description: The name to use for the binary. default: "deno" + cache: + description: Cache downloaded modules & packages automatically in GitHub Actions cache. + default: "false" + cache-hash: + description: A hash used as part of the cache key. Use e.g. `$\{{ hashFiles('**/deno.lock') }}` to cache based on the lockfile contents. outputs: + cache-hit: + description: A boolean indicating whether the cache was hit. deno-version: description: "The Deno version that was installed." release-channel: @@ -21,3 +28,5 @@ outputs: runs: using: "node20" main: "main.mjs" + post: "save-cache.mjs" + post-if: always() diff --git a/main.mjs b/main.mjs index 9e59fd89..e5eef337 100644 --- a/main.mjs +++ b/main.mjs @@ -42,6 +42,11 @@ async function main() { core.setOutput("release-channel", version.kind); core.info("Installation complete."); + + if (core.getInput("cache") === "true") { + const { restoreCache } = await import("./src/cache.mjs"); + await restoreCache(core.getInput("cache-hash")); + } } catch (err) { core.setFailed((err instanceof Error) ? err : String(err)); process.exit(); diff --git a/package.json b/package.json index a8eec546..1e86aca0 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "license": "MIT", "type": "module", "dependencies": { + "@actions/cache": "^3.3.0", "@actions/core": "^1.10.1", "@actions/tool-cache": "^2.0.1", "semver": "^7.6.3", diff --git a/save-cache.mjs b/save-cache.mjs new file mode 100644 index 00000000..dce1db01 --- /dev/null +++ b/save-cache.mjs @@ -0,0 +1,14 @@ +import process from "node:process"; +import core from "@actions/core"; +import { saveCache } from "./src/cache.mjs"; + +async function main() { + try { + await saveCache(); + } catch (err) { + core.setFailed((err instanceof Error) ? err : String(err)); + process.exit(); + } +} + +main(); diff --git a/src/cache.mjs b/src/cache.mjs new file mode 100644 index 00000000..6e357039 --- /dev/null +++ b/src/cache.mjs @@ -0,0 +1,80 @@ +import process from "node:process"; +import cache from "@actions/cache"; +import core from "@actions/core"; + +const state = { + DENO_DIR: "DENO_DIR", + CACHE_HIT: "CACHE_HIT", + CACHE_SAVE: "CACHE_SAVE", +}; + +export async function saveCache() { + if (!cache.isFeatureAvailable()) { + core.warning("Caching is not available. Caching is skipped."); + return; + } + + const denoDir = core.getState(state.DENO_DIR); + const saveKey = core.getState(state.CACHE_SAVE); + if (!denoDir || !saveKey) { + core.info("Caching is not enabled. Caching is skipped."); + return; + } else if (core.getState(state.CACHE_HIT) === "true") { + core.info( + `Cache hit occurred on the primary key "${saveKey}", not saving cache.`, + ); + return; + } + + await cache.saveCache([denoDir], saveKey); + core.info(`Cache saved with key: "${saveKey}".`); +} + +/** + * @param {string} cacheHash Should be a hash of any lockfiles or similar. + */ +export async function restoreCache(cacheHash) { + try { + const denoDir = await resolveDenoDir(); + core.saveState(state.DENO_DIR, denoDir); + + const { GITHUB_JOB, RUNNER_OS, RUNNER_ARCH } = process.env; + const restoreKey = `deno-cache-${RUNNER_OS}-${RUNNER_ARCH}`; + // CI jobs often download different dependencies, so include Job ID in the cache key. + const primaryKey = `${restoreKey}-${GITHUB_JOB}-${cacheHash}`; + core.saveState(state.CACHE_SAVE, primaryKey); + + const loadedCacheKey = await cache.restoreCache([denoDir], primaryKey, [ + restoreKey, + ]); + const cacheHit = primaryKey === loadedCacheKey; + core.setOutput("cache-hit", cacheHit); + core.saveState(state.CACHE_HIT, cacheHit); + + const message = loadedCacheKey + ? `Cache key used: "${loadedCacheKey}".` + : `No cache found for restore key: "${restoreKey}".`; + core.info(message); + } catch (err) { + core.warning( + new Error("Failed to restore cache. Continuing without cache.", { + cause: err, + }), + ); + } +} + +/** + * @returns {Promise} + */ +async function resolveDenoDir() { + const { DENO_DIR } = process.env; + if (DENO_DIR) return DENO_DIR; + + // Retrieve the DENO_DIR from `deno info --json` + const { exec } = await import("node:child_process"); + const output = await new Promise((res, rej) => { + exec("deno info --json", (err, stdout) => err ? rej(err) : res(stdout)); + }); + return JSON.parse(output).denoDir; +}