gembrew 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cea714943b01c87fdc8175f290093cb509dc6df00eb84fb5081cda8db9714e8c
4
- data.tar.gz: 3255bb06ae22d87e2a6f81e7cab6484cf6ac2fe832e881b53bde0b9c95e4be78
3
+ metadata.gz: 1b5f610bdf10a353ade54ab13743f154b56d0af7e5aceac0d6b3364135520f97
4
+ data.tar.gz: dd0daaec17e0f724b80f477b0f2c0ab68e5e02ebfef7248ca5cb8a4cd1164390
5
5
  SHA512:
6
- metadata.gz: fc656e03acea77d7b449b4985cdf18a6f8fea25bce6c3f14df19c145a6d38e86059c02dc43e572ce776fcda64d6f2154bcc8c23d9de791b42aed87ed775e1795
7
- data.tar.gz: 981ce3693cced89e212da3ab775ffef24875b8128a63e32ef9d32be123bbf9d24fe6d20c4648df8f50543c27b548bf8f6e0ad451cbe76032ea3b886730a1b2d8
6
+ metadata.gz: 6a09ac710d80edfb925b6e8935df241c05b3cec090f9ae21c24fb19ab9b0d7fcb04ef867c8b6959303e4d276d00f82acfe65b1d31acf40d3eb81c030a6567ca6
7
+ data.tar.gz: a458d322068a01abbb692df5919efb9be8bec07edcf1e518d569bfa019befde0dd3d03e7916b7656f831de5a6391b7b402ab47b47ffc1a749bf610fc2aa92fdb
data/README.md CHANGED
@@ -20,38 +20,51 @@ gembrew build
20
20
  gembrew check
21
21
  ```
22
22
 
23
- `init` creates a tap repository containing `README.md`, `gembrew/`, and
24
- `Formula/`. It accepts an empty directory or an existing tap containing
25
- `Formula/`. Supplying a gem name also creates its first configuration. The gem
26
- name is optional, so `gembrew init` can prepare an empty tap or add Gembrew to
27
- an existing one. An existing `README.md` is never overwritten.
23
+ `init` creates a tap repository containing `README.md`, `gembrew/`, `Formula/`,
24
+ and a GitHub Actions workflow that uses Homebrew's `test-bot` on Linux, Apple
25
+ Silicon, and Intel macOS. Pull requests test changed formulae; pushes, scheduled
26
+ runs, and manual runs test every formula. It accepts an empty directory or an
27
+ existing tap containing `Formula/`.
28
+ Supplying a gem name also creates its first configuration. The gem name is
29
+ optional, so `gembrew init` can prepare an empty tap or add Gembrew to an
30
+ existing one. Existing README and workflow files are never overwritten.
28
31
 
29
32
  For example, `gembrew init example` generates:
30
33
 
31
34
  ```text
32
35
  README.md
36
+ .github/
37
+ workflows/
38
+ test.yml
33
39
  gembrew/
34
- example.yml
40
+ example/
41
+ formula.yml
42
+ test.rb
35
43
  Formula/
36
44
  ```
37
45
 
38
- `gembrew/example.yml` contains:
46
+ `gembrew/example/formula.yml` contains the gem metadata and source:
39
47
 
40
48
  ```yaml
41
49
  gem: example
50
+ version: "" # required
51
+ source:
52
+ type: rubygems
53
+ ```
54
+
55
+ `gembrew/example/test.rb` contains the generated default test:
42
56
 
43
- test: |-
44
- system bin/"example", "--version"
57
+ ```ruby
58
+ system bin/"example", "--version"
45
59
  ```
46
60
 
47
- Edit the configuration as needed, then generate `Formula/example.rb`:
61
+ Edit these files as needed, then generate `Formula/example.rb`:
48
62
 
49
63
  ```shell
50
64
  gembrew build
51
65
  ```
52
66
 
53
- Each YAML file under `gembrew/` describes one formula. Add more published gems
54
- with:
67
+ Each directory under `gembrew/` describes one formula. Add more published gems with:
55
68
 
56
69
  ```shell
57
70
  gembrew add another-gem
@@ -64,15 +77,15 @@ generate just one:
64
77
  gembrew build example
65
78
  ```
66
79
 
67
- The required settings are `gem` and exactly one of `test` or `test_from_file`.
80
+ The required settings in `formula.yml` are `gem`, `version`, and `source`.
68
81
  The output defaults to `Formula/NAME.rb`, where `NAME` is the configuration
69
- filename. Relative paths are resolved from the tap root.
82
+ directory name. Relative output paths are resolved from the tap root.
70
83
 
71
84
  ```yaml
72
85
  gem: example
73
-
74
- # Optional published gem version. The latest stable version is used by default.
75
86
  version: "1.2.3"
87
+ source:
88
+ type: rubygems
76
89
 
77
90
  # Optional gem metadata overrides:
78
91
  desc: Example command-line application
@@ -80,16 +93,47 @@ homepage: https://example.com
80
93
  license: MIT
81
94
  executable: example
82
95
 
83
- # Optional output override. The default for gembrew/example.yml is shown here.
96
+ # Optional additional Homebrew formula dependencies:
97
+ dependencies:
98
+ - bash
99
+ - bash :macos_only
100
+ - libffi :system_on_macos
101
+
102
+ # Optional output override. The default for gembrew/example is shown here.
84
103
  output: Formula/example.rb
104
+ ```
105
+
106
+ Tag a dependency with `:system_on_macos` when macOS provides it and Homebrew
107
+ should install its formula only on other platforms. Tag it with `:macos_only`
108
+ when Homebrew should install it only on macOS.
85
109
 
86
- test: |-
87
- system bin/"example", "--version"
110
+ You may add these optional Ruby hook files beside `formula.yml`:
88
111
 
89
- # Use this instead of `test` to load the test body from another file:
90
- # test_from_file: support/test.rb
112
+ ```text
113
+ install_extra.rb Runs after the standard gem installation
114
+ test.rb Defines the formula test
91
115
  ```
92
116
 
117
+ Gembrew generates a basic `COMMAND --version` test when `test.rb` is absent.
118
+ `gembrew init GEM` and `gembrew add GEM` create that test file so it can be
119
+ replaced with a meaningful functional test. Gembrew inserts `install_extra.rb`
120
+ at the end of the formula's `install` method, after installing the gem and
121
+ creating its executable wrapper.
122
+
123
+ To build the root gem from a GitHub tag while continuing to fetch its
124
+ dependencies from RubyGems, use:
125
+
126
+ ```yaml
127
+ source:
128
+ type: github
129
+ repo: owner/repository
130
+ # tag: v1.2.3
131
+ # gemspec: example.gemspec
132
+ ```
133
+
134
+ The tag defaults to `vVERSION`; the gemspec path defaults to
135
+ `GEMNAME.gemspec`.
136
+
93
137
  Gembrew resolves the generic Ruby dependency graph and generates a Homebrew
94
138
  `resource` for every runtime dependency. Original `.gem` archives are reused
95
139
  from RubyGems' local cache when available. Downloaded archives are retained in
@@ -105,14 +149,16 @@ The repository is mounted as a local tap, so formulae can be addressed by gem
105
149
  name inside the shell:
106
150
 
107
151
  ```shell
108
- brew style example
109
- brew audit --new --online example
110
- brew install --build-from-source example
111
- brew test example
152
+ brew style gembrew/tap/example
153
+ brew audit --new --online gembrew/tap/example
154
+ brew install --build-from-source gembrew/tap/example
155
+ brew test gembrew/tap/example
156
+ brew linkage --test gembrew/tap/example
112
157
  ```
113
158
 
114
- To test a published tap without mounting the local repository, open a pristine
115
- Homebrew shell:
159
+ To use a stock, current Homebrew container without mounting the local
160
+ repository or changing Homebrew's update, API, or cleanup behavior, open a
161
+ pristine shell:
116
162
 
117
163
  ```shell
118
164
  gembrew shell --pristine
@@ -125,9 +171,10 @@ gembrew check
125
171
  ```
126
172
 
127
173
  This rebuilds every configured formula, then runs Homebrew style, online audit,
128
- source installation, and the formula test in disposable Homebrew containers.
129
- Use `gembrew check example` to check only one formula. Gembrew invokes Docker
130
- directly; the tap does not need generated container support files.
174
+ source installation, the formula test, and linkage validation in disposable
175
+ Homebrew containers. Use `gembrew check example` to check only one formula.
176
+ Gembrew invokes Docker directly; the tap does not need generated container
177
+ support files.
131
178
 
132
179
  ## Contributing / Support
133
180
 
data/lib/gembrew/adder.rb CHANGED
@@ -18,18 +18,22 @@ module Gembrew
18
18
  raise Error, "Directory is not a Homebrew tap: #{project_path} (Formula/ does not exist)"
19
19
  end
20
20
 
21
- config_directory.mkpath
22
- raise Error, "Gem configuration already exists: #{config_path}" if config_path.exist?
21
+ gembrew_directory.mkpath
22
+ raise Error, "Gem configuration already exists: #{config_directory}" if config_directory.exist?
23
23
 
24
+ config_directory.mkpath
24
25
  config_path.write render
25
- config_path
26
+ test_path.write %[system bin/"#{gem_name}", "--version"\n]
27
+ config_directory
26
28
  end
27
29
 
28
30
  private
29
31
 
30
32
  def formula_path = project_path/'Formula'
31
- def config_directory = project_path/'gembrew'
32
- def config_path = config_directory/"#{gem_name}.yml"
33
+ def gembrew_directory = project_path/'gembrew'
34
+ def config_directory = gembrew_directory/gem_name
35
+ def config_path = config_directory/'formula.yml'
36
+ def test_path = config_directory/'test.rb'
33
37
 
34
38
  def render
35
39
  template = Pathname(__dir__)/'templates/gembrew.yml.erb'
@@ -4,7 +4,7 @@ require 'gembrew/generator'
4
4
  module Gembrew
5
5
  module Commands
6
6
  class Build < MisterBin::Command
7
- help 'Generate Homebrew formulae from gembrew/*.yml'
7
+ help 'Generate Homebrew formulae from gembrew/*/formula.yml'
8
8
 
9
9
  usage 'gembrew build [GEM]'
10
10
  usage 'gembrew build (-h|--help)'
@@ -9,7 +9,7 @@ module Gembrew
9
9
  usage 'gembrew shell [--pristine]'
10
10
  usage 'gembrew shell (-h|--help)'
11
11
 
12
- option '-p --pristine', 'Open without mounting the local tap'
12
+ option '-p --pristine', 'Open a stock, current Homebrew container'
13
13
 
14
14
  def run
15
15
  DockerShell.new.call pristine: args['--pristine']
@@ -4,16 +4,23 @@ require 'gembrew/error'
4
4
 
5
5
  module Gembrew
6
6
  class Config
7
+ Dependency = Data.define(:name, :tags) do
8
+ def system_on_macos? = tags.include? :system_on_macos
9
+ def macos_only? = tags.include? :macos_only
10
+ end
11
+
12
+ DEPENDENCY_TAGS = %w[:macos_only :system_on_macos].freeze
7
13
  DIRECTORY = 'gembrew'
14
+ FILENAME = 'formula.yml'
8
15
  ALLOWED_KEYS = %w[
9
- gem version output desc homepage license executable test test_from_file
16
+ gem version source output desc homepage license executable dependencies
10
17
  ].freeze
11
18
 
12
19
  attr_reader :path, :project_path
13
20
 
14
21
  def self.all(project_path = Pathname.pwd)
15
22
  project_path = Pathname(project_path).expand_path
16
- paths = (project_path/DIRECTORY).glob('*.yml').sort
23
+ paths = (project_path/DIRECTORY).glob("*/#{FILENAME}").sort
17
24
  raise Error, "No gem configurations found in #{project_path/DIRECTORY}" if paths.empty?
18
25
 
19
26
  paths.map { |path| new path, project_path: project_path }
@@ -22,7 +29,7 @@ module Gembrew
22
29
  def self.find(name, project_path = Pathname.pwd)
23
30
  validate_name! name
24
31
  project_path = Pathname(project_path).expand_path
25
- new project_path/DIRECTORY/"#{name}.yml", project_path: project_path
32
+ new project_path/DIRECTORY/name/FILENAME, project_path: project_path
26
33
  end
27
34
 
28
35
  def self.validate_name!(name)
@@ -38,26 +45,27 @@ module Gembrew
38
45
  validate
39
46
  end
40
47
 
41
- def name = path.basename('.yml').to_s
48
+ def name = path.dirname.basename.to_s
42
49
  def gem_name = data.fetch('gem').to_s
43
50
  def version = data['version']&.to_s
51
+ def source = data.fetch('source')
52
+ def source_type = source.fetch('type')
53
+ def source_repo = source['repo']
54
+ def source_tag = source['tag'] || "v#{version}"
55
+ def source_gemspec = source['gemspec'] || "#{gem_name}.gemspec"
44
56
  def output_path = (project_path/(data['output'] || "Formula/#{name}.rb")).expand_path
45
57
  def description = data['desc']
46
58
  def homepage = data['homepage']
47
59
  def license = data['license']
48
60
  def executable = data['executable']
49
61
 
50
- def test_body
51
- @test_body ||= if data.has_key?('test')
52
- data.fetch('test').to_s
53
- else
54
- test_path = project_path/data.fetch('test_from_file')
55
- raise Error, "Test file does not exist: #{test_path}" unless test_path.file?
56
-
57
- test_path.read
58
- end
62
+ def dependencies
63
+ @dependencies ||= data.fetch('dependencies', []).map { |value| parse_dependency value }
59
64
  end
60
65
 
66
+ def install_extra_body = hook_body 'install_extra.rb'
67
+ def test_body = hook_body 'test.rb'
68
+
61
69
  private
62
70
 
63
71
  attr_reader :data
@@ -77,15 +85,87 @@ module Gembrew
77
85
  raise Error, "Unknown configuration keys: #{unknown_keys.join(', ')}" if unknown_keys.any?
78
86
 
79
87
  require_value 'gem'
80
-
81
- return unless data.has_key?('test') == data.has_key?('test_from_file')
82
-
83
- raise Error, 'Provide exactly one of test or test_from_file'
88
+ require_value 'version'
89
+ require_value 'source'
90
+ validate_source
91
+ validate_dependencies
84
92
  end
85
93
 
86
94
  def require_value(key)
87
95
  value = data[key]
88
96
  raise Error, "#{key} is required" if value.nil? || value.to_s.empty?
89
97
  end
98
+
99
+ def validate_source
100
+ raise Error, 'source must be a mapping' unless source.is_a?(Hash)
101
+
102
+ validate_source_keys
103
+ case source['type']&.to_s
104
+ when 'rubygems' then validate_rubygems_source
105
+ when 'github' then validate_github_source
106
+ else raise Error, 'source.type must be github or rubygems'
107
+ end
108
+ end
109
+
110
+ def validate_source_keys
111
+ unknown_keys = source.keys - %w[type repo tag gemspec]
112
+ raise Error, "Unknown source keys: #{unknown_keys.join(', ')}" if unknown_keys.any?
113
+ end
114
+
115
+ def validate_rubygems_source
116
+ extra_keys = source.keys - ['type']
117
+ raise Error, "source.#{extra_keys.first} is only valid for GitHub sources" if extra_keys.any?
118
+ end
119
+
120
+ def validate_github_source
121
+ repo = source['repo']&.to_s
122
+ raise Error, 'source.repo is required for GitHub sources' if repo.nil? || repo.empty?
123
+ raise Error, 'source.repo must be in owner/repository form' unless repo.match?(%r{\A[^/\s]+/[^/\s]+\z})
124
+
125
+ validate_relative_source_path 'gemspec' if source.has_key?('gemspec')
126
+ raise Error, 'source.tag must not be empty' if source.has_key?('tag') && source['tag'].to_s.empty?
127
+ end
128
+
129
+ def validate_relative_source_path(key)
130
+ value = source[key].to_s
131
+ path = Pathname(value)
132
+ valid = !value.empty? && !path.absolute? && path.each_filename.none?('..')
133
+ raise Error, "source.#{key} must be a relative path within the archive" unless valid
134
+ end
135
+
136
+ def validate_dependencies
137
+ return unless data.has_key?('dependencies')
138
+
139
+ raise Error, 'dependencies must be a sequence' unless data['dependencies'].is_a?(Array)
140
+
141
+ dependencies
142
+ names = dependencies.map(&:name)
143
+ duplicate = names.find { |name| names.count(name) > 1 }
144
+ raise Error, "dependency #{duplicate.inspect} is specified more than once" if duplicate
145
+ end
146
+
147
+ def parse_dependency(value)
148
+ raise Error, 'each dependency must be a name followed by optional tags' unless value.is_a?(String)
149
+
150
+ values = value.split
151
+ name = values.shift
152
+ unless name && !name.start_with?(':')
153
+ raise Error, 'dependency name must be a non-empty string'
154
+ end
155
+
156
+ invalid_tag = values.find { |tag| !tag.is_a?(String) || !DEPENDENCY_TAGS.include?(tag) }
157
+ raise Error, "unknown dependency tag: #{invalid_tag.inspect}" if invalid_tag
158
+ raise Error, "dependency #{name.inspect} has duplicate tags" unless values.uniq.length == values.length
159
+
160
+ Dependency.new(name:, tags: values.map { |tag| tag.delete_prefix(':').to_sym }.freeze)
161
+ end
162
+
163
+ def hook_body(filename)
164
+ hook_path = path.dirname/filename
165
+ return unless hook_path.file?
166
+
167
+ body = hook_path.read
168
+ body unless body.strip.empty?
169
+ end
90
170
  end
91
171
  end
@@ -24,14 +24,17 @@ module Gembrew
24
24
  <<~BASH
25
25
  caption() { printf '\\n\\033[1;34m==> gembrew: %s\\033[0m\\n' "$1"; }
26
26
  formula=#{formula}
27
+ qualified_formula="gembrew/tap/$formula"
27
28
  caption "styling $formula"
28
- brew style "$formula"
29
+ brew style "$qualified_formula"
29
30
  caption "auditing $formula"
30
- brew audit --new --online "$formula"
31
+ brew audit --new --online "$qualified_formula"
31
32
  caption "installing $formula"
32
- brew install --build-from-source "$formula"
33
+ brew install --build-from-source "$qualified_formula"
33
34
  caption "testing $formula"
34
- brew test "$formula"
35
+ brew test "$qualified_formula"
36
+ caption "checking linkage for $formula"
37
+ brew linkage --test "$qualified_formula"
35
38
  BASH
36
39
  end
37
40
  end
@@ -14,6 +14,7 @@ module Gembrew
14
14
 
15
15
  def call(*command, interactive: false, pristine: false)
16
16
  docker_command = ['docker', 'run', '--rm', '--init', '--name', container_name]
17
+ docker_command << '--pull=always' if pristine
17
18
  docker_command << '-it' if interactive
18
19
  docker_command.concat environment(interactive: interactive, pristine: pristine)
19
20
  unless pristine
@@ -31,11 +32,15 @@ module Gembrew
31
32
  attr_reader :command_runner
32
33
 
33
34
  def environment(interactive:, pristine:)
34
- result = %w[
35
- --env HOMEBREW_NO_AUTO_UPDATE=1
36
- --env HOMEBREW_NO_INSTALL_CLEANUP=1
37
- --env HOMEBREW_NO_INSTALL_FROM_API=1
38
- ]
35
+ result = if pristine
36
+ []
37
+ else
38
+ %w[
39
+ --env HOMEBREW_NO_AUTO_UPDATE=1
40
+ --env HOMEBREW_NO_INSTALL_CLEANUP=1
41
+ --env HOMEBREW_NO_INSTALL_FROM_API=1
42
+ ]
43
+ end
39
44
  prompt = pristine ? 'homebrew' : 'gembrew'
40
45
  result.push '--env', "PS1=\\n\\n#{prompt} $ " if interactive
41
46
  result
@@ -11,7 +11,7 @@ module Gembrew
11
11
  end
12
12
 
13
13
  def call
14
- resolution = resolver.resolve(config.gem_name, config.version)
14
+ resolution = resolver.resolve(config.gem_name, config.version, source: config.source)
15
15
  output_path = config.output_path
16
16
  output_path.dirname.mkpath
17
17
  output_path.write renderer.render(formula_data(resolution))
@@ -35,14 +35,30 @@ module Gembrew
35
35
 
36
36
  def metadata(spec)
37
37
  {
38
- description: config.description || spec.summary,
39
- homepage: config.homepage || inferred_homepage(spec),
40
- license: config.license || spec.license || spec.licenses.first,
41
- executable: config.executable || spec.executables.first || spec.name,
42
- test_body: config.test_body,
38
+ description: config.description || spec.summary,
39
+ homepage: config.homepage || inferred_homepage(spec),
40
+ license: config.license || spec.license || spec.licenses.first,
41
+ executable: executable(spec),
42
+ source_type: config.source_type,
43
+ source_repo: config.source_repo,
44
+ source_tag: config.source_tag,
45
+ source_gemspec: config.source_gemspec,
46
+ dependencies: formula_dependencies,
47
+ install_extra_body: config.install_extra_body,
48
+ test_body: config.test_body || %[system bin/#{executable(spec).inspect}, "--version"\n],
43
49
  }
44
50
  end
45
51
 
52
+ def executable(spec)
53
+ config.executable || spec.executables.first || spec.name
54
+ end
55
+
56
+ def formula_dependencies
57
+ ([Config::Dependency.new(name: 'ruby', tags: [])] + config.dependencies)
58
+ .uniq(&:name)
59
+ .sort_by(&:name)
60
+ end
61
+
46
62
  def formula_name(gem_name)
47
63
  gem_name.split(/[-_]/).map(&:capitalize).join
48
64
  end
@@ -0,0 +1,51 @@
1
+ require 'fileutils'
2
+ require 'open3'
3
+ require 'pathname'
4
+ require 'gembrew/error'
5
+ require 'gembrew/reporter'
6
+
7
+ module Gembrew
8
+ class GithubArchiveStore
9
+ def initialize(cache_path:, reporter: Reporter.new, command_runner: nil)
10
+ @cache_path = Pathname(cache_path)
11
+ @reporter = reporter
12
+ @command_runner = command_runner || method(:run_command)
13
+ @cache_path.mkpath
14
+ end
15
+
16
+ def fetch(repository, tag)
17
+ name = repository.tr '/', '-'
18
+ path = @cache_path/"#{name}-#{tag.tr '/', '-'}.tar.gz"
19
+ if path.file? && !path.empty?
20
+ reporter.archive :github_cache, repository, tag
21
+ return path
22
+ end
23
+
24
+ reporter.archive :downloading, repository, tag
25
+ url = "https://github.com/#{repository}/archive/refs/tags/#{tag}.tar.gz"
26
+ command_runner.call 'curl', '--fail', '--location', '--silent', '--show-error',
27
+ '--output', path.to_s, url, chdir: @cache_path
28
+ raise Error, "GitHub archive is empty: #{url}" unless path.file? && !path.empty?
29
+
30
+ path
31
+ end
32
+
33
+ def extract(archive, destination)
34
+ destination = Pathname(destination)
35
+ destination.mkpath
36
+ command_runner.call 'tar', '-xzf', archive.to_s, '-C', destination.to_s, chdir: destination
37
+ destination
38
+ end
39
+
40
+ private
41
+
42
+ attr_reader :reporter, :command_runner
43
+
44
+ def run_command(*command, chdir:)
45
+ result, status = Open3.capture2e(*command, chdir: chdir.to_s)
46
+ raise Error, result unless status.success?
47
+
48
+ result
49
+ end
50
+ end
51
+ end
@@ -20,6 +20,8 @@ module Gembrew
20
20
  formula_path.mkpath
21
21
  config_directory.mkpath
22
22
  readme_path.write render_readme unless readme_path.exist?
23
+ workflow_path.dirname.mkpath
24
+ workflow_path.write workflow_template.read unless workflow_path.exist?
23
25
  Adder.new(gem_name, project_path: project_path).call if gem_name
24
26
 
25
27
  project_path
@@ -39,6 +41,14 @@ module Gembrew
39
41
  project_path/'README.md'
40
42
  end
41
43
 
44
+ def workflow_path
45
+ project_path/'.github/workflows/test.yml'
46
+ end
47
+
48
+ def workflow_template
49
+ Pathname(__dir__)/'templates/test.yml'
50
+ end
51
+
42
52
  def entries
43
53
  @entries ||= project_path.children.reject { |path| path.basename.to_s == '.git' }
44
54
  end
@@ -7,6 +7,7 @@ module Gembrew
7
7
  LABELS = {
8
8
  rubygems_cache: ['g', 'RubyGems cache'],
9
9
  gembrew_cache: ['g', 'Gembrew cache'],
10
+ github_cache: ['g', 'GitHub cache'],
10
11
  downloading: %w[y Downloading],
11
12
  }.freeze
12
13
 
@@ -6,6 +6,7 @@ require 'rubygems/package'
6
6
  require 'tmpdir'
7
7
  require 'gembrew/archive_store'
8
8
  require 'gembrew/error'
9
+ require 'gembrew/github_archive_store'
9
10
  require 'gembrew/reporter'
10
11
 
11
12
  module Gembrew
@@ -15,21 +16,22 @@ module Gembrew
15
16
  class Resolver
16
17
  attr_reader :cache_root
17
18
 
18
- def initialize(cache_root: nil, reporter: Reporter.new, command_runner: nil, archive_store: nil)
19
+ def initialize(cache_root: nil, reporter: Reporter.new, command_runner: nil, archive_store: nil,
20
+ github_archive_store: nil)
19
21
  @cache_root = Pathname(cache_root || default_cache_root)
20
22
  @reporter = reporter
21
23
  @command_runner = command_runner || method(:run_command)
22
24
  @archive_store = archive_store || ArchiveStore.new(
23
25
  cache_path: @cache_root/'gems', reporter: reporter, command_runner: @command_runner
24
26
  )
27
+ @github_archive_store = github_archive_store
25
28
  temporary_root.mkpath
26
29
  end
27
30
 
28
- def resolve(name, version = nil)
31
+ def resolve(name, version, source:)
29
32
  Dir.mktmpdir('build-', temporary_root.to_s) do |directory|
30
33
  temporary = Pathname(directory)
31
- root_archive = archive_store.fetch(name, version, temporary)
32
- root_spec = Gem::Package.new(root_archive.to_s).spec
34
+ root_archive, root_spec = root_source name, version, source, temporary
33
35
  dependencies = dependency_specs(lock(root_spec, temporary), root_spec)
34
36
 
35
37
  reporter.collecting dependencies.length
@@ -45,6 +47,57 @@ module Gembrew
45
47
 
46
48
  attr_reader :reporter, :command_runner, :archive_store
47
49
 
50
+ def github_archive_store
51
+ @github_archive_store ||= GithubArchiveStore.new(
52
+ cache_path: cache_root/'github', reporter: reporter, command_runner: command_runner
53
+ )
54
+ end
55
+
56
+ def root_source(name, version, source_config, temporary)
57
+ return rubygems_root_source(name, version, temporary) if source_config.fetch('type') == 'rubygems'
58
+
59
+ github_root_source name, version, source_config, temporary
60
+ end
61
+
62
+ def rubygems_root_source(name, version, temporary)
63
+ archive = archive_store.fetch(name, version, temporary)
64
+ [archive, Gem::Package.new(archive.to_s).spec]
65
+ end
66
+
67
+ def github_root_source(name, version, source_config, temporary)
68
+ repository = source_config.fetch('repo')
69
+ tag = source_config['tag'] || "v#{version}"
70
+ gemspec_path = source_config['gemspec'] || "#{name}.gemspec"
71
+ archive = github_archive_store.fetch repository, tag
72
+ root = github_project_root archive, temporary
73
+ spec = load_github_spec root/gemspec_path, gemspec_path
74
+ validate_github_spec spec, name, version
75
+
76
+ [archive, spec]
77
+ end
78
+
79
+ def github_project_root(archive, temporary)
80
+ extracted = github_archive_store.extract archive, temporary/'source'
81
+ roots = extracted.children.select(&:directory?)
82
+ raise Error, 'Expected one project directory in the GitHub archive' unless roots.one?
83
+
84
+ roots.first
85
+ end
86
+
87
+ def load_github_spec(gemspec, gemspec_path)
88
+ raise Error, "Gemspec not found in GitHub archive: #{gemspec_path}" unless gemspec.file?
89
+
90
+ spec = Dir.chdir(gemspec.dirname) { Gem::Specification.load(gemspec.basename.to_s) }
91
+ raise Error, "Could not load gemspec from GitHub archive: #{gemspec_path}" unless spec
92
+
93
+ spec
94
+ end
95
+
96
+ def validate_github_spec(spec, name, version)
97
+ raise Error, "Gemspec name #{spec.name} does not match configured gem #{name}" unless spec.name == name
98
+ raise Error, "GitHub gemspec version #{spec.version} does not match #{version}" unless spec.version == Gem::Version.new(version)
99
+ end
100
+
48
101
  def resource(spec, temporary, index, total)
49
102
  archive = archive_store.fetch spec.name, spec.version, temporary, index: index, total: total
50
103
  Resource.new spec.name, spec.version.to_s, Digest::SHA256.file(archive).hexdigest
@@ -62,10 +115,11 @@ module Gembrew
62
115
  def lock(root_spec, temporary)
63
116
  gemfile = temporary/'Gemfile'
64
117
  lockfile = temporary/'Gemfile.lock'
65
- gemfile.write <<~RUBY
66
- source "https://rubygems.org"
67
- gem #{root_spec.name.inspect}, "= #{root_spec.version}"
68
- RUBY
118
+ dependencies = root_spec.runtime_dependencies.map do |dependency|
119
+ requirements = dependency.requirement.requirements.map { |operator, version| "#{operator} #{version}" }
120
+ "gem #{dependency.name.inspect}, #{requirements.map(&:inspect).join(', ')}"
121
+ end
122
+ gemfile.write([[%[source "https://rubygems.org"]], dependencies, ['']].flatten.join("\n"))
69
123
 
70
124
  reporter.resolving root_spec.name, root_spec.version
71
125
  command_runner.call(
@@ -23,6 +23,11 @@ Install [Gembrew](https://github.com/DannyBen/gembrew):
23
23
  gem install gembrew
24
24
  ```
25
25
 
26
+ The generated GitHub Actions workflow uses Homebrew's `test-bot` on Linux,
27
+ Apple Silicon, and Intel macOS. Pull requests test changed formulae; pushes,
28
+ and manual runs test every formula. Homebrew style checks apply only to files in
29
+ `Formula/`; Gembrew's Ruby hook files are not standalone Homebrew formulae.
30
+
26
31
  Generate all formulae:
27
32
 
28
33
  ```shell
@@ -48,6 +53,14 @@ Add another Ruby gem:
48
53
  gembrew add another-gem
49
54
  ```
50
55
 
56
+ Each gem has a `gembrew/NAME/formula.yml` configuration. You may add these
57
+ optional Ruby hook files beside it:
58
+
59
+ ```text
60
+ install_extra.rb Runs after the standard gem installation
61
+ test.rb Defines the formula test
62
+ ```
63
+
51
64
  Open an interactive Homebrew environment:
52
65
 
53
66
  ```shell
@@ -1,16 +1,34 @@
1
- # Generated by Gembrew: https://github.com/dannyben/gembrew
2
- # Manual edits are not recommended. Update gembrew/*.yml and regenerate with:
3
- # gem install gembrew
4
- # gembrew build
5
-
6
1
  class <%= formula_name %> < Formula
7
2
  desc <%= description.inspect %>
8
3
  homepage <%= homepage.inspect %>
4
+ <% if source_type == 'github' -%>
5
+ url "https://github.com/<%= source_repo %>/archive/refs/tags/<%= source_tag %>.tar.gz"
6
+ <% else -%>
9
7
  url "https://rubygems.org/downloads/<%= gem_name %>-<%= version %>.gem"
8
+ <% end -%>
10
9
  sha256 "<%= sha256 %>"
11
10
  license <%= license.inspect %>
12
11
 
13
- depends_on "ruby"
12
+ <% formula_dependencies = dependencies.reject { |dependency| dependency.system_on_macos? || dependency.macos_only? } -%>
13
+ <% macos_dependencies = dependencies.select(&:system_on_macos?) -%>
14
+ <% macos_only_dependencies = dependencies.select(&:macos_only?) -%>
15
+ <% formula_dependencies.each do |dependency| -%>
16
+ depends_on <%= dependency.name.inspect %>
17
+ <% end -%>
18
+ <% if formula_dependencies.any? && (macos_dependencies.any? || macos_only_dependencies.any?) -%>
19
+
20
+ <% end -%>
21
+ <% macos_dependencies.each do |dependency| -%>
22
+ uses_from_macos <%= dependency.name.inspect %>
23
+ <% end -%>
24
+ <% if macos_only_dependencies.any? -%>
25
+
26
+ on_macos do
27
+ <% macos_only_dependencies.each do |dependency| -%>
28
+ depends_on <%= dependency.name.inspect %>
29
+ <% end -%>
30
+ end
31
+ <% end -%>
14
32
  <% resources.each do |resource| -%>
15
33
 
16
34
  resource <%= resource.name.inspect %> do
@@ -27,13 +45,23 @@ class <%= formula_name %> < Formula
27
45
  "--ignore-dependencies", "--install-dir", libexec, "--no-document"
28
46
  end
29
47
 
48
+ <% if source_type == 'github' -%>
49
+ system "gem", "build", "<%= source_gemspec %>"
50
+ system "gem", "install", "--ignore-dependencies", "<%= gem_name %>-#{version}.gem",
51
+ "--install-dir", libexec, "--no-document"
52
+ <% else -%>
30
53
  system "gem", "install", cached_download,
31
54
  "--ignore-dependencies", "--install-dir", libexec, "--no-document"
55
+ <% end -%>
32
56
 
33
57
  (bin/<%= executable.inspect %>).write_env_script libexec/"bin/<%= executable %>", GEM_HOME: ENV.fetch("GEM_HOME")
58
+ <% if install_extra_body -%>
59
+
60
+ <%= install_extra_body.lines(chomp: true).map { |line| line.strip.empty? ? '' : " #{line}" }.join("\n") %>
61
+ <% end -%>
34
62
  end
35
63
 
36
64
  test do
37
- <%= test_body.lines.map { |line| " #{line}" }.join %>
65
+ <%= test_body.lines(chomp: true).map { |line| line.strip.empty? ? '' : " #{line}" }.join("\n") %>
38
66
  end
39
67
  end
@@ -1,14 +1,29 @@
1
1
  gem: <%= gem_name %>
2
+ version: "" # required
2
3
 
3
- test: |-
4
- system bin/"<%= gem_name %>", "--version"
4
+ # Required source. Use the published RubyGem:
5
+ source:
6
+ type: rubygems
5
7
 
6
- # Optional overrides:
7
- # version: "1.2.3"
8
+ # Or replace the source above with a GitHub tag archive:
9
+ # source:
10
+ # type: github
11
+ # repo: owner/repository # required for GitHub
12
+ # tag: v1.2.3 # optional; defaults to vVERSION
13
+ # gemspec: <%= gem_name %>.gemspec # optional; defaults to GEMNAME.gemspec
14
+
15
+ # Optional metadata and formula overrides:
8
16
  # desc: Example command-line application
9
17
  # homepage: https://example.com
10
18
  # license: MIT
11
19
  # executable: <%= gem_name %>
20
+ # output: Formula/<%= gem_name %>.rb
21
+ # dependencies:
22
+ # - bash
23
+ # - bash :macos_only
24
+ # - libffi :system_on_macos
12
25
 
13
- # To load the Homebrew test body from a Ruby file, replace `test` above with:
14
- # test_from_file: support/test.rb
26
+ # Optional Ruby hook files:
27
+ #
28
+ # install_extra.rb Runs after the standard gem installation
29
+ # test.rb Defines the formula test
@@ -0,0 +1,105 @@
1
+ name: Test Formulae
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+ - master
8
+ pull_request:
9
+ workflow_dispatch:
10
+
11
+ permissions: {}
12
+
13
+ defaults:
14
+ run:
15
+ shell: bash -xeuo pipefail {0}
16
+
17
+ jobs:
18
+ test-bot:
19
+ name: ${{ matrix.os }}
20
+ runs-on: ${{ matrix.os }}
21
+ timeout-minutes: 60
22
+ container: ${{ fromJSON(toJSON(matrix.container)) }} # zizmor: ignore[unpinned-images]
23
+ strategy:
24
+ fail-fast: false
25
+ matrix:
26
+ os:
27
+ - macos-26
28
+ - macos-15-intel
29
+ include:
30
+ - os: ubuntu-latest
31
+ container:
32
+ image: ghcr.io/homebrew/brew:main
33
+ options: --privileged
34
+ permissions:
35
+ actions: read
36
+ checks: read
37
+ contents: read
38
+ pull-requests: read
39
+
40
+ steps:
41
+ - name: Set up Homebrew
42
+ id: set-up-homebrew
43
+ uses: Homebrew/actions/setup-homebrew@b35a29a0f83ee8b8ca07b219fc8db3d7bb88ab49 # 2026.08.10.2
44
+ with:
45
+ token: ${{ secrets.GITHUB_TOKEN }}
46
+
47
+ - name: Cache Homebrew Bundler gems
48
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
49
+ with:
50
+ path: ${{ steps.set-up-homebrew.outputs.gems-path }}
51
+ key: ${{ matrix.os }}-rubygems-${{ steps.set-up-homebrew.outputs.gems-hash }}
52
+ restore-keys: ${{ matrix.os }}-rubygems-
53
+
54
+ - name: Clean runner
55
+ run: brew test-bot --only-cleanup-before
56
+
57
+ - name: Set up test-bot
58
+ run: brew test-bot --only-setup
59
+
60
+ - name: Check tap syntax
61
+ run: |
62
+ tap="${GITHUB_REPOSITORY/homebrew-/}"
63
+ tap_path="${{ steps.set-up-homebrew.outputs.repository-path }}"
64
+ brew style "$tap_path/Formula"
65
+ brew readall --aliases --os=all --arch=all "$tap"
66
+ brew audit --except=installed --tap="$tap"
67
+
68
+ - name: Test changed formulae
69
+ if: github.event_name == 'pull_request'
70
+ run: brew test-bot --only-formulae
71
+
72
+ - name: Collect all formulae
73
+ id: all-formulae
74
+ if: github.event_name != 'pull_request'
75
+ run: |
76
+ shopt -s nullglob
77
+ tap_path="${{ steps.set-up-homebrew.outputs.repository-path }}"
78
+ paths=("$tap_path"/Formula/*.rb)
79
+
80
+ if (( ${#paths[@]} == 0 )); then
81
+ echo "No formulae found in $tap_path/Formula/"
82
+ exit 1
83
+ fi
84
+
85
+ formulae=()
86
+ for path in "${paths[@]}"; do
87
+ formula="${path##*/}"
88
+ formulae+=("${formula%.rb}")
89
+ done
90
+
91
+ joined=$(IFS=,; echo "${formulae[*]}")
92
+ echo "formulae=${joined}" >> "$GITHUB_OUTPUT"
93
+
94
+ - name: Test all formulae
95
+ if: github.event_name != 'pull_request'
96
+ run: >-
97
+ brew test-bot --only-formulae
98
+ --testing-formulae="${{ steps.all-formulae.outputs.formulae }}"
99
+
100
+ - name: Upload bottles as artifacts
101
+ if: always() && github.event_name == 'pull_request'
102
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
103
+ with:
104
+ name: bottles_${{ matrix.os }}
105
+ path: "*.bottle.*"
@@ -1,3 +1,3 @@
1
1
  module Gembrew
2
- VERSION = '0.1.1'
2
+ VERSION = '0.2.0'
3
3
  end
data/lib/gembrew.rb CHANGED
@@ -6,6 +6,7 @@ require 'gembrew/docker_shell'
6
6
  require 'gembrew/docker_runner'
7
7
  require 'gembrew/error'
8
8
  require 'gembrew/generator'
9
+ require 'gembrew/github_archive_store'
9
10
  require 'gembrew/initializer'
10
11
  require 'gembrew/reporter'
11
12
  require 'gembrew/renderer'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gembrew
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Danny Ben Shitrit
@@ -77,6 +77,7 @@ files:
77
77
  - lib/gembrew/docker_shell.rb
78
78
  - lib/gembrew/error.rb
79
79
  - lib/gembrew/generator.rb
80
+ - lib/gembrew/github_archive_store.rb
80
81
  - lib/gembrew/initializer.rb
81
82
  - lib/gembrew/renderer.rb
82
83
  - lib/gembrew/reporter.rb
@@ -84,6 +85,7 @@ files:
84
85
  - lib/gembrew/templates/README.md.erb
85
86
  - lib/gembrew/templates/formula.rb.erb
86
87
  - lib/gembrew/templates/gembrew.yml.erb
88
+ - lib/gembrew/templates/test.yml
87
89
  - lib/gembrew/version.rb
88
90
  homepage: https://github.com/DannyBen/gembrew
89
91
  licenses: