Skip to content

Commit 86b2dd0

Browse files
committed
Maintain bottle coverage during macOS bootstrap
Signed-off-by: Patrick Linnane <patrick@linnane.io>
1 parent c1016b5 commit 86b2dd0

9 files changed

Lines changed: 677 additions & 7 deletions

File tree

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# typed: strict
2+
# frozen_string_literal: true
3+
4+
require "formula"
5+
require "formulary"
6+
require "test_runner_formula"
7+
8+
# Preserve bottles while bootstrapping a macOS release.
9+
# TODO: remove this class and its callers when `HOMEBREW_MACOS_NEWEST_SUPPORTED` is `27`.
10+
class BottleTransition
11+
MACOS = :golden_gate
12+
13+
sig { returns(Utils::Bottles::Tag) }
14+
def self.tag
15+
Utils::Bottles::Tag.new(system: MACOS, arch: :arm64)
16+
end
17+
18+
sig { returns(T::Boolean) }
19+
def self.active?
20+
MacOSVersion.from_symbol(MACOS) > HOMEBREW_MACOS_NEWEST_SUPPORTED
21+
end
22+
23+
sig { params(base_ref: T.nilable(String)).void }
24+
def initialize(base_ref: nil)
25+
if ENV.fetch("GITHUB_EVENT_NAME", nil) == "merge_group"
26+
event = JSON.parse(File.read(ENV.fetch("GITHUB_EVENT_PATH")))
27+
base_ref = event.dig("merge_group", "base_sha")
28+
if !base_ref.is_a?(String) || !/\A[0-9a-f]{40}\z/.match?(base_ref)
29+
raise UsageError, "Missing immutable merge-group base SHA."
30+
end
31+
end
32+
base_branch = ENV.fetch("GITHUB_BASE_REF", nil).presence
33+
@base_ref = T.let(base_ref || (base_branch ? "origin/#{base_branch}" : "origin/HEAD"), String)
34+
@base_revision = T.let(nil, T.nilable(String))
35+
@required = T.let({}, T::Hash[String, T::Boolean])
36+
end
37+
38+
sig { params(formula: Formula).returns(T::Boolean) }
39+
def required?(formula)
40+
return false unless self.class.active?
41+
return false unless formula.tap&.core_tap?
42+
return false if formula.disabled?
43+
44+
compatible = Homebrew::SimulateSystem.with(os: MACOS, arch: :arm) do
45+
current = TestRunnerFormula.new(Formulary.factory(formula.path))
46+
current.macos_compatible? && current.arm64_compatible? &&
47+
current.compatible_with?(MacOSVersion.from_symbol(MACOS))
48+
end
49+
return false unless compatible
50+
51+
@required.fetch(formula.name) do
52+
repository = CoreTap.instance.path
53+
@base_revision ||= Utils.safe_popen_read("git", "-C", repository, "rev-parse", "--verify",
54+
"#{@base_ref}^{commit}").strip
55+
path = formula.tap_path.relative_path_from(repository)
56+
files = Utils.safe_popen_read("git", "-C", repository, "ls-tree", "--name-only", "-z",
57+
@base_revision, "--", path)
58+
return @required[formula.name] = false if files.empty?
59+
60+
contents = Utils.safe_popen_read("git", "-C", repository, "show", "#{@base_revision}:#{path}")
61+
@required[formula.name] = Homebrew::SimulateSystem.with(os: MACOS, arch: :arm) do
62+
previous = Formulary.from_contents(formula.name, formula.tap_path, contents)
63+
previous.bottle_specification.collector.tags.include?(self.class.tag)
64+
end
65+
end
66+
end
67+
68+
sig { params(formula: Formula, tags: T::Array[Utils::Bottles::Tag]).void }
69+
def check!(formula, tags:)
70+
return unless required?(formula)
71+
return if tags.include?(self.class.tag) || tags.include?(Utils::Bottles.tag(:all))
72+
73+
raise UsageError, <<~EOS
74+
#{formula.full_name} already has #{self.class.tag} bottle coverage on #{@base_ref}.
75+
Build an #{self.class.tag} bottle for #{formula.pkg_version} before publishing or merging.
76+
Re-run CI against the updated base branch if mass bottling finished after CI started.
77+
EOS
78+
end
79+
end

Library/Homebrew/dev-cmd/pr-upload.rb

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
require "github_packages"
99
require "github_releases"
1010
require "extend/hash/deep_merge"
11+
require "bottle_transition"
1112

1213
module Homebrew
1314
module DevCmd
@@ -108,6 +109,8 @@ def run
108109
end
109110
end
110111

112+
check_transition_bottles!(bottles_hash)
113+
111114
if github_releases?(bottles_hash)
112115
github_releases = GitHubReleases.new
113116
github_releases.upload_bottles(bottles_hash)
@@ -122,6 +125,44 @@ def run
122125
end
123126
end
124127

128+
# Verify transition bottles before upload.
129+
sig { params(bottles_hash: T::Hash[String, T.untyped]).void }
130+
def check_transition_bottles!(bottles_hash)
131+
# Reload the bottle block written by `brew bottle --merge`.
132+
Formulary.clear_cache
133+
transition = BottleTransition.new
134+
bottles_hash.each_value do |bottle_hash|
135+
formula_path = HOMEBREW_REPOSITORY/bottle_hash.fetch("formula").fetch("path")
136+
formula = Formulary.factory(formula_path)
137+
next unless transition.required?(formula)
138+
139+
spec = formula.bottle_specification
140+
transition.check!(formula, tags: spec.collector.tags)
141+
bottle = bottle_hash.fetch("bottle")
142+
tags = bottle.fetch("tags").keys.map { |tag| Utils::Bottles.tag(tag.to_sym) }
143+
if !args.keep_old? && tags.exclude?(BottleTransition.tag) && tags.exclude?(Utils::Bottles.tag(:all))
144+
raise UsageError, <<~EOS
145+
#{formula.full_name} #{formula.pkg_version}: upload set is missing
146+
#{BottleTransition.tag} or `all` bottle artifacts.
147+
Restore the matching bottle artifacts and JSON files before retrying.
148+
EOS
149+
end
150+
151+
root_url = bottle.fetch("root_url")
152+
metadata_matches = bottle_hash.fetch("formula").fetch("pkg_version") == formula.pkg_version.to_s &&
153+
bottle.fetch("rebuild", 0).to_i == spec.rebuild &&
154+
(GitHubPackages.root_url_if_match(root_url) || root_url) == spec.root_url &&
155+
bottle.fetch("tags").all? do |tag, tag_hash|
156+
tag_spec = spec.collector.specification_for(Utils::Bottles.tag(tag.to_sym),
157+
no_older_versions: true)
158+
tag_spec && tag_spec.checksum.hexdigest == tag_hash.fetch("sha256")
159+
end
160+
next if metadata_matches
161+
162+
raise UsageError, "#{formula.full_name}: bottle metadata does not match the committed formula."
163+
end
164+
end
165+
125166
private
126167

127168
sig { params(bottles_hash: T::Hash[String, T.untyped]).void }
@@ -154,12 +195,28 @@ def github_packages?(bottles_hash)
154195
end, T.nilable(T::Boolean))
155196
end
156197

198+
public
199+
200+
# Merge compatible bottle metadata.
157201
sig { params(json_files: T::Array[String], args: T.untyped).returns(T::Hash[String, T.untyped]) }
158202
def bottles_hash_from_json_files(json_files, args)
159203
puts "Reading JSON files: #{json_files.join(", ")}" if args.verbose?
160204

161205
bottles_hash = json_files.reduce({}) do |hash, json_file|
162-
hash.deep_merge(JSON.parse(File.read(json_file)))
206+
incoming = JSON.parse(File.read(json_file))
207+
incoming.each do |name, bottle_hash|
208+
bottle = bottle_hash.fetch("bottle")
209+
bottle["root_url"] = GitHubPackages.root_url_if_match(bottle["root_url"]) || bottle["root_url"]
210+
previous = hash[name]
211+
next unless previous
212+
next if previous.fetch("formula").slice("path", "pkg_version") ==
213+
bottle_hash.fetch("formula").slice("path", "pkg_version") &&
214+
previous.fetch("bottle").slice("root_url", "rebuild") ==
215+
bottle_hash.fetch("bottle").slice("root_url", "rebuild")
216+
217+
raise UsageError, "Inconsistent bottle metadata for #{name} in #{json_file}."
218+
end
219+
hash.deep_merge(incoming)
163220
end
164221

165222
if args.root_url

Library/Homebrew/github_runner_matrix.rb

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,12 @@
33

44
require "test_runner_formula"
55
require "github_runner"
6+
require "bottle_transition"
7+
require "utils/output"
68

79
class GitHubRunnerMatrix
10+
include Utils::Output::Mixin
11+
812
# When bumping newest runner, run e.g. `git log -p --reverse -G "sha256 tahoe"`
913
# on homebrew/core and tag the first commit with a bottle e.g.
1014
# `git tag 15-sequoia f42c4a659e4da887fc714f8f41cc26794a4bb320`
@@ -209,7 +213,57 @@ def create_runner(platform, arch, spec, macos_version = nil)
209213

210214
sig { params(macos_version: MacOSVersion).returns(T::Boolean) }
211215
def runner_enabled?(macos_version)
212-
macos_version.between?(OLDEST_HOMEBREW_CORE_MACOS_RUNNER, NEWEST_HOMEBREW_CORE_MACOS_RUNNER)
216+
return true if macos_version.between?(OLDEST_HOMEBREW_CORE_MACOS_RUNNER, NEWEST_HOMEBREW_CORE_MACOS_RUNNER)
217+
return false if @all_supported || @dependent_matrix
218+
219+
macos_version.to_sym == BottleTransition::MACOS && transition_formulae.present?
220+
end
221+
222+
sig { returns(T::Array[TestRunnerFormula]) }
223+
def transition_formulae
224+
@transition_formulae ||= T.let(begin
225+
transition = BottleTransition.new
226+
covered = @testing_formulae.select { |formula| transition.required?(formula.formula) }
227+
needed_names = []
228+
testing_names = @testing_formulae.map(&:name)
229+
230+
Homebrew::SimulateSystem.with(os: BottleTransition::MACOS, arch: :arm) do
231+
covered.each do |formula|
232+
dependencies = Formulary.factory(formula.name).recursive_dependencies do |dependent, dependency|
233+
next Dependable::PRUNE if dependency.optional?
234+
235+
# Let the runner resolve older bottles for test-only dependencies.
236+
if dependency.test? && !dependency.build? && testing_names.exclude?(dependency.name)
237+
next Dependable::PRUNE
238+
end
239+
if dependency.is_a?(UsesFromMacOSDependency) && dependency.use_macos_install?
240+
next Dependable::PRUNE
241+
end
242+
next unless dependent.is_a?(Formula)
243+
next unless dependency.build?
244+
next if testing_names.include?(dependent.name)
245+
246+
Dependable::PRUNE unless dependent.bottle_specification.tag?(Utils::Bottles.tag(:all))
247+
end
248+
missing = dependencies.reject do |dependency|
249+
testing_names.include?(dependency.name) ||
250+
dependency.to_formula.bottle_specification.tag?(BottleTransition.tag, no_older_versions: true)
251+
end
252+
if missing.present?
253+
opoo <<~EOS
254+
Skipping #{formula.name}'s #{BottleTransition.tag} build: missing bottles for #{missing.map(&:name).join(", ")}.
255+
Bootstrap these dependencies and retry CI before publishing.
256+
EOS
257+
next
258+
end
259+
260+
needed_names << formula.name
261+
needed_names.concat(dependencies.map(&:name) & testing_names)
262+
end
263+
end
264+
265+
@testing_formulae.select { |formula| needed_names.include?(formula.name) }
266+
end, T.nilable(T::Array[TestRunnerFormula]))
213267
end
214268

215269
sig { returns(String) }
@@ -254,8 +308,12 @@ def compatible_testing_formulae(runner)
254308
arch = runner.arch
255309
macos_version = runner.macos_version
256310

257-
@testing_formulae.select do |formula|
258-
Homebrew::SimulateSystem.with(os: platform, arch: Homebrew::SimulateSystem.arch_symbols.fetch(arch)) do
311+
transition_runner = BottleTransition.active? && macos_version&.to_sym == BottleTransition::MACOS
312+
testing_formulae = transition_runner ? transition_formulae : @testing_formulae
313+
os = transition_runner ? BottleTransition::MACOS : platform
314+
315+
testing_formulae.select do |formula|
316+
Homebrew::SimulateSystem.with(os:, arch: Homebrew::SimulateSystem.arch_symbols.fetch(arch)) do
259317
simulated_formula = TestRunnerFormula.new(Formulary.factory(formula.name))
260318
next false if macos_version && !simulated_formula.compatible_with?(macos_version)
261319

0 commit comments

Comments
 (0)