diff --git a/Library/Homebrew/api/formula_struct.rb b/Library/Homebrew/api/formula_struct.rb index 681c0afa7f5ad..5da81f441d926 100644 --- a/Library/Homebrew/api/formula_struct.rb +++ b/Library/Homebrew/api/formula_struct.rb @@ -3,6 +3,7 @@ require "service" require "utils/spdx" +require "install_steps" module Homebrew module API @@ -104,6 +105,7 @@ def self.from_hash(formula_hash) const :no_autobump_args, T::Hash[Symbol, T.any(String, Symbol)], default: {} const :oldnames, T::Array[String], default: [] const :post_install_defined, T::Boolean, default: false + const :post_install_steps, Homebrew::InstallSteps::Steps, default: [] const :pour_bottle_args, T::Hash[Symbol, Symbol], default: {} const :revision, Integer, default: 0 const :ruby_source_checksum, String diff --git a/Library/Homebrew/cmd/postinstall.rb b/Library/Homebrew/cmd/postinstall.rb index 78c96b7f1466f..63c75dc21a858 100644 --- a/Library/Homebrew/cmd/postinstall.rb +++ b/Library/Homebrew/cmd/postinstall.rb @@ -20,7 +20,10 @@ def run args.named.to_resolved_formulae.each do |f| ohai "Postinstalling #{f}" f.install_etc_var - if f.post_install_defined? + if f.post_install_steps_defined? + f.warn_on_post_install_steps_conflict if f.post_install_steps_conflict? && !args.quiet? + f.run_post_install_steps + elsif f.post_install_defined? fi = FormulaInstaller.new(f, **{ debug: args.debug?, quiet: args.quiet?, verbose: args.verbose? }.compact) fi.post_install else diff --git a/Library/Homebrew/formula.rb b/Library/Homebrew/formula.rb index 457d7ebc573f6..3e1f9a989df39 100644 --- a/Library/Homebrew/formula.rb +++ b/Library/Homebrew/formula.rb @@ -38,6 +38,7 @@ require "tab" require "mktemp" require "find" +require "install_steps" require "utils/spdx" require "on_system" require "api" @@ -636,6 +637,11 @@ def bottle_for_tag(tag = nil) # @see .api_source delegate api_source: :"self.class" + # The post-install steps. + # @!method post_install_steps + # @see .post_install_steps + delegate post_install_steps: :"self.class" + sig { void } def update_head_version return unless head? @@ -1595,6 +1601,19 @@ def post_install_defined? method(:post_install).owner != Formula end + sig { returns(T::Boolean) } + def post_install_steps_defined? = self.class.post_install_steps_defined? + + sig { returns(T::Boolean) } + def post_install_steps_conflict? + post_install_steps_defined? && post_install_defined? + end + + sig { void } + def warn_on_post_install_steps_conflict + opoo "#{full_name}: `post_install` is ignored because `post_install_steps` are defined!" + end + sig { void } def install_etc_var etc_var_dirs = [bottle_prefix/"etc", bottle_prefix/"var"] @@ -1608,6 +1627,21 @@ def install_etc_var end end + sig { void } + def run_post_install_steps + return if post_install_steps.empty? + + @prefix_returns_versioned_prefix = T.let(true, T.nilable(T::Boolean)) + + begin + with_logging("post_install_steps") do + Homebrew::InstallSteps::Runner.new(context: self).run(post_install_steps) + end + ensure + @prefix_returns_versioned_prefix = T.let(false, T.nilable(T::Boolean)) + end + end + sig { void } def run_post_install @prefix_returns_versioned_prefix = T.let(true, T.nilable(T::Boolean)) @@ -2936,6 +2970,7 @@ def to_hash "disable_replacement_formula" => disable_replacement_formula, "disable_replacement_cask" => disable_replacement_cask, "disable_args" => disable_args, + "post_install_steps" => post_install_steps, "post_install_defined" => post_install_defined?, "service" => (service.to_hash if service?), "tap_git_head" => tap_git_head, @@ -3735,6 +3770,8 @@ def inherited(child) @conflicts = T.let([], T.nilable(T::Array[FormulaConflict])) @skip_clean_paths = T.let(Set.new, T.nilable(T::Set[T.any(String, Symbol)])) @link_overwrite_paths = T.let(Set.new, T.nilable(T::Set[String])) + @post_install_steps = T.let([], T.nilable(Homebrew::InstallSteps::Steps)) + @post_install_steps_defined = T.let(false, T.nilable(T::Boolean)) @loaded_from_api = T.let(false, T.nilable(T::Boolean)) @loaded_from_internal_api = T.let(false, T.nilable(T::Boolean)) @api_source = T.let(nil, T.nilable(T::Hash[String, T.untyped])) @@ -3754,6 +3791,7 @@ def freeze @conflicts.freeze @skip_clean_paths.freeze @link_overwrite_paths.freeze + @post_install_steps.freeze @preserve_rpath&.freeze super end @@ -3935,6 +3973,40 @@ def network_access_allowed?(phase) env_var.nil? ? network_access_allowed[phase] : env_var == "allow" end + sig { returns(T::Boolean) } + def post_install_steps_defined? = @post_install_steps_defined == true + + # Declarative steps to run after bottle installation. + # + # ### Example + # + # ```ruby + # post_install_steps do + # mkdir "log/foo", base: :var + # end + # ``` + # + # @api public + sig { params(steps: T.untyped, block: T.nilable(T.proc.void)).returns(Homebrew::InstallSteps::Steps) } + def post_install_steps(*steps, &block) + current_steps = @post_install_steps || [] + return current_steps if steps.empty? && block.nil? + + @post_install_steps_defined = T.let(true, T.nilable(T::Boolean)) + current_steps.concat( + if block + Homebrew::InstallSteps::DSL.build( + default_base: :var, + default_source_base: :prefix, + default_target_base: :prefix, + &block + ) + else + Homebrew::InstallSteps::DSL.normalise_steps(steps) + end, + ) + end + # The homepage for the software. Used by users to get more information # about the software and Homebrew maintainers as a point of contact for # e.g. submitting patches. diff --git a/Library/Homebrew/formula_installer.rb b/Library/Homebrew/formula_installer.rb index 515536480ad11..38e3faaa10d4b 100644 --- a/Library/Homebrew/formula_installer.rb +++ b/Library/Homebrew/formula_installer.rb @@ -988,7 +988,12 @@ def finish end else formula.install_etc_var - post_install if formula.post_install_defined? + if formula.post_install_steps_defined? + formula.warn_on_post_install_steps_conflict if formula.post_install_steps_conflict? && !quiet? + formula.run_post_install_steps + elsif formula.post_install_defined? + post_install + end end keg.prepare_debug_symbols if debug_symbols? diff --git a/Library/Homebrew/formulary.rb b/Library/Homebrew/formulary.rb index 6c5e1128f6c3e..8c7af16bba78a 100644 --- a/Library/Homebrew/formulary.rb +++ b/Library/Homebrew/formulary.rb @@ -306,6 +306,12 @@ def self.load_formula_from_struct!(name, formula_struct, api_source:, tap_git_he link_overwrite path end + @post_install_steps = T.let( + formula_struct.post_install_steps, + T.nilable(Homebrew::InstallSteps::Steps), + ) + @post_install_steps_defined = T.let(formula_struct.post_install_steps.present?, T.nilable(T::Boolean)) + define_method(:install) do raise NotImplementedError, "Cannot build from source from abstract formula." end diff --git a/Library/Homebrew/json_api_postinstall_preflight_postflight_plan.md b/Library/Homebrew/json_api_postinstall_preflight_postflight_plan.md index 955a323d9b827..e54b1ee63a52b 100644 --- a/Library/Homebrew/json_api_postinstall_preflight_postflight_plan.md +++ b/Library/Homebrew/json_api_postinstall_preflight_postflight_plan.md @@ -98,11 +98,10 @@ Local scan source: `homebrew/cask` at `4ed4e04eaa5`. `_steps` blocks literal-only; when a phase gets wired in, add the runtime warning that steps win over the legacy Ruby block; add conservative autocorrection only where every legacy statement maps mechanically. -- [ ] PR 2, formula `post_install_steps`. +- [x] PR 2, formula `post_install_steps`. Commit: `Add formula install steps`. Scope: formula DSL, formula JSON API data, API formula loading, installer and - `brew postinstall` execution, formula cookbook docs, formula fixture and - formula-specific autocorrection. + `brew postinstall` execution, formula cookbook docs and formula fixture. Estimated existing formulae affected: `178` formulae currently define `post_install`. The first useful conversion surface is roughly `73` formulae creating shared directories and `9` touching marker or lock files; parts of @@ -112,7 +111,8 @@ Local scan source: `homebrew/cask` at `4ed4e04eaa5`. Notes for implementation: default `mkdir`/`touch` to `var` and source/target paths to `prefix`; expose the ordered array through `FormulaStruct`; make `post_install_steps` take precedence over `post_install`; document that the - two forms must not be mixed. + two forms must not be mixed. Keep the tap-wide autocorrect audit in a + follow-up commit so the implementation can land before converted formulae. - [ ] PR 3, cask flight steps. Commit: `Add cask install steps`. Scope: cask artifacts for `preflight_steps`, `postflight_steps`, diff --git a/Library/Homebrew/rubocops/install_steps.rb b/Library/Homebrew/rubocops/install_steps.rb index 416ee6711c8e0..b767269e0533c 100644 --- a/Library/Homebrew/rubocops/install_steps.rb +++ b/Library/Homebrew/rubocops/install_steps.rb @@ -18,7 +18,8 @@ def audit_formula(formula_nodes) return if (body_node = formula_nodes.body_node).nil? post_install_steps_block = find_block(body_node, :post_install_steps) - if post_install_steps_block && find_method_def(body_node, :post_install) + post_install_method = find_method_def(body_node, :post_install) + if post_install_steps_block && post_install_method offending_node(post_install_steps_block) problem CONFLICT_MSG end diff --git a/Library/Homebrew/rubocops/shared/api_annotation_helper.rb b/Library/Homebrew/rubocops/shared/api_annotation_helper.rb index 9de4b22ece872..10380b35efd48 100644 --- a/Library/Homebrew/rubocops/shared/api_annotation_helper.rb +++ b/Library/Homebrew/rubocops/shared/api_annotation_helper.rb @@ -34,6 +34,7 @@ module ApiAnnotationHelper "desc" => "formula.rb", "env_script_all_files" => "extend/pathname.rb", "fails_with" => "formula.rb", + "post_install_steps" => "formula.rb", "head" => "formula.rb", "homepage" => "formula.rb", "install_symlink" => "extend/pathname.rb", diff --git a/Library/Homebrew/sorbet/rbi/dsl/formula.rbi b/Library/Homebrew/sorbet/rbi/dsl/formula.rbi index 1825280420028..92be9a139449e 100644 --- a/Library/Homebrew/sorbet/rbi/dsl/formula.rbi +++ b/Library/Homebrew/sorbet/rbi/dsl/formula.rbi @@ -96,6 +96,9 @@ class Formula sig { params(args: T.untyped, block: T.untyped).returns(T.untyped) } def env(*args, &block); end + sig { params(args: T.untyped, block: T.untyped).returns(T.untyped) } + def post_install_steps(*args, &block); end + sig { params(args: T.untyped, block: T.untyped).returns(T.untyped) } def homepage(*args, &block); end diff --git a/Library/Homebrew/test/api/formula_struct_spec.rb b/Library/Homebrew/test/api/formula_struct_spec.rb index 1f9296c2e7d64..c86d7bcba6df5 100644 --- a/Library/Homebrew/test/api/formula_struct_spec.rb +++ b/Library/Homebrew/test/api/formula_struct_spec.rb @@ -287,5 +287,23 @@ def build_formula_struct(checksums) expect(restored).to eq(original) end + + it "serializes post-install steps", :needs_macos do + original = klass.new( + desc: "install steps test", + homepage: "https://example.com", + license: "MIT", + ruby_source_checksum: "abc123", + stable_version: "1.0.0", + post_install_steps: [ + { "type" => "mkdir_p", "path" => { "base" => "var", "path" => "log/foo" } }, + ], + ) + + serialized = original.serialize(bottle_tag: Utils::Bottles::Tag.from_symbol(:arm64_sequoia)) + restored = klass.deserialize(serialized, bottle_tag: Utils::Bottles::Tag.from_symbol(:arm64_sequoia)) + + expect(restored.post_install_steps).to eq(original.post_install_steps) + end end end diff --git a/Library/Homebrew/test/formula_spec.rb b/Library/Homebrew/test/formula_spec.rb index bf4d28a3a3257..7cba3e06285c1 100644 --- a/Library/Homebrew/test/formula_spec.rb +++ b/Library/Homebrew/test/formula_spec.rb @@ -1082,6 +1082,97 @@ def post_install expect(f2).not_to have_post_install_defined end + specify "#post_install_steps" do + f = formula do + url "foo-1.0" + + post_install_steps do + mkdir_p "log/foo" + touch "foo/marker" + mv "move-source", "move-target" + move_children "children-source", "children-target" + ln_s "move-target", "linked-target", source_base: :relative, uninstall: true + end + end + + expect(f.post_install_steps).to eq([ + { "type" => "mkdir_p", "path" => { "base" => "var", "path" => "log/foo" } }, + { "type" => "touch", "path" => { "base" => "var", "path" => "foo/marker" } }, + { + "type" => "move", + "source" => { "base" => "prefix", "path" => "move-source" }, + "target" => { "base" => "prefix", "path" => "move-target" }, + }, + { + "type" => "move_children", + "source" => { "base" => "prefix", "path" => "children-source" }, + "target" => { "base" => "prefix", "path" => "children-target" }, + }, + { + "type" => "symlink", + "source" => { "base" => "relative", "path" => "move-target" }, + "target" => { "base" => "prefix", "path" => "linked-target" }, + "uninstall" => true, + }, + ]) + expect(f.post_install_steps_defined?).to be(true) + expect(f.to_hash["post_install_steps"]).to eq(f.post_install_steps) + end + + specify "#post_install_steps_defined? with an empty block" do + f = formula do + url "foo-1.0" + + # This intentionally declares no steps to test definition tracking. + # rubocop:disable Lint/EmptyBlock + post_install_steps do + end + # rubocop:enable Lint/EmptyBlock + end + + expect(f.post_install_steps).to be_empty + expect(f.post_install_steps_defined?).to be(true) + end + + specify "#post_install_steps_conflict?" do + f = formula do + url "foo-1.0" + + # This intentionally declares no steps to test conflict tracking. + # rubocop:disable Lint/EmptyBlock + post_install_steps do + end + # rubocop:enable Lint/EmptyBlock + + def post_install; end + end + + expect(f.post_install_steps_conflict?).to be(true) + end + + specify "#run_post_install_steps uses the versioned prefix" do + f = formula "post-install-steps-prefix" do + url "foo-1.0" + + post_install_steps do + ln_s "source", "linked", source_base: :prefix, target_base: :prefix + end + end + + versioned_prefix = f.rack/f.pkg_version.to_s + FileUtils.rm_f f.opt_prefix + versioned_prefix.mkpath + f.opt_prefix.parent.mkpath + FileUtils.ln_s versioned_prefix, f.opt_prefix + + f.run_post_install_steps + + expect((versioned_prefix/"linked").readlink).to eq(versioned_prefix/"source") + ensure + FileUtils.rm_f f.opt_prefix + FileUtils.rm_rf f.rack + end + describe "#install_etc_var" do let(:f) do formula "config-upgrade" do diff --git a/Library/Homebrew/test/rubocops/install_steps_spec.rb b/Library/Homebrew/test/rubocops/install_steps_spec.rb index f7550d23b9d9d..fb73d456715c6 100644 --- a/Library/Homebrew/test/rubocops/install_steps_spec.rb +++ b/Library/Homebrew/test/rubocops/install_steps_spec.rb @@ -49,4 +49,19 @@ class Foo < Formula end RUBY end + + it "does not report simple legacy `post_install` file preparation" do + expect_no_offenses(<<~RUBY) + class Foo < Formula + url "https://brew.sh/foo-1.0.tgz" + + def post_install + (var/"log/foo").mkpath + FileUtils.touch var/"foo/state" + FileUtils.mv prefix/"move-source", prefix/"move-target" + FileUtils.ln_sf "move-target", prefix/"linked-target" + end + end + RUBY + end end diff --git a/Library/Homebrew/test/support/fixtures/formulae/install-steps.rb b/Library/Homebrew/test/support/fixtures/formulae/install-steps.rb new file mode 100644 index 0000000000000..7fc45763144d2 --- /dev/null +++ b/Library/Homebrew/test/support/fixtures/formulae/install-steps.rb @@ -0,0 +1,21 @@ +# typed: false +# frozen_string_literal: true + +class InstallSteps < Formula + # Sorbet type members are mutable by design and cannot be frozen. + # rubocop:disable Style/MutableConstant + Cache = type_template { { fixed: T::Hash[Symbol, T.untyped] } } + # rubocop:enable Style/MutableConstant + + desc "Formula with structured install steps" + homepage "https://brew.sh/install-steps" + url "https://brew.sh/install-steps-1.0" + + post_install_steps do + mkdir_p "log/install-steps" + touch "install-steps/state" + mv "move-source", "move-target" + move_children "move-children-source", "move-children-target" + ln_sf "move-target", "linked-target", source_base: :relative, uninstall: true + end +end diff --git a/docs/Formula-Cookbook.md b/docs/Formula-Cookbook.md index 4b1279460a318..ebc1d9d1f3f1e 100644 --- a/docs/Formula-Cookbook.md +++ b/docs/Formula-Cookbook.md @@ -1099,6 +1099,25 @@ end Any initialization steps that aren't necessarily part of the install process can be located in a `post_install` block, such as setup commands or data directory creation. This block can be re-run separately with `brew postinstall `. +For simple file preparation, prefer [`post_install_steps`](/rubydoc/Formula.html#post_install_steps-class_method). These steps are stored in the JSON API and do not require evaluating formula Ruby. A `post_install_steps` block may only contain the supported step calls with literal arguments. It cannot call the wider formula DSL or arbitrary Ruby code. + +```ruby +class Foo < Formula + # ... + url "https://example.com/foo-1.0.tar.gz" + + post_install_steps do + mkdir_p "log/foo" + touch "foo/state" + mv "default.conf", "foo/default.conf" + ln_s "cert.pem", "foo/cert.pem", source_base: :relative + end + # ... +end +``` + +`mkdir`, `mkdir_p` and `touch` default to paths relative to `var`. `move`, `mv`, `move_children`, `symlink`, `ln_s` and `ln_sf` default their source and target paths to `prefix`. Use `base:`, `source_base:` or `target_base:` when a step needs another formula path such as `pkgetc`; use `source_base: :relative` for relative symlink sources. A formula may define either `post_install_steps` or `post_install`, not both. + ```ruby class Foo < Formula # ...