From fa0901cfa2cd486b0504bb70672177456a69a1d6 Mon Sep 17 00:00:00 2001 From: Justin Kenyon Date: Fri, 13 Feb 2026 15:40:06 -0500 Subject: [PATCH 1/2] Cache API verification results across CI runs Add a JSON-based cache for GitHub API results (repo existence/rename checks and user/org lookups) that persists across CI runs using GitHub Actions cache. Each cached entry has a 24-hour TTL. On subsequent runs, items verified within the last 24 hours are loaded from cache, avoiding redundant API calls. This is safe because the collections-renames workflow runs hourly to catch any renames. The cache is scoped per matrix test type (collections vs all) and uses the run ID as the cache key with restore-keys fallback to pick up the most recent cache. --- .github/workflows/test.yml | 11 ++++++ .gitignore | 1 + test/test_helper.rb | 76 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 042e62c9ec5..057076b4494 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -47,6 +47,17 @@ jobs: with: bundler-cache: true + - name: Restore API cache + if: | + (matrix.test_type == 'collections' && steps.collections.outputs.changed) || + (matrix.test_type == 'all' && steps.all.outputs.changed) + uses: actions/cache@v4 + with: + path: .api-cache.json + key: api-cache-${{ matrix.test_type }}-${{ github.run_id }} + restore-keys: | + api-cache-${{ matrix.test_type }}- + - name: Build and test with Rake if: | (matrix.test_type == 'topics' && steps.topics.outputs.changed) || diff --git a/.gitignore b/.gitignore index a4c640a221d..71b5d53b1ee 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ vendor .bundle .idea .tool-versions +.api-cache.json diff --git a/test/test_helper.rb b/test/test_helper.rb index c878c64d302..f965bc11a89 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -270,6 +270,79 @@ def add_message(type, file, line_number, message) client.messages << "::#{type} file=#{file},line=#{line_number}::#{message}" end +CACHE_FILE = File.expand_path("../.api-cache.json", File.dirname(__FILE__)) +CACHE_TTL_SECONDS = 24 * 60 * 60 # 24 hours + +def self.load_api_cache! + return unless File.exist?(CACHE_FILE) + + data = JSON.parse(File.read(CACHE_FILE)) + now = Time.now.to_i + ttl = CACHE_TTL_SECONDS + + if data["repos"] + data["repos"].each do |key, entry| + next if now - entry["cached_at"].to_i > ttl + + result = entry["value"] + # Reconstruct a minimal object that responds to .full_name + cached = if result.nil? + nil + else + Struct.new(:full_name).new(result["full_name"]) + end + NewOctokit.class_variable_get(:@@repos)[key] = cached + end + end + + if data["users"] + data["users"].each do |key, entry| + next if now - entry["cached_at"].to_i > ttl + + result = entry["value"] + cached = if result.nil? + nil + else + Struct.new(:login).new(result["login"]) + end + NewOctokit.class_variable_get(:@@users)[key] = cached + end + end +rescue JSON::ParserError, StandardError => e + warn "Failed to load API cache: #{e.message}" +end + +def self.save_api_cache! + now = Time.now.to_i + repos_data = {} + users_data = {} + + NewOctokit.class_variable_get(:@@repos).each do |key, value| + next if key == :skip_requests + + repos_data[key.to_s] = { + "cached_at" => now, + "value" => value.nil? ? nil : { "full_name" => value.respond_to?(:full_name) ? value.full_name : value.to_s }, + } + end + + NewOctokit.class_variable_get(:@@users).each do |key, value| + next if key == :skip_requests + + users_data[key.to_s] = { + "cached_at" => now, + "value" => value.nil? ? nil : { "login" => value.respond_to?(:login) ? value.login : value.to_s }, + } + end + + File.write(CACHE_FILE, JSON.pretty_generate({ "repos" => repos_data, "users" => users_data })) +rescue StandardError => e + warn "Failed to save API cache: #{e.message}" +end + +# Load cached API results at startup +load_api_cache! + Minitest.after_run do warn "Repo checks were rate limited during this CI run" if NewOctokit.repos_skipped? warn "User checks were rate limited during this CI run" if NewOctokit.users_skipped? @@ -279,4 +352,7 @@ def add_message(type, file, line_number, message) NewOctokit.messages.each do |message| puts message end + + # Persist cache for next CI run + save_api_cache! end From ba1b294cab5a304a7ff1486492feb8a800c9a9aa Mon Sep 17 00:00:00 2001 From: Justin Kenyon Date: Fri, 13 Feb 2026 16:37:45 -0500 Subject: [PATCH 2/2] Address review feedback on cache persistence --- test/test_helper.rb | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/test/test_helper.rb b/test/test_helper.rb index f965bc11a89..53b2984e6f7 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -270,10 +270,10 @@ def add_message(type, file, line_number, message) client.messages << "::#{type} file=#{file},line=#{line_number}::#{message}" end -CACHE_FILE = File.expand_path("../.api-cache.json", File.dirname(__FILE__)) +CACHE_FILE = File.expand_path("../.api-cache.json", __dir__) CACHE_TTL_SECONDS = 24 * 60 * 60 # 24 hours -def self.load_api_cache! +def load_api_cache! return unless File.exist?(CACHE_FILE) data = JSON.parse(File.read(CACHE_FILE)) @@ -282,13 +282,17 @@ def self.load_api_cache! if data["repos"] data["repos"].each do |key, entry| - next if now - entry["cached_at"].to_i > ttl + cached_at = entry["cached_at"] + next unless cached_at + next if now - cached_at.to_i > ttl result = entry["value"] # Reconstruct a minimal object that responds to .full_name cached = if result.nil? nil else + next unless result["full_name"] + Struct.new(:full_name).new(result["full_name"]) end NewOctokit.class_variable_get(:@@repos)[key] = cached @@ -297,12 +301,16 @@ def self.load_api_cache! if data["users"] data["users"].each do |key, entry| - next if now - entry["cached_at"].to_i > ttl + cached_at = entry["cached_at"] + next unless cached_at + next if now - cached_at.to_i > ttl result = entry["value"] cached = if result.nil? nil else + next unless result["login"] + Struct.new(:login).new(result["login"]) end NewOctokit.class_variable_get(:@@users)[key] = cached @@ -312,13 +320,14 @@ def self.load_api_cache! warn "Failed to load API cache: #{e.message}" end -def self.save_api_cache! +def save_api_cache! now = Time.now.to_i repos_data = {} users_data = {} NewOctokit.class_variable_get(:@@repos).each do |key, value| next if key == :skip_requests + next if value == true repos_data[key.to_s] = { "cached_at" => now, @@ -328,6 +337,7 @@ def self.save_api_cache! NewOctokit.class_variable_get(:@@users).each do |key, value| next if key == :skip_requests + next if value == true users_data[key.to_s] = { "cached_at" => now,