ci: validate contracts early and report real cache restores
This commit is contained in:
1 parent
7c8298d817
commit
0133873c46
5 files changed
+183
-1
No files matched your search
@@ -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:-<none>}"
|
||||
save_key="${SAVE_KEY:-<none>}"
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 '<none>'}`", 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()
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in new issue
Block a user