| author | |
| committer | |
| log | 68d9fb0eeb2d423cdf9254cec4919e7e84627ca7 |
| tree | 05f00f0609460a4ce3c4e2f9f2ea4e569e14131f |
| parent | 549549b097928ce56e2cc8b8e07ba3530d8e418b |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU (~clover) |
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-sol12 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. |
| 12 | exec 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" \ | 12 | exec 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" $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" | ... | @@ -10,6 +10,7 @@ mkdir -p "$target/dist" |
| 10 | [ $# -gt 0 ] || set -- x86_64 aarch64 | 10 | [ $# -gt 0 ] || set -- x86_64 aarch64 |
| 11 | for arch do | 11 | for arch do |
| 12 | triple=$arch-unknown-linux-gnu | 12 | 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 snowbound | 15 | --manifest-path "$root/Cargo.toml" --release -p snowbound |
| 15 | # PT_INTERP becomes PT_NULL, so the kernel starts the executable at `loader`'s entry, which | 16 | # 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 |
| 7 | workspace's own commit, a child of `main`, at the revision's files, so other | 7 | workspace's own commit, a child of `main`, at the revision's files, so other |
| 8 | checkouts' edits in progress never reach the result, and a working copy is | 8 | checkouts' edits in progress never reach the result, and a working copy is |
| 9 | frozen as it was when the run started. Its build cache is | 9 | frozen 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 |
| 11 | another, and the exit status is the result. | 11 | another, and the exit status is the result. |
| 12 | 12 | ||
| 13 | Cargo normally checks source mtimes, so a shared cache can reuse another | ||
| 14 | checkout's artifacts when the current sources are older. CI and release/build | ||
| 15 | entry points clean local packages from `cargo metadata` before each build epoch, | ||
| 16 | scoped to its target and release profile where applicable. This removes only | ||
| 17 | workspace and other path package artifacts, including vendored patches; | ||
| 18 | registry and Git dependencies stay cached. | ||
| 19 | All CI cleanup precedes concurrent lanes. Direct Cargo runs after switching | ||
| 20 | checkouts need the same cleanup; `python3 tools/cargo_fresh.py` accepts Cargo's | ||
| 21 | `--target`, `--target-dir`, and `--release` options. Shared-cache builds and | ||
| 22 | cleanup 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 |
| 10 | import re | 10 | import re |
| 11 | import shutil | 11 | import shutil |
| 12 | import subprocess | 12 | import subprocess |
| 13 | import sys | ||
| 13 | import tempfile | 14 | import tempfile |
| 14 | 15 | ||
| 15 | parser = argparse.ArgumentParser(description=__doc__) | 16 | parser = 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' |
| 38 | if args.output and (args.output.suffix != '.app' or args.output.exists()): | 39 | if 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.') |
| 40 | root = Path(__file__).resolve().parents[2] | 41 | root = Path(__file__).resolve().parents[2] |
| 42 | sys.path.insert(0, str(root / 'tools')) | ||
| 43 | from cargo_fresh import clean_local | ||
| 41 | # The Apple Developer team and what is registered under it. With the team's Developer ID | 44 | # 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 is | 45 | # 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 None | 91 | return None |
| 89 | profile = ['--release'] if args.release else [] | 92 | profile = ['--release'] if args.release else [] |
| 90 | if args.snow_leopard: | 93 | if 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) |
| 103 | bundle = args.output.resolve() if args.output else target / 'Snowbound.app' | 108 | bundle = 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.""" | ||
| 2 | import json | ||
| 3 | from pathlib import Path | ||
| 4 | import subprocess | ||
| 5 | import sys | ||
| 6 | |||
| 7 | |||
| 8 | def 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 | |||
| 18 | def 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 | |||
| 25 | if __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 |
| 17 | import threading | 17 | import threading |
| 18 | import time | 18 | import time |
| 19 | 19 | ||
| 20 | from cargo_fresh import local_packages | ||
| 21 | |||
| 20 | ROOT = Path(__file__).resolve().parents[1] | 22 | ROOT = 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. |
| 22 | STORE = ROOT / '.jj/repo' | 24 | STORE = ROOT / '.jj/repo' |
| ... | @@ -30,11 +32,11 @@ JSON = '--message-format=json' | ... | @@ -30,11 +32,11 @@ JSON = '--message-format=json' |
| 30 | 32 | ||
| 31 | 33 | ||
| 32 | def cargo(*args): | 34 | def 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:]] |
| 34 | 36 | ||
| 35 | 37 | ||
| 36 | def cross(script, arch, *args): | 38 | def cross(script, arch, *args): |
| 37 | return ['sh', script, arch, args[0], '--locked', JSON, *args[1:]] | 39 | return ['sh', script, arch, *cargo(*args)[1:]] |
| 38 | 40 | ||
| 39 | 41 | ||
| 40 | def lanes(): | 42 | def 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) |
| 431 | 437 | ||
| 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 |
| 16 | import zipfile | 16 | import zipfile |
| 17 | from zoneinfo import ZoneInfo | 17 | from zoneinfo import ZoneInfo |
| 18 | 18 | ||
| 19 | from cargo_fresh import clean_local | ||
| 20 | |||
| 19 | ROOT = Path(__file__).resolve().parents[1] | 21 | ROOT = Path(__file__).resolve().parents[1] |
| 20 | PUBLISHED = Path('/Volumes/clover/Documents/Public/Snowbound') | 22 | PUBLISHED = Path('/Volumes/clover/Documents/Public/Snowbound') |
| 21 | URL = 'https://file.paperclover.net/shr/snowbound/' | 23 | URL = '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 built | 239 | 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 |
| 10 | import shutil | 10 | import shutil |
| 11 | import subprocess | 11 | import subprocess |
| 12 | 12 | ||
| 13 | from cargo_fresh import clean_local | ||
| 14 | |||
| 13 | ROOT = Path(__file__).resolve().parents[1] | 15 | ROOT = Path(__file__).resolve().parents[1] |
| 14 | ARCHITECTURES = ['x86_64', 'aarch64'] | 16 | ARCHITECTURES = ['x86_64', 'aarch64'] |
| 15 | 17 | ||
| ... | @@ -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 |
| 27 | import time | 27 | import time |
| 28 | import urllib.request | 28 | import urllib.request |
| 29 | 29 | ||
| 30 | from cargo_fresh import clean_local | ||
| 31 | |||
| 30 | ROOT = Path(__file__).resolve().parents[1] | 32 | ROOT = 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/. |
| 32 | TARGET = ROOT / 'target/wasm' | 34 | TARGET = 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 knowing | 102 | """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 @@ | ||
| 1 | import os | ||
| 2 | from pathlib import Path | ||
| 3 | import shutil | ||
| 4 | import subprocess | ||
| 5 | import tempfile | ||
| 6 | import unittest | ||
| 7 | from unittest.mock import patch | ||
| 8 | |||
| 9 | import cargo_fresh | ||
| 10 | import ci | ||
| 11 | import release_web | ||
| 12 | |||
| 13 | |||
| 14 | @unittest.skipUnless(shutil.which('cargo'), 'needs Cargo') | ||
| 15 | class 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 | |||
| 109 | if __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): |
| 85 | 85 | ||
| 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], |