Files
Facundo MenzellaandRCGitBot 3cc82762f7 Add workflow prewarming performance benchmark (#8206)
* Add workflow prewarming performance benchmark

* Add performance baseline recording and comparison

* Move performance baseline recording and checks into XCTest

* Declare performance tolerance in tests and exclude legacy project checks

* Simplify memory benchmark and run it on each PR

* Use published workflow for performance benchmark

* Add PR job to record and push performance baselines

* Update performance baselines

* Run baseline automation tests on PRs and use Fastlane with Xcode 27

* Update performance baselines

* Require performance benchmark in PR merge gate

---------

Co-authored-by: RCGitBot <dev+RCGitBot@revenuecat.com>
2026-11-05 14:16:45 +00:00

221 lines
7.3 KiB
Ruby

danger.import_dangerfile(github: 'RevenueCat/Dangerfile')
require 'pathname'
require 'set'
EXCLUDED_SWIFT_PATH_PREFIXES = [
'Tests/APITesters/',
'Tests/PerformanceTests/',
# Tuist-only: these targets exist solely in Projects/PaywallFixtures/Project.swift and are
# deliberately absent from RevenueCat.xcodeproj.
'Tests/TestingApps/PaywallFixtures/',
'Tests/TestingApps/PaywallFixturesUITests/',
# SPM / Tuist-only: Release-stripped integration tests are not in RevenueCat.xcodeproj.
'Tests/PurchasesUIServiceIntegrationTests/',
'Tests/TestingApps/PaywallsTester/'
].freeze
COMMENT_MARKER = "<!-- purchases-ios-danger -->"
PROD_LINES_LIMIT = 300
SKIP_SIZE_LABEL = "skip-pr-lines-changed-check"
def normalize_path(path)
Pathname(path).cleanpath.to_s.sub(%r{\A\./}, '')
end
def relevant_swift_file?(path)
path = normalize_path(path)
return false unless path.end_with?('.swift')
return false if EXCLUDED_SWIFT_PATH_PREFIXES.any? { |prefix| path.start_with?(prefix) }
in_project_sources = path.start_with?('Sources/') || path.start_with?('RevenueCatUI/')
in_tests = path.start_with?('Tests/')
in_project_sources || in_tests
end
def production_swift_file?(path)
path = normalize_path(path)
return false unless path.end_with?('.swift')
return false if path.start_with?('Sources/Generated/')
path.start_with?('Sources/') || path.start_with?('RevenueCatUI/')
end
def project_swift_file_paths(project_file)
require 'xcodeproj'
project = Xcodeproj::Project.open(project_file)
root = Pathname.pwd
project.files
.map { |file| file.real_path if file.path }
.compact
.select { |path| path.extname == '.swift' }
.map { |path| normalize_path(path.cleanpath.relative_path_from(root).to_s) }
.to_set
rescue LoadError
nil
rescue StandardError
nil
end
def xcodeproj_messages
failures = []
warnings = []
project_file = 'RevenueCat.xcodeproj'
unless File.exist?(project_file)
warnings << "RevenueCat.xcodeproj not found"
return [failures, warnings]
end
project_swift_files = project_swift_file_paths(project_file)
return [failures, warnings] if project_swift_files.nil?
added_swift_files = git.added_files
.select { |file| relevant_swift_file?(file) }
.map { |file| normalize_path(file) }
deleted_swift_files = git.deleted_files
.select { |file| relevant_swift_file?(file) }
.map { |file| normalize_path(file) }
missing_files = added_swift_files.reject { |file| project_swift_files.include?(file) }
lingering_references = deleted_swift_files.select { |file| project_swift_files.include?(file) }
return [failures, warnings] if missing_files.empty? && lingering_references.empty?
message = "**`RevenueCat.xcodeproj` is out of sync.**\n"
unless missing_files.empty?
message += "\nThe following Swift files were added but are missing from `RevenueCat.xcodeproj`:\n"
missing_files.each { |file| message += "• `#{file}`\n" }
end
unless lingering_references.empty?
message += "\nThe following Swift files were deleted but still referenced in `RevenueCat.xcodeproj`:\n"
lingering_references.each { |file| message += "• `#{file}`\n" }
end
message += "\nTo fix: open `RevenueCat.xcodeproj` in Xcode, add/remove the files above in the appropriate target. "
message += "Check where similar files in the same directory are assigned if you're unsure which target to use."
failures << message
[failures, warnings]
end
def public_enum_messages
swift_files = (git.added_files + git.modified_files)
.select { |file| file.start_with?('Sources/') || file.start_with?('RevenueCatUI/') }
.select { |file| file.end_with?('.swift') }
.select { |file| File.exist?(file) }
public_enum_pattern = /^\+\s*public\s+enum\s+/
spi_public_enum_pattern = /@_spi\([^)]*\)\s*public\s+enum/
files_with_public_enums = []
swift_files.each do |file|
diff = git.diff_for_file(file)
next unless diff
diff.patch.each_line do |line|
if line.match?(public_enum_pattern) && !line.match?(spi_public_enum_pattern)
files_with_public_enums << file
break
end
end
end
return [[], []] if files_with_public_enums.empty?
message = "Public enums should not be added. Consider using a struct with static properties or an @objc enum instead.\n\n"
message += "The following files contain new public enums:\n"
files_with_public_enums.each { |file| message += "• #{file}\n" }
[[], [message]]
end
# Fail PRs that change too many lines of production Swift code.
# Large PRs are hard to review well, so we cap the churn (insertions + deletions)
# across real .swift files, excluding tests, example apps, and generated output.
# Authors who legitimately need a large PR can add the bypass label.
def pr_size_messages
prod_files = (git.modified_files + git.added_files).uniq.select { |f| production_swift_file?(f) }
total_changed = prod_files.sum do |f|
# `git.info_for_file` raises for renamed files: the new path is listed in
# `added_files`/`modified_files`, but git keys the diff stats under the
# `{old => new}` rename entry, so the internal `stats[:insertions]` lookup
# hits nil. Treat any file whose churn we can't resolve as zero.
info =
begin
git.info_for_file(f)
rescue StandardError
nil
end
info ? info[:insertions] + info[:deletions] : 0
end
return [[], []] if total_changed <= PROD_LINES_LIMIT
if github.pr_labels.include?(SKIP_SIZE_LABEL)
[[], ["This PR changes #{total_changed} lines of production Swift (limit #{PROD_LINES_LIMIT}); " \
"skipped via `#{SKIP_SIZE_LABEL}` label."]]
else
[["This PR changes #{total_changed} lines of production Swift code, over the #{PROD_LINES_LIMIT}-line " \
"limit. Split it into smaller PRs, or add the `#{SKIP_SIZE_LABEL}` label to bypass."], []]
end
end
# Collect all issues
all_failures = []
all_warnings = []
submodules = `git submodule status`.strip
unless submodules.empty?
all_failures << "This repository should not contain any submodules. When using Swift Package Manager, developers will get a resolution error because SPM cannot access private submodules."
end
f, w = xcodeproj_messages
all_failures.concat(f)
all_warnings.concat(w)
f, w = public_enum_messages
all_failures.concat(f)
all_warnings.concat(w)
f, w = pr_size_messages
all_failures.concat(f)
all_warnings.concat(w)
# Manage a single sticky comment — edit it on each run, delete it when all checks pass
repo = github.pr_json["base"]["repo"]["full_name"]
pr_number = github.pr_json["number"]
existing_comment = github.api.issue_comments(repo, pr_number)
.find { |c| c.body.include?(COMMENT_MARKER) }
if all_failures.empty? && all_warnings.empty?
github.api.delete_comment(repo, existing_comment.id) if existing_comment
else
sections = [COMMENT_MARKER]
unless all_failures.empty?
sections << "### :x: Failures\n\n" + all_failures.join("\n\n---\n\n")
end
unless all_warnings.empty?
sections << "### :warning: Warnings\n\n" + all_warnings.join("\n\n---\n\n")
end
body = sections.join("\n\n")
if existing_comment
github.api.update_comment(repo, existing_comment.id, body)
else
github.api.add_comment(repo, pr_number, body)
end
fail("Purchases iOS checks failed — see the comment above for details.") unless all_failures.empty?
end
fail_on_generated_edits(["Sources/Generated/"])