From 50e86677bf9b72b882b6e7188bb93f82acffd251 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Thu, 1 Oct 2026 22:22:07 +0100 Subject: [PATCH 1/7] Add sccache-report: a statistics step that survives a fallback With setup-rust failing open, a server that never started has no statistics, and sccache --show-stats would start it again and fail the step, so every consumer that read statistics after the build needed the same guard. The new composite action prints the statistics, writes them as text and JSON, adds them to the job summary under the chosen backend, and stands down with a notice and reported=false when setup-rust reports sccache-status fallback or sccache is absent. A consumer's health check conditions on reported. --- .github/actions/sccache-report/CHANGELOG.md | 13 ++ .github/actions/sccache-report/README.md | 58 +++++ .github/actions/sccache-report/action.yml | 96 ++++++++ .../tests/test_sccache_report.py | 208 ++++++++++++++++++ .github/actions/setup-rust/README.md | 5 + CODEOWNERS | 1 + README.md | 1 + docs/developers-guide.md | 20 ++ 8 files changed, 402 insertions(+) create mode 100644 .github/actions/sccache-report/CHANGELOG.md create mode 100644 .github/actions/sccache-report/README.md create mode 100644 .github/actions/sccache-report/action.yml create mode 100644 .github/actions/sccache-report/tests/test_sccache_report.py diff --git a/.github/actions/sccache-report/CHANGELOG.md b/.github/actions/sccache-report/CHANGELOG.md new file mode 100644 index 000000000..c7fe54750 --- /dev/null +++ b/.github/actions/sccache-report/CHANGELOG.md @@ -0,0 +1,13 @@ +# Changelog + +All notable changes to the `sccache-report` action will be documented in this +file. + +## Unreleased + +- Add the action. It prints sccache's statistics (text and JSON), appends them + to the job summary under the backend `setup-rust` chose, and stands down, + with a notice and `reported=false`, when `setup-rust` reports `sccache-status` + `fallback` or sccache is not on `PATH`. A consumer's health check conditions + on `reported` instead of repeating the guard that keeps a fallback from + turning red. diff --git a/.github/actions/sccache-report/README.md b/.github/actions/sccache-report/README.md new file mode 100644 index 000000000..f1abf866f --- /dev/null +++ b/.github/actions/sccache-report/README.md @@ -0,0 +1,58 @@ +# sccache-report + +Prints sccache's statistics after a build, writes them as text and JSON, adds +them to the job summary, and stands down when [`setup-rust`](../setup-rust) +reports that the sccache server fell back to an uncached build. + +## Why it exists + +With `setup-rust` at shared-actions #546 a server that will not start within +its 60 s timeout no longer fails the job: the action clears `RUSTC_WRAPPER`, +raises a `sccache-fallback` annotation and sets its `sccache-status` output to +`fallback`. A server that never started has no statistics, and +`sccache --show-stats` would start it again, wait out the startup timeout and +fail the step, turning the documented fail-open into a red job. Every consumer +that read statistics after the build needed the same guard. This action owns it +once. + +It cannot live inside `setup-rust`: the statistics exist only after the build, +when `setup-rust` has long finished, and a composite action has no post step. + +## Usage + +```yaml +- uses: leynos/shared-actions/.github/actions/setup-rust@ + id: setup-rust +- run: cargo build +- id: sccache + if: always() + uses: leynos/shared-actions/.github/actions/sccache-report@ + with: + status: ${{ steps.setup-rust.outputs.sccache-status }} + backend: ${{ steps.setup-rust.outputs.cache-backend }} +- name: Check sccache health + if: steps.sccache.outputs.reported == 'true' + run: python3 scripts/check_sccache_health.py ${{ steps.sccache.outputs.stats-file }} +``` + +## Inputs + +| Name | Description | Default | +| ---------- | ---------------------------------------------------------------------------------------------------- | -------------------- | +| status | The `sccache-status` output of setup-rust. `fallback` stands the action down; anything else reports. | `''` | +| backend | The `cache-backend` output of setup-rust, named in the summary. | `''` | +| stats-file | Path the JSON statistics are written to. | `sccache-stats.json` | +| text-file | Path the human-readable statistics are written to. | `sccache-stats.txt` | +| summary | `true` appends the statistics to the job summary. | `true` | + +## Outputs + +| Name | Description | +| ---------- | -------------------------------------------------------------------------------------------------------------- | +| reported | `true` when statistics were written, `false` when the action stood down (a fallback, or no sccache on `PATH`). | +| stats-file | The JSON path when `reported` is `true`, else empty. | + +A health check that reads the JSON conditions on `reported`, so the guard lives +here and not in each consumer. Standing down is reported as a notice titled +`sccache-report` and as +`metric sccache-report.outcome=`. diff --git a/.github/actions/sccache-report/action.yml b/.github/actions/sccache-report/action.yml new file mode 100644 index 000000000..5327420ae --- /dev/null +++ b/.github/actions/sccache-report/action.yml @@ -0,0 +1,96 @@ +name: Report sccache statistics +description: >- + Print sccache's statistics, write them as text and JSON, and add them to the + job summary, standing down when setup-rust reports that the server fell back + to an uncached build. Call it after the build, under `if: always()`. + +inputs: + status: + description: >- + The `sccache-status` output of setup-rust. `fallback` means the server + never started: there are no statistics, and asking for them would start + the server again and fail the step, so the action reports nothing and + says so. Any other value, including empty, reports. + required: false + default: '' + backend: + description: >- + The `cache-backend` output of setup-rust, named in the summary because + `Cache location` reads `ghac` for Ubicloud's proxy and for GitHub's own + service alike. + required: false + default: '' + stats-file: + description: Path the JSON statistics are written to. + required: false + default: sccache-stats.json + text-file: + description: Path the human-readable statistics are written to. + required: false + default: sccache-stats.txt + summary: + description: >- + "true" (the default) appends the statistics to the job summary. Anything + else leaves the summary alone. + required: false + default: 'true' + +outputs: + reported: + description: >- + "true" when statistics were written, "false" when the action stood down + (a fallback, or no sccache on PATH). A consumer's health check conditions + on this instead of repeating the guard. + value: ${{ steps.report.outputs.reported }} + stats-file: + description: Path of the JSON statistics when `reported` is "true", else empty. + value: ${{ steps.report.outputs.stats-file }} + +runs: + using: composite + steps: + - name: Report sccache statistics + id: report + shell: bash + env: + SR_STATUS: ${{ inputs.status }} + SR_BACKEND: ${{ inputs.backend }} + SR_STATS_FILE: ${{ inputs.stats-file }} + SR_TEXT_FILE: ${{ inputs.text-file }} + SR_SUMMARY: ${{ inputs.summary }} + run: | + set -euo pipefail + stand_down() { + echo "reported=false" >> "$GITHUB_OUTPUT" + echo "stats-file=" >> "$GITHUB_OUTPUT" + echo "::notice title=sccache-report::$1" + echo "metric sccache-report.outcome=$2" + } + # A server that fell back never started. `sccache --show-stats` would + # start it again, wait out its startup timeout and fail the step, which + # would turn the documented fail-open into a red job. + if [[ "${SR_STATUS}" == fallback ]]; then + stand_down "sccache fell back to an uncached build; there are no statistics to report" fallback + exit 0 + fi + if ! command -v sccache >/dev/null 2>&1; then + stand_down "sccache is not on PATH; there are no statistics to report" not-installed + exit 0 + fi + stats="$(sccache --show-stats)" + printf '%s\n' "$stats" | tee "${SR_TEXT_FILE}" + sccache --show-stats --stats-format json > "${SR_STATS_FILE}" + # The log copy is the one that can be read afterwards: the job summary + # is not available through the REST API. + if [[ "${SR_SUMMARY}" == true && -n "${GITHUB_STEP_SUMMARY:-}" ]]; then + { + printf '### sccache\n\n' + if [[ -n "${SR_BACKEND}" ]]; then + printf -- '- backend: `%s`\n\n' "${SR_BACKEND}" + fi + printf '```text\n%s\n```\n' "$stats" + } >> "${GITHUB_STEP_SUMMARY}" + fi + echo "reported=true" >> "$GITHUB_OUTPUT" + echo "stats-file=${SR_STATS_FILE}" >> "$GITHUB_OUTPUT" + echo "metric sccache-report.outcome=reported" diff --git a/.github/actions/sccache-report/tests/test_sccache_report.py b/.github/actions/sccache-report/tests/test_sccache_report.py new file mode 100644 index 000000000..0c3d3a962 --- /dev/null +++ b/.github/actions/sccache-report/tests/test_sccache_report.py @@ -0,0 +1,208 @@ +"""Tests for the `sccache-report` composite action's report step. + +A consumer used to repeat `sccache --show-stats` after its build. With +`setup-rust` failing open (shared-actions #546), a server that never started has +no statistics, and asking for them starts it again and fails the job the +fallback had just saved. This action owns that guard once; the tests run its +shipped script against a stub sccache. +""" + +from __future__ import annotations + +import os +import subprocess +import typing as typ +from pathlib import Path + +import pytest +import yaml + +ACTION_PATH = Path(__file__).resolve().parents[1] / "action.yml" +STEP = "Report sccache statistics" + + +#: A callable that runs the shipped script with overrides and returns its result. +Runner = typ.Callable[..., "Run"] + + +def _step() -> dict[str, typ.Any]: + steps = yaml.safe_load(ACTION_PATH.read_text(encoding="utf-8"))["runs"]["steps"] + return next(step for step in steps if step["name"] == STEP) + + +class Run(typ.NamedTuple): + """One run of the report script and the files it wrote.""" + + completed: subprocess.CompletedProcess[str] + home: Path + + def read(self, name: str) -> str: + """Return a file the script wrote, or an empty string.""" + path = self.home / name + return path.read_text(encoding="utf-8") if path.exists() else "" + + def outputs(self) -> dict[str, str]: + """Return the step outputs as a mapping.""" + pairs = ( + line.split("=", 1) + for line in self.read("github_output").splitlines() + if "=" in line + ) + return dict(pairs) + + +@pytest.fixture +def run_report(tmp_path: Path) -> typ.Callable[..., Run]: + """Return a runner for the shipped script with a stub sccache on PATH.""" + stub_dir = tmp_path / "bin" + stub_dir.mkdir() + stub = stub_dir / "sccache" + stub.write_text( + "#!/usr/bin/env bash\n" + 'echo "$*" >> "$(dirname "$0")/calls.log"\n' + 'if [[ "$*" == *json* ]]; then\n' + " echo '{\"stats\":{}}'\n" + "else\n" + ' echo "Compile requests 7"\n' + "fi\n", + encoding="utf-8", + ) + stub.chmod(0o755) + + def run(*, with_stub: bool = True, **env: str) -> Run: + path = f"{stub_dir}:{os.environ['PATH']}" if with_stub else "/usr/bin:/bin" + environment = { + "PATH": path, + "SR_STATUS": "", + "SR_BACKEND": "", + "SR_STATS_FILE": str(tmp_path / "sccache-stats.json"), + "SR_TEXT_FILE": str(tmp_path / "sccache-stats.txt"), + "SR_SUMMARY": "true", + "GITHUB_OUTPUT": str(tmp_path / "github_output"), + "GITHUB_STEP_SUMMARY": str(tmp_path / "summary"), + **env, + } + completed = subprocess.run( # noqa: S603,TID251 - the script under test. + ["bash", "-c", _step()["run"]], # noqa: S607 + capture_output=True, + check=False, + env=environment, + text=True, + timeout=30, + ) + return Run(completed, tmp_path) + + return run + + +def _calls(run: Run) -> str: + path = run.home / "bin" / "calls.log" + return path.read_text(encoding="utf-8") if path.exists() else "" + + +class TestFallback: + """A fallen-back server has no statistics, so nothing may ask for them.""" + + def test_a_fallback_never_calls_sccache(self, run_report: Runner) -> None: + """Calling sccache would start the dead server again.""" + result = run_report(SR_STATUS="fallback") + + assert result.completed.returncode == 0, result.completed.stderr + assert _calls(result) == "" + + def test_a_fallback_reports_false_and_writes_nothing( + self, run_report: Runner + ) -> None: + """The consumer's health check reads `reported` and skips.""" + result = run_report(SR_STATUS="fallback") + + assert result.outputs() == {"reported": "false", "stats-file": ""} + assert result.read("sccache-stats.json") == "" + assert result.read("summary") == "" + assert "fell back" in result.completed.stdout + + def test_a_fallback_is_still_visible(self, run_report: Runner) -> None: + """Standing down must say so, or a missing report looks like a bug.""" + result = run_report(SR_STATUS="fallback") + + assert "::notice title=sccache-report::" in result.completed.stdout + assert "metric sccache-report.outcome=fallback" in result.completed.stdout + + +class TestReporting: + """Any other status reports, in the log, the files and the summary.""" + + @pytest.mark.parametrize("status", ["", "started"]) + def test_it_reports_statistics(self, run_report: Runner, status: str) -> None: + """An empty status (no server started by setup-rust) still reports.""" + result = run_report(SR_STATUS=status, SR_BACKEND="ubicloud") + + assert result.completed.returncode == 0, result.completed.stderr + assert "Compile requests 7" in result.completed.stdout + assert "Compile requests 7" in result.read("sccache-stats.txt") + assert result.read("sccache-stats.json").strip() == '{"stats":{}}' + assert result.outputs()["reported"] == "true" + assert result.outputs()["stats-file"].endswith("sccache-stats.json") + + def test_the_summary_names_the_backend(self, run_report: Runner) -> None: + """`Cache location` cannot tell Ubicloud's proxy from GitHub's service.""" + result = run_report(SR_STATUS="started", SR_BACKEND="ubicloud") + + summary = result.read("summary") + assert "- backend: `ubicloud`" in summary + assert "Compile requests 7" in summary + + def test_the_summary_can_be_switched_off(self, run_report: Runner) -> None: + """A caller with its own summary format opts out.""" + result = run_report(SR_STATUS="started", SR_SUMMARY="false") + + assert result.read("summary") == "" + assert result.outputs()["reported"] == "true" + + def test_it_asks_sccache_for_text_and_json(self, run_report: Runner) -> None: + """Both formats are written, since consumers' health checks read JSON.""" + result = run_report(SR_STATUS="started") + + calls = _calls(result).splitlines() + assert "--show-stats" in calls + assert "--show-stats --stats-format json" in calls + + +class TestNoSccache: + """A job that failed before sccache existed must not gain a second failure.""" + + def test_a_missing_binary_stands_down(self, run_report: Runner) -> None: + """Reporting nothing is correct; failing would bury the real error.""" + result = run_report(with_stub=False, SR_STATUS="started") + + assert result.completed.returncode == 0, result.completed.stderr + assert result.outputs()["reported"] == "false" + assert "metric sccache-report.outcome=not-installed" in result.completed.stdout + + +class TestManifest: + """The wiring consumers depend on.""" + + def test_the_outputs_read_the_report_step(self) -> None: + """`reported` and `stats-file` must come from the step that decides.""" + manifest = yaml.safe_load(ACTION_PATH.read_text(encoding="utf-8")) + + assert manifest["outputs"]["reported"]["value"] == ( + "${{ steps.report.outputs.reported }}" + ) + assert manifest["outputs"]["stats-file"]["value"] == ( + "${{ steps.report.outputs.stats-file }}" + ) + assert _step()["id"] == "report" + + def test_the_step_is_bash(self) -> None: + """The script uses Bash features, so the shell is pinned to it.""" + assert _step()["shell"] == "bash" + + def test_the_inputs_have_the_documented_defaults(self) -> None: + """A caller that sets only `status` gets the files setup-rust users expect.""" + inputs = yaml.safe_load(ACTION_PATH.read_text(encoding="utf-8"))["inputs"] + + assert inputs["stats-file"]["default"] == "sccache-stats.json" + assert inputs["text-file"]["default"] == "sccache-stats.txt" + assert inputs["summary"]["default"] == "true" diff --git a/.github/actions/setup-rust/README.md b/.github/actions/setup-rust/README.md index 3af1bd5b5..a4af74e66 100644 --- a/.github/actions/setup-rust/README.md +++ b/.github/actions/setup-rust/README.md @@ -265,6 +265,11 @@ to local disk. Each run sets the `cache-backend` output and reports A caller that has already set `RUSTC_WRAPPER` keeps its value, and the action says so in a notice. +A step that reads sccache's statistics after the build should use +[`sccache-report`](../sccache-report) rather than call `sccache --show-stats` +itself: a server that fell back never started, and asking it for statistics +would start it again and fail the job. + ### Who starts the server, and when The action starts the sccache server itself, in a `run:` step that is the last diff --git a/CODEOWNERS b/CODEOWNERS index 46c703802..459ad96e3 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1,6 +1,7 @@ .github/actions/export-postgres-url/ @leynos .github/actions/generate-coverage/ @leynos .github/actions/setup-rust/ @leynos +.github/actions/sccache-report/ @leynos .github/actions/upload-codescene-coverage/ @leynos .github/actions/ratchet-coverage/ @leynos .github/actions/install-mdtablefix/ @leynos diff --git a/README.md b/README.md index abc99d63d..a61f42a0f 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ GitHub Actions | Rust build release | `.github/actions/rust-build-release` | v1 | | Resolve workflow source | `.github/actions/resolve-workflow-source` | unreleased | | Setup Rust | `.github/actions/setup-rust` | v1 | +| Report sccache statistics | `.github/actions/sccache-report` | unreleased | | Stage release artefacts | `.github/actions/stage-release-artefacts` | v1 | | Upload CodeScene Coverage | `.github/actions/upload-codescene-coverage` | v1 | | Upload release assets | `.github/actions/upload-release-assets` | v1 | diff --git a/docs/developers-guide.md b/docs/developers-guide.md index a703e9922..98e5d655e 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -460,6 +460,26 @@ that does call `export-ubicloud-cache-credentials` must still call it **before** `setup-rust`: the GitHub Actions backend reads its endpoint when the sccache server starts, so credentials published afterwards arrive too late. +## `sccache-report` and the fallback-statistics trap + +Since `setup-rust` fails open (a server that will not start falls back to an +uncached build and reports `sccache-status` `fallback`), any step that reads +sccache's statistics after the build must stand down on a fallback: the dead +server has none, and `sccache --show-stats` would start it again, wait out the +startup timeout and fail the step. whitaker, netsuke and podbot each needed the +same guard added by hand. + +The `sccache-report` action owns it once. It cannot be part of `setup-rust`, +whose work ends before the build and which, as a composite action, has no post +step; and a shim replacing `sccache --show-stats` was rejected because it +changes a binary callers also run directly. A consumer calls the action after +the build under `if: always()`, passing `status` and `backend` from +`setup-rust`'s outputs, and conditions its health check on the `reported` +output. `test_sccache_report.py` runs the shipped script against a stub +sccache: a fallback never invokes sccache, reports `false` and writes nothing; +any other status writes text and JSON and the summary; a missing binary stands +down instead of failing. Seven mutations each fail a named case. + ## `setup-rust` and the mold linker `install-mold` installs a pinned mold release on Linux runners so that the From 9723741bcf5ec0acc1b92738f685d122de3fb40f Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Thu, 1 Oct 2026 22:30:58 +0100 Subject: [PATCH 2/7] Run the sccache-report script tests on POSIX hosts only On a Windows host bash resolves to WSL's launcher rather than the Git Bash the action's shell: bash uses, so the stub-based behaviour tests cannot run there. The manifest tests still run everywhere. --- .../sccache-report/tests/test_sccache_report.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/actions/sccache-report/tests/test_sccache_report.py b/.github/actions/sccache-report/tests/test_sccache_report.py index 0c3d3a962..a81bc331f 100644 --- a/.github/actions/sccache-report/tests/test_sccache_report.py +++ b/.github/actions/sccache-report/tests/test_sccache_report.py @@ -11,6 +11,7 @@ import os import subprocess +import sys import typing as typ from pathlib import Path @@ -20,6 +21,14 @@ ACTION_PATH = Path(__file__).resolve().parents[1] / "action.yml" STEP = "Report sccache statistics" +#: The behaviour tests run the shipped script under a POSIX `bash` against a +#: stub executable. On a Windows host `bash` resolves to WSL's launcher, not +#: the Git Bash the action's `shell: bash` uses, so they run on POSIX hosts only; +#: the manifest tests below run everywhere. +posix_only = pytest.mark.skipif( + sys.platform == "win32", reason="runs the script under a POSIX bash" +) + #: A callable that runs the shipped script with overrides and returns its result. Runner = typ.Callable[..., "Run"] @@ -100,6 +109,7 @@ def _calls(run: Run) -> str: return path.read_text(encoding="utf-8") if path.exists() else "" +@posix_only class TestFallback: """A fallen-back server has no statistics, so nothing may ask for them.""" @@ -129,6 +139,7 @@ def test_a_fallback_is_still_visible(self, run_report: Runner) -> None: assert "metric sccache-report.outcome=fallback" in result.completed.stdout +@posix_only class TestReporting: """Any other status reports, in the log, the files and the summary.""" @@ -168,6 +179,7 @@ def test_it_asks_sccache_for_text_and_json(self, run_report: Runner) -> None: assert "--show-stats --stats-format json" in calls +@posix_only class TestNoSccache: """A job that failed before sccache existed must not gain a second failure.""" From cf261a0c2b9bd17ed25f2879699baff0714610dd Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Thu, 1 Oct 2026 22:31:49 +0100 Subject: [PATCH 3/7] Prove the sccache-report guard under Git Bash on Windows Skipping the behaviour tests on win32 left the guard unproved on the platform where Windows consumers run it. The tests now resolve the Bash that shell: bash uses there: Git Bash at its usual install paths, or a bash on PATH that is not WSL's System32 launcher. They skip only when no Git Bash exists, naming every path tried. Path lists use os.pathsep. --- .../tests/test_sccache_report.py | 50 +++++++++++++++---- 1 file changed, 39 insertions(+), 11 deletions(-) diff --git a/.github/actions/sccache-report/tests/test_sccache_report.py b/.github/actions/sccache-report/tests/test_sccache_report.py index a81bc331f..3a66d395c 100644 --- a/.github/actions/sccache-report/tests/test_sccache_report.py +++ b/.github/actions/sccache-report/tests/test_sccache_report.py @@ -10,6 +10,7 @@ from __future__ import annotations import os +import shutil import subprocess import sys import typing as typ @@ -21,15 +22,35 @@ ACTION_PATH = Path(__file__).resolve().parents[1] / "action.yml" STEP = "Report sccache statistics" -#: The behaviour tests run the shipped script under a POSIX `bash` against a -#: stub executable. On a Windows host `bash` resolves to WSL's launcher, not -#: the Git Bash the action's `shell: bash` uses, so they run on POSIX hosts only; -#: the manifest tests below run everywhere. -posix_only = pytest.mark.skipif( - sys.platform == "win32", reason="runs the script under a POSIX bash" +GIT_BASH_CANDIDATES = ( + r"C:\Program Files\Git\bin\bash.exe", + r"C:\Program Files (x86)\Git\bin\bash.exe", ) +def _bash() -> str: + """Return the Bash the action's `shell: bash` uses on this host. + + On GitHub's Windows images that is Git Bash. A plain `bash` lookup there + can find WSL's launcher in System32 instead, which is a different shell + over a different filesystem, so the candidates are tried explicitly and + any System32 hit is ignored. The behaviour tests skip only when no Git + Bash exists, naming every path tried. + """ + if sys.platform != "win32": + return shutil.which("bash") or "bash" + tried = list(GIT_BASH_CANDIDATES) + for candidate in tried: + if Path(candidate).is_file(): + return candidate + found = shutil.which("bash") + if found and "system32" not in found.lower(): + return found + tried.append(f"shutil.which('bash') -> {found}") + pytest.skip(f"no Git Bash found; tried {', '.join(tried)}") + return "" # pragma: no cover - pytest.skip raises + + #: A callable that runs the shipped script with overrides and returns its result. Runner = typ.Callable[..., "Run"] @@ -79,7 +100,17 @@ def run_report(tmp_path: Path) -> typ.Callable[..., Run]: stub.chmod(0o755) def run(*, with_stub: bool = True, **env: str) -> Run: - path = f"{stub_dir}:{os.environ['PATH']}" if with_stub else "/usr/bin:/bin" + bash = _bash() + # Without the stub, only the shell's own tools are on PATH, so a real + # sccache on the host cannot answer. + system_tools = ( + str(Path(bash).parent.parent / "usr" / "bin") + if sys.platform == "win32" + else "/usr/bin:/bin" + ) + path = ( + f"{stub_dir}{os.pathsep}{os.environ['PATH']}" if with_stub else system_tools + ) environment = { "PATH": path, "SR_STATUS": "", @@ -92,7 +123,7 @@ def run(*, with_stub: bool = True, **env: str) -> Run: **env, } completed = subprocess.run( # noqa: S603,TID251 - the script under test. - ["bash", "-c", _step()["run"]], # noqa: S607 + [bash, "-c", _step()["run"]], capture_output=True, check=False, env=environment, @@ -109,7 +140,6 @@ def _calls(run: Run) -> str: return path.read_text(encoding="utf-8") if path.exists() else "" -@posix_only class TestFallback: """A fallen-back server has no statistics, so nothing may ask for them.""" @@ -139,7 +169,6 @@ def test_a_fallback_is_still_visible(self, run_report: Runner) -> None: assert "metric sccache-report.outcome=fallback" in result.completed.stdout -@posix_only class TestReporting: """Any other status reports, in the log, the files and the summary.""" @@ -179,7 +208,6 @@ def test_it_asks_sccache_for_text_and_json(self, run_report: Runner) -> None: assert "--show-stats --stats-format json" in calls -@posix_only class TestNoSccache: """A job that failed before sccache existed must not gain a second failure.""" From 89a88c8b3833cb92bc3e1400f75e9f4fdf7f6986 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Fri, 2 Oct 2026 00:35:35 +0100 Subject: [PATCH 4/7] Validate the sccache-report paths and document the input contract A stats-file with a line break would add records to the step output (x then reported=false would override the real value), so both paths are refused when they contain CR or LF. tee now gets -- before the text path, so an option-like name is a file name. The README input table gains Type and Required columns. --- .github/actions/sccache-report/README.md | 16 +++++---- .github/actions/sccache-report/action.yml | 14 +++++++- .../tests/test_sccache_report.py | 33 +++++++++++++++++++ 3 files changed, 55 insertions(+), 8 deletions(-) diff --git a/.github/actions/sccache-report/README.md b/.github/actions/sccache-report/README.md index f1abf866f..f12939326 100644 --- a/.github/actions/sccache-report/README.md +++ b/.github/actions/sccache-report/README.md @@ -37,13 +37,15 @@ when `setup-rust` has long finished, and a composite action has no post step. ## Inputs -| Name | Description | Default | -| ---------- | ---------------------------------------------------------------------------------------------------- | -------------------- | -| status | The `sccache-status` output of setup-rust. `fallback` stands the action down; anything else reports. | `''` | -| backend | The `cache-backend` output of setup-rust, named in the summary. | `''` | -| stats-file | Path the JSON statistics are written to. | `sccache-stats.json` | -| text-file | Path the human-readable statistics are written to. | `sccache-stats.txt` | -| summary | `true` appends the statistics to the job summary. | `true` | +All inputs are strings and all are optional. + +| Name | Type | Required? | Description | Default | +| ---------- | ------ | --------- | ---------------------------------------------------------------------------------------------------- | -------------------- | +| status | string | no | The `sccache-status` output of setup-rust. `fallback` stands the action down; anything else reports. | `''` | +| backend | string | no | The `cache-backend` output of setup-rust, named in the summary. | `''` | +| stats-file | string | no | Path the JSON statistics are written to. Must not contain a line break. | `sccache-stats.json` | +| text-file | string | no | Path the human-readable statistics are written to. Must not contain a line break. | `sccache-stats.txt` | +| summary | string | no | `true` appends the statistics to the job summary. | `true` | ## Outputs diff --git a/.github/actions/sccache-report/action.yml b/.github/actions/sccache-report/action.yml index 5327420ae..c78bc79a6 100644 --- a/.github/actions/sccache-report/action.yml +++ b/.github/actions/sccache-report/action.yml @@ -60,6 +60,18 @@ runs: SR_SUMMARY: ${{ inputs.summary }} run: | set -euo pipefail + # The paths are caller input and the stats-file is written as a + # `name=value` output record, where a line break would start another + # record (`x\nreported=false` would override the real one). A path with + # a line break is refused rather than encoded. + for path in "${SR_STATS_FILE}" "${SR_TEXT_FILE}"; do + case "$path" in + *$'\n'*|*$'\r'*) + echo "::error title=sccache-report::stats-file and text-file must not contain line breaks" >&2 + exit 1 + ;; + esac + done stand_down() { echo "reported=false" >> "$GITHUB_OUTPUT" echo "stats-file=" >> "$GITHUB_OUTPUT" @@ -78,7 +90,7 @@ runs: exit 0 fi stats="$(sccache --show-stats)" - printf '%s\n' "$stats" | tee "${SR_TEXT_FILE}" + printf '%s\n' "$stats" | tee -- "${SR_TEXT_FILE}" sccache --show-stats --stats-format json > "${SR_STATS_FILE}" # The log copy is the one that can be read afterwards: the job summary # is not available through the REST API. diff --git a/.github/actions/sccache-report/tests/test_sccache_report.py b/.github/actions/sccache-report/tests/test_sccache_report.py index 3a66d395c..53a4a6d9f 100644 --- a/.github/actions/sccache-report/tests/test_sccache_report.py +++ b/.github/actions/sccache-report/tests/test_sccache_report.py @@ -126,6 +126,7 @@ def run(*, with_stub: bool = True, **env: str) -> Run: [bash, "-c", _step()["run"]], capture_output=True, check=False, + cwd=tmp_path, env=environment, text=True, timeout=30, @@ -208,6 +209,38 @@ def test_it_asks_sccache_for_text_and_json(self, run_report: Runner) -> None: assert "--show-stats --stats-format json" in calls +class TestPathInputs: + """The paths are caller input, so they are validated and not parsed.""" + + @pytest.mark.parametrize("name", ["SR_STATS_FILE", "SR_TEXT_FILE"]) + @pytest.mark.parametrize("separator", ["\n", "\r"]) + def test_a_path_with_a_line_break_is_refused( + self, run_report: Runner, name: str, separator: str + ) -> None: + """A path ending in `reported=false` would otherwise add an output record.""" + result = run_report( + SR_STATUS="started", **{name: f"x{separator}reported=false"} + ) + + assert result.completed.returncode != 0 + assert "must not contain line breaks" in result.completed.stderr + assert result.read("github_output") == "" + + def test_an_option_like_text_path_is_a_file_name( + self, run_report: Runner, tmp_path: Path + ) -> None: + """`tee --help` would exit successfully and write nothing.""" + result = run_report( + SR_STATUS="started", + SR_TEXT_FILE="-a", + SR_STATS_FILE=str(tmp_path / "s.json"), + ) + + assert result.completed.returncode == 0, result.completed.stderr + assert result.outputs()["reported"] == "true" + assert "Compile requests 7" in (tmp_path / "-a").read_text(encoding="utf-8") + + class TestNoSccache: """A job that failed before sccache existed must not gain a second failure.""" From 1bbcddfbe06e240e00c82cc1d6a4d8dd8fb64c88 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Fri, 2 Oct 2026 02:36:15 +0100 Subject: [PATCH 5/7] Quote the stats-file in the example and document the action for callers The usage example now passes the output through env: and quotes it, so a path with spaces or metacharacters cannot break or inject into the command. The users' guide gains a section on reading statistics after the build, pointing callers at sccache-report. --- .github/actions/sccache-report/README.md | 4 +++- docs/users-guide.md | 30 ++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/.github/actions/sccache-report/README.md b/.github/actions/sccache-report/README.md index f12939326..81a599414 100644 --- a/.github/actions/sccache-report/README.md +++ b/.github/actions/sccache-report/README.md @@ -32,7 +32,9 @@ when `setup-rust` has long finished, and a composite action has no post step. backend: ${{ steps.setup-rust.outputs.cache-backend }} - name: Check sccache health if: steps.sccache.outputs.reported == 'true' - run: python3 scripts/check_sccache_health.py ${{ steps.sccache.outputs.stats-file }} + env: + STATS_FILE: ${{ steps.sccache.outputs.stats-file }} + run: python3 scripts/check_sccache_health.py "$STATS_FILE" ``` ## Inputs diff --git a/docs/users-guide.md b/docs/users-guide.md index e829ae682..edd7975b7 100644 --- a/docs/users-guide.md +++ b/docs/users-guide.md @@ -166,6 +166,36 @@ So leave `use-sccache: 'true'` on either kind of runner. On Ubicloud a local sccache directory and its cache, as described under [Rust cache ownership](#rust-cache-ownership). +### Reading sccache's statistics after the build + +When the server fell back to an uncached build (`sccache-status` is +`fallback`), it never started and has no statistics: `sccache --show-stats` +would start it again, wait out the startup timeout and fail the step, turning +the fail-open back into a red job. A step that reports statistics, or runs a +health check on them, should call the +[`sccache-report`](../.github/actions/sccache-report) action after the build, +under `if: always()`, rather than call `sccache --show-stats` itself: + +```yaml +- id: sccache + if: always() + uses: leynos/shared-actions/.github/actions/sccache-report@ + with: + status: ${{ steps.setup-rust.outputs.sccache-status }} + backend: ${{ steps.setup-rust.outputs.cache-backend }} +- name: Check sccache health + if: steps.sccache.outputs.reported == 'true' + env: + STATS_FILE: ${{ steps.sccache.outputs.stats-file }} + run: python3 scripts/check_sccache_health.py "$STATS_FILE" +``` + +It prints the statistics, writes them as text and JSON, adds them to the job +summary under the backend `setup-rust` chose, and stands down with a notice and +`reported=false` on a fallback (or when sccache is absent). A health check +conditions on `reported`, so a fallback run stays green with the +`sccache-fallback` annotation as its evidence. + ### Reserved `ACTIONS_*` variables, once The rule behind all of this is worth stating once, because it is not written From 7bde12b02411940a6ef68cb6db7e8e44940b20c4 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Fri, 2 Oct 2026 16:25:58 +0100 Subject: [PATCH 6/7] Correct the claim that --show-stats starts a server With no server, sccache --show-stats prints empty default statistics (src/commands.rs, ShowStats uses connect_to_server); only --zero-stats starts one. The stand-down on a fallback stays, because an unguarded report would publish a table of zeros for an uncached job. --- .github/actions/sccache-report/CHANGELOG.md | 4 ++-- .github/actions/sccache-report/README.md | 11 ++++++----- .github/actions/sccache-report/action.yml | 8 +++++--- .../sccache-report/tests/test_sccache_report.py | 7 ++++--- .github/actions/setup-rust/README.md | 3 ++- docs/developers-guide.md | 8 +++++--- docs/users-guide.md | 11 +++++++---- 7 files changed, 31 insertions(+), 21 deletions(-) diff --git a/.github/actions/sccache-report/CHANGELOG.md b/.github/actions/sccache-report/CHANGELOG.md index c7fe54750..574c7d429 100644 --- a/.github/actions/sccache-report/CHANGELOG.md +++ b/.github/actions/sccache-report/CHANGELOG.md @@ -9,5 +9,5 @@ file. to the job summary under the backend `setup-rust` chose, and stands down, with a notice and `reported=false`, when `setup-rust` reports `sccache-status` `fallback` or sccache is not on `PATH`. A consumer's health check conditions - on `reported` instead of repeating the guard that keeps a fallback from - turning red. + on `reported` instead of repeating the guard that keeps an uncached job from + publishing a table of zeros. diff --git a/.github/actions/sccache-report/README.md b/.github/actions/sccache-report/README.md index 81a599414..9bbacdae1 100644 --- a/.github/actions/sccache-report/README.md +++ b/.github/actions/sccache-report/README.md @@ -9,11 +9,12 @@ reports that the sccache server fell back to an uncached build. With `setup-rust` at shared-actions #546 a server that will not start within its 60 s timeout no longer fails the job: the action clears `RUSTC_WRAPPER`, raises a `sccache-fallback` annotation and sets its `sccache-status` output to -`fallback`. A server that never started has no statistics, and -`sccache --show-stats` would start it again, wait out the startup timeout and -fail the step, turning the documented fail-open into a red job. Every consumer -that read statistics after the build needed the same guard. This action owns it -once. +`fallback`. A server that never started has no statistics: with no server, +`sccache --show-stats` does not start one (`sccache --zero-stats` does) and +prints empty default statistics, a table of zeros for a job that never used the +cache, which reads as a wrapper that never reached the compiler. Every consumer +that read statistics after the build carried the same guard by hand. This +action owns it once. It cannot live inside `setup-rust`: the statistics exist only after the build, when `setup-rust` has long finished, and a composite action has no post step. diff --git a/.github/actions/sccache-report/action.yml b/.github/actions/sccache-report/action.yml index c78bc79a6..9182d14f2 100644 --- a/.github/actions/sccache-report/action.yml +++ b/.github/actions/sccache-report/action.yml @@ -78,9 +78,11 @@ runs: echo "::notice title=sccache-report::$1" echo "metric sccache-report.outcome=$2" } - # A server that fell back never started. `sccache --show-stats` would - # start it again, wait out its startup timeout and fail the step, which - # would turn the documented fail-open into a red job. + # A server that fell back never started. With no server, + # `sccache --show-stats` does not start one (that is `--zero-stats`); + # it prints empty default statistics. Reporting them would publish a + # table of zeros for a job that never used the cache, which reads as a + # wrapper that never reached the compiler. if [[ "${SR_STATUS}" == fallback ]]; then stand_down "sccache fell back to an uncached build; there are no statistics to report" fallback exit 0 diff --git a/.github/actions/sccache-report/tests/test_sccache_report.py b/.github/actions/sccache-report/tests/test_sccache_report.py index 53a4a6d9f..a4add7ca6 100644 --- a/.github/actions/sccache-report/tests/test_sccache_report.py +++ b/.github/actions/sccache-report/tests/test_sccache_report.py @@ -2,8 +2,9 @@ A consumer used to repeat `sccache --show-stats` after its build. With `setup-rust` failing open (shared-actions #546), a server that never started has -no statistics, and asking for them starts it again and fails the job the -fallback had just saved. This action owns that guard once; the tests run its +no statistics: with no server `sccache --show-stats` prints empty defaults +rather than starting one, so an unguarded report publishes a table of zeros for +an uncached job. This action owns that guard once; the tests run its shipped script against a stub sccache. """ @@ -145,7 +146,7 @@ class TestFallback: """A fallen-back server has no statistics, so nothing may ask for them.""" def test_a_fallback_never_calls_sccache(self, run_report: Runner) -> None: - """Calling sccache would start the dead server again.""" + """Calling sccache would publish empty statistics for an uncached job.""" result = run_report(SR_STATUS="fallback") assert result.completed.returncode == 0, result.completed.stderr diff --git a/.github/actions/setup-rust/README.md b/.github/actions/setup-rust/README.md index a4af74e66..2eff9ee37 100644 --- a/.github/actions/setup-rust/README.md +++ b/.github/actions/setup-rust/README.md @@ -268,7 +268,8 @@ says so in a notice. A step that reads sccache's statistics after the build should use [`sccache-report`](../sccache-report) rather than call `sccache --show-stats` itself: a server that fell back never started, and asking it for statistics -would start it again and fail the job. +returns empty defaults, a table of zeros that reads as a broken integration. +(`sccache --zero-stats`, by contrast, starts a server when none is running.) ### Who starts the server, and when diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 98e5d655e..25cba0adc 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -465,9 +465,11 @@ server starts, so credentials published afterwards arrive too late. Since `setup-rust` fails open (a server that will not start falls back to an uncached build and reports `sccache-status` `fallback`), any step that reads sccache's statistics after the build must stand down on a fallback: the dead -server has none, and `sccache --show-stats` would start it again, wait out the -startup timeout and fail the step. whitaker, netsuke and podbot each needed the -same guard added by hand. +server has none, and with no server `sccache --show-stats` prints empty default +statistics instead of starting one (`sccache --zero-stats` is the command that +starts a server), so an unguarded report publishes a table of zeros for a job +that never used the cache. whitaker, netsuke and podbot each carry the same +guard added by hand. The `sccache-report` action owns it once. It cannot be part of `setup-rust`, whose work ends before the build and which, as a composite action, has no post diff --git a/docs/users-guide.md b/docs/users-guide.md index edd7975b7..2e60bdc02 100644 --- a/docs/users-guide.md +++ b/docs/users-guide.md @@ -140,8 +140,11 @@ step can act on the output: This adds an output and changes a failure into a warning; no input changes, so existing callers need do nothing. A caller that itself runs `sccache --show-stats` after the build should skip it when the status is -`fallback`, since asking a server that never started would try to start it -again. +`fallback`, since a server that never started has no statistics: with no server +`sccache --show-stats` prints empty defaults rather than starting one. The +command that does start a server when none is running is +`sccache --zero-stats`, so a caller that zeroes the counters itself after a +failed start can see it fail. Some exports have to be put back rather than made, and that is the reason `use-sccache: 'true'` used to be unusable on Ubicloud. The last thing @@ -170,8 +173,8 @@ a local sccache directory and its cache, as described under When the server fell back to an uncached build (`sccache-status` is `fallback`), it never started and has no statistics: `sccache --show-stats` -would start it again, wait out the startup timeout and fail the step, turning -the fail-open back into a red job. A step that reports statistics, or runs a +prints empty defaults instead of starting one, so an unguarded report publishes +a table of zeros for an uncached job. A step that reports statistics, or runs a health check on them, should call the [`sccache-report`](../.github/actions/sccache-report) action after the build, under `if: always()`, rather than call `sccache --show-stats` itself: From 02d05118bc93bdbee1d5514a260c91b508a7a4d3 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Sat, 3 Oct 2026 00:51:17 +0100 Subject: [PATCH 7/7] Run sccache-report through its composite boundary; add the migration note A new test resolves a caller's with: mapping against the manifest, renders the step env, runs the step under the composite-fragment harness against a controlled sccache and reads the declared outputs, so input-to-environment mapping, custom paths, summary, fallback and missing-binary paths and output propagation are held. Add docs/migrating-to-sccache-report.md, link it from the users' guide, and correct the status input description that still said asking for statistics restarts the server. --- .github/actions/sccache-report/action.yml | 4 +- .../tests/test_sccache_report_boundary.py | 238 ++++++++++++++++++ docs/migrating-to-sccache-report.md | 74 ++++++ docs/users-guide.md | 3 + 4 files changed, 317 insertions(+), 2 deletions(-) create mode 100644 .github/actions/sccache-report/tests/test_sccache_report_boundary.py create mode 100644 docs/migrating-to-sccache-report.md diff --git a/.github/actions/sccache-report/action.yml b/.github/actions/sccache-report/action.yml index 9182d14f2..7bd0a6c95 100644 --- a/.github/actions/sccache-report/action.yml +++ b/.github/actions/sccache-report/action.yml @@ -8,8 +8,8 @@ inputs: status: description: >- The `sccache-status` output of setup-rust. `fallback` means the server - never started: there are no statistics, and asking for them would start - the server again and fail the step, so the action reports nothing and + never started: there are no statistics (with no server `sccache + --show-stats` prints empty defaults), so the action reports nothing and says so. Any other value, including empty, reports. required: false default: '' diff --git a/.github/actions/sccache-report/tests/test_sccache_report_boundary.py b/.github/actions/sccache-report/tests/test_sccache_report_boundary.py new file mode 100644 index 000000000..07e84c8c8 --- /dev/null +++ b/.github/actions/sccache-report/tests/test_sccache_report_boundary.py @@ -0,0 +1,238 @@ +"""Run `sccache-report` through its composite-action boundary. + +`test_sccache_report.py` runs the report step's script with the `SR_*` +variables set directly, which proves the script and not the manifest around it: +a manifest that mapped `stats-file` to the wrong variable, or published the +wrong step output, would pass every one of those. These tests start from what a +caller writes. A `with:` mapping is resolved against the manifest's declared +inputs and defaults, the step's `env:` block is rendered from it, the step runs +under the shared composite-fragment harness against a controlled `sccache`, and +the action's declared `outputs:` are read the way a caller's `steps..outputs` +would be. + +That is the caller-visible boundary: input to environment, files written, job +summary, and output propagation, for a normal run, a custom path, a fallback and +a missing binary. It stops short of a GitHub-hosted runner, which a unit test +cannot arrange. + +Run with the rest of the suite via ``make test``. +""" + +from __future__ import annotations + +import typing as typ +from pathlib import Path + +import pytest +import yaml + +from composite_fragments import ( + ActionContext, + CompositeStep, + FragmentEnvironment, + LifecycleResult, + ambient_env, + bash_file_path, + bash_path, + require_posix_host, + run_lifecycle, +) + +ACTION_DIR = Path(__file__).resolve().parents[1] +STUB_JSON = '{"stats":{"compile_requests":7}}' +STUB_TEXT = "Compile requests 7" + + +class Outcome(typ.NamedTuple): + """What a caller sees after one use of the action.""" + + result: LifecycleResult + outputs: dict[str, str] + cwd: Path + summary: str + calls: str + + +def _manifest() -> dict[str, typ.Any]: + """Return the parsed `action.yml`.""" + return yaml.safe_load((ACTION_DIR / "action.yml").read_text(encoding="utf-8")) + + +def _write_stub(directory: Path) -> None: + """Install a controlled `sccache` that records each call it receives.""" + directory.mkdir() + stub = directory / "sccache" + stub.write_text( + "#!/usr/bin/env bash\n" + 'echo "$*" >> "$(dirname "$0")/calls.log"\n' + 'if [[ "$*" == *json* ]]; then\n' + f" echo '{STUB_JSON}'\n" + "else\n" + f' echo "{STUB_TEXT}"\n' + "fi\n", + encoding="utf-8", + ) + stub.chmod(0o755) + + +def use_action( + tmp_path: Path, with_inputs: dict[str, str] | None = None, *, installed: bool = True +) -> Outcome: + """Use the action the way a workflow would, and return what the caller sees. + + Parameters + ---------- + tmp_path : Path + The working directory and scratch space for the run. + with_inputs : dict[str, str] | None + The caller's `with:` mapping. Every key must be a declared input. + installed : bool + Whether the controlled `sccache` is on `PATH`. + + Returns + ------- + Outcome + The step results, the action's resolved outputs, the working directory, + the job summary text and the calls the controlled `sccache` received. + """ + require_posix_host() + manifest = _manifest() + declared = manifest["inputs"] + supplied = with_inputs or {} + unknown = sorted(set(supplied) - set(declared)) + assert not unknown, f"`with:` names inputs the action does not declare: {unknown}" + inputs = {name: str(spec.get("default", "")) for name, spec in declared.items()} + inputs.update(supplied) + bin_dir = tmp_path / "bin" + _write_stub(bin_dir) + summary = tmp_path / "summary" + summary.write_text("", encoding="utf-8") + base_env = ambient_env() + base_env["PATH"] = ( + f"{bash_path(bin_dir)}:/usr/bin:/bin" if installed else "/usr/bin:/bin" + ) + base_env["GITHUB_STEP_SUMMARY"] = bash_file_path(summary) + context = ActionContext( + inputs=inputs, + runner_os="Linux", + runner_arch="X64", + action_path=str(ACTION_DIR), + ) + steps = typ.cast("list[CompositeStep]", manifest["runs"]["steps"]) + result = run_lifecycle( + steps, + context, + FragmentEnvironment( + base_env=base_env, cwd=tmp_path, output_dir=tmp_path / "out" + ), + ) + outputs = { + name: context.render(spec["value"]) + for name, spec in manifest["outputs"].items() + } + calls_log = bin_dir / "calls.log" + return Outcome( + result=result, + outputs=outputs, + cwd=tmp_path, + summary=summary.read_text(encoding="utf-8"), + calls=calls_log.read_text(encoding="utf-8") if calls_log.exists() else "", + ) + + +def test_a_custom_stats_file_receives_the_json_and_is_the_output( + tmp_path: Path, +) -> None: + """The caller's path, not the default, gets the JSON and is published. + + A manifest that ignored `stats-file` and wrote `sccache-stats.json` would + pass every test that sets `SR_STATS_FILE` directly. + """ + outcome = use_action( + tmp_path, {"stats-file": "custom.json", "text-file": "custom.txt"} + ) + + assert outcome.result.returncode == 0, outcome.result.stderr + assert outcome.outputs == {"reported": "true", "stats-file": "custom.json"}, ( + "the caller-visible outputs must name the caller's path" + ) + assert (tmp_path / "custom.json").read_text(encoding="utf-8").strip() == STUB_JSON + assert STUB_TEXT in (tmp_path / "custom.txt").read_text(encoding="utf-8") + assert not (tmp_path / "sccache-stats.json").exists(), ( + "the default JSON path must not be used when the caller names another" + ) + assert not (tmp_path / "sccache-stats.txt").exists(), ( + "the default text path must not be used when the caller names another" + ) + + +def test_the_defaults_apply_when_the_caller_names_no_paths(tmp_path: Path) -> None: + """With an empty `with:`, the manifest's own defaults are the paths.""" + outcome = use_action(tmp_path) + + assert outcome.outputs == {"reported": "true", "stats-file": "sccache-stats.json"} + assert (tmp_path / "sccache-stats.json").read_text(encoding="utf-8").strip() == ( + STUB_JSON + ) + assert STUB_TEXT in (tmp_path / "sccache-stats.txt").read_text(encoding="utf-8") + + +def test_the_backend_input_reaches_the_job_summary(tmp_path: Path) -> None: + """`backend` is named in the summary, with the statistics under it.""" + outcome = use_action(tmp_path, {"backend": "ubicloud"}) + + assert "- backend: `ubicloud`" in outcome.summary, outcome.summary + assert STUB_TEXT in outcome.summary, outcome.summary + + +def test_summary_false_leaves_the_job_summary_alone(tmp_path: Path) -> None: + """Only the literal `true` appends to the summary.""" + outcome = use_action(tmp_path, {"summary": "false"}) + + assert outcome.summary == "", outcome.summary + assert outcome.outputs["reported"] == "true", ( + "declining the summary must not decline the report" + ) + + +@pytest.mark.parametrize("status", ["started", ""]) +def test_any_status_but_a_fallback_reports(tmp_path: Path, status: str) -> None: + """Only `fallback` stands down; `started` and empty report.""" + outcome = use_action(tmp_path, {"status": status}) + + assert outcome.outputs["reported"] == "true", outcome.outputs + assert "--show-stats" in outcome.calls, "the report must ask sccache" + + +def test_a_fallback_stands_down_without_asking_sccache(tmp_path: Path) -> None: + """A fallback reports nothing, writes nothing, and the step still succeeds.""" + outcome = use_action( + tmp_path, + {"status": "fallback", "stats-file": "custom.json", "text-file": "custom.txt"}, + ) + + assert outcome.result.returncode == 0, outcome.result.stderr + assert outcome.outputs == {"reported": "false", "stats-file": ""}, outcome.outputs + assert outcome.calls == "", "a fallback must not call sccache at all" + assert outcome.summary == "", "a fallback must not write to the summary" + assert not (tmp_path / "custom.json").exists() + assert not (tmp_path / "custom.txt").exists() + assert "sccache-report::sccache fell back" in outcome.result.stdout, ( + "the stand-down must say why" + ) + + +def test_a_missing_sccache_stands_down_without_failing(tmp_path: Path) -> None: + """With no `sccache` on `PATH` the action reports false and succeeds.""" + outcome = use_action(tmp_path, installed=False) + + assert outcome.result.returncode == 0, outcome.result.stderr + assert outcome.outputs == {"reported": "false", "stats-file": ""}, outcome.outputs + assert outcome.summary == "" + assert "not on PATH" in outcome.result.stdout + + +def test_an_undeclared_input_is_refused_by_the_harness(tmp_path: Path) -> None: + """A `with:` key the manifest does not declare must not be silently ignored.""" + with pytest.raises(AssertionError, match="does not declare"): + use_action(tmp_path, {"stat-file": "typo.json"}) diff --git a/docs/migrating-to-sccache-report.md b/docs/migrating-to-sccache-report.md new file mode 100644 index 000000000..6b1454897 --- /dev/null +++ b/docs/migrating-to-sccache-report.md @@ -0,0 +1,74 @@ +# Migrating to `sccache-report` + +This guide covers the new `sccache-report` action, added in the next release. +It replaces the `sccache --show-stats` step that each consumer of `setup-rust` +carried by hand, and it is optional: nothing changes for a repository that +keeps its own step. + +## What changed, and why + +Since `setup-rust` fails open (shared-actions #546), a server that will not +start within its 60 s timeout no longer fails the job. It reports +`sccache-status` as `fallback` and the job compiles uncached. A server that +never started has no statistics: with no server, `sccache --show-stats` prints +empty defaults rather than starting one, so an unguarded report publishes a +table of zeros for a job that never used the cache, and a health check run over +it reads as a broken integration. + +Every consumer therefore needed the same guard, written by hand. +`sccache-report` owns it once. + +## Do I need to migrate? + +Consider it when a workflow runs `sccache --show-stats` after a build, writes +the statistics to a file for a health check, or adds them to the job summary. A +workflow that only builds needs nothing. + +## How to migrate + +Call the action after the build, under `if: always()`, and give it the two +outputs `setup-rust` already publishes: + +```yaml +- id: sccache + if: always() + uses: leynos/shared-actions/.github/actions/sccache-report@ + with: + status: ${{ steps.setup-rust.outputs.sccache-status }} + backend: ${{ steps.setup-rust.outputs.cache-backend }} +``` + +- `status` is `setup-rust`'s `sccache-status`. `fallback` stands the action + down; any other value, including empty, reports. +- `backend` is `setup-rust`'s `cache-backend`, named in the job summary because + `Cache location` reads `ghac` for Ubicloud's proxy and for GitHub's own + service alike. +- `stats-file`, `text-file` and `summary` are optional: the paths the JSON and + text statistics are written to (defaults `sccache-stats.json` and + `sccache-stats.txt`), and whether to append to the job summary. + +The action sets two outputs. `reported` is `"true"` when statistics were +written and `"false"` when it stood down (a fallback, or no `sccache` on +`PATH`). `stats-file` is the JSON path when `reported` is `"true"` and empty +otherwise. + +Gate any health check on `reported`, so a fallback run stays green with the +`sccache-fallback` annotation as its evidence: + +```yaml +- name: Check sccache health + if: steps.sccache.outputs.reported == 'true' + env: + STATS_FILE: ${{ steps.sccache.outputs.stats-file }} + run: python3 scripts/check_sccache_health.py "$STATS_FILE" +``` + +Then delete the handwritten `sccache --show-stats` step and its own fallback +guard. Keep `if: always()` on the action: a failed build is when the numbers +are wanted. + +## Rolling back + +Restore the previous `sccache --show-stats` step, with its own guard on +`sccache-status`, and remove the action. Nothing outside the workflow was +written, so there is nothing to undo. diff --git a/docs/users-guide.md b/docs/users-guide.md index 2e60bdc02..6e93842d7 100644 --- a/docs/users-guide.md +++ b/docs/users-guide.md @@ -24,6 +24,9 @@ documents how to use the `install-nixie` action. - [Migrating to verified prebuilt CI tools](./migrating-to-verified-prebuilt-tools.md) – upgrade guidance for the `install-whitaker` and `generate-coverage` verified prebuilt tool installation. +- [Migrating to `sccache-report`](./migrating-to-sccache-report.md) + – replacing a handwritten `sccache --show-stats` step with the fallback-aware + action. ## Node.js 24 action dependencies