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/../.."...@@ -11,4 +11,7 @@ root="$SRCROOT/../.."
11# Xcode's SDK variables would point host build scripts at the iOS SDK.11# Xcode's SDK variables would point host build scripts at the iOS SDK.
12exec 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" \12exec 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" \
13 CARGO_TARGET_DIR="$root/target/ios" IPHONEOS_DEPLOYMENT_TARGET="$IPHONEOS_DEPLOYMENT_TARGET" \13 CARGO_TARGET_DIR="$root/target/ios" IPHONEOS_DEPLOYMENT_TARGET="$IPHONEOS_DEPLOYMENT_TARGET" \
14 cargo build --manifest-path "$root/Cargo.toml" -p mobile --target "$target" $release14 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"...@@ -10,6 +10,7 @@ mkdir -p "$target/dist"
10[ $# -gt 0 ] || set -- x86_64 aarch6410[ $# -gt 0 ] || set -- x86_64 aarch64
11for arch do11for arch do
12 triple=$arch-unknown-linux-gnu12 triple=$arch-unknown-linux-gnu
13 python3 "$root/tools/cargo_fresh.py" --target "$triple" --release
13 sh "$root/platform/linux/cargo.sh" "$arch" build \14 sh "$root/platform/linux/cargo.sh" "$arch" build \
14 --manifest-path "$root/Cargo.toml" --release -p snowbound15 --manifest-path "$root/Cargo.toml" --release -p snowbound
15 # PT_INTERP becomes PT_NULL, so the kernel starts the executable at `loader`'s entry, which16 # 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...@@ -7,9 +7,20 @@ checkout's `@`) in the jj workspace `workspaces/ci`: it points that
7workspace's own commit, a child of `main`, at the revision's files, so other7workspace's own commit, a child of `main`, at the revision's files, so other
8checkouts' edits in progress never reach the result, and a working copy is8checkouts' edits in progress never reach the result, and a working copy is
9frozen as it was when the run started. Its build cache is9frozen as it was when the run started. Its build cache is
10`workspaces/ci/target`, apart from every agent's `target/`. Runs wait for one10`workspaces/ci/target`, which can link to a shared `target/`. Runs wait for one
11another, and the exit status is the result.11another, 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
13| Lane | Runs |24| Lane | Runs |
14| --- | --- |25| --- | --- |
15| `fmt` | `cargo fmt --all --check` |26| `fmt` | `cargo fmt --all --check` |
tools/canvas/build_macos.py+5
...@@ -10,6 +10,7 @@ import plistlib...@@ -10,6 +10,7 @@ import plistlib
10import re10import re
11import shutil11import shutil
12import subprocess12import subprocess
13import sys
13import tempfile14import tempfile
1415
15parser = argparse.ArgumentParser(description=__doc__)16parser = argparse.ArgumentParser(description=__doc__)
...@@ -38,6 +39,8 @@ minimum = '10.6' if args.snow_leopard else {'x86_64': '10.13', 'aarch64': '11.0'...@@ -38,6 +39,8 @@ minimum = '10.6' if args.snow_leopard else {'x86_64': '10.13', 'aarch64': '11.0'
38if args.output and (args.output.suffix != '.app' or args.output.exists()):39if args.output and (args.output.suffix != '.app' or args.output.exists()):
39 parser.error('Choose a new output path ending in .app.')40 parser.error('Choose a new output path ending in .app.')
40root = Path(__file__).resolve().parents[2]41root = Path(__file__).resolve().parents[2]
42sys.path.insert(0, str(root / 'tools'))
43from cargo_fresh import clean_local
41# The Apple Developer team and what is registered under it. With the team's Developer ID44# The Apple Developer team and what is registered under it. With the team's Developer ID
42# Application identity and a Developer ID profile for BUNDLE_ID naming CONTAINER, the app is45# Application identity and a Developer ID profile for BUNDLE_ID naming CONTAINER, the app is
43# signed with hardened runtime and the iCloud container (iCloud notebooks, NSUbiquitousContainers);46# signed with hardened runtime and the iCloud container (iCloud notebooks, NSUbiquitousContainers);
...@@ -88,6 +91,7 @@ def developer_id_profile():...@@ -88,6 +91,7 @@ def developer_id_profile():
88 return None91 return None
89profile = ['--release'] if args.release else []92profile = ['--release'] if args.release else []
90if args.snow_leopard:93if args.snow_leopard:
94 clean_local(root, *profile, command=[root / 'platform/snow-leopard/cargo.sh'])
91 subprocess.run([root / 'platform/snow-leopard/cargo.sh', 'build', '-p', 'snowbound', '--no-default-features'] + profile,95 subprocess.run([root / 'platform/snow-leopard/cargo.sh', 'build', '-p', 'snowbound', '--no-default-features'] + profile,
92 cwd=root, check=True)96 cwd=root, check=True)
93 target = root / 'target/snow-leopard'97 target = root / 'target/snow-leopard'
...@@ -98,6 +102,7 @@ else:...@@ -98,6 +102,7 @@ else:
98 cross = [] if args.arch == host else ['--target', f'{args.arch}-apple-darwin']102 cross = [] if args.arch == host else ['--target', f'{args.arch}-apple-darwin']
99 if cross:103 if cross:
100 built = target / cross[1]104 built = target / cross[1]
105 clean_local(root, *profile, *cross)
101 subprocess.run(['cargo', 'build', '-p', 'snowbound'] + profile + cross, cwd=root, check=True,106 subprocess.run(['cargo', 'build', '-p', 'snowbound'] + profile + cross, cwd=root, check=True,
102 env={**os.environ, 'MACOSX_DEPLOYMENT_TARGET': minimum} if args.arch == 'x86_64' else None)107 env={**os.environ, 'MACOSX_DEPLOYMENT_TARGET': minimum} if args.arch == 'x86_64' else None)
103bundle = args.output.resolve() if args.output else target / 'Snowbound.app'108bundle = 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():...@@ -24,6 +24,7 @@ def main():
24 commands = [24 commands = [
25 ('python-dependencies', [sys.executable, '-c', 'import PIL, pdfplumber']),25 ('python-dependencies', [sys.executable, '-c', 'import PIL, pdfplumber']),
26 ('format', ['cargo', 'fmt', '--all', '--check']),26 ('format', ['cargo', 'fmt', '--all', '--check']),
27 ('clean', [sys.executable, str(root / 'tools/cargo_fresh.py')]),
27 ('rust', ['cargo', 'test', '--locked', '--workspace', '--all-features', '--all-targets']),28 ('rust', ['cargo', 'test', '--locked', '--workspace', '--all-features', '--all-targets']),
28 ('doctests', ['cargo', 'test', '--locked', '--workspace', '--all-features', '--doc']),29 ('doctests', ['cargo', 'test', '--locked', '--workspace', '--all-features', '--doc']),
29 ('clippy', ['cargo', 'clippy', '--locked', '--workspace', '--all-features', '--all-targets', '--', '-D', 'warnings']),30 ('clippy', ['cargo', 'clippy', '--locked', '--workspace', '--all-features', '--all-targets', '--', '-D', 'warnings']),
tools/ci.py+20-2
...@@ -17,6 +17,8 @@ import sys...@@ -17,6 +17,8 @@ import sys
17import threading17import threading
18import time18import time
1919
20from cargo_fresh import local_packages
21
20ROOT = Path(__file__).resolve().parents[1]22ROOT = Path(__file__).resolve().parents[1]
21# The checkout holding the jj repository, which a nested workspace's `.jj/repo` file names.23# The checkout holding the jj repository, which a nested workspace's `.jj/repo` file names.
22STORE = ROOT / '.jj/repo'24STORE = ROOT / '.jj/repo'
...@@ -30,11 +32,11 @@ JSON = '--message-format=json'...@@ -30,11 +32,11 @@ JSON = '--message-format=json'
3032
3133
32def cargo(*args):34def 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
36def cross(script, arch, *args):38def 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
40def lanes():42def lanes():
...@@ -67,6 +69,7 @@ def lanes():...@@ -67,6 +69,7 @@ def lanes():
67 for arch in ('x86_64', 'aarch64'):69 for arch in ('x86_64', 'aarch64'):
68 result.append(dict(70 result.append(dict(
69 name=f'windows-{arch}', minutes=45, packages=['snowbound'], paths=('platform/windows/',),71 name=f'windows-{arch}', minutes=45, packages=['snowbound'], paths=('platform/windows/',),
72 clean=cross(windows, arch, 'clean'),
70 # x86_64's nightly Clippy would lint std, which it builds, and nightly's own new lints.73 # x86_64's nightly Clippy would lint std, which it builds, and nightly's own new lints.
71 commands=[*([] if arch == 'x86_64' else [cross(windows, arch, *cross_clippy)]),74 commands=[*([] if arch == 'x86_64' else [cross(windows, arch, *cross_clippy)]),
72 cross(windows, arch, 'build', '-p', 'snowbound')],75 cross(windows, arch, 'build', '-p', 'snowbound')],
...@@ -75,6 +78,7 @@ def lanes():...@@ -75,6 +78,7 @@ def lanes():
75 for arch in ('x86_64', 'aarch64'):78 for arch in ('x86_64', 'aarch64'):
76 result.append(dict(79 result.append(dict(
77 name=f'linux-{arch}', minutes=30, packages=['snowbound'], paths=('platform/linux/',),80 name=f'linux-{arch}', minutes=30, packages=['snowbound'], paths=('platform/linux/',),
81 clean=cross(linux, arch, 'clean'),
78 commands=[cross(linux, arch, *cross_clippy), cross(linux, arch, 'build', '-p', 'snowbound')],82 commands=[cross(linux, arch, *cross_clippy), cross(linux, arch, 'build', '-p', 'snowbound')],
79 environment={'CARGO_TARGET_DIR': str(TARGET / 'linux')},83 environment={'CARGO_TARGET_DIR': str(TARGET / 'linux')},
80 missing=None if shutil.which('zig') else 'needs zig'))84 missing=None if shutil.which('zig') else 'needs zig'))
...@@ -86,6 +90,7 @@ def lanes():...@@ -86,6 +90,7 @@ def lanes():
86 wasm = web_environment()90 wasm = web_environment()
87 result.append(dict(91 result.append(dict(
88 name='web', minutes=20, packages=['snowbound'], paths=('crates/snowbound/web/', 'tools/release_web.py', 'tools/web/'),92 name='web', minutes=20, packages=['snowbound'], paths=('crates/snowbound/web/', 'tools/release_web.py', 'tools/web/'),
93 clean=cargo('clean', '--target', 'wasm32-unknown-unknown'),
89 # Clippy builds it; the module itself is linked by release_web.py.94 # Clippy builds it; the module itself is linked by release_web.py.
90 commands=[cargo('clippy', '-p', 'snowbound', '--target', 'wasm32-unknown-unknown', '--no-default-features', '--features', 'wgpu,live', '--', '-D', 'warnings'),95 commands=[cargo('clippy', '-p', 'snowbound', '--target', 'wasm32-unknown-unknown', '--no-default-features', '--features', 'wgpu,live', '--', '-D', 'warnings'),
91 ['python3', 'tools/web/receipt_probe.py']],96 ['python3', 'tools/web/receipt_probe.py']],
...@@ -101,6 +106,7 @@ def lanes():...@@ -101,6 +106,7 @@ def lanes():
101 missing=None if shutil.which('xcodebuild') else 'needs Xcode'))106 missing=None if shutil.which('xcodebuild') else 'needs Xcode'))
102 result.append(dict(107 result.append(dict(
103 name='macos-10.6', minutes=45, packages=['snowbound'], paths=('platform/snow-leopard/',),108 name='macos-10.6', minutes=45, packages=['snowbound'], paths=('platform/snow-leopard/',),
109 clean=['sh', CI / 'platform/snow-leopard/cargo.sh', 'clean', '--locked'],
104 commands=[['sh', CI / 'platform/snow-leopard/cargo.sh', 'build', '--locked', JSON, '-p', 'snowbound',110 commands=[['sh', CI / 'platform/snow-leopard/cargo.sh', 'build', '--locked', JSON, '-p', 'snowbound',
105 '--no-default-features']],111 '--no-default-features']],
106 environment={'SNOW_LEOPARD_SDK': str(sdk)},112 environment={'SNOW_LEOPARD_SDK': str(sdk)},
...@@ -429,6 +435,18 @@ def main():...@@ -429,6 +435,18 @@ def main():
429 if not key.startswith(('ONESTORE_', 'SNOWBOUND_', 'CARGO_TARGET_DIR'))}435 if not key.startswith(('ONESTORE_', 'SNOWBOUND_', 'CARGO_TARGET_DIR'))}
430 run = Run(folder, environment)436 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
432 def gate(lane):450 def gate(lane):
433 result = {'name': lane['name']}451 result = {'name': lane['name']}
434 if lane.get('missing'):452 if lane.get('missing'):
tools/release.py+4
...@@ -16,6 +16,8 @@ import time...@@ -16,6 +16,8 @@ import time
16import zipfile16import zipfile
17from zoneinfo import ZoneInfo17from zoneinfo import ZoneInfo
1818
19from cargo_fresh import clean_local
20
19ROOT = Path(__file__).resolve().parents[1]21ROOT = Path(__file__).resolve().parents[1]
20PUBLISHED = Path('/Volumes/clover/Documents/Public/Snowbound')22PUBLISHED = Path('/Volumes/clover/Documents/Public/Snowbound')
21URL = 'https://file.paperclover.net/shr/snowbound/'23URL = 'https://file.paperclover.net/shr/snowbound/'
...@@ -231,6 +233,7 @@ def build_windows(architectures):...@@ -231,6 +233,7 @@ def build_windows(architectures):
231 Windows it supports."""233 Windows it supports."""
232 built = {}234 built = {}
233 for arch in architectures:235 for arch in architectures:
236 clean_local(ROOT, '--release', command=['sh', ROOT / 'platform/windows/cargo.sh', arch])
234 run(['sh', ROOT / 'platform/windows/cargo.sh', arch, 'build', '--release', '-p', 'snowbound'])237 run(['sh', ROOT / 'platform/windows/cargo.sh', arch, 'build', '--release', '-p', 'snowbound'])
235 built[f'windows-{arch}'] = ROOT / f'target/windows/{WINDOWS[arch]}/release/snowbound.exe'238 built[f'windows-{arch}'] = ROOT / f'target/windows/{WINDOWS[arch]}/release/snowbound.exe'
236 return built239 return built
...@@ -314,6 +317,7 @@ def main():...@@ -314,6 +317,7 @@ def main():
314 symbols.append(debug.with_name(f'{debug.name}.zip'))317 symbols.append(debug.with_name(f'{debug.name}.zip'))
315 with zipfile.ZipFile(symbols[-1], 'w', zipfile.ZIP_DEFLATED) as archive:318 with zipfile.ZipFile(symbols[-1], 'w', zipfile.ZIP_DEFLATED) as archive:
316 archive.write(debug, debug.name)319 archive.write(debug, debug.name)
320 clean_local(ROOT, '--release')
317 signatures = sign(files.values())321 signatures = sign(files.values())
318 # A dry run's changes too start after the newest build the share holds.322 # A dry run's changes too start after the newest build the share holds.
319 before = [each for each in builds(PUBLISHED) if each < version] if PUBLISHED.is_dir() else []323 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...@@ -10,6 +10,8 @@ from pathlib import Path
10import shutil10import shutil
11import subprocess11import subprocess
1212
13from cargo_fresh import clean_local
14
13ROOT = Path(__file__).resolve().parents[1]15ROOT = Path(__file__).resolve().parents[1]
14ARCHITECTURES = ['x86_64', 'aarch64']16ARCHITECTURES = ['x86_64', 'aarch64']
1517
...@@ -21,6 +23,7 @@ def build(arch, output):...@@ -21,6 +23,7 @@ def build(arch, output):
21 subprocess.run(['rustup', 'target', 'add', triple], check=True, capture_output=True)23 subprocess.run(['rustup', 'target', 'add', triple], check=True, capture_output=True)
22 environment = {**os.environ, f'CARGO_TARGET_{triple.upper().replace("-", "_")}_LINKER': 'rust-lld',24 environment = {**os.environ, f'CARGO_TARGET_{triple.upper().replace("-", "_")}_LINKER': 'rust-lld',
23 'CARGO_PROFILE_RELEASE_STRIP': 'symbols'}25 'CARGO_PROFILE_RELEASE_STRIP': 'symbols'}
26 clean_local(ROOT, '--release', '--target', triple, environment=environment)
24 subprocess.run(['cargo', 'build', '--locked', '--release', '-p', 'relay', '--target', triple],27 subprocess.run(['cargo', 'build', '--locked', '--release', '-p', 'relay', '--target', triple],
25 cwd=ROOT, env=environment, check=True)28 cwd=ROOT, env=environment, check=True)
26 target = Path(os.environ.get('CARGO_TARGET_DIR') or ROOT / 'target')29 target = Path(os.environ.get('CARGO_TARGET_DIR') or ROOT / 'target')
tools/release_web.py+5-1
...@@ -27,6 +27,8 @@ import tempfile...@@ -27,6 +27,8 @@ import tempfile
27import time27import time
28import urllib.request28import urllib.request
2929
30from cargo_fresh import clean_local
31
30ROOT = Path(__file__).resolve().parents[1]32ROOT = Path(__file__).resolve().parents[1]
31# The one target folder wasm builds share, apart from the native builds in target/.33# The one target folder wasm builds share, apart from the native builds in target/.
32TARGET = ROOT / 'target/wasm'34TARGET = ROOT / 'target/wasm'
...@@ -100,8 +102,10 @@ def build(out):...@@ -100,8 +102,10 @@ def build(out):
100 """Writes index.html, the module, its JavaScript and the fonts to `out`, the module knowing102 """Writes index.html, the module, its JavaScript and the fonts to `out`, the module knowing
101 when it was built."""103 when it was built."""
102 built = str(int(time.time()))104 built = str(int(time.time()))
105 build_environment = environment()
106 clean_local(ROOT, '--release', '--target', 'wasm32-unknown-unknown', environment=build_environment)
103 run(['cargo', 'build', '--locked', '-p', 'snowbound', '--release', '--target', 'wasm32-unknown-unknown', '--no-default-features', '--features', 'wgpu,live',107 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})
105 if out.exists():109 if out.exists():
106 shutil.rmtree(out)110 shutil.rmtree(out)
107 out.mkdir(parents=True)111 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):...@@ -85,7 +85,7 @@ class ReleaseTest(unittest.TestCase):
8585
86 overrides = {86 overrides = {
87 'ROOT': root, 'PUBLISHED': published, 'FIRST': 'a',87 '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(),
89 'history': lambda: {'a': ([], utc('2026-10-05T12:00:00'), 'fix: publish')},89 'history': lambda: {'a': ([], utc('2026-10-05T12:00:00'), 'fix: publish')},
90 'build_linux': lambda architectures: {'linux-x86_64': source},90 'build_linux': lambda architectures: {'linux-x86_64': source},
91 'split_debug': split_debug, 'sign': lambda files: ['00' for _ in files],91 'split_debug': split_debug, 'sign': lambda files: ['00' for _ in files],