Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/test-packages-input.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/**"
Expand Down
11 changes: 11 additions & 0 deletions .github/workflows/test-workflow.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
__pycache__/
*.py[cod]
47 changes: 45 additions & 2 deletions README.MD
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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` |
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
<rust-project-path>/target/<target-triple>/doc
```

Otherwise the cached doc path is:

```text
<rust-project-path>/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.
Expand Down
26 changes: 25 additions & 1 deletion action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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'
Expand Down
146 changes: 146 additions & 0 deletions scripts/resolve_cache_directories.py
Original file line number Diff line number Diff line change
@@ -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<<EOF\n")
for directory in directories:
handle.write(f"{directory}\n")
handle.write("EOF\n")


def _qualify_path(path: str, project_path: str) -> 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())
65 changes: 65 additions & 0 deletions tests/test_resolve_cache_directories.py
Original file line number Diff line number Diff line change
@@ -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<<EOF\ntarget/doc\nsrc/generated-api\nEOF\n",
)


if __name__ == "__main__":
unittest.main()