ci: preserve dependency cache reuse across root manifest updates (#108)

* ci: preserve dependency cache reuse across root manifest updates

* docs: align gate cache overview with profile keys and dispatch opt-in
This commit is contained in:
FredApps
2026-10-30 12:47:08 +01:00
committed by GitHub
parent fa5ef18780
commit 2b3fb9dc05
3 changed files with 83 additions and 11 deletions
+30 -5
View File
@@ -81,6 +81,11 @@ on:
type: string
required: false
default: "v1"
cache-save-on-dispatch:
description: "Allow manually dispatched branch runs to save their branch-scoped dependency cache; pull requests never write"
type: boolean
required: false
default: false
timeout-minutes:
description: "Timeout of each leg"
type: number
@@ -197,14 +202,34 @@ jobs:
with:
tool: ${{ inputs.tools }}
# Only the default branch writes, so PR caches never evict its entry; a failing gate still
# saves. rust-cache hashes only the members' manifests, so the root Cargo.toml goes in the key
# or a profile change in a virtual root keeps restoring the old cache.
# Root workspace profiles are not among rust-cache's member manifests. Hash only
# those profiles here: a dependency/version/comment change must not discard the
# fallback cache for every other dependency. rust-cache still hashes the lockfile,
# toolchain, member manifests and build environment, and Cargo checks fingerprints.
- name: Cache profile key
id: cache-profile
shell: bash
run: |
python - <<'PYTHON'
import hashlib
import json
import os
import tomllib
from pathlib import Path
manifest = tomllib.loads(Path("Cargo.toml").read_text(encoding="utf-8"))
profiles = json.dumps(manifest.get("profile", {}), sort_keys=True, separators=(",", ":"))
key = hashlib.sha256(profiles.encode("utf-8")).hexdigest()
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output:
output.write(f"key={key}\n")
PYTHON
# Default-branch writes remain the default. Maintainers can explicitly opt manual
# branch runs into branch-scoped writes; pull-request runs never save a cache.
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
prefix-key: ${{ inputs.cache-version }}-rust
key: ${{ hashFiles('Cargo.toml') }}
save-if: ${{ github.event_name != 'pull_request' && github.ref_name == github.event.repository.default_branch }}
key: ${{ steps.cache-profile.outputs.key }}
save-if: ${{ github.event_name != 'pull_request' && (github.ref_name == github.event.repository.default_branch || (inputs.cache-save-on-dispatch && github.event_name == 'workflow_dispatch')) }}
cache-on-failure: true
- name: Gate
+20 -3
View File
@@ -352,9 +352,12 @@ The quality gate for an app's pull requests and pushes to main. It runs `command
- prebuilt cargo tools from `tools` (by default `cargo-nextest@0.9.150`);
- `CARGO_PROFILE_DEV_DEBUG=line-tables-only` (set `dev-debug: ""` to keep the repository's
profile) and the caller's `env`, `linux-env` and `windows-env` lines, each `KEY=VALUE`;
- a rust-cache dependency cache that only the default branch saves, so pull requests restore
main's entry and never evict it. The key includes the root `Cargo.toml`, since rust-cache hashes
only the members' manifests. Bump `cache-version` to start afresh.
- a rust-cache dependency cache saved by the default branch by default. Pull requests only
restore caches; callers may opt manual branch runs into branch-scoped writes with
`cache-save-on-dispatch: true`. The restore prefix includes the canonical root `[profile]`
table; dependency, version and comment edits do not discard reusable dependency artifacts.
rust-cache still keys on lockfiles, member manifests, toolchain and build environment, and
Cargo validates restored fingerprints. Bump `cache-version` to start afresh.
After the gate, the job fails if `Cargo.lock` changed. Legs run with `fail-fast: false`; each has
`timeout-minutes` (default 60). Jobs only some apps need, such as fuzzing or web builds, stay in
@@ -559,3 +562,17 @@ The tests execute the FreeBSD run template under the host's POSIX shell with
fixture build/test commands and real tar archives. They cover failures in each
custom test group, default build/test failures, missing/corrupt packages and font
environment propagation. They do not replace native FreeBSD caller runs.
### Gate cache reuse
The gate cache key includes the canonical root `[profile]` table, including per-package
profile overrides. Dependency and version edits no longer change the restore prefix;
`rust-cache` still keys on the lockfile, toolchain, environment and member manifests,
and Cargo validates restored fingerprints. The first run after adopting this key
is cold.
Only the default branch saves by default. A caller may set
`cache-save-on-dispatch: true` for repeated manual integration testing. This permits
`workflow_dispatch` runs to save a cache scoped by GitHub to that branch, never a
pull-request run. Use this selectively to avoid storage churn; sibling branches cannot
restore each other's caches. Do not pass the input until this workflow change has landed.
+33 -3
View File
@@ -1,4 +1,4 @@
"""The shared gate workflow: pinned actions, a cache only the default branch writes, and caller
"""The shared gate workflow: pinned actions, a cache with explicit manual-branch opt-in, and caller
input that reaches the shell only through the environment."""
from pathlib import Path
import os
@@ -27,12 +27,13 @@ class GateWorkflowTests(unittest.TestCase):
if not OWN_ACTION.match(s['uses']):
self.assertRegex(s['uses'], r'@[0-9a-f]{40}$')
def test_only_pushes_to_the_default_branch_save_the_cache(self):
def test_manual_branch_saves_require_explicit_opt_in(self):
cache = next(s for s in STEPS if 'rust-cache' in s.get('uses', ''))
condition = re.sub(r'\s+', ' ', cache['with']['save-if'])
self.assertIn("github.event_name != 'pull_request'", condition)
self.assertIn('github.ref_name == github.event.repository.default_branch', condition)
self.assertNotIn('||', condition)
self.assertIn("inputs.cache-save-on-dispatch && github.event_name == 'workflow_dispatch'", condition)
self.assertEqual(WORKFLOW['on']['workflow_call']['inputs']['cache-save-on-dispatch']['default'], 'false')
def test_inputs_reach_scripts_only_through_env(self):
for s in STEPS:
@@ -71,5 +72,34 @@ class CallerEnvironmentTests(unittest.TestCase):
self.assertEqual(self.run_step(ALL=f'GOOD=1\n{line}')[0], 1)
class ProfileCacheTests(unittest.TestCase):
def key(self, manifest):
with tempfile.TemporaryDirectory() as directory:
Path(directory, 'Cargo.toml').write_text(manifest)
output = Path(directory, 'output')
subprocess.run(['bash', '-euo', 'pipefail', '-c', step('Cache profile key')['run']],
cwd=directory, check=True, capture_output=True,
env={**os.environ, 'GITHUB_OUTPUT': str(output)})
return output.read_text()
def test_dependency_revision_does_not_discard_fallback(self):
profile = '[profile.dev]\nopt-level = 1\n'
self.assertEqual(self.key('[workspace.dependencies]\na = "1"\n' + profile),
self.key('[workspace.dependencies]\na = "2"\n' + profile))
def test_profile_changes_invalidate(self):
self.assertNotEqual(self.key('[profile.dev]\nopt-level = 1'),
self.key('[profile.dev]\nopt-level = 2'))
self.assertNotEqual(self.key('[profile.dev.package.foo]\nopt-level = 1'),
self.key('[profile.dev.package.foo]\nopt-level = 2'))
def test_order_and_comments_do_not_invalidate(self):
self.assertEqual(self.key('[profile.dev]\ndebug = false\nopt-level = 1'),
self.key('# comment\n[profile.dev]\nopt-level = 1\ndebug = false'))
def test_no_profiles_is_supported(self):
self.assertRegex(self.key('[workspace]\nmembers = []'), r'^key=[a-f0-9]{64}\n$')
if __name__ == '__main__':
unittest.main()