diff --git a/README.md b/README.md new file mode 100644 index 0000000..7b1a311 --- /dev/null +++ b/README.md @@ -0,0 +1,17 @@ +# 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. 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."