mirror of
https://github.com/storytold/craft-common-actions.git
synced 2026-11-02 18:06:16 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user