diff --git a/.github/workflows/test-packages-input.yml b/.github/workflows/test-packages-input.yml index 2a83f9c..fbf7aa0 100644 --- a/.github/workflows/test-packages-input.yml +++ b/.github/workflows/test-packages-input.yml @@ -17,6 +17,8 @@ on: branches: [v1-master] paths: - "action.yml" + - "scripts/**" + - "tests/**/*.py" - "README.MD" - ".github/workflows/test-packages-input.yml" - "tests/fixtures/packages-input-workspace/**" diff --git a/.github/workflows/test-workflow.yml b/.github/workflows/test-workflow.yml index c2dae75..1085227 100644 --- a/.github/workflows/test-workflow.yml +++ b/.github/workflows/test-workflow.yml @@ -8,9 +8,20 @@ on: branches: [v1-master] paths: - "action.yml" + - "scripts/**" + - "tests/**/*.py" - ".github/workflows/test-workflow.yml" jobs: + test-python-helper: + runs-on: ubuntu-latest + steps: + - name: Checkout Action Repository + uses: actions/checkout@v6 + - name: Run helper unit tests + shell: bash + run: python3 -m unittest tests/test_resolve_cache_directories.py + test-coverage-build: strategy: matrix: diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..43ae0e2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.py[cod] diff --git a/README.MD b/README.MD index b77f172..07ecc24 100644 --- a/README.MD +++ b/README.MD @@ -39,6 +39,7 @@ To use this action in your GitHub workflow, add the following step: target: 'x86_64-unknown-linux-gnu' install-rust-toolchain: true setup-rust-cache: true + cache-workspace-crates: true use-tarpaulin: true use-binstall: true install-binstall: true @@ -66,6 +67,8 @@ To use this action in your GitHub workflow, add the following step: | `target` | The target platform for the Rust compiler | No | `''` | | `install-rust-toolchain` | Whether to install the specified Rust toolchain | No | `true` | | `setup-rust-cache` | Whether to set up Rust caching | No | `true` | +| `cache-workspace-crates` | Whether `rust-cache` should preserve workspace crate artifacts | No | `true` | +| `cache-directories` | Extra cache directories, relative to `rust-project-path` unless absolute. | No | `''` | | `use-tarpaulin` | Whether to use Tarpaulin for code coverage. If false, only runs tests. | No | `true` | | `use-binstall` | Use `cargo-binstall` for installing tarpaulin. Else uses cargo install. | No | `true` | | `install-binstall` | Installs `cargo-binstall`. If false, assumes it is already available. | No | `true` | @@ -169,7 +172,7 @@ This workflow runs on every push and performs the following steps: ### Multiple Action Calls If you're calling this action multiple times within the same job (e.g., testing different feature combinations), -you can optimize build times by being selective about certain setup options on subsequent calls: +you can optimise build times by being selective about certain setup options on subsequent calls: ```yaml # First call - full setup @@ -192,7 +195,7 @@ you can optimize build times by being selective about certain setup options on s # ... other options ``` -**Key optimizations:** +**Key optimisations:** - `setup-rust-cache: false` - Skip cache setup after the first call - `install-rust-toolchain: false` - Skip toolchain installation if already installed @@ -209,6 +212,46 @@ The action automatically configures Rust caching based on your settings: - Because it's assumed you'd use `cargo-binstall` for all CI tooling. - When `use-binstall` is disabled, binary caching is enabled for normal `cargo install` operations +### Rustdoc Cache Behaviour + +This action caches the rustdoc output directory by default so later +`cargo doc` steps can reuse generated documentation artifacts. + +It also configures `Swatinem/rust-cache` to keep workspace crate artifacts by +default (`cache-workspace-crates: true`), which helps later `cargo clippy`, +`cargo check`, and similar commands reuse more compiled outputs. + +If your workflow uses an explicit target triple, the cached doc path is: + +```text +/target//doc +``` + +Otherwise the cached doc path is: + +```text +/target/doc +``` + +You can cache additional directories too: + +```yaml +- name: Run Tests and Upload Coverage + uses: Reloaded-Project/devops-rust-test-and-coverage@v1 + with: + rust-project-path: src + target: x86_64-pc-windows-msvc + cache-directories: | + target/custom-tool-cache + generated-api +``` + +In that example, the action caches all of the following: + +- `src/target/x86_64-pc-windows-msvc/doc` (automatic) +- `src/target/custom-tool-cache` +- `src/generated-api` + ### Workflow for Binary Projects This action is designed for Rust libraries without binary artifacts. diff --git a/action.yml b/action.yml index aadb517..9c9723f 100644 --- a/action.yml +++ b/action.yml @@ -25,6 +25,16 @@ inputs: description: "Whether to set up Rust caching" required: false default: "true" + cache-workspace-crates: + description: "Whether rust-cache should cache workspace crates" + required: false + default: "true" + cache-directories: + description: >- + Additional directories to cache, relative to rust-project-path unless + absolute. The rustdoc directory is cached automatically. + required: false + default: "" use-tarpaulin: description: "Whether to use Tarpaulin for code coverage. If false, only runs tests." required: false @@ -116,6 +126,17 @@ runs: fi echo "PACKAGES_ARG=${PACKAGES_ARG}" >> $GITHUB_ENV + # rust-cache prunes rustdoc output by default, so resolve the automatic + # rustdoc path plus any caller-provided cache directories here. + - name: Normalize rust-cache directories + if: inputs.setup-rust-cache == 'true' + shell: bash + run: python3 "${{ github.action_path }}/scripts/resolve_cache_directories.py" + env: + INPUT_RUST_PROJECT_PATH: ${{ inputs.rust-project-path }} + INPUT_TARGET: ${{ inputs.target }} + INPUT_CACHE_DIRECTORIES: ${{ inputs.cache-directories }} + - name: Install Rust Toolchain if: inputs.install-rust-toolchain == 'true' uses: actions-rust-lang/setup-rust-toolchain@v1 @@ -132,12 +153,15 @@ runs: with: key: test-${{ inputs.rust-toolchain }}-${{ inputs.rust-project-path }}-${{ inputs.target }}-${{ env.SAFE_FEATURES - }}-${{ inputs.no-default-features }}-${{ inputs.use-cross }}-${{ env.SAFE_ADDITIONAL_TEST_ARGS }} + }}-${{ inputs.no-default-features }}-${{ inputs.use-cross }}-${{ env.SAFE_ADDITIONAL_TEST_ARGS + }}-${{ inputs.cache-workspace-crates }} cache-on-failure: true cache-all-crates: true + cache-workspace-crates: ${{ inputs.cache-workspace-crates }} cache-bin: ${{ inputs.use-binstall == 'true' && 'false' || 'true' }} # Disable binary caching when using binstall to avoid conflicts workspaces: | ${{ inputs.rust-project-path }} -> target + cache-directories: ${{ env.RUST_CACHE_DIRECTORIES }} - name: Install gcc-multilib (if on Ubuntu based System, and Target is not Host) if: inputs.use-cross == 'false' && runner.os == 'Linux' diff --git a/scripts/resolve_cache_directories.py b/scripts/resolve_cache_directories.py new file mode 100644 index 0000000..d9ec2d9 --- /dev/null +++ b/scripts/resolve_cache_directories.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +"""Build the cache directory list for this action. + +We always include the rustdoc output directory, then merge any extra +``cache-directories`` entries from the workflow. Keeping that logic here makes +``action.yml`` smaller and easy to test. +""" + +from __future__ import annotations + +import ntpath +import os +import posixpath + + +def main() -> int: + """Resolve cache directories and export them to GitHub Actions environment. + + Reads configuration from environment variables, builds the cache directory list, + and either writes it to the GitHub environment file or prints to stdout. + + Returns: + 0 on success, non-zero on failure. + """ + project_path = os.environ.get("INPUT_RUST_PROJECT_PATH", ".") + target_triple = os.environ.get("INPUT_TARGET", "") + extra_directories = os.environ.get("INPUT_CACHE_DIRECTORIES", "") + github_env = os.environ.get("GITHUB_ENV") + + directories = resolve_cache_directories( + project_path=project_path, + target_triple=target_triple, + extra_directories=extra_directories, + ) + + if github_env: + write_github_env(github_env, directories) + else: + for directory in directories: + print(directory) + + return 0 + + +def resolve_cache_directories( + project_path: str, target_triple: str, extra_directories: str +) -> list[str]: + """Build the cache directory list for a Rust project. + + Always includes the rustdoc output directory, then merges any extra directories + from the workflow input, deduplicating by qualified path. + + Arguments: + project_path: Path to the Rust project root (relative or absolute). + target_triple: Target triple for cross-compilation (e.g., "x86_64-unknown-linux-gnu"). + extra_directories: Newline-separated list of additional cache directories. + + Returns: + List of qualified cache directory paths, with rustdoc directory first. + """ + default_doc_dir = "target/doc" + if target_triple: + default_doc_dir = f"target/{target_triple}/doc" + + resolved: list[str] = [_qualify_path(default_doc_dir, project_path)] + seen = set(resolved) + + for raw_line in extra_directories.splitlines(): + directory = raw_line.strip().rstrip("\r") + if not directory: + continue + + qualified = _qualify_path(directory, project_path) + if qualified in seen: + continue + + resolved.append(qualified) + seen.add(qualified) + + return resolved + + +def write_github_env(env_file: str, directories: list[str]) -> None: + """Export the resolved cache directories to a GitHub Actions environment file. + + Writes the directories as a multiline environment variable that downstream + workflow steps can read from `RUST_CACHE_DIRECTORIES`. + + Arguments: + env_file: Path to the GitHub environment file. + directories: List of cache directory paths to export. + """ + with open(env_file, "a", encoding="utf-8") as handle: + handle.write("RUST_CACHE_DIRECTORIES< str: + """Resolve a path relative to the Rust project root. + + Absolute paths are normalized as-is. Relative paths are joined with + the project path and normalized. + + Arguments: + path: The path to qualify (can be absolute or relative). + project_path: The Rust project root path. + + Returns: + Normalized path, or empty string if path is empty. + """ + if not path: + return "" + + normalized_for_check = path.replace("\\", "/") + if posixpath.isabs(normalized_for_check) or ntpath.isabs(path): + return _normalize_path(path) + + if project_path == ".": + return _normalize_path(path) + + return _normalize_path(posixpath.join(project_path, path)) + + +def _normalize_path(path: str) -> str: + """Normalize a path using POSIX forward slashes. + + Converts backslashes to forward slashes and normalizes the path, + but does not make it absolute. + + Arguments: + path: The path to normalize. + + Returns: + Normalized POSIX-style path. + """ + path = path.replace("\\", "/") + normalized = posixpath.normpath(path) + if normalized == ".": + return "." + return normalized + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_resolve_cache_directories.py b/tests/test_resolve_cache_directories.py new file mode 100644 index 0000000..27599c4 --- /dev/null +++ b/tests/test_resolve_cache_directories.py @@ -0,0 +1,65 @@ +import tempfile +import unittest +from pathlib import Path + +from scripts.resolve_cache_directories import resolve_cache_directories +from scripts.resolve_cache_directories import write_github_env + + +class ResolveCacheDirectoriesTests(unittest.TestCase): + def test_defaults_to_root_doc_directory(self) -> None: + self.assertEqual(resolve_cache_directories(".", "", ""), ["target/doc"]) + + def test_uses_target_specific_doc_directory(self) -> None: + self.assertEqual( + resolve_cache_directories("src", "x86_64-pc-windows-msvc", ""), + ["src/target/x86_64-pc-windows-msvc/doc"], + ) + + def test_qualifies_relative_directories_from_project_root(self) -> None: + self.assertEqual( + resolve_cache_directories( + "src", + "x86_64-pc-windows-msvc", + "ci-extra-cache\ntarget/custom-tool-cache\ngenerated-api\n", + ), + [ + "src/target/x86_64-pc-windows-msvc/doc", + "src/ci-extra-cache", + "src/target/custom-tool-cache", + "src/generated-api", + ], + ) + + def test_deduplicates_and_normalizes_paths(self) -> None: + self.assertEqual( + resolve_cache_directories( + ".", + "", + "target/doc\n./extra\nfoo/./bar/\nfoo/bar\n", + ), + ["target/doc", "extra", "foo/bar"], + ) + + def test_preserves_posix_and_windows_absolute_paths(self) -> None: + self.assertEqual( + resolve_cache_directories( + "src", + "", + "/tmp/cache-dir\nC:\\cache-dir\nrelative-cache\n", + ), + ["src/target/doc", "/tmp/cache-dir", "C:/cache-dir", "src/relative-cache"], + ) + + def test_writes_multiline_env_output(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + env_file = Path(temp_dir) / "github-env.txt" + write_github_env(str(env_file), ["target/doc", "src/generated-api"]) + self.assertEqual( + env_file.read_text(encoding="utf-8"), + "RUST_CACHE_DIRECTORIES<