Skip to content

Commit 48c6113

Browse files
committed
Auto-merge check makes additional checks
Per conversation in #848, I've added automerge-check.rb which: - checks changed files against a small allowlist - checks the URLs in the windows version and toolchain files The script contains tests for itself which can be run by passing a `--test` flag on the commandline. Note that automerge-check.rb is run from the base branch (master) to avoid malicious PRs from changing the script. Also note that dist/index.js is previously being checked in the "lint" job in test.yml (which this pipeline depends upon).
1 parent 15921c7 commit 48c6113

File tree

2 files changed

+279
-5
lines changed

2 files changed

+279
-5
lines changed

.github/workflows/auto-merge-bot-prs.yml

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,45 @@ on:
44
workflows: ["Test this action"]
55
types: [completed]
66

7-
permissions:
8-
contents: write
9-
pull-requests: write
10-
117
jobs:
12-
auto-merge:
8+
check:
139
runs-on: ubuntu-latest
1410
if: >
1511
github.event.workflow_run.conclusion == 'success' &&
1612
github.event.workflow_run.event == 'pull_request' &&
1713
github.event.workflow_run.actor.login == 'ruby-builder-bot' &&
1814
github.event.workflow_run.pull_requests[0].user.login == 'ruby-builder-bot' &&
1915
github.event.workflow_run.pull_requests[0].head.repo.full_name == 'ruby-builder-bot/setup-ruby'
16+
permissions:
17+
contents: read
18+
steps:
19+
- name: Checkout base branch
20+
uses: actions/checkout@v4
21+
with:
22+
fetch-depth: 0
23+
24+
- name: Set up Ruby
25+
uses: ruby/setup-ruby@8a836efbcebe5de0fe86b48a775b7a31b5c70c93
26+
with:
27+
ruby-version: ruby
28+
29+
- name: Fetch PR head commit
30+
run: |
31+
git fetch --no-tags origin \
32+
${{ github.event.workflow_run.pull_requests[0].head.sha }}
33+
34+
- name: Run automerge checks
35+
run: |
36+
./automerge-check.rb \
37+
${{ github.event.workflow_run.pull_requests[0].base.sha }} \
38+
${{ github.event.workflow_run.pull_requests[0].head.sha }}
39+
40+
merge:
41+
runs-on: ubuntu-latest
42+
needs: check
43+
permissions:
44+
contents: write
45+
pull-requests: write
2046
steps:
2147
- name: Merge PR
2248
env:

automerge-check.rb

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
#!/usr/bin/env ruby
2+
# frozen_string_literal: true
3+
4+
require "json"
5+
require "open3"
6+
7+
ALLOWED_FILES = [
8+
"README.md",
9+
"dist/index.js",
10+
"ruby-builder-versions.json",
11+
"windows-toolchain-versions.json",
12+
"windows-versions.json",
13+
].freeze
14+
15+
WINDOWS_TOOLCHAIN_URL_PREFIXES = [
16+
"https://github.com/ruby/setup-msys2-gcc/releases/",
17+
"https://github.com/oneclick/rubyinstaller/releases/download/devkit-",
18+
].freeze
19+
20+
WINDOWS_VERSIONS_URL_PREFIXES = [
21+
"https://github.com/oneclick/rubyinstaller2/releases/download/",
22+
"https://github.com/oneclick/rubyinstaller/releases/download/",
23+
"https://github.com/MSP-Greg/ruby-loco/releases/download/",
24+
].freeze
25+
26+
class AutomergeCheck
27+
attr_reader :errors
28+
29+
def initialize(base_ref, head_ref = "HEAD")
30+
@base_ref = base_ref
31+
@head_ref = head_ref
32+
@errors = []
33+
end
34+
35+
def run
36+
check_changed_files
37+
check_windows_toolchain_urls
38+
check_windows_versions_urls
39+
40+
if @errors.empty?
41+
puts "All checks passed."
42+
true
43+
else
44+
puts "Automerge check failed:"
45+
@errors.each { |e| puts " - #{e}" }
46+
false
47+
end
48+
end
49+
50+
def check_changed_files(changed_files = git_changed_files)
51+
disallowed = changed_files - ALLOWED_FILES
52+
53+
if disallowed.any?
54+
@errors << "Disallowed files changed: #{disallowed.join(', ')}"
55+
end
56+
end
57+
58+
def check_windows_toolchain_urls(filename = "windows-toolchain-versions.json")
59+
check_json_urls(filename, WINDOWS_TOOLCHAIN_URL_PREFIXES)
60+
end
61+
62+
def check_windows_versions_urls(filename = "windows-versions.json")
63+
check_json_urls(filename, WINDOWS_VERSIONS_URL_PREFIXES)
64+
end
65+
66+
def check_json_urls(filename, allowed_prefixes)
67+
content = read_file_at_ref(filename)
68+
return unless content
69+
70+
check_json_urls_from_content(filename, content, allowed_prefixes)
71+
end
72+
73+
def check_json_urls_from_content(filename, content, allowed_prefixes)
74+
data = JSON.parse(content)
75+
urls = extract_urls(data)
76+
77+
urls.each do |url|
78+
unless allowed_prefixes.any? { |prefix| url.start_with?(prefix) }
79+
@errors << "#{filename}: invalid URL #{url}"
80+
end
81+
end
82+
end
83+
84+
def read_file_at_ref(filename)
85+
output, _, status = Open3.capture3("git", "show", "#{@head_ref}:#{filename}")
86+
unless status.success?
87+
@errors << "#{filename} missing at #{@head_ref}"
88+
return nil
89+
end
90+
output
91+
end
92+
93+
def extract_urls(data, urls = [])
94+
case data
95+
when Hash
96+
data.each_value { |v| extract_urls(v, urls) }
97+
when Array
98+
data.each { |v| extract_urls(v, urls) }
99+
when String
100+
urls << data if data.start_with?("http://", "https://")
101+
end
102+
urls
103+
end
104+
105+
private
106+
107+
def git_changed_files
108+
output, status = Open3.capture2("git", "diff", "--name-only", "#{@base_ref}...#{@head_ref}")
109+
unless status.success?
110+
raise "git diff failed: #{output}"
111+
end
112+
output.split("\n").map(&:strip).reject(&:empty?)
113+
end
114+
end
115+
116+
if __FILE__ == $0
117+
if ARGV[0] == "--test"
118+
ARGV.clear
119+
require "minitest/autorun"
120+
121+
class AutomergeCheckTest < Minitest::Test
122+
def setup
123+
@checker = AutomergeCheck.new("master")
124+
end
125+
126+
def test_allowed_files_not_flagged
127+
@checker.check_changed_files(["README.md", "dist/index.js"])
128+
assert_empty @checker.errors
129+
end
130+
131+
def test_disallowed_files_detected
132+
@checker.check_changed_files(["README.md", "evil.js", "dist/index.js"])
133+
assert_equal 1, @checker.errors.length
134+
assert_match(/evil\.js/, @checker.errors.first)
135+
end
136+
137+
def test_all_allowed_files_accepted
138+
@checker.check_changed_files(ALLOWED_FILES)
139+
assert_empty @checker.errors
140+
end
141+
142+
def test_url_extraction_simple
143+
data = { "3.0.0" => { "x64" => "https://example.com/a.7z" } }
144+
urls = @checker.extract_urls(data)
145+
assert_equal ["https://example.com/a.7z"], urls
146+
end
147+
148+
def test_url_extraction_nested
149+
data = {
150+
"3.0.0" => { "x64" => "https://example.com/a.7z" },
151+
"3.1.0" => { "x64" => "https://example.com/b.7z", "arm64" => "https://example.com/c.7z" },
152+
}
153+
urls = @checker.extract_urls(data)
154+
assert_equal 3, urls.length
155+
end
156+
157+
def test_url_extraction_ignores_non_urls
158+
data = { "version" => "3.0.0", "url" => "https://example.com/a.7z" }
159+
urls = @checker.extract_urls(data)
160+
assert_equal ["https://example.com/a.7z"], urls
161+
end
162+
163+
def test_valid_toolchain_urls
164+
valid_urls = [
165+
"https://github.com/ruby/setup-msys2-gcc/releases/latest/download/msys2-ucrt64.7z",
166+
"https://github.com/ruby/setup-msys2-gcc/releases/download/v1/file.7z",
167+
"https://github.com/oneclick/rubyinstaller/releases/download/devkit-4.7.2/DevKit.exe",
168+
]
169+
valid_urls.each do |url|
170+
result = WINDOWS_TOOLCHAIN_URL_PREFIXES.any? { |p| url.start_with?(p) }
171+
assert result, "Expected #{url} to be valid for toolchain"
172+
end
173+
end
174+
175+
def test_invalid_toolchain_urls
176+
invalid_urls = [
177+
"https://evil.com/malware.exe",
178+
"https://github.com/attacker/evil/releases/download/v1/bad.7z",
179+
"https://github.com/oneclick/rubyinstaller2/releases/download/v1/file.7z",
180+
]
181+
invalid_urls.each do |url|
182+
result = WINDOWS_TOOLCHAIN_URL_PREFIXES.any? { |p| url.start_with?(p) }
183+
refute result, "Expected #{url} to be invalid for toolchain"
184+
end
185+
end
186+
187+
def test_valid_versions_urls
188+
valid_urls = [
189+
"https://github.com/oneclick/rubyinstaller2/releases/download/RubyInstaller-3.0.0-1/file.7z",
190+
"https://github.com/oneclick/rubyinstaller/releases/download/ruby-2.0.0-p648/file.7z",
191+
"https://github.com/MSP-Greg/ruby-loco/releases/download/ruby-master/ruby-mingw.7z",
192+
]
193+
valid_urls.each do |url|
194+
result = WINDOWS_VERSIONS_URL_PREFIXES.any? { |p| url.start_with?(p) }
195+
assert result, "Expected #{url} to be valid for versions"
196+
end
197+
end
198+
199+
def test_invalid_versions_urls
200+
invalid_urls = [
201+
"https://evil.com/malware.exe",
202+
"https://github.com/attacker/evil/releases/download/v1/bad.7z",
203+
]
204+
invalid_urls.each do |url|
205+
result = WINDOWS_VERSIONS_URL_PREFIXES.any? { |p| url.start_with?(p) }
206+
refute result, "Expected #{url} to be invalid for versions"
207+
end
208+
end
209+
210+
def test_check_json_urls_from_content_with_valid_urls
211+
content = JSON.generate({
212+
"3.0.0" => { "x64" => "https://github.com/ruby/setup-msys2-gcc/releases/latest/download/file.7z" },
213+
})
214+
@checker.check_json_urls_from_content("test.json", content, WINDOWS_TOOLCHAIN_URL_PREFIXES)
215+
assert_empty @checker.errors
216+
end
217+
218+
def test_check_json_urls_from_content_with_invalid_urls
219+
content = JSON.generate({
220+
"3.0.0" => { "x64" => "https://evil.com/malware.exe" },
221+
})
222+
@checker.check_json_urls_from_content("test.json", content, WINDOWS_TOOLCHAIN_URL_PREFIXES)
223+
assert_equal 1, @checker.errors.length
224+
assert_match(/invalid URL/, @checker.errors.first)
225+
end
226+
227+
def test_read_file_at_ref_returns_nil_for_missing_file
228+
result = @checker.send(:read_file_at_ref, "nonexistent-file-that-does-not-exist.json")
229+
assert_nil result
230+
assert_match(/missing/, @checker.errors.first)
231+
end
232+
233+
def test_check_json_urls_records_error_when_file_missing
234+
@checker.check_json_urls("nonexistent-file-that-does-not-exist.json", WINDOWS_TOOLCHAIN_URL_PREFIXES)
235+
assert_includes @checker.errors.join("\n"), "nonexistent-file-that-does-not-exist.json missing"
236+
end
237+
end
238+
elsif ARGV.length < 1 || ARGV.length > 2
239+
puts "Usage: #{$0} <base-ref> [head-ref]"
240+
puts " #{$0} --test"
241+
exit 1
242+
else
243+
base_ref = ARGV[0]
244+
head_ref = ARGV[1] || "HEAD"
245+
checker = AutomergeCheck.new(base_ref, head_ref)
246+
exit(checker.run ? 0 : 1)
247+
end
248+
end

0 commit comments

Comments
 (0)