From 2a07ab2087f4e7ebde7e3e2d4c72df0c78602264 Mon Sep 17 00:00:00 2001 From: Hal <784999+hallee@users.noreply.github.com> Date: Sat, 31 Oct 2026 11:23:18 -0400 Subject: [PATCH 1/2] Use Python for nanosecond-accurate source timestamp caching --- .github/workflows/test.yml | 19 +++ README.md | 26 ++++ action.yml | 4 +- mtimes.py | 138 +++++++++++++++++++ restore-mtimes.rb | 35 ----- store-mtimes.rb | 59 -------- tests/fixtures/ruby-mtimes.json | 17 +++ tests/test_mtimes.py | 234 ++++++++++++++++++++++++++++++++ 8 files changed, 436 insertions(+), 96 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 README.md create mode 100644 mtimes.py delete mode 100644 restore-mtimes.rb delete mode 100644 store-mtimes.rb create mode 100644 tests/fixtures/ruby-mtimes.json create mode 100644 tests/test_mtimes.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..acf2602 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,19 @@ +name: Tests + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + mtimes: + name: Source timestamps retain nanosecond precision + runs-on: macos-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v6 + - name: Test timestamp storage and restoration + run: /usr/bin/python3 -B -m unittest discover -s tests -v diff --git a/README.md b/README.md new file mode 100644 index 0000000..8d2eec6 --- /dev/null +++ b/README.md @@ -0,0 +1,26 @@ +# Xcode cache + +A composite GitHub Action that restores or saves Xcode DerivedData, SwiftPM checkouts, +and the source modification times needed to reuse cached build products. + +The action uses `actions/cache` for cache transport and a Python 3 script for timestamp +metadata. It requires `python3` on the runner and has no third-party Python dependencies. +Xcode's command-line tools provide Python on macOS. + +Source timestamps are stored and restored as integer nanoseconds. Restoration only +updates existing paths whose content hashes still match the cached metadata. The +metadata remains compatible with caches written by the previous Ruby implementation. + +`mtime-targets` accepts additional newline-separated glob patterns, including `**/` +and brace alternatives such as `App/**/*.{pbxproj,xcconfig}`. Recursive matching skips +hidden directories and directory symlinks unless explicitly targeted. DerivedData and +SwiftPM checkout directories are excluded from source metadata. + +Run the tests with: + +```sh +python3 -B -m unittest discover -s tests -v +``` + +The tests include a metadata fixture produced by the original Ruby storage script to +check compatibility in both directions. diff --git a/action.yml b/action.yml index cb68242..61228f3 100644 --- a/action.yml +++ b/action.yml @@ -57,7 +57,7 @@ runs: shell: bash env: DERIVED_DATA_PATH: ${{ inputs.derived-data-path }} - run: ruby "$GITHUB_ACTION_PATH/restore-mtimes.rb" + run: python3 "$GITHUB_ACTION_PATH/mtimes.py" restore - name: Store source mtimes if: inputs.operation == 'save' shell: bash @@ -65,7 +65,7 @@ runs: DERIVED_DATA_PATH: ${{ inputs.derived-data-path }} SOURCE_PACKAGES_PATH: ${{ inputs.source-packages-path }} MTIME_TARGETS: ${{ inputs.mtime-targets }} - run: ruby "$GITHUB_ACTION_PATH/store-mtimes.rb" + run: python3 "$GITHUB_ACTION_PATH/mtimes.py" store - name: Save SwiftPM checkouts if: inputs.operation == 'save' uses: actions/cache/save@v6 diff --git a/mtimes.py b/mtimes.py new file mode 100644 index 0000000..c5cc577 --- /dev/null +++ b/mtimes.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +"""Store and restore source mtimes with integer nanosecond precision.""" + +import argparse +import glob +import hashlib +import json +import os +from pathlib import Path +import re +import sys + + +METADATA_FILENAME = "xcode-cache-mtime.json" +DEFAULT_PATTERNS = """ + **/*.swift **/*.xib **/*.storyboard **/*.strings **/*.xcstrings **/*.plist + **/*.intentdefinition **/*.json **/*.xcassets **/*.xcassets/**/* **/*.bundle + **/*.bundle/**/* **/*.xcdatamodel **/*.xcdatamodel/**/* **/*.framework + **/*.framework/**/* **/*.xcframework **/*.xcframework/**/* **/*.m **/*.mm + **/*.h **/*.c **/*.cc **/*.cpp **/*.hpp **/*.hxx +""".split() + + +def digest_for(path): + digest = hashlib.sha256() + if path.is_dir(): + digest.update("".join(sorted(child.name for child in path.iterdir())).encode()) + else: + with path.open("rb") as source: + for chunk in iter(lambda: source.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def expand_braces(pattern): + """Preserve Ruby glob alternatives, including nested braces.""" + match = re.search(r"\{([^{}]*)\}", pattern) + if match is None: + yield pattern + return + for alternative in match.group(1).split(","): + expanded = pattern[:match.start()] + alternative + pattern[match.end():] + yield from expand_braces(expanded) + + +def is_excluded(path, excluded): + absolute_path = Path(os.path.abspath(path)) + return any(root == absolute_path or root in absolute_path.parents for root in excluded) + + +def glob_paths(pattern, excluded): + # Ruby's **/ skips hidden directories and does not follow directory symlinks. + # os.walk preserves that behavior; ordinary glob handles each remaining segment. + recursive = re.search(r"(?:^|/)\*\*/", pattern) + if recursive is None: + yield from glob.iglob(pattern) + return + prefix = pattern[:recursive.end() - 3] + suffix = pattern[recursive.end():] + for root in glob.iglob(prefix or "."): + if is_excluded(root, excluded): + continue + for current, directories, _ in os.walk(root, followlinks=False): + directories[:] = [ + name for name in directories + if not name.startswith(".") + and not is_excluded(Path(current) / name, excluded) + ] + yield from glob_paths(os.path.join(glob.escape(current), suffix), excluded) + + +def store(): + derived_data = Path(os.path.abspath(os.environ["DERIVED_DATA_PATH"])) + source_packages = Path(os.path.abspath(os.environ["SOURCE_PACKAGES_PATH"])) + workspace = Path(os.path.abspath(os.environ.get("GITHUB_WORKSPACE", os.getcwd()))) + if not derived_data.is_dir(): + print( + f"DerivedData does not exist at {derived_data}; skipping mtime metadata.", + file=sys.stderr, + ) + return + + custom_patterns = [ + line.strip() for line in os.environ.get("MTIME_TARGETS", "").splitlines() + if line.strip() + ] + excluded = (derived_data, source_packages) + paths = set() + for pattern in DEFAULT_PATTERNS + custom_patterns: + for expanded in expand_braces(pattern): + absolute_pattern = os.path.join(glob.escape(str(workspace)), expanded) + for match in glob_paths(absolute_pattern, excluded): + path = Path(match) + if path.exists() and not is_excluded(path, excluded): + paths.add(os.path.relpath(path, workspace)) + + metadata = [] + for relative_path in sorted(paths): + path = workspace / relative_path + seconds, nanoseconds = divmod(path.stat().st_mtime_ns, 1_000_000_000) + metadata.append({ + "path": relative_path, + "time": f"{seconds}.{nanoseconds:09d}", + "sha256": digest_for(path), + }) + + (derived_data / METADATA_FILENAME).write_text( + json.dumps(metadata, ensure_ascii=False), encoding="utf-8" + ) + print(f"Stored {len(metadata)} source mtimes.") + + +def restore(): + metadata_path = Path(os.environ["DERIVED_DATA_PATH"]) / METADATA_FILENAME + if not metadata_path.is_file(): + print(f"No source mtime metadata found at {metadata_path}.") + return + + restored = 0 + for entry in json.loads(metadata_path.read_text(encoding="utf-8")): + path = Path(entry["path"]) + if not path.exists() or digest_for(path) != entry["sha256"]: + continue + + seconds, nanoseconds = entry["time"].split(".", 1) + timestamp = int(seconds) * 1_000_000_000 + int(nanoseconds) + # Integer nanoseconds avoid both float rounding and Ruby's microsecond truncation. + os.utime(path, ns=(timestamp, timestamp)) + restored += 1 + + print(f"Restored {restored} source mtimes.") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("operation", choices=("store", "restore")) + arguments = parser.parse_args() + {"store": store, "restore": restore}[arguments.operation]() diff --git a/restore-mtimes.rb b/restore-mtimes.rb deleted file mode 100644 index d8b7ced..0000000 --- a/restore-mtimes.rb +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env ruby -# frozen_string_literal: true - -require 'digest' -require 'json' - -derived_data_path = ENV.fetch('DERIVED_DATA_PATH') -metadata_path = File.join(derived_data_path, 'xcode-cache-mtime.json') - -unless File.file?(metadata_path) - puts "No source mtime metadata found at #{metadata_path}." - exit 0 -end - -def digest_for(path) - if File.directory?(path) - Digest::SHA256.hexdigest(Dir.children(path).sort.join) - else - Digest::SHA256.file(path).hexdigest - end -end - -restored = 0 -JSON.parse(File.read(metadata_path)).each do |entry| - path = entry.fetch('path') - next unless File.exist?(path) - next unless digest_for(path) == entry.fetch('sha256') - - seconds, nanoseconds = entry.fetch('time').split('.', 2).map(&:to_i) - time = Time.at(seconds, nanoseconds, :nanosecond) - File.utime(time, time, path) - restored += 1 -end - -puts "Restored #{restored} source mtimes." diff --git a/store-mtimes.rb b/store-mtimes.rb deleted file mode 100644 index 6c8dbc4..0000000 --- a/store-mtimes.rb +++ /dev/null @@ -1,59 +0,0 @@ -#!/usr/bin/env ruby -# frozen_string_literal: true - -require 'digest' -require 'json' -require 'pathname' - -derived_data_path = File.expand_path(ENV.fetch('DERIVED_DATA_PATH')) -source_packages_path = File.expand_path(ENV.fetch('SOURCE_PACKAGES_PATH')) -workspace = File.expand_path(ENV.fetch('GITHUB_WORKSPACE', Dir.pwd)) - -unless File.directory?(derived_data_path) - warn "DerivedData does not exist at #{derived_data_path}; skipping mtime metadata." - exit 0 -end - -default_patterns = %w[ - **/*.swift **/*.xib **/*.storyboard **/*.strings **/*.xcstrings **/*.plist - **/*.intentdefinition **/*.json **/*.xcassets **/*.xcassets/**/* **/*.bundle - **/*.bundle/**/* **/*.xcdatamodel **/*.xcdatamodel/**/* **/*.framework - **/*.framework/**/* **/*.xcframework **/*.xcframework/**/* **/*.m **/*.mm - **/*.h **/*.c **/*.cc **/*.cpp **/*.hpp **/*.hxx -] -custom_patterns = ENV.fetch('MTIME_TARGETS', '').lines.map(&:strip).reject(&:empty?) -excluded_prefixes = [derived_data_path, source_packages_path] - -def digest_for(path) - if File.directory?(path) - Digest::SHA256.hexdigest(Dir.children(path).sort.join) - else - Digest::SHA256.file(path).hexdigest - end -end - -paths = Dir.chdir(workspace) do - (default_patterns + custom_patterns).flat_map { |pattern| Dir.glob(pattern, File::FNM_EXTGLOB) } - .uniq - .select { |path| File.exist?(path) } - .reject do |path| - excluded_prefixes.any? do |prefix| - absolute_path = File.expand_path(path, workspace) - absolute_path == prefix || absolute_path.start_with?("#{prefix}/") - end - end - .sort -end - -metadata = paths.map do |path| - absolute_path = File.expand_path(path, workspace) - mtime = File.stat(absolute_path).mtime - { - path: Pathname.new(absolute_path).relative_path_from(Pathname.new(workspace)).to_s, - time: format('%d.%09d', mtime.to_i, mtime.nsec), - sha256: digest_for(absolute_path), - } -end - -File.write(File.join(derived_data_path, 'xcode-cache-mtime.json'), JSON.generate(metadata)) -puts "Stored #{metadata.count} source mtimes." diff --git a/tests/fixtures/ruby-mtimes.json b/tests/fixtures/ruby-mtimes.json new file mode 100644 index 0000000..4d45fc2 --- /dev/null +++ b/tests/fixtures/ruby-mtimes.json @@ -0,0 +1,17 @@ +[ + { + "path": "Assets.xcassets", + "time": "1700000000.123456789", + "sha256": "9f3731c4a8f65cb3b730925d5ac1f11acf8617b6d7d3fad66e02ab15a79c3a43" + }, + { + "path": "Assets.xcassets/Contents.json", + "time": "1700000000.123456789", + "sha256": "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a" + }, + { + "path": "Source with spaces.swift", + "time": "1700000000.123456789", + "sha256": "ccdab916a2664126bae77709d63891fea8e32421e01f33fa9814de8abe9ba3e3" + } +] diff --git a/tests/test_mtimes.py b/tests/test_mtimes.py new file mode 100644 index 0000000..0405661 --- /dev/null +++ b/tests/test_mtimes.py @@ -0,0 +1,234 @@ +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + + +ACTION_ROOT = Path(__file__).resolve().parents[1] +ORIGINAL_MTIME = 1_700_000_000_123_456_789 +CHECKOUT_MTIME = 1_800_000_000_987_654_321 + + +class SourceMtimeTests(unittest.TestCase): + def setUp(self): + temporary_directory = tempfile.TemporaryDirectory() + self.addCleanup(temporary_directory.cleanup) + self.workspace = Path(temporary_directory.name) + self.derived_data = self.workspace / "DerivedData" + self.derived_data.mkdir() + self.environment = { + **os.environ, + "GITHUB_WORKSPACE": str(self.workspace), + "DERIVED_DATA_PATH": str(self.derived_data), + "SOURCE_PACKAGES_PATH": str(self.workspace / "SourcePackages"), + "MTIME_TARGETS": "", + } + + def make_source(self): + path = self.workspace / "Source with spaces.swift" + path.write_text("let value = 1\n") + self.set_mtime(path, ORIGINAL_MTIME) + return path + + def make_assets(self): + path = self.workspace / "Assets.xcassets" + path.mkdir() + (path / "Contents.json").write_text("{}") + self.set_mtime(path, ORIGINAL_MTIME) + return path + + def set_mtime(self, path, timestamp): + os.utime(path, ns=(timestamp, timestamp)) + self.assertEqual(path.stat().st_mtime_ns, timestamp) + + def run_script(self, command): + result = subprocess.run( + command, + cwd=self.workspace, + env=self.environment, + capture_output=True, + text=True, + ) + self.assertEqual(result.returncode, 0, result.stderr) + return result.stdout + + def store(self): + return self.run_script([sys.executable, str(ACTION_ROOT / "mtimes.py"), "store"]) + + def restore(self): + return self.run_script([sys.executable, str(ACTION_ROOT / "mtimes.py"), "restore"]) + + def read_metadata(self): + return json.loads((self.derived_data / "xcode-cache-mtime.json").read_text()) + + def make_file(self, name): + path = self.workspace / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("example") + return path + + def test_unchanged_file_restores_exact_nanoseconds(self): + path = self.make_source() + self.store() + metadata = json.loads( + (self.derived_data / "xcode-cache-mtime.json").read_text() + ) + self.assertEqual(metadata[0]["path"], path.name) + self.assertEqual(metadata[0]["time"], "1700000000.123456789") + self.set_mtime(path, CHECKOUT_MTIME) + + self.assertIn("Restored 1 source mtimes.", self.restore()) + + self.assertEqual(path.stat().st_mtime_ns, ORIGINAL_MTIME) + + def test_unchanged_directory_restores_exact_nanoseconds(self): + path = self.make_assets() + self.store() + self.set_mtime(path, CHECKOUT_MTIME) + + self.restore() + + self.assertEqual(path.stat().st_mtime_ns, ORIGINAL_MTIME) + + def test_changed_file_keeps_current_timestamp(self): + path = self.make_source() + self.store() + path.write_text("let value = 2\n") + self.set_mtime(path, CHECKOUT_MTIME) + + self.assertIn("Restored 0 source mtimes.", self.restore()) + + self.assertEqual(path.stat().st_mtime_ns, CHECKOUT_MTIME) + + def test_changed_directory_keeps_current_timestamp(self): + path = self.make_assets() + self.store() + (path / "New.imageset").mkdir() + self.set_mtime(path, CHECKOUT_MTIME) + + self.restore() + + self.assertEqual(path.stat().st_mtime_ns, CHECKOUT_MTIME) + + def test_deleted_path_is_skipped(self): + path = self.make_source() + self.store() + path.unlink() + + self.assertIn("Restored 0 source mtimes.", self.restore()) + + self.assertFalse(path.exists()) + + def test_missing_metadata_is_a_no_op(self): + path = self.make_source() + + self.assertIn("No source mtime metadata found", self.restore()) + + self.assertEqual(path.stat().st_mtime_ns, ORIGINAL_MTIME) + + def test_storage_matches_ruby_metadata(self): + self.make_source() + assets = self.make_assets() + self.set_mtime(assets / "Contents.json", ORIGINAL_MTIME) + self.set_mtime(assets, ORIGINAL_MTIME) + + self.store() + + legacy_metadata = json.loads( + (ACTION_ROOT / "tests/fixtures/ruby-mtimes.json").read_text() + ) + self.assertEqual(self.read_metadata(), legacy_metadata) + + def test_restores_existing_ruby_metadata(self): + source = self.make_source() + assets = self.make_assets() + legacy_metadata = (ACTION_ROOT / "tests/fixtures/ruby-mtimes.json").read_text() + (self.derived_data / "xcode-cache-mtime.json").write_text(legacy_metadata) + paths = (source, assets / "Contents.json", assets) + for path in paths: + self.set_mtime(path, CHECKOUT_MTIME) + + self.assertIn("Restored 3 source mtimes.", self.restore()) + + for path in paths: + self.assertEqual(path.stat().st_mtime_ns, ORIGINAL_MTIME) + + def test_default_globs_include_nested_sources_and_bundle_contents(self): + expected = { + "Root.swift", "Sources/Nested/Feature.swift", "Model.xcdatamodel", + "Model.xcdatamodel/contents", "Assets.xcassets", "Assets.xcassets/image.png", + } + for name in expected - {"Model.xcdatamodel", "Assets.xcassets"}: + self.make_file(name) + self.make_file("README.md") + self.make_file(".hidden.swift") + self.make_file(".hidden/Ignored.swift") + + self.store() + + self.assertEqual({entry["path"] for entry in self.read_metadata()}, expected) + + def test_custom_globs_expand_braces_and_deduplicate(self): + expected = { + "App/project.pbxproj", "App/Debug.xcconfig", ".config/settings.txt", + "ConfigFiles/settings.txt", + } + for name in expected: + self.make_file(name) + self.environment["MTIME_TARGETS"] = ( + " App/**/*.{pbxproj,{xcconfig,entitlements}}\n" + "App/**/*.pbxproj\n\n.config/*.txt\nConfig**/*.txt\n" + ) + + self.store() + + paths = [entry["path"] for entry in self.read_metadata()] + self.assertEqual(paths, sorted(expected)) + + def test_cache_directories_are_excluded_without_excluding_siblings(self): + source_packages = self.workspace / "SourcePackages" + for name in ( + "DerivedData/Build/Generated.swift", "SourcePackages/Checkout/Dependency.swift", + "DerivedDataOther/Source.swift", "SourcePackagesOther/Source.swift", + ): + self.make_file(name) + self.environment["MTIME_TARGETS"] = f"{self.derived_data}/**/*\n{source_packages}/**/*" + + self.store() + + self.assertEqual( + {entry["path"] for entry in self.read_metadata()}, + {"DerivedDataOther/Source.swift", "SourcePackagesOther/Source.swift"}, + ) + + def test_recursive_globs_do_not_follow_directory_symlinks(self): + self.make_file("Sources/Feature.swift") + (self.workspace / "Link").symlink_to(self.workspace / "Sources", target_is_directory=True) + (self.workspace / "Sources/Loop").symlink_to(self.workspace, target_is_directory=True) + (self.workspace / "Missing.swift").symlink_to(self.workspace / "deleted.swift") + + self.store() + + self.assertEqual([entry["path"] for entry in self.read_metadata()], ["Sources/Feature.swift"]) + + def test_workspace_path_can_differ_from_working_directory(self): + self.make_file("Project [1]/Sources/Feature.swift") + self.environment["GITHUB_WORKSPACE"] = str(self.workspace / "Project [1]") + + self.store() + + self.assertEqual([entry["path"] for entry in self.read_metadata()], ["Sources/Feature.swift"]) + + def test_missing_derived_data_is_a_no_op(self): + self.derived_data.rmdir() + + self.store() + + self.assertFalse(self.derived_data.exists()) + + +if __name__ == "__main__": + unittest.main() From 93aec967c29cc44fe99d21b8a48903b49c4c528e Mon Sep 17 00:00:00 2001 From: Hal <784999+hallee@users.noreply.github.com> Date: Sat, 31 Oct 2026 11:26:40 -0400 Subject: [PATCH 2/2] Remove added workflow and tests --- .github/workflows/test.yml | 19 --- README.md | 9 -- tests/fixtures/ruby-mtimes.json | 17 --- tests/test_mtimes.py | 234 -------------------------------- 4 files changed, 279 deletions(-) delete mode 100644 .github/workflows/test.yml delete mode 100644 tests/fixtures/ruby-mtimes.json delete mode 100644 tests/test_mtimes.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index acf2602..0000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,19 +0,0 @@ -name: Tests - -on: - pull_request: - push: - branches: [main] - -permissions: - contents: read - -jobs: - mtimes: - name: Source timestamps retain nanosecond precision - runs-on: macos-latest - timeout-minutes: 5 - steps: - - uses: actions/checkout@v6 - - name: Test timestamp storage and restoration - run: /usr/bin/python3 -B -m unittest discover -s tests -v diff --git a/README.md b/README.md index 8d2eec6..7b1a311 100644 --- a/README.md +++ b/README.md @@ -15,12 +15,3 @@ metadata remains compatible with caches written by the previous Ruby implementat and brace alternatives such as `App/**/*.{pbxproj,xcconfig}`. Recursive matching skips hidden directories and directory symlinks unless explicitly targeted. DerivedData and SwiftPM checkout directories are excluded from source metadata. - -Run the tests with: - -```sh -python3 -B -m unittest discover -s tests -v -``` - -The tests include a metadata fixture produced by the original Ruby storage script to -check compatibility in both directions. diff --git a/tests/fixtures/ruby-mtimes.json b/tests/fixtures/ruby-mtimes.json deleted file mode 100644 index 4d45fc2..0000000 --- a/tests/fixtures/ruby-mtimes.json +++ /dev/null @@ -1,17 +0,0 @@ -[ - { - "path": "Assets.xcassets", - "time": "1700000000.123456789", - "sha256": "9f3731c4a8f65cb3b730925d5ac1f11acf8617b6d7d3fad66e02ab15a79c3a43" - }, - { - "path": "Assets.xcassets/Contents.json", - "time": "1700000000.123456789", - "sha256": "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a" - }, - { - "path": "Source with spaces.swift", - "time": "1700000000.123456789", - "sha256": "ccdab916a2664126bae77709d63891fea8e32421e01f33fa9814de8abe9ba3e3" - } -] diff --git a/tests/test_mtimes.py b/tests/test_mtimes.py deleted file mode 100644 index 0405661..0000000 --- a/tests/test_mtimes.py +++ /dev/null @@ -1,234 +0,0 @@ -import json -import os -from pathlib import Path -import subprocess -import sys -import tempfile -import unittest - - -ACTION_ROOT = Path(__file__).resolve().parents[1] -ORIGINAL_MTIME = 1_700_000_000_123_456_789 -CHECKOUT_MTIME = 1_800_000_000_987_654_321 - - -class SourceMtimeTests(unittest.TestCase): - def setUp(self): - temporary_directory = tempfile.TemporaryDirectory() - self.addCleanup(temporary_directory.cleanup) - self.workspace = Path(temporary_directory.name) - self.derived_data = self.workspace / "DerivedData" - self.derived_data.mkdir() - self.environment = { - **os.environ, - "GITHUB_WORKSPACE": str(self.workspace), - "DERIVED_DATA_PATH": str(self.derived_data), - "SOURCE_PACKAGES_PATH": str(self.workspace / "SourcePackages"), - "MTIME_TARGETS": "", - } - - def make_source(self): - path = self.workspace / "Source with spaces.swift" - path.write_text("let value = 1\n") - self.set_mtime(path, ORIGINAL_MTIME) - return path - - def make_assets(self): - path = self.workspace / "Assets.xcassets" - path.mkdir() - (path / "Contents.json").write_text("{}") - self.set_mtime(path, ORIGINAL_MTIME) - return path - - def set_mtime(self, path, timestamp): - os.utime(path, ns=(timestamp, timestamp)) - self.assertEqual(path.stat().st_mtime_ns, timestamp) - - def run_script(self, command): - result = subprocess.run( - command, - cwd=self.workspace, - env=self.environment, - capture_output=True, - text=True, - ) - self.assertEqual(result.returncode, 0, result.stderr) - return result.stdout - - def store(self): - return self.run_script([sys.executable, str(ACTION_ROOT / "mtimes.py"), "store"]) - - def restore(self): - return self.run_script([sys.executable, str(ACTION_ROOT / "mtimes.py"), "restore"]) - - def read_metadata(self): - return json.loads((self.derived_data / "xcode-cache-mtime.json").read_text()) - - def make_file(self, name): - path = self.workspace / name - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text("example") - return path - - def test_unchanged_file_restores_exact_nanoseconds(self): - path = self.make_source() - self.store() - metadata = json.loads( - (self.derived_data / "xcode-cache-mtime.json").read_text() - ) - self.assertEqual(metadata[0]["path"], path.name) - self.assertEqual(metadata[0]["time"], "1700000000.123456789") - self.set_mtime(path, CHECKOUT_MTIME) - - self.assertIn("Restored 1 source mtimes.", self.restore()) - - self.assertEqual(path.stat().st_mtime_ns, ORIGINAL_MTIME) - - def test_unchanged_directory_restores_exact_nanoseconds(self): - path = self.make_assets() - self.store() - self.set_mtime(path, CHECKOUT_MTIME) - - self.restore() - - self.assertEqual(path.stat().st_mtime_ns, ORIGINAL_MTIME) - - def test_changed_file_keeps_current_timestamp(self): - path = self.make_source() - self.store() - path.write_text("let value = 2\n") - self.set_mtime(path, CHECKOUT_MTIME) - - self.assertIn("Restored 0 source mtimes.", self.restore()) - - self.assertEqual(path.stat().st_mtime_ns, CHECKOUT_MTIME) - - def test_changed_directory_keeps_current_timestamp(self): - path = self.make_assets() - self.store() - (path / "New.imageset").mkdir() - self.set_mtime(path, CHECKOUT_MTIME) - - self.restore() - - self.assertEqual(path.stat().st_mtime_ns, CHECKOUT_MTIME) - - def test_deleted_path_is_skipped(self): - path = self.make_source() - self.store() - path.unlink() - - self.assertIn("Restored 0 source mtimes.", self.restore()) - - self.assertFalse(path.exists()) - - def test_missing_metadata_is_a_no_op(self): - path = self.make_source() - - self.assertIn("No source mtime metadata found", self.restore()) - - self.assertEqual(path.stat().st_mtime_ns, ORIGINAL_MTIME) - - def test_storage_matches_ruby_metadata(self): - self.make_source() - assets = self.make_assets() - self.set_mtime(assets / "Contents.json", ORIGINAL_MTIME) - self.set_mtime(assets, ORIGINAL_MTIME) - - self.store() - - legacy_metadata = json.loads( - (ACTION_ROOT / "tests/fixtures/ruby-mtimes.json").read_text() - ) - self.assertEqual(self.read_metadata(), legacy_metadata) - - def test_restores_existing_ruby_metadata(self): - source = self.make_source() - assets = self.make_assets() - legacy_metadata = (ACTION_ROOT / "tests/fixtures/ruby-mtimes.json").read_text() - (self.derived_data / "xcode-cache-mtime.json").write_text(legacy_metadata) - paths = (source, assets / "Contents.json", assets) - for path in paths: - self.set_mtime(path, CHECKOUT_MTIME) - - self.assertIn("Restored 3 source mtimes.", self.restore()) - - for path in paths: - self.assertEqual(path.stat().st_mtime_ns, ORIGINAL_MTIME) - - def test_default_globs_include_nested_sources_and_bundle_contents(self): - expected = { - "Root.swift", "Sources/Nested/Feature.swift", "Model.xcdatamodel", - "Model.xcdatamodel/contents", "Assets.xcassets", "Assets.xcassets/image.png", - } - for name in expected - {"Model.xcdatamodel", "Assets.xcassets"}: - self.make_file(name) - self.make_file("README.md") - self.make_file(".hidden.swift") - self.make_file(".hidden/Ignored.swift") - - self.store() - - self.assertEqual({entry["path"] for entry in self.read_metadata()}, expected) - - def test_custom_globs_expand_braces_and_deduplicate(self): - expected = { - "App/project.pbxproj", "App/Debug.xcconfig", ".config/settings.txt", - "ConfigFiles/settings.txt", - } - for name in expected: - self.make_file(name) - self.environment["MTIME_TARGETS"] = ( - " App/**/*.{pbxproj,{xcconfig,entitlements}}\n" - "App/**/*.pbxproj\n\n.config/*.txt\nConfig**/*.txt\n" - ) - - self.store() - - paths = [entry["path"] for entry in self.read_metadata()] - self.assertEqual(paths, sorted(expected)) - - def test_cache_directories_are_excluded_without_excluding_siblings(self): - source_packages = self.workspace / "SourcePackages" - for name in ( - "DerivedData/Build/Generated.swift", "SourcePackages/Checkout/Dependency.swift", - "DerivedDataOther/Source.swift", "SourcePackagesOther/Source.swift", - ): - self.make_file(name) - self.environment["MTIME_TARGETS"] = f"{self.derived_data}/**/*\n{source_packages}/**/*" - - self.store() - - self.assertEqual( - {entry["path"] for entry in self.read_metadata()}, - {"DerivedDataOther/Source.swift", "SourcePackagesOther/Source.swift"}, - ) - - def test_recursive_globs_do_not_follow_directory_symlinks(self): - self.make_file("Sources/Feature.swift") - (self.workspace / "Link").symlink_to(self.workspace / "Sources", target_is_directory=True) - (self.workspace / "Sources/Loop").symlink_to(self.workspace, target_is_directory=True) - (self.workspace / "Missing.swift").symlink_to(self.workspace / "deleted.swift") - - self.store() - - self.assertEqual([entry["path"] for entry in self.read_metadata()], ["Sources/Feature.swift"]) - - def test_workspace_path_can_differ_from_working_directory(self): - self.make_file("Project [1]/Sources/Feature.swift") - self.environment["GITHUB_WORKSPACE"] = str(self.workspace / "Project [1]") - - self.store() - - self.assertEqual([entry["path"] for entry in self.read_metadata()], ["Sources/Feature.swift"]) - - def test_missing_derived_data_is_a_no_op(self): - self.derived_data.rmdir() - - self.store() - - self.assertFalse(self.derived_data.exists()) - - -if __name__ == "__main__": - unittest.main()