diff --git a/.github/actions/rust-cache/action.yml b/.github/actions/rust-cache/action.yml index 309c24b..3415bd8 100644 --- a/.github/actions/rust-cache/action.yml +++ b/.github/actions/rust-cache/action.yml @@ -22,6 +22,15 @@ outputs: cache-hit: description: Whether the compiled target cache matched exactly value: ${{ steps.target.outputs.cache-hit }} + cache-matched-key: + description: The compiled target cache key actually restored by actions/cache + value: ${{ steps.target.outputs.cache-matched-key }} + legacy-fallback-invoked: + description: Whether the optional legacy cache fallback action was invoked + value: ${{ steps.cache-summary.outputs.legacy-fallback-invoked }} + legacy-fallback-outcome: + description: Outcome of the optional legacy cache fallback action + value: ${{ steps.cache-summary.outputs.legacy-fallback-outcome }} runs: using: composite @@ -96,6 +105,7 @@ runs: ${{ steps.identity.outputs.restore-key }} ${{ steps.identity.outputs.migration-key }} - name: Recover an existing combined cache during migration + id: legacy-fallback if: steps.target.outputs.cache-matched-key == '' && inputs.legacy-key != '' uses: Swatinem/rust-cache@v2 with: @@ -104,6 +114,50 @@ runs: cache-bin: false cache-on-failure: true save-if: false + - name: Summarize compiled target cache + id: cache-summary + if: always() + shell: bash + env: + TARGET_CACHE_HIT: ${{ steps.target.outputs.cache-hit }} + TARGET_CACHE_MATCHED_KEY: ${{ steps.target.outputs.cache-matched-key }} + SAVE_KEY: ${{ steps.identity.outputs.key }} + LEGACY_CONFIGURED: ${{ inputs.legacy-key != '' && 'true' || 'false' }} + LEGACY_OUTCOME: ${{ steps.legacy-fallback.outcome }} + CACHE_WORKLOAD: ${{ inputs.key }} + RUNNER_OS_VALUE: ${{ runner.os }} + RUNNER_ARCH_VALUE: ${{ runner.arch }} + run: | + set -euo pipefail + + target_cache_hit="${TARGET_CACHE_HIT:-false}" + target_cache_matched_key="${TARGET_CACHE_MATCHED_KEY:-}" + save_key="${SAVE_KEY:-}" + legacy_configured="${LEGACY_CONFIGURED:-false}" + legacy_outcome="${LEGACY_OUTCOME:-skipped}" + legacy_invoked=false + if [[ "$legacy_configured" == 'true' && "$legacy_outcome" != 'skipped' && -n "$legacy_outcome" ]]; then + legacy_invoked=true + fi + + { + echo '### Rust compiled target cache' + echo + echo "- Workload: \`$CACHE_WORKLOAD\`" + echo "- Runner: \`$RUNNER_OS_VALUE / $RUNNER_ARCH_VALUE\`" + echo "- Target cache exact hit: \`$target_cache_hit\`" + echo "- Target cache matched key: \`$target_cache_matched_key\`" + echo "- Save key: \`$save_key\`" + echo "- Legacy fallback configured: \`$legacy_configured\`" + echo "- Legacy fallback invoked: \`$legacy_invoked\`" + echo "- Legacy fallback outcome: \`$legacy_outcome\`" + echo '- Legacy fallback status is separate and is not counted as target exact hit' + } >> "$GITHUB_STEP_SUMMARY" + + { + echo "legacy-fallback-invoked=$legacy_invoked" + echo "legacy-fallback-outcome=$legacy_outcome" + } >> "$GITHUB_OUTPUT" - name: Populate complete downloads before sharing the cache if: steps.downloads.outputs.cache-hit != 'true' shell: bash diff --git a/.github/workflows/linux-lua.yml b/.github/workflows/linux-lua.yml index 40a4806..feea014 100644 --- a/.github/workflows/linux-lua.yml +++ b/.github/workflows/linux-lua.yml @@ -54,6 +54,13 @@ jobs: - uses: actions/setup-python@v5 with: python-version: "3.13" + - name: Test CI contracts before matrix planning + run: | + python3 -m unittest \ + scripts.tests.test_ci_plan \ + scripts.tests.test_ci_native_tests \ + scripts.tests.test_stable_release \ + scripts.tests.test_ci_cache -v - name: Plan native validation id: matrix shell: bash diff --git a/architecture/notes/scripts/ci/2026-09-23-native-matrix-planning.md b/architecture/notes/scripts/ci/2026-09-23-native-matrix-planning.md index 3b3c9d5..dfe22fd 100644 --- a/architecture/notes/scripts/ci/2026-09-23-native-matrix-planning.md +++ b/architecture/notes/scripts/ci/2026-09-23-native-matrix-planning.md @@ -60,6 +60,15 @@ The existing Windows diagnostic dispatch continues to use its separate workflow. ## Validation +The required `lint` job now runs the lightweight planner/native-workflow/ +stable-release and cache contract tests after `cargo fmt --all -- --check` and before +`ci_plan.py` writes either matrix. The count is 7 planner tests, 10 native +workflow tests, 17 stable-release tests and 8 cache contract tests. A final local run of the exact +command completed 42 tests (41 passed, one Windows-only case skipped) in 3.341 seconds on 2026-09-23; this is a local +measurement, not a general speed guarantee. The workflow contract test checks +that this step precedes planning and that both dynamic matrices depend on the +required lint job. + Planner tests cover draft and ready PRs, all full-validation caller events, malformed input and real command-line output. Workflow contract tests require the existing lint check to own planning, both matrices to depend on it, draft step diff --git a/scripts/tests/test_ci_cache.py b/scripts/tests/test_ci_cache.py index f3e51bc..764ea3d 100644 --- a/scripts/tests/test_ci_cache.py +++ b/scripts/tests/test_ci_cache.py @@ -3,6 +3,8 @@ import os from pathlib import Path import re +import shutil +import subprocess import tempfile import textwrap import unittest @@ -13,6 +15,10 @@ ACTION = Path(__file__).resolve().parents[2] / ".github/actions/rust-cache/actio class CacheIdentityTests(unittest.TestCase): + @staticmethod + def action_text() -> str: + return ACTION.read_text(encoding="utf-8") + def identity(self, *, compiler="rustc 1.97.1", sdk="15.0", **changes): source = ACTION.read_text(encoding="utf-8") body = re.search(r"python - <<'PY'\n(.*?)\n PY", source, re.S) @@ -67,6 +73,97 @@ class CacheIdentityTests(unittest.TestCase): self.assertEqual(before["restore-key"], after["restore-key"]) self.assertEqual(before["key"], self.identity(CACHE_REVISION="one", PREVIEW_ID="third")["key"]) + def test_summary_exposes_real_target_cache_outputs(self): + source = self.action_text() + self.assertIn( + "value: ${{ steps.target.outputs.cache-matched-key }}", + source, + ) + self.assertIn( + "value: ${{ steps.cache-summary.outputs.legacy-fallback-invoked }}", + source, + ) + self.assertIn( + "value: ${{ steps.cache-summary.outputs.legacy-fallback-outcome }}", + source, + ) + self.assertIn( + "TARGET_CACHE_HIT: ${{ steps.target.outputs.cache-hit }}", + source, + ) + self.assertIn( + "TARGET_CACHE_MATCHED_KEY: ${{ steps.target.outputs.cache-matched-key }}", + source, + ) + self.assertIn("SAVE_KEY: ${{ steps.identity.outputs.key }}", source) + self.assertIn("CACHE_WORKLOAD: ${{ inputs.key }}", source) + self.assertIn("RUNNER_OS_VALUE: ${{ runner.os }}", source) + self.assertIn("RUNNER_ARCH_VALUE: ${{ runner.arch }}", source) + + summary = source.split("- name: Summarize compiled target cache", 1)[1] + summary = summary.split("\n - name:", 1)[0] + run_body = summary.split(" run: |", 1)[1] + self.assertNotIn("${{", run_body) + for field in ( + "Target cache exact hit", + "Target cache matched key", + "Save key", + "Legacy fallback invoked", + "Legacy fallback outcome", + ): + self.assertIn(field, run_body) + + def test_legacy_fallback_is_distinct_from_target_cache_hit(self): + source = self.action_text() + legacy = source.split("- name: Recover an existing combined cache during migration", 1)[1] + self.assertIn("id: legacy-fallback", legacy) + self.assertIn( + "if: steps.target.outputs.cache-matched-key == '' && inputs.legacy-key != ''", + legacy, + ) + self.assertIn("LEGACY_OUTCOME: ${{ steps.legacy-fallback.outcome }}", source) + self.assertIn("legacy-fallback-invoked=", source) + self.assertIn("Legacy fallback status is separate and is not counted as target exact hit", source) + self.assertNotIn("LEGACY_KEY:", source) + self.assertNotIn("ACTIONS_RUNTIME_TOKEN", source) + self.assertNotIn("GITHUB_TOKEN", source) + + @unittest.skipUnless(os.name != "nt" and shutil.which("bash"), "POSIX summary shell") + def test_summary_executes_exact_partial_and_legacy_cache_cases(self): + source = self.action_text().split("- name: Summarize compiled target cache", 1)[1] + script = textwrap.dedent(source.split(" run: |\n", 1)[1].split("\n - name:", 1)[0]) + cases = ( + ("true", "current-key", "false", "skipped", "false"), + ("false", "older-compatible-key", "true", "skipped", "false"), + ("", "", "true", "success", "true"), + ("", "", "true", "failure", "true"), + ) + for hit, matched, configured, outcome, invoked in cases: + with self.subTest(hit=hit, matched=matched, outcome=outcome), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + marker = root / "must-not-be-created" + workload = f"$(touch {marker})" + environment = { + "PATH": os.environ["PATH"], + "GITHUB_STEP_SUMMARY": str(root / "summary"), + "GITHUB_OUTPUT": str(root / "outputs"), + "TARGET_CACHE_HIT": hit, "TARGET_CACHE_MATCHED_KEY": matched, + "SAVE_KEY": "new-save-key", "LEGACY_CONFIGURED": configured, + "LEGACY_OUTCOME": outcome, "CACHE_WORKLOAD": workload, + "RUNNER_OS_VALUE": "Linux", "RUNNER_ARCH_VALUE": "X64", + } + result = subprocess.run([shutil.which("bash"), "-c", script], env=environment, + text=True, capture_output=True, check=False) + self.assertEqual(result.returncode, 0, result.stderr) + summary = (root / "summary").read_text() + outputs = (root / "outputs").read_text() + self.assertIn(f"Target cache exact hit: `{hit or 'false'}`", summary) + self.assertIn(f"Target cache matched key: `{matched or ''}`", summary) + self.assertIn(f"legacy-fallback-invoked={invoked}\n", outputs) + self.assertIn(f"legacy-fallback-outcome={outcome}\n", outputs) + self.assertIn(workload, summary) + self.assertFalse(marker.exists(), "summary values must never execute as shell code") + @unittest.skipUnless(os.name == "nt", "Windows cache migration") def test_windows_migration_preserves_compiler_and_workload_boundaries(self): before = self.identity() diff --git a/scripts/tests/test_ci_native_tests.py b/scripts/tests/test_ci_native_tests.py index 17d5912..5a771fe 100644 --- a/scripts/tests/test_ci_native_tests.py +++ b/scripts/tests/test_ci_native_tests.py @@ -16,9 +16,24 @@ class NativeSuiteTests(unittest.TestCase): workflow = (root / ".github/workflows/linux-lua.yml").read_text() lint = workflow.split("\n lint:\n", 1)[1].split("\n native-tests:\n", 1)[0] self.assertIn("name: lint", lint) + self.assertIn("Test CI contracts before matrix planning", lint) + for contract in ( + "scripts.tests.test_ci_plan", + "scripts.tests.test_ci_native_tests", + "scripts.tests.test_stable_release", + "scripts.tests.test_ci_cache", + ): + self.assertIn(contract, lint) self.assertIn("python scripts/ci_plan.py", lint) self.assertIn('--event-path "$GITHUB_EVENT_PATH"', lint) - self.assertLess(lint.index("cargo fmt"), lint.index("python scripts/ci_plan.py")) + self.assertLess( + lint.index("cargo fmt"), + lint.index("Test CI contracts before matrix planning"), + ) + self.assertLess( + lint.index("Test CI contracts before matrix planning"), + lint.index("python scripts/ci_plan.py"), + ) for job, output in (("native-tests", "native_matrix"), ("macos-release-check", "release_matrix")): body = workflow.split(f"\n {job}:\n", 1)[1]