authorclover caruso <git@paperclover.net> 2026-10-10 19:29:56-07:00
committerclover caruso <git@paperclover.net> 2026-10-10 22:56:05-07:00
log68d9fb0eeb2d423cdf9254cec4919e7e84627ca7
tree05f00f0609460a4ce3c4e2f9f2ea4e569e14131f
parent549549b097928ce56e2cc8b8e07ba3530d8e418b
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU (~clover)

fix: invalidate local packages before shared Cargo cache gates

Derive local path packages, including excluded vendor patches, from Cargo metadata. Clean their artifacts before concurrent CI lanes and desktop, web, relay and iOS build epochs while retaining registry and Git dependencies. Scope cleanup to each target and release profile so other architectures remain available until packaging. Reject empty local-package metadata and stop before builds or packaging if cleanup fails. Reproduce stale tests with frozen source mtimes in two disposable checkouts; verify changed Rust, build-script data and vendor code, plus dependency and other-target artifact retention. Use the frozen CI Linux wrapper too. Fixes #112 Assisted-by: gpt-6.1-sol

12 files changed, 192 insertions(+), 6 deletions(-)

apps/ios/build-rust.sh+4-1
......@@ -11,4 +11,7 @@ root="$SRCROOT/../.."
1111# Xcode's SDK variables would point host build scripts at the iOS SDK.
1212exec env -i HOME="$HOME" PATH="$HOME/.cargo/bin:/etc/profiles/per-user/$USER/bin:/run/current-system/sw/bin:/usr/bin:/bin:/usr/sbin:/sbin" USER="$USER" \
1313 CARGO_TARGET_DIR="$root/target/ios" IPHONEOS_DEPLOYMENT_TARGET="$IPHONEOS_DEPLOYMENT_TARGET" \
14 cargo build --manifest-path "$root/Cargo.toml" -p mobile --target "$target" $release
14 sh -ec '
15 python3 "$1/tools/cargo_fresh.py" --target "$2" $3
16 exec cargo build --manifest-path "$1/Cargo.toml" -p mobile --target "$2" $3
17 ' sh "$root" "$target" "$release"
crates/snowbound/linux/package.sh+1
......@@ -10,6 +10,7 @@ mkdir -p "$target/dist"
1010[ $# -gt 0 ] || set -- x86_64 aarch64
1111for arch do
1212 triple=$arch-unknown-linux-gnu
13 python3 "$root/tools/cargo_fresh.py" --target "$triple" --release
1314 sh "$root/platform/linux/cargo.sh" "$arch" build \
1415 --manifest-path "$root/Cargo.toml" --release -p snowbound
1516 # PT_INTERP becomes PT_NULL, so the kernel starts the executable at `loader`'s entry, which
tools/TESTING.md+12-1
......@@ -7,9 +7,20 @@ checkout's `@`) in the jj workspace `workspaces/ci`: it points that
77workspace's own commit, a child of `main`, at the revision's files, so other
88checkouts' edits in progress never reach the result, and a working copy is
99frozen as it was when the run started. Its build cache is
10`workspaces/ci/target`, apart from every agent's `target/`. Runs wait for one
10`workspaces/ci/target`, which can link to a shared `target/`. Runs wait for one
1111another, and the exit status is the result.
1212
13Cargo normally checks source mtimes, so a shared cache can reuse another
14checkout's artifacts when the current sources are older. CI and release/build
15entry points clean local packages from `cargo metadata` before each build epoch,
16scoped to its target and release profile where applicable. This removes only
17workspace and other path package artifacts, including vendored patches;
18registry and Git dependencies stay cached.
19All CI cleanup precedes concurrent lanes. Direct Cargo runs after switching
20checkouts need the same cleanup; `python3 tools/cargo_fresh.py` accepts Cargo's
21`--target`, `--target-dir`, and `--release` options. Shared-cache builds and
22cleanup must run serially across checkouts.
23
1324| Lane | Runs |
1425| --- | --- |
1526| `fmt` | `cargo fmt --all --check` |
tools/canvas/build_macos.py+5
......@@ -10,6 +10,7 @@ import plistlib
1010import re
1111import shutil
1212import subprocess
13import sys
1314import tempfile
1415
1516parser = argparse.ArgumentParser(description=__doc__)
......@@ -38,6 +39,8 @@ minimum = '10.6' if args.snow_leopard else {'x86_64': '10.13', 'aarch64': '11.0'
3839if args.output and (args.output.suffix != '.app' or args.output.exists()):
3940 parser.error('Choose a new output path ending in .app.')
4041root = Path(__file__).resolve().parents[2]
42sys.path.insert(0, str(root / 'tools'))
43from cargo_fresh import clean_local
4144# The Apple Developer team and what is registered under it. With the team's Developer ID
4245# Application identity and a Developer ID profile for BUNDLE_ID naming CONTAINER, the app is
4346# signed with hardened runtime and the iCloud container (iCloud notebooks, NSUbiquitousContainers);
......@@ -88,6 +91,7 @@ def developer_id_profile():
8891 return None
8992profile = ['--release'] if args.release else []
9093if args.snow_leopard:
94 clean_local(root, *profile, command=[root / 'platform/snow-leopard/cargo.sh'])
9195 subprocess.run([root / 'platform/snow-leopard/cargo.sh', 'build', '-p', 'snowbound', '--no-default-features'] + profile,
9296 cwd=root, check=True)
9397 target = root / 'target/snow-leopard'
......@@ -98,6 +102,7 @@ else:
98102 cross = [] if args.arch == host else ['--target', f'{args.arch}-apple-darwin']
99103 if cross:
100104 built = target / cross[1]
105 clean_local(root, *profile, *cross)
101106 subprocess.run(['cargo', 'build', '-p', 'snowbound'] + profile + cross, cwd=root, check=True,
102107 env={**os.environ, 'MACOSX_DEPLOYMENT_TARGET': minimum} if args.arch == 'x86_64' else None)
103108bundle = args.output.resolve() if args.output else target / 'Snowbound.app'
tools/cargo_fresh.py created+26
......@@ -0,0 +1,26 @@
1"""Discard local package artifacts before building against a shared Cargo cache."""
2import json
3from pathlib import Path
4import subprocess
5import sys
6
7
8def local_packages(root, environment=None):
9 metadata = json.loads(subprocess.check_output(
10 ['cargo', 'metadata', '--locked', '--all-features', '--format-version=1',
11 '--manifest-path', str(root / 'Cargo.toml')], cwd=root, env=environment))
12 packages = sorted({package['name'] for package in metadata['packages'] if package['source'] is None})
13 if not packages:
14 raise ValueError('Cargo metadata contains no local packages')
15 return packages
16
17
18def clean_local(root, *args, environment=None, command=('cargo',)):
19 packages = local_packages(root, environment)
20 subprocess.run([*command, 'clean', '--locked', '--manifest-path', str(root / 'Cargo.toml'),
21 *map(str, args), *(arg for package in packages for arg in ('-p', package))],
22 cwd=root, env=environment, check=True)
23
24
25if __name__ == '__main__':
26 clean_local(Path(__file__).resolve().parents[1], *sys.argv[1:])
tools/check_public.py+1
......@@ -24,6 +24,7 @@ def main():
2424 commands = [
2525 ('python-dependencies', [sys.executable, '-c', 'import PIL, pdfplumber']),
2626 ('format', ['cargo', 'fmt', '--all', '--check']),
27 ('clean', [sys.executable, str(root / 'tools/cargo_fresh.py')]),
2728 ('rust', ['cargo', 'test', '--locked', '--workspace', '--all-features', '--all-targets']),
2829 ('doctests', ['cargo', 'test', '--locked', '--workspace', '--all-features', '--doc']),
2930 ('clippy', ['cargo', 'clippy', '--locked', '--workspace', '--all-features', '--all-targets', '--', '-D', 'warnings']),
tools/ci.py+20-2
......@@ -17,6 +17,8 @@ import sys
1717import threading
1818import time
1919
20from cargo_fresh import local_packages
21
2022ROOT = Path(__file__).resolve().parents[1]
2123# The checkout holding the jj repository, which a nested workspace's `.jj/repo` file names.
2224STORE = ROOT / '.jj/repo'
......@@ -30,11 +32,11 @@ JSON = '--message-format=json'
3032
3133
3234def cargo(*args):
33 return ['cargo', args[0], '--locked', JSON, *args[1:]]
35 return ['cargo', args[0], '--locked', *([] if args[0] == 'clean' else [JSON]), *args[1:]]
3436
3537
3638def cross(script, arch, *args):
37 return ['sh', script, arch, args[0], '--locked', JSON, *args[1:]]
39 return ['sh', script, arch, *cargo(*args)[1:]]
3840
3941
4042def lanes():
......@@ -67,6 +69,7 @@ def lanes():
6769 for arch in ('x86_64', 'aarch64'):
6870 result.append(dict(
6971 name=f'windows-{arch}', minutes=45, packages=['snowbound'], paths=('platform/windows/',),
72 clean=cross(windows, arch, 'clean'),
7073 # x86_64's nightly Clippy would lint std, which it builds, and nightly's own new lints.
7174 commands=[*([] if arch == 'x86_64' else [cross(windows, arch, *cross_clippy)]),
7275 cross(windows, arch, 'build', '-p', 'snowbound')],
......@@ -75,6 +78,7 @@ def lanes():
7578 for arch in ('x86_64', 'aarch64'):
7679 result.append(dict(
7780 name=f'linux-{arch}', minutes=30, packages=['snowbound'], paths=('platform/linux/',),
81 clean=cross(linux, arch, 'clean'),
7882 commands=[cross(linux, arch, *cross_clippy), cross(linux, arch, 'build', '-p', 'snowbound')],
7983 environment={'CARGO_TARGET_DIR': str(TARGET / 'linux')},
8084 missing=None if shutil.which('zig') else 'needs zig'))
......@@ -86,6 +90,7 @@ def lanes():
8690 wasm = web_environment()
8791 result.append(dict(
8892 name='web', minutes=20, packages=['snowbound'], paths=('crates/snowbound/web/', 'tools/release_web.py', 'tools/web/'),
93 clean=cargo('clean', '--target', 'wasm32-unknown-unknown'),
8994 # Clippy builds it; the module itself is linked by release_web.py.
9095 commands=[cargo('clippy', '-p', 'snowbound', '--target', 'wasm32-unknown-unknown', '--no-default-features', '--features', 'wgpu,live', '--', '-D', 'warnings'),
9196 ['python3', 'tools/web/receipt_probe.py']],
......@@ -101,6 +106,7 @@ def lanes():
101106 missing=None if shutil.which('xcodebuild') else 'needs Xcode'))
102107 result.append(dict(
103108 name='macos-10.6', minutes=45, packages=['snowbound'], paths=('platform/snow-leopard/',),
109 clean=['sh', CI / 'platform/snow-leopard/cargo.sh', 'clean', '--locked'],
104110 commands=[['sh', CI / 'platform/snow-leopard/cargo.sh', 'build', '--locked', JSON, '-p', 'snowbound',
105111 '--no-default-features']],
106112 environment={'SNOW_LEOPARD_SDK': str(sdk)},
......@@ -429,6 +435,18 @@ def main():
429435 if not key.startswith(('ONESTORE_', 'SNOWBOUND_', 'CARGO_TARGET_DIR'))}
430436 run = Run(folder, environment)
431437
438 ready = [lane for lane in chosen if not lane.get('missing')]
439 cleaning = ([(cargo('clean'), {})]
440 if any(lane['name'] in ('test', 'clippy', 'python') for lane in ready) else [])
441 cleaning += [(lane['clean'], lane.get('environment', {})) for lane in ready if 'clean' in lane]
442 packages = local_packages(CI, environment) if cleaning else []
443 for command, extra in cleaning:
444 command += [arg for package in packages for arg in ('-p', package)]
445 code = run.execute(command, folder / 'clean.log', time.monotonic() + 300, extra)
446 if code != 0:
447 lock.close()
448 sys.exit(f'Workspace cleanup {status(code)}: {folder / "clean.log"}')
449
432450 def gate(lane):
433451 result = {'name': lane['name']}
434452 if lane.get('missing'):
tools/release.py+4
......@@ -16,6 +16,8 @@ import time
1616import zipfile
1717from zoneinfo import ZoneInfo
1818
19from cargo_fresh import clean_local
20
1921ROOT = Path(__file__).resolve().parents[1]
2022PUBLISHED = Path('/Volumes/clover/Documents/Public/Snowbound')
2123URL = 'https://file.paperclover.net/shr/snowbound/'
......@@ -231,6 +233,7 @@ def build_windows(architectures):
231233 Windows it supports."""
232234 built = {}
233235 for arch in architectures:
236 clean_local(ROOT, '--release', command=['sh', ROOT / 'platform/windows/cargo.sh', arch])
234237 run(['sh', ROOT / 'platform/windows/cargo.sh', arch, 'build', '--release', '-p', 'snowbound'])
235238 built[f'windows-{arch}'] = ROOT / f'target/windows/{WINDOWS[arch]}/release/snowbound.exe'
236239 return built
......@@ -314,6 +317,7 @@ def main():
314317 symbols.append(debug.with_name(f'{debug.name}.zip'))
315318 with zipfile.ZipFile(symbols[-1], 'w', zipfile.ZIP_DEFLATED) as archive:
316319 archive.write(debug, debug.name)
320 clean_local(ROOT, '--release')
317321 signatures = sign(files.values())
318322 # A dry run's changes too start after the newest build the share holds.
319323 before = [each for each in builds(PUBLISHED) if each < version] if PUBLISHED.is_dir() else []
tools/release_relay.py+3
......@@ -10,6 +10,8 @@ from pathlib import Path
1010import shutil
1111import subprocess
1212
13from cargo_fresh import clean_local
14
1315ROOT = Path(__file__).resolve().parents[1]
1416ARCHITECTURES = ['x86_64', 'aarch64']
1517
......@@ -21,6 +23,7 @@ def build(arch, output):
2123 subprocess.run(['rustup', 'target', 'add', triple], check=True, capture_output=True)
2224 environment = {**os.environ, f'CARGO_TARGET_{triple.upper().replace("-", "_")}_LINKER': 'rust-lld',
2325 'CARGO_PROFILE_RELEASE_STRIP': 'symbols'}
26 clean_local(ROOT, '--release', '--target', triple, environment=environment)
2427 subprocess.run(['cargo', 'build', '--locked', '--release', '-p', 'relay', '--target', triple],
2528 cwd=ROOT, env=environment, check=True)
2629 target = Path(os.environ.get('CARGO_TARGET_DIR') or ROOT / 'target')
tools/release_web.py+5-1
......@@ -27,6 +27,8 @@ import tempfile
2727import time
2828import urllib.request
2929
30from cargo_fresh import clean_local
31
3032ROOT = Path(__file__).resolve().parents[1]
3133# The one target folder wasm builds share, apart from the native builds in target/.
3234TARGET = ROOT / 'target/wasm'
......@@ -100,8 +102,10 @@ def build(out):
100102 """Writes index.html, the module, its JavaScript and the fonts to `out`, the module knowing
101103 when it was built."""
102104 built = str(int(time.time()))
105 build_environment = environment()
106 clean_local(ROOT, '--release', '--target', 'wasm32-unknown-unknown', environment=build_environment)
103107 run(['cargo', 'build', '--locked', '-p', 'snowbound', '--release', '--target', 'wasm32-unknown-unknown', '--no-default-features', '--features', 'wgpu,live',
104 *PROFILE], env={**environment(), 'SNOWBOUND_WEB_BUILD': built})
108 *PROFILE], env={**build_environment, 'SNOWBOUND_WEB_BUILD': built})
105109 if out.exists():
106110 shutil.rmtree(out)
107111 out.mkdir(parents=True)
tools/test_cargo_fresh.py created+110
......@@ -0,0 +1,110 @@
1import os
2from pathlib import Path
3import shutil
4import subprocess
5import tempfile
6import unittest
7from unittest.mock import patch
8
9import cargo_fresh
10import ci
11import release_web
12
13
14@unittest.skipUnless(shutil.which('cargo'), 'needs Cargo')
15class CargoFreshTest(unittest.TestCase):
16 def test_old_checkout_inputs_are_rebuilt_without_cleaning_dependencies(self):
17 with tempfile.TemporaryDirectory(prefix='snowbound-cargo-fresh-') as folder:
18 folder = Path(folder)
19 for name, value in [('a', 1), ('b', 2)]:
20 root = folder / name
21 (root / 'app/src').mkdir(parents=True)
22 (root / 'owned/src').mkdir(parents=True)
23 (root / 'vendor/src').mkdir(parents=True)
24 (root / 'vendor/Cargo.toml').write_text(
25 '[package]\nname="shared_dependency"\nversion="0.1.0"\nedition="2024"\n')
26 (root / 'vendor/src/lib.rs').write_text(f'pub fn value() -> u8 {{ {value * 10} }}\n')
27 (root / 'Cargo.toml').write_text(
28 '[workspace]\nmembers=["app", "owned"]\nexclude=["vendor"]\nresolver="2"\n')
29 (root / 'app/Cargo.toml').write_text(
30 '[package]\nname="freshness_fixture"\nversion="0.1.0"\nedition="2024"\n'
31 '[dependencies]\nowned={path="../owned"}\nshared_dependency={path="../vendor"}\nitoa="=1.0.18"\n')
32 (root / 'owned/Cargo.toml').write_text(
33 '[package]\nname="owned"\nversion="0.1.0"\nedition="2024"\n')
34 (root / 'owned/src/lib.rs').write_text('include!(concat!(env!("OUT_DIR"), "/value.rs"));\n')
35 (root / 'owned/value.txt').write_text(str(value))
36 (root / 'owned/build.rs').write_text(
37 'fn main() {\n'
38 ' println!("cargo::rerun-if-changed=value.txt");\n'
39 ' let value = std::fs::read_to_string("value.txt").unwrap();\n'
40 ' let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap());\n'
41 ' std::fs::write(out.join("value.rs"), format!("pub fn value() -> u8 {{ {value} }}")).unwrap();\n'
42 '}\n')
43 (root / 'app/src/lib.rs').write_text(
44 f'#[test] fn checkout_{name}() {{ '
45 f'assert_eq!(itoa::Buffer::new().format(owned::value() + shared_dependency::value()), "{value * 11}"); }}\n')
46 for path in root.rglob('*'):
47 if path.is_file():
48 os.utime(path, (1, 1))
49 environment = {key: value for key, value in os.environ.items()
50 if not key.startswith(('CARGO_', 'RUST', 'SNOWBOUND_', 'ONESTORE_'))}
51 environment.update(CARGO_TARGET_DIR=str(folder / 'cache'), CARGO_BUILD_JOBS='2', CARGO_NET_OFFLINE='true')
52
53 def cargo(root, *args):
54 return subprocess.run(['cargo', *args], cwd=root, env=environment, check=True,
55 capture_output=True, text=True).stdout
56
57 self.assertIn('test checkout_a ... ok', cargo(folder / 'a', 'test', '--offline'))
58 artifacts = {path: (path.stat().st_mtime_ns, path.read_bytes())
59 for path in (folder / 'cache/debug/deps').glob('*itoa*')}
60 self.assertTrue(artifacts)
61 cargo(folder / 'b', 'generate-lockfile', '--offline')
62 host = subprocess.check_output(['rustc', '-vV'], env=environment, text=True)
63 host = next(line.removeprefix('host: ') for line in host.splitlines() if line.startswith('host: '))
64 cargo(folder / 'a', 'build', '--offline', '--release')
65 cargo(folder / 'a', 'build', '--offline', '--release', '--target', host)
66 previous = {path: (path.stat().st_mtime_ns, path.read_bytes())
67 for path in (folder / 'cache/release/deps').glob('*.rlib')}
68 self.assertTrue(previous)
69 cargo_fresh.clean_local(folder / 'b', '--release', '--target', host, environment=environment)
70 cargo(folder / 'b', 'build', '--offline', '--release', '--target', host)
71 for path, expected in previous.items():
72 self.assertEqual((path.stat().st_mtime_ns, path.read_bytes()), expected)
73 cargo_fresh.clean_local(folder / 'b', environment=environment)
74 self.assertIn('test checkout_b ... ok',
75 cargo(folder / 'b', 'test', '--offline', '--', '--exact', 'checkout_b'))
76 for path, expected in artifacts.items():
77 self.assertEqual((path.stat().st_mtime_ns, path.read_bytes()), expected)
78
79 def test_clean_failure_stops_web_build_before_packaging(self):
80 with patch.object(release_web, 'environment', return_value={}), \
81 patch.object(release_web, 'clean_local', side_effect=subprocess.CalledProcessError(1, 'cargo')), \
82 patch.object(release_web, 'run') as run:
83 with self.assertRaises(subprocess.CalledProcessError):
84 release_web.build(Path('unused-output'))
85 run.assert_not_called()
86
87 def test_empty_metadata_never_cleans_the_entire_cache(self):
88 with patch.object(cargo_fresh.subprocess, 'check_output', return_value='{"packages": []}'), \
89 patch.object(cargo_fresh.subprocess, 'run') as run:
90 with self.assertRaisesRegex(ValueError, 'no local packages'):
91 cargo_fresh.clean_local(Path('unused-checkout'))
92 run.assert_not_called()
93
94 def test_clean_failure_stops_ci_before_concurrent_lanes(self):
95 with tempfile.TemporaryDirectory(prefix='snowbound-ci-clean-') as folder, \
96 patch.object(ci, 'RUNS', Path(folder)), \
97 patch.object(ci, 'lanes', return_value=[{'name': 'test'}]), \
98 patch.object(ci, 'checkout', return_value='commit'), \
99 patch.object(ci, 'jj', return_value='fixture'), \
100 patch.object(ci, 'local_packages', return_value=['fixture']), \
101 patch.object(ci.sys, 'argv', ['ci.py']), \
102 patch.object(ci.Run, 'execute', return_value=101), \
103 patch.object(ci, 'ThreadPoolExecutor') as pool:
104 with self.assertRaisesRegex(SystemExit, 'Workspace cleanup failed'):
105 ci.main()
106 pool.assert_not_called()
107
108
109if __name__ == '__main__':
110 unittest.main()
tools/test_release.py+1-1
......@@ -85,7 +85,7 @@ class ReleaseTest(unittest.TestCase):
8585
8686 overrides = {
8787 'ROOT': root, 'PUBLISHED': published, 'FIRST': 'a',
88 'jj': Mock(return_value='a'), 'clean': Mock(), 'run': Mock(),
88 'jj': Mock(return_value='a'), 'clean': Mock(), 'clean_local': Mock(), 'run': Mock(),
8989 'history': lambda: {'a': ([], utc('2026-10-05T12:00:00'), 'fix: publish')},
9090 'build_linux': lambda architectures: {'linux-x86_64': source},
9191 'split_debug': split_debug, 'sign': lambda files: ['00' for _ in files],