mirror of
https://github.com/RevenueCat/purchases-ios
synced 2026-11-09 22:35:56 +05:45
* 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>
221 lines
7.3 KiB
Ruby
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/"])
|