From 9f59c32b66423c00eb7bee310b94f0b31f38306e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 23 Nov 2025 21:29:22 +0000 Subject: [PATCH 01/10] Initial plan From 8e78912860dafa4548aded371f86a6194899e67d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 23 Nov 2025 21:38:19 +0000 Subject: [PATCH 02/10] Implement SourceControlCacheStore gem with full functionality Co-authored-by: nhorton <204146+nhorton@users.noreply.github.com> --- .gitignore | 2 + .rspec | 3 + Gemfile | 5 + Gemfile.lock | 63 ++++++ README.md | 109 +++++++++- Rakefile | 8 + .../cache/source_control_cache_store.rb | 131 ++++++++++++ lib/source_control_cache_store.rb | 3 + source_control_cache_store.gemspec | 25 +++ spec/source_control_cache_store_spec.rb | 201 ++++++++++++++++++ spec/spec_helper.rb | 17 ++ 11 files changed, 565 insertions(+), 2 deletions(-) create mode 100644 .rspec create mode 100644 Gemfile create mode 100644 Gemfile.lock create mode 100644 Rakefile create mode 100644 lib/active_support/cache/source_control_cache_store.rb create mode 100644 lib/source_control_cache_store.rb create mode 100644 source_control_cache_store.gemspec create mode 100644 spec/source_control_cache_store_spec.rb create mode 100644 spec/spec_helper.rb diff --git a/.gitignore b/.gitignore index e3200e0..8aea8fb 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ /test/tmp/ /test/version_tmp/ /tmp/ +.rspec_status # Used by dotenv library to load environment variables. # .env @@ -41,6 +42,7 @@ build-iPhoneSimulator/ ## Environment normalization: /.bundle/ /vendor/bundle +/vendor/ /lib/bundler/man/ # for a library or gem, you might want to ignore these files since the code is diff --git a/.rspec b/.rspec new file mode 100644 index 0000000..7a2cc1a --- /dev/null +++ b/.rspec @@ -0,0 +1,3 @@ +--require spec_helper +--format documentation +--color diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..be173b2 --- /dev/null +++ b/Gemfile @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +gemspec diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..1ead83c --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,63 @@ +PATH + remote: . + specs: + source_control_cache_store (0.1.0) + activesupport (>= 7.1.0) + +GEM + remote: https://rubygems.org/ + specs: + activesupport (8.1.1) + base64 + bigdecimal + concurrent-ruby (~> 1.0, >= 1.3.1) + connection_pool (>= 2.2.5) + drb + i18n (>= 1.6, < 2) + json + logger (>= 1.4.2) + minitest (>= 5.1) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) + uri (>= 0.13.1) + base64 (0.3.0) + bigdecimal (3.3.1) + concurrent-ruby (1.3.5) + connection_pool (2.5.4) + diff-lcs (1.6.2) + drb (2.2.3) + i18n (1.14.7) + concurrent-ruby (~> 1.0) + json (2.16.0) + logger (1.7.0) + minitest (5.26.2) + rake (13.3.1) + rspec (3.13.2) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) + rspec-core (3.13.6) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.7) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-support (3.13.6) + securerandom (0.4.1) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + uri (1.1.1) + +PLATFORMS + ruby + x86_64-linux-gnu + +DEPENDENCIES + rake (~> 13.0) + rspec (~> 3.0) + source_control_cache_store! + +BUNDLED WITH + 2.7.2 diff --git a/README.md b/README.md index 3c93f7f..04b12d5 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,107 @@ -# source_control_cache_store -Rails cache store appropriate for storing the results in source control +# SourceControlCacheStore + +Rails cache store appropriate for storing the results in source control. + +## Overview + +`SourceControlCacheStore` is a Rails cache store (compatible with Rails 7.1 and higher) that stores cache entries as files suitable for version control. Each cache entry is stored as two files: + +- `#{hash}.key` - the full key that was used +- `#{hash}.value` - the serialized value that was stored + +This cache store is designed to be committed to version control, making it ideal for caching build artifacts, compiled assets, or other deterministic results that should be shared across different environments. + +## Features + +- **File-based storage**: Each cache entry is stored as separate `.key` and `.value` files +- **Hashed filenames**: Uses SHA256 hashing for keys to create consistent, filesystem-safe filenames +- **No expiration**: Cache entries do NOT honor expiration parameters - they persist until explicitly deleted +- **Rails 7.1+ compatible**: Implements the ActiveSupport::Cache::Store interface + +## Installation + +Add this line to your application's Gemfile: + +```ruby +gem 'source_control_cache_store' +``` + +And then execute: + +```bash +$ bundle install +``` + +## Usage + +Configure your Rails application to use the SourceControlCacheStore: + +```ruby +# config/application.rb or config/environments/*.rb +config.cache_store = :source_control_cache_store, cache_path: Rails.root.join("tmp", "cache") +``` + +Or create an instance directly: + +```ruby +require 'source_control_cache_store' + +cache = ActiveSupport::Cache::SourceControlCacheStore.new( + cache_path: "/path/to/cache/directory" +) + +# Write to cache +cache.write("my_key", "my_value") + +# Read from cache +value = cache.read("my_key") # => "my_value" + +# Fetch with block (returns cached value or executes block and caches result) +result = cache.fetch("computed_key") do + expensive_computation() +end + +# Delete a cache entry +cache.delete("my_key") + +# Clear all cache entries +cache.clear +``` + +## Key Features + +### Hashed Keys + +Keys are hashed using SHA256 to create filesystem-safe filenames. The original key is preserved in the `.key` file, while the hash is used for the filename: + +```ruby +cache.write("user:123:profile", { name: "John" }) +# Creates: +# - abc123def456.key (contains "user:123:profile") +# - abc123def456.value (contains serialized hash) +``` + +### No Expiration + +Unlike other cache stores, `SourceControlCacheStore` intentionally ignores expiration parameters: + +```ruby +# The expires_in option is ignored +cache.write("key", "value", expires_in: 1.hour) +cache.read("key") # => "value" (will never expire) +``` + +This behavior is by design, as the cache is intended for version-controlled content that should be explicitly managed rather than automatically expired. + +## Development + +After checking out the repo, run `bundle install` to install dependencies. Then, run `bundle exec rspec` to run the tests. + +## Contributing + +Bug reports and pull requests are welcome on GitHub at https://github.com/Unsupervisedcom/source_control_cache_store. + +## License + +The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). + diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..b6ae734 --- /dev/null +++ b/Rakefile @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +require "bundler/gem_tasks" +require "rspec/core/rake_task" + +RSpec::Core::RakeTask.new(:spec) + +task default: :spec diff --git a/lib/active_support/cache/source_control_cache_store.rb b/lib/active_support/cache/source_control_cache_store.rb new file mode 100644 index 0000000..63ddcc0 --- /dev/null +++ b/lib/active_support/cache/source_control_cache_store.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +require "active_support" +require "active_support/cache" +require "active_support/notifications" +require "active_support/core_ext/object/json" +require "active_support/core_ext/digest" +require "digest" +require "fileutils" + +module ActiveSupport + module Cache + # A cache store implementation that stores cache entries as files + # suitable for version control. Each cache entry is stored as two files: + # - #{hash}.key: the full cache key + # - #{hash}.value: the serialized cache value + # + # This store does NOT honor expiration parameters. + # + # Example usage: + # config.cache_store = :source_control_cache_store, cache_path: "tmp/cache" + class SourceControlCacheStore < Store + attr_reader :cache_path + + # Initialize a new SourceControlCacheStore + # + # @param cache_path [String] The directory where cache files will be stored + # @param options [Hash] Additional options (currently unused) + def initialize(cache_path:, **options) + super(options) + @cache_path = cache_path + FileUtils.mkdir_p(@cache_path) + end + + # Clear all cache entries + def clear(options = nil) + if File.directory?(@cache_path) + Dir.glob(File.join(@cache_path, "*")).each do |file| + File.delete(file) if File.file?(file) + end + end + true + end + + private + + # Read an entry from the cache + # + # @param key [String] The cache key + # @param options [Hash] Options (unused) + # @return [Object, nil] The cached value or nil if not found + def read_entry(key, **options) + hash = hash_key(key) + value_file = value_path(hash) + + return nil unless File.exist?(value_file) + + value = File.read(value_file) + entry = deserialize_entry(value) + + # Ignore expiration by creating a new entry without expiration + return entry unless entry.is_a?(ActiveSupport::Cache::Entry) + + # Create a new entry that never expires + ActiveSupport::Cache::Entry.new(entry.value, expires_in: nil) + rescue => e + # If we can't read or deserialize, treat as cache miss + nil + end + + # Write an entry to the cache + # + # @param key [String] The cache key + # @param entry [ActiveSupport::Cache::Entry] The cache entry + # @param options [Hash] Options (expiration is ignored) + # @return [Boolean] Always returns true + def write_entry(key, entry, **options) + hash = hash_key(key) + + # Write the key file + File.write(key_path(hash), key) + + # Write the value file + File.write(value_path(hash), serialize_entry(entry, **options)) + + true + end + + # Delete an entry from the cache + # + # @param key [String] The cache key + # @param options [Hash] Options (unused) + # @return [Boolean] Returns true if the entry was deleted + def delete_entry(key, **options) + hash = hash_key(key) + key_file = key_path(hash) + value_file = value_path(hash) + + deleted = false + deleted = File.delete(key_file) if File.exist?(key_file) + deleted = File.delete(value_file) if File.exist?(value_file) + + deleted + end + + # Generate a hash for the given key + # + # @param key [String] The cache key + # @return [String] The SHA256 hash of the key + def hash_key(key) + ::Digest::SHA256.hexdigest(key.to_s) + end + + # Get the path for the key file + # + # @param hash [String] The hash of the key + # @return [String] The full path to the key file + def key_path(hash) + File.join(@cache_path, "#{hash}.key") + end + + # Get the path for the value file + # + # @param hash [String] The hash of the key + # @return [String] The full path to the value file + def value_path(hash) + File.join(@cache_path, "#{hash}.value") + end + end + end +end diff --git a/lib/source_control_cache_store.rb b/lib/source_control_cache_store.rb new file mode 100644 index 0000000..be85a4b --- /dev/null +++ b/lib/source_control_cache_store.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +require "active_support/cache/source_control_cache_store" diff --git a/source_control_cache_store.gemspec b/source_control_cache_store.gemspec new file mode 100644 index 0000000..d023e8d --- /dev/null +++ b/source_control_cache_store.gemspec @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +Gem::Specification.new do |spec| + spec.name = "source_control_cache_store" + spec.version = "0.1.0" + spec.authors = ["Unsupervised.com"] + spec.email = ["hello@unsupervised.com"] + + spec.summary = "Rails cache store appropriate for storing the results in source control" + spec.description = "A Rails cache store that stores cache entries as files suitable for version control" + spec.homepage = "https://github.com/Unsupervisedcom/source_control_cache_store" + spec.license = "MIT" + spec.required_ruby_version = ">= 2.7.0" + + spec.metadata["homepage_uri"] = spec.homepage + spec.metadata["source_code_uri"] = spec.homepage + + spec.files = Dir["lib/**/*", "LICENSE", "README.md"] + spec.require_paths = ["lib"] + + spec.add_dependency "activesupport", ">= 7.1.0" + + spec.add_development_dependency "rake", "~> 13.0" + spec.add_development_dependency "rspec", "~> 3.0" +end diff --git a/spec/source_control_cache_store_spec.rb b/spec/source_control_cache_store_spec.rb new file mode 100644 index 0000000..7b8f1ed --- /dev/null +++ b/spec/source_control_cache_store_spec.rb @@ -0,0 +1,201 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe ActiveSupport::Cache::SourceControlCacheStore do + let(:cache_path) { Dir.mktmpdir } + let(:store) { described_class.new(cache_path: cache_path) } + + after do + FileUtils.rm_rf(cache_path) if File.exist?(cache_path) + end + + describe "#initialize" do + it "creates the cache directory if it doesn't exist" do + new_path = File.join(cache_path, "new_cache") + expect(File.directory?(new_path)).to be false + + described_class.new(cache_path: new_path) + + expect(File.directory?(new_path)).to be true + end + + it "stores the cache path" do + expect(store.cache_path).to eq(cache_path) + end + end + + describe "#write and #read" do + it "writes and reads a simple value" do + store.write("test_key", "test_value") + expect(store.read("test_key")).to eq("test_value") + end + + it "writes and reads a complex object" do + complex_object = { name: "John", age: 30, hobbies: ["reading", "coding"] } + store.write("complex", complex_object) + expect(store.read("complex")).to eq(complex_object) + end + + it "returns nil for non-existent keys" do + expect(store.read("non_existent")).to be_nil + end + + it "creates both .key and .value files" do + store.write("my_key", "my_value") + + # Calculate the expected hash + hash = Digest::SHA256.hexdigest("my_key") + key_file = File.join(cache_path, "#{hash}.key") + value_file = File.join(cache_path, "#{hash}.value") + + expect(File.exist?(key_file)).to be true + expect(File.exist?(value_file)).to be true + end + + it "stores the original key in the .key file" do + original_key = "my_special_key" + store.write(original_key, "value") + + hash = Digest::SHA256.hexdigest(original_key) + key_file = File.join(cache_path, "#{hash}.key") + + expect(File.read(key_file)).to eq(original_key) + end + + it "overwrites existing values" do + store.write("key", "value1") + store.write("key", "value2") + expect(store.read("key")).to eq("value2") + end + end + + describe "#delete" do + it "deletes an existing entry" do + store.write("key_to_delete", "value") + expect(store.read("key_to_delete")).to eq("value") + + result = store.delete("key_to_delete") + expect(result).to be_truthy + expect(store.read("key_to_delete")).to be_nil + end + + it "removes both .key and .value files" do + store.write("key", "value") + hash = Digest::SHA256.hexdigest("key") + key_file = File.join(cache_path, "#{hash}.key") + value_file = File.join(cache_path, "#{hash}.value") + + expect(File.exist?(key_file)).to be true + expect(File.exist?(value_file)).to be true + + store.delete("key") + + expect(File.exist?(key_file)).to be false + expect(File.exist?(value_file)).to be false + end + + it "returns false for non-existent keys" do + result = store.delete("non_existent") + expect(result).to be_falsey + end + end + + describe "#clear" do + it "removes all cache files" do + store.write("key1", "value1") + store.write("key2", "value2") + store.write("key3", "value3") + + expect(Dir.glob(File.join(cache_path, "*")).length).to be > 0 + + store.clear + + expect(Dir.glob(File.join(cache_path, "*")).length).to eq(0) + end + + it "returns true" do + expect(store.clear).to be true + end + end + + describe "#fetch" do + it "returns cached value if present" do + store.write("fetch_key", "cached_value") + + result = store.fetch("fetch_key") { "block_value" } + expect(result).to eq("cached_value") + end + + it "executes block and caches result if not present" do + result = store.fetch("new_key") { "computed_value" } + + expect(result).to eq("computed_value") + expect(store.read("new_key")).to eq("computed_value") + end + end + + describe "expiration (should be ignored)" do + it "ignores expires_in option" do + # Write with expiration - it should be ignored + store.write("expiring_key", "value", expires_in: 0.001) + + # Sleep a bit to ensure expiration would have happened + sleep(0.01) + + # Value should still be present since expiration is ignored + expect(store.read("expiring_key")).to eq("value") + end + end + + describe "key hashing" do + it "uses SHA256 for hashing keys" do + key = "test_key" + expected_hash = Digest::SHA256.hexdigest(key) + + store.write(key, "value") + + key_file = File.join(cache_path, "#{expected_hash}.key") + expect(File.exist?(key_file)).to be true + end + + it "handles keys with special characters" do + key = "key/with:special*chars?" + store.write(key, "value") + + expect(store.read(key)).to eq("value") + end + + it "handles very long keys" do + key = "a" * 1000 + store.write(key, "value") + + expect(store.read(key)).to eq("value") + end + end + + describe "edge cases" do + it "handles nil values" do + store.write("nil_key", nil) + expect(store.read("nil_key")).to be_nil + end + + it "handles empty string values" do + store.write("empty_key", "") + expect(store.read("empty_key")).to eq("") + end + + it "handles numeric values" do + store.write("number", 42) + expect(store.read("number")).to eq(42) + end + + it "handles boolean values" do + store.write("true_key", true) + store.write("false_key", false) + + expect(store.read("true_key")).to eq(true) + expect(store.read("false_key")).to eq(false) + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..7bd1024 --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "source_control_cache_store" +require "tmpdir" +require "fileutils" + +RSpec.configure do |config| + # Enable flags like --only-failures and --next-failure + config.example_status_persistence_file_path = ".rspec_status" + + # Disable RSpec exposing methods globally on `Module` and `main` + config.disable_monkey_patching! + + config.expect_with :rspec do |c| + c.syntax = :expect + end +end From 8e397289a74ad0b3740ca8b5b232ddec5d516c96 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 23 Nov 2025 21:40:27 +0000 Subject: [PATCH 03/10] Fix delete_entry to correctly track deletion status Co-authored-by: nhorton <204146+nhorton@users.noreply.github.com> --- lib/active_support/cache/source_control_cache_store.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/active_support/cache/source_control_cache_store.rb b/lib/active_support/cache/source_control_cache_store.rb index 63ddcc0..32a13f0 100644 --- a/lib/active_support/cache/source_control_cache_store.rb +++ b/lib/active_support/cache/source_control_cache_store.rb @@ -90,15 +90,15 @@ def write_entry(key, entry, **options) # # @param key [String] The cache key # @param options [Hash] Options (unused) - # @return [Boolean] Returns true if the entry was deleted + # @return [Boolean] Returns true if any file was deleted def delete_entry(key, **options) hash = hash_key(key) key_file = key_path(hash) value_file = value_path(hash) deleted = false - deleted = File.delete(key_file) if File.exist?(key_file) - deleted = File.delete(value_file) if File.exist?(value_file) + deleted = true if File.exist?(key_file) && File.delete(key_file) + deleted = true if File.exist?(value_file) && File.delete(value_file) deleted end From 0bb3579d10663b7a29ca5cce7112646095586a77 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 23 Nov 2025 21:42:18 +0000 Subject: [PATCH 04/10] Add error handling for write and delete operations Co-authored-by: nhorton <204146+nhorton@users.noreply.github.com> --- .../cache/source_control_cache_store.rb | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/lib/active_support/cache/source_control_cache_store.rb b/lib/active_support/cache/source_control_cache_store.rb index 32a13f0..9c9e464 100644 --- a/lib/active_support/cache/source_control_cache_store.rb +++ b/lib/active_support/cache/source_control_cache_store.rb @@ -73,7 +73,7 @@ def read_entry(key, **options) # @param key [String] The cache key # @param entry [ActiveSupport::Cache::Entry] The cache entry # @param options [Hash] Options (expiration is ignored) - # @return [Boolean] Always returns true + # @return [Boolean] Returns true on success, false on failure def write_entry(key, entry, **options) hash = hash_key(key) @@ -84,6 +84,9 @@ def write_entry(key, entry, **options) File.write(value_path(hash), serialize_entry(entry, **options)) true + rescue => e + # Return false if write fails (permissions, disk space, etc.) + false end # Delete an entry from the cache @@ -97,8 +100,18 @@ def delete_entry(key, **options) value_file = value_path(hash) deleted = false - deleted = true if File.exist?(key_file) && File.delete(key_file) - deleted = true if File.exist?(value_file) && File.delete(value_file) + + begin + deleted = true if File.exist?(key_file) && File.delete(key_file) + rescue => e + # Ignore errors, continue trying to delete value file + end + + begin + deleted = true if File.exist?(value_file) && File.delete(value_file) + rescue => e + # Ignore errors + end deleted end From 6eb668edd2fa272fc9eaf77f563c565a92f07e52 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 23 Nov 2025 21:43:40 +0000 Subject: [PATCH 05/10] Clean up unused exception variables and unnecessary require Co-authored-by: nhorton <204146+nhorton@users.noreply.github.com> --- lib/active_support/cache/source_control_cache_store.rb | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/lib/active_support/cache/source_control_cache_store.rb b/lib/active_support/cache/source_control_cache_store.rb index 9c9e464..bb00ab2 100644 --- a/lib/active_support/cache/source_control_cache_store.rb +++ b/lib/active_support/cache/source_control_cache_store.rb @@ -4,7 +4,6 @@ require "active_support/cache" require "active_support/notifications" require "active_support/core_ext/object/json" -require "active_support/core_ext/digest" require "digest" require "fileutils" @@ -63,7 +62,7 @@ def read_entry(key, **options) # Create a new entry that never expires ActiveSupport::Cache::Entry.new(entry.value, expires_in: nil) - rescue => e + rescue # If we can't read or deserialize, treat as cache miss nil end @@ -84,7 +83,7 @@ def write_entry(key, entry, **options) File.write(value_path(hash), serialize_entry(entry, **options)) true - rescue => e + rescue # Return false if write fails (permissions, disk space, etc.) false end @@ -103,13 +102,13 @@ def delete_entry(key, **options) begin deleted = true if File.exist?(key_file) && File.delete(key_file) - rescue => e + rescue # Ignore errors, continue trying to delete value file end begin deleted = true if File.exist?(value_file) && File.delete(value_file) - rescue => e + rescue # Ignore errors end From aee587ac1bfaa9ffd71c9c46ed3451145c817a74 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 23 Nov 2025 21:44:42 +0000 Subject: [PATCH 06/10] Add GitHub Actions CI workflow for running tests Co-authored-by: nhorton <204146+nhorton@users.noreply.github.com> --- .github/workflows/ci.yml | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..148d07a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: CI + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + ruby-version: ['3.0', '3.1', '3.2', '3.3'] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Ruby ${{ matrix.ruby-version }} + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby-version }} + bundler-cache: true + + - name: Install dependencies + run: bundle install + + - name: Run tests + run: bundle exec rspec From 3f810771e225480eeaa91c00d2914b3ceb416855 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 23 Nov 2025 21:46:08 +0000 Subject: [PATCH 07/10] Use StandardError in rescue clauses instead of bare rescue Co-authored-by: nhorton <204146+nhorton@users.noreply.github.com> --- lib/active_support/cache/source_control_cache_store.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/active_support/cache/source_control_cache_store.rb b/lib/active_support/cache/source_control_cache_store.rb index bb00ab2..62421d1 100644 --- a/lib/active_support/cache/source_control_cache_store.rb +++ b/lib/active_support/cache/source_control_cache_store.rb @@ -62,7 +62,7 @@ def read_entry(key, **options) # Create a new entry that never expires ActiveSupport::Cache::Entry.new(entry.value, expires_in: nil) - rescue + rescue StandardError # If we can't read or deserialize, treat as cache miss nil end @@ -83,7 +83,7 @@ def write_entry(key, entry, **options) File.write(value_path(hash), serialize_entry(entry, **options)) true - rescue + rescue StandardError # Return false if write fails (permissions, disk space, etc.) false end @@ -102,13 +102,13 @@ def delete_entry(key, **options) begin deleted = true if File.exist?(key_file) && File.delete(key_file) - rescue + rescue StandardError # Ignore errors, continue trying to delete value file end begin deleted = true if File.exist?(value_file) && File.delete(value_file) - rescue + rescue StandardError # Ignore errors end From 6ccb6313ea7f13c2dbdba7ff63891eadf669fa57 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 23 Nov 2025 21:48:59 +0000 Subject: [PATCH 08/10] Add explicit permissions to GitHub Actions workflow for security Co-authored-by: nhorton <204146+nhorton@users.noreply.github.com> --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 148d07a..3a8a27e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,8 @@ on: jobs: test: runs-on: ubuntu-latest + permissions: + contents: read strategy: matrix: ruby-version: ['3.0', '3.1', '3.2', '3.3'] From 4e46d8dda9da4a124c1ea70eadaae1924e324edb Mon Sep 17 00:00:00 2001 From: Noah Horton Date: Mon, 24 Nov 2025 10:24:15 -0700 Subject: [PATCH 09/10] Update source_control_cache_store.gemspec --- source_control_cache_store.gemspec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source_control_cache_store.gemspec b/source_control_cache_store.gemspec index d023e8d..7b5ee41 100644 --- a/source_control_cache_store.gemspec +++ b/source_control_cache_store.gemspec @@ -4,7 +4,7 @@ Gem::Specification.new do |spec| spec.name = "source_control_cache_store" spec.version = "0.1.0" spec.authors = ["Unsupervised.com"] - spec.email = ["hello@unsupervised.com"] + spec.email = ["noah@unsupervised.com"] spec.summary = "Rails cache store appropriate for storing the results in source control" spec.description = "A Rails cache store that stores cache entries as files suitable for version control" From 71930ca106c099b49372c256cd7b394f84b32383 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Nov 2025 17:28:46 +0000 Subject: [PATCH 10/10] Require Ruby 3.2+ and update CI to test only 3.2 and 3.3 Co-authored-by: nhorton <204146+nhorton@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- source_control_cache_store.gemspec | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3a8a27e..a2d918a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: contents: read strategy: matrix: - ruby-version: ['3.0', '3.1', '3.2', '3.3'] + ruby-version: ['3.2', '3.3'] steps: - uses: actions/checkout@v4 diff --git a/source_control_cache_store.gemspec b/source_control_cache_store.gemspec index 7b5ee41..1d38ec0 100644 --- a/source_control_cache_store.gemspec +++ b/source_control_cache_store.gemspec @@ -10,7 +10,7 @@ Gem::Specification.new do |spec| spec.description = "A Rails cache store that stores cache entries as files suitable for version control" spec.homepage = "https://github.com/Unsupervisedcom/source_control_cache_store" spec.license = "MIT" - spec.required_ruby_version = ">= 2.7.0" + spec.required_ruby_version = ">= 3.2.0" spec.metadata["homepage_uri"] = spec.homepage spec.metadata["source_code_uri"] = spec.homepage