diff --git a/.github/actions/dotnet-cache/action.yml b/.github/actions/dotnet-cache/action.yml index 243fef9..514d4c7 100644 --- a/.github/actions/dotnet-cache/action.yml +++ b/.github/actions/dotnet-cache/action.yml @@ -11,6 +11,14 @@ inputs: description: Newline-separated paths to cache. Keep it identical everywhere; a different path list is a different cache. required: false default: ~/.nuget/packages + exclude: + description: >- + Opt-in, newline-separated glob patterns to leave out of the cache, for example + ~/.nuget/packages/**/*.nupkg. Each is appended to the path list as a negated (!) pattern, so + the same list is used by restore and save. Blank lines are ignored and a leading ! is + optional. Empty (default) changes nothing. A different path list is a different cache. + required: false + default: "" key-files: description: Newline-separated hashFiles patterns the cache key is computed from. required: false @@ -54,6 +62,7 @@ runs: env: INPUT_MODE: ${{ inputs.mode }} INPUT_PATH: ${{ inputs.path }} + INPUT_EXCLUDE: ${{ inputs.exclude }} CACHE_KEY_PREFIX: ${{ inputs.key-prefix }}-${{ runner.os }}- CACHE_KEY_HASH: ${{ hashFiles(inputs.key-files) }} EVENT_NAME: ${{ github.event_name }} @@ -89,6 +98,18 @@ runs: echo "key=${CACHE_KEY_PREFIX}${CACHE_KEY_HASH}" echo "path<<${delimiter}" printf '%s\n' "$INPUT_PATH" + # actions/glob strips each leading ! (toggling negation) before it expands ~, so !~/... works. + while IFS= read -r pattern || [ -n "$pattern" ]; do + pattern="${pattern#"${pattern%%[![:space:]]*}"}" + pattern="${pattern%"${pattern##*[![:space:]]}"}" + while [ "${pattern#!}" != "$pattern" ]; do + pattern="${pattern#!}" + pattern="${pattern#"${pattern%%[![:space:]]*}"}" + done + if [ -n "$pattern" ]; then + printf '!%s\n' "$pattern" + fi + done <<< "$INPUT_EXCLUDE" echo "${delimiter}" } >> "$GITHUB_OUTPUT" echo "dotnet-cache: mode=${mode} key=${CACHE_KEY_PREFIX}${CACHE_KEY_HASH}" diff --git a/.github/scripts/tests/cache-actions.test.py b/.github/scripts/tests/cache-actions.test.py index a050633..92cff0b 100644 --- a/.github/scripts/tests/cache-actions.test.py +++ b/.github/scripts/tests/cache-actions.test.py @@ -42,7 +42,7 @@ def step(action, uses_prefix): def resolve(name, *, mode="auto", event="pull_request", ref="refs/pull/1/merge", default_branch="main", path=None, working_directory=None, key_hash="abc123", cwd=ROOT, - yarn=None): + yarn=None, exclude=None): """Run the action's production resolve script with the env GitHub would give it. PATH holds only a temporary bin directory, so the script sees no yarn unless the @@ -70,6 +70,8 @@ def resolve(name, *, mode="auto", event="pull_request", ref="refs/pull/1/merge", "GIT_REF": ref, "DEFAULT_BRANCH": default_branch, } + if "exclude" in action["inputs"]: + env["INPUT_EXCLUDE"] = exclude or "" if working_directory is not None: env["WORKING_DIRECTORY"] = working_directory result = subprocess.run([BASH, "-c", script], env=env, cwd=cwd, @@ -235,6 +237,43 @@ def test_multi_line_paths_survive_the_output(self): self.assertEqual(output["path"], "~/.nuget/packages\n~/.local/share/NuGet") +class DotnetExcludeTests(unittest.TestCase): + DEFAULT_PATH = "~/.nuget/packages" + + def test_exclude_is_opt_in_and_empty_by_default(self): + self.assertEqual(load("dotnet-cache")["inputs"]["exclude"]["default"], "") + env = next(s for s in load("dotnet-cache")["runs"]["steps"] if s.get("id") == "resolve")["env"] + self.assertEqual(env["INPUT_EXCLUDE"], "${{ inputs.exclude }}") + + def test_empty_or_unset_exclude_leaves_the_path_byte_identical(self): + for exclude in [None, "", "\n", " \n\t\n"]: + with self.subTest(exclude=exclude): + _, output = resolve("dotnet-cache", exclude=exclude) + self.assertEqual(output["path"], self.DEFAULT_PATH) + self.assertEqual(output["key"], "nuget-v1-Linux-abc123") + self.assertEqual(output["restore-key"], "nuget-v1-Linux-") + + def test_exclude_adds_negated_lines_after_the_path(self): + _, output = resolve("dotnet-cache", exclude="~/.nuget/packages/**/*.nupkg\n~/.nuget/packages/ilcompiler*") + self.assertEqual(output["path"], "\n".join([ + self.DEFAULT_PATH, "!~/.nuget/packages/**/*.nupkg", "!~/.nuget/packages/ilcompiler*"])) + + def test_blank_lines_and_surrounding_whitespace_are_ignored(self): + _, output = resolve("dotnet-cache", exclude="\n \n ~/.nuget/packages/**/*.nupkg \n\n") + self.assertEqual(output["path"], f"{self.DEFAULT_PATH}\n!~/.nuget/packages/**/*.nupkg") + + def test_an_existing_negation_is_not_doubled(self): + # A second ! would cancel the first inside actions/glob and re-include the files. + _, output = resolve("dotnet-cache", exclude="!~/.nuget/packages/**/*.nupkg\n! ~/x") + self.assertEqual(output["path"], f"{self.DEFAULT_PATH}\n!~/.nuget/packages/**/*.nupkg\n!~/x") + + def test_exclude_composes_with_a_custom_path_and_leaves_the_keys_alone(self): + _, output = resolve("dotnet-cache", path="a\nb", exclude="c") + self.assertEqual(output["path"], "a\nb\n!c") + self.assertEqual(output["key"], "nuget-v1-Linux-abc123") + self.assertEqual(output["restore-key"], "nuget-v1-Linux-") + + class YarnPathTests(unittest.TestCase): def test_default_path_is_the_yarn_cache_of_the_working_directory(self): _, output = resolve("yarn-cache", working_directory=FIXTURE) diff --git a/.github/workflows/verify-cache-actions.yml b/.github/workflows/verify-cache-actions.yml index 3123f3d..284f623 100644 --- a/.github/workflows/verify-cache-actions.yml +++ b/.github/workflows/verify-cache-actions.yml @@ -81,6 +81,19 @@ jobs: key-prefix: selftest-nuget-v1 mode: restore + - name: dotnet-cache (exclude) + id: exclude + uses: ./.github/actions/dotnet-cache + with: + path: .github/scripts/tests/fixtures/cache-actions/nuget + exclude: | + .github/scripts/tests/fixtures/cache-actions/nuget/**/*.nupkg + + !.github/scripts/tests/fixtures/cache-actions/nuget/skipped + key-files: .github/scripts/tests/fixtures/cache-actions/global.json + key-prefix: selftest-nuget-exclude-v1 + mode: restore + - name: dotnet-cache (unknown mode must fail) id: bad-mode continue-on-error: true @@ -102,6 +115,8 @@ jobs: YARN_HIT: ${{ steps.yarn.outputs.cache-hit }} YARN_MATCHED: ${{ steps.yarn.outputs.matched-key }} FORCED_MODE: ${{ steps.forced-restore.outputs.mode }} + EXCLUDE_MODE: ${{ steps.exclude.outputs.mode }} + EXCLUDE_KEY: ${{ steps.exclude.outputs.key }} BAD_OUTCOME: ${{ steps.bad-mode.outcome }} run: | set -euo pipefail @@ -145,8 +160,10 @@ jobs: check "explicit restore mode" "$FORCED_MODE" restore check "unknown mode outcome" "$BAD_OUTCOME" failure check_key "dotnet key" "$DOTNET_KEY" selftest-nuget-v1 + check "exclude mode" "$EXCLUDE_MODE" restore + check_key "exclude key" "$EXCLUDE_KEY" selftest-nuget-exclude-v1 check_key "yarn key" "$YARN_KEY" selftest-yarn-v1 check_hit dotnet "$DOTNET_HIT" "$DOTNET_MATCHED" "$DOTNET_KEY" selftest-nuget-v1 check_hit yarn "$YARN_HIT" "$YARN_MATCHED" "$YARN_KEY" selftest-yarn-v1 - echo "checked 8 assertions, failures: ${failures}" + echo "checked 10 assertions, failures: ${failures}" [ "$failures" -eq 0 ] diff --git a/README.md b/README.md index f250670..2979c9c 100644 --- a/README.md +++ b/README.md @@ -695,8 +695,11 @@ Caches NuGet packages only. Build output (`obj`) is not cached because it goes s | `path` | `~/.nuget/packages` | | `key-files` | `**/packages.lock.json`, `**/*.props`, `**/*.targets`, `**/*.csproj`, `**/[Nn]u[Gg]et.[Cc]onfig`, `*.slnx`, `global.json` (one per line) | | `key-prefix` | `nuget-v1` | +| `exclude` | empty (one glob per line, opt-in) | | `mode` | `auto` | +`exclude` appends each non-blank line to the path list as a `!` negation (for example `~/.nuget/packages/**/*.nupkg`), identically for restore and save. Empty leaves the path, key and restore key exactly as before. It changes the path list, so it is a different cache: warm it from the default branch before relying on it. + The key is `--`, with `--` as the restore key. The action fails when `key-files` matches no file, because a key without a content hash would make every run share one stale cache. ### `yarn-cache`