Skip to content

Commit a88cb57

Browse files
committed
feat(bundler): support git gems
1 parent b04ef85 commit a88cb57

2 files changed

Lines changed: 70 additions & 39 deletions

File tree

hermeto/core/package_managers/bundler/main.py

Lines changed: 46 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -28,14 +28,12 @@
2828
log = logging.getLogger(__name__)
2929

3030
CONFIG_OVERRIDE = "bundler/config_override"
31+
GIT_CONFIG_OVERRIDE = "bundler/git_config"
3132

3233

3334
def fetch_bundler_source(request: Request) -> RequestOutput:
3435
"""Resolve and process all bundler packages."""
3536
components: list[Component] = []
36-
environment_variables: list[EnvironmentVariable] = (
37-
_prepare_environment_variables_for_hermetic_build()
38-
)
3937
project_files: list[ProjectFile] = []
4038
git_paths = []
4139

@@ -48,7 +46,11 @@ def fetch_bundler_source(request: Request) -> RequestOutput:
4846
)
4947
components.extend(_comps)
5048
git_paths.extend(_git_paths)
51-
project_files.append(
49+
50+
environment_variables: list[EnvironmentVariable] = (
51+
_prepare_environment_variables_for_hermetic_build(has_git_deps=bool(git_paths))
52+
)
53+
project_files.extend(
5254
_prepare_for_hermetic_build(request.source_dir, request.output_dir, git_paths)
5355
)
5456

@@ -63,17 +65,17 @@ def fetch_bundler_source(request: Request) -> RequestOutput:
6365
)
6466

6567

66-
# Aliases for git dependency name and git dependency name as
67-
# it is written to file system:
68+
# Aliases for git dependency name, file system name, and remote URL:
6869
DepName = str
6970
FSDepName = str
71+
DepURL = str
7072

7173

7274
def _resolve_bundler_package(
7375
package_dir: RootedPath,
7476
output_dir: RootedPath,
7577
binary_filters: BundlerBinaryFilters | None = None,
76-
) -> tuple[list[Component], list[tuple[DepName, FSDepName]]]:
78+
) -> tuple[list[Component], list[tuple[DepName, FSDepName, DepURL]]]:
7779
"""Process a request for a single bundler package."""
7880
deps_dir = output_dir.join_within_root("deps", "bundler")
7981
deps_dir.path.mkdir(parents=True, exist_ok=True)
@@ -104,7 +106,7 @@ def _resolve_bundler_package(
104106
else:
105107
properties = []
106108
if isinstance(dep, GitDependency):
107-
git_paths.append((dep.name, dep.repo_name + "-" + dep.ref[:12]))
109+
git_paths.append((dep.name, dep.repo_name + "-" + dep.ref[:12], str(dep.url)))
108110

109111
c = Component(name=dep.name, version=dep.version, purl=dep.purl, properties=properties)
110112
components.append(c)
@@ -183,16 +185,28 @@ def _get_repo_name_from_origin_remote(package_dir: RootedPath) -> str:
183185
return str(resolved_path)
184186

185187

186-
def _prepare_environment_variables_for_hermetic_build() -> list[EnvironmentVariable]:
187-
return [
188+
def _prepare_environment_variables_for_hermetic_build(
189+
has_git_deps: bool = False,
190+
) -> list[EnvironmentVariable]:
191+
env_vars = [
188192
# Contains path to a directory where a new config could be found.
189193
EnvironmentVariable(name="BUNDLE_APP_CONFIG", value="${output_dir}/" + CONFIG_OVERRIDE),
190194
]
195+
if has_git_deps:
196+
# Redirect git remote URLs to pre-fetched local clones so that bundler
197+
# installs git gems into BUNDLE_PATH (not in-place from the clone).
198+
# See: https://git-scm.com/docs/git-config#Documentation/git-config.txt-urlltbasegtinsteadOf
199+
env_vars.append(
200+
EnvironmentVariable(
201+
name="GIT_CONFIG_GLOBAL", value="${output_dir}/" + GIT_CONFIG_OVERRIDE
202+
)
203+
)
204+
return env_vars
191205

192206

193207
def _prepare_for_hermetic_build(
194208
source_dir: RootedPath, output_dir: RootedPath, git_paths: list | None = None
195-
) -> ProjectFile:
209+
) -> list[ProjectFile]:
196210
"""Prepare a package for hermetic build by injecting necessary config."""
197211
potential_bundle_config = source_dir.join_within_root(".bundle/config").path
198212
hermetic_config = dedent(
@@ -205,30 +219,6 @@ def _prepare_for_hermetic_build(
205219
BUNDLE_VERSION: "system"
206220
"""
207221
)
208-
# Note: if a package depends on a git revision then the following variables
209-
# are necessary for a hermetic build:
210-
# BUNDLE_DISABLE_LOCAL_BRANCH_CHECK
211-
# BUNDLE_DISABLE_LOCAL_REVISION_CHECK
212-
# because otherwise some (potentially all, depending on exact set of
213-
# ecosystem components versions, environment variables and celestial
214-
# alignment) Bundler versions will try to fetch the latest changes of the
215-
# remotes which may be present even when instructed not to with --local
216-
# flag.
217-
# See https://bundler.io/guides/git.html#local-git-repos for details.
218-
# (or https://github.com/rubygems/bundler-site/blob/
219-
# 9ff3b76e9866524ecefe165633ffb547f0004a99/source/guides/git.html.md
220-
# if the link above ceases to exist).
221-
if git_paths is not None:
222-
hermetic_config += 'BUNDLE_DISABLE_LOCAL_BRANCH_CHECK: "true"\n'
223-
hermetic_config += 'BUNDLE_DISABLE_LOCAL_REVISION_CHECK: "true"\n'
224-
for packname, dirname in git_paths:
225-
# "-" in variable names is deprecated in Bundler and now generates
226-
# a warning and a suggestion to replace all dashes with triple
227-
# underscores. Package names sometimes contain dashes:
228-
varname = "BUNDLE_LOCAL." + packname.upper().replace("-", "___")
229-
location = "${output_dir}/deps/bundler/" + dirname
230-
config_entry = varname + f': "{location}"'
231-
hermetic_config += f"{config_entry}\n"
232222
if potential_bundle_config.is_file():
233223
config_data = potential_bundle_config.read_text()
234224
config_data += hermetic_config
@@ -241,4 +231,24 @@ def _prepare_for_hermetic_build(
241231
else:
242232
config_data = hermetic_config
243233
overriding_bundler_config_path = output_dir.join_within_root(CONFIG_OVERRIDE, "config").path
244-
return ProjectFile(abspath=overriding_bundler_config_path, template=config_data)
234+
project_files = [ProjectFile(abspath=overriding_bundler_config_path, template=config_data)]
235+
236+
if git_paths:
237+
# Redirect each git remote URL to its pre-fetched local clone via a git
238+
# url.insteadOf rewrite. This causes bundler to clone from the local
239+
# filesystem (no network) and then check out the gem into BUNDLE_PATH,
240+
# so the gem is present in the runtime image after a multi-stage build.
241+
#
242+
# Using BUNDLE_LOCAL.* instead would make bundler call set_install_path!
243+
# with the clone directory, causing it to use the gem in-place and never
244+
# copy it into BUNDLE_PATH — leaving the gem absent from the runtime image.
245+
git_config_lines = []
246+
for _packname, dirname, url in git_paths:
247+
clone_file_url = "file://${output_dir}/deps/bundler/" + dirname + "/"
248+
git_config_lines.append(f'[url "{clone_file_url}"]\n\tinsteadOf = {url}\n')
249+
git_config_path = output_dir.join_within_root(GIT_CONFIG_OVERRIDE).path
250+
project_files.append(
251+
ProjectFile(abspath=git_config_path, template="".join(git_config_lines))
252+
)
253+
254+
return project_files

tests/unit/package_managers/bundler/test_main.py

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,8 @@ def test_prepare_for_hermetic_build_injects_necessary_variable_into_empty_config
8383

8484
result = _prepare_for_hermetic_build(rooted_tmp_path, rooted_tmp_path)
8585

86-
assert result.template == expected_config_contents
86+
assert len(result) == 1
87+
assert result[0].template == expected_config_contents
8788

8889

8990
def test_prepare_for_hermetic_build_injects_necessary_variable_into_existing_config(
@@ -115,7 +116,8 @@ def test_prepare_for_hermetic_build_injects_necessary_variable_into_existing_con
115116

116117
result = _prepare_for_hermetic_build(rooted_tmp_path, rooted_tmp_path)
117118

118-
assert result.template == existing_preamble + expected_config_contents
119+
assert len(result) == 1
120+
assert result[0].template == existing_preamble + expected_config_contents
119121

120122

121123
def test_prepare_for_hermetic_build_injects_necessary_variable_into_existing_alternate_config(
@@ -152,7 +154,26 @@ def test_prepare_for_hermetic_build_injects_necessary_variable_into_existing_alt
152154
ge.return_value = str(expected_alternate_config_location.parent)
153155
result = _prepare_for_hermetic_build(rooted_tmp_path, rooted_tmp_path)
154156

155-
assert result.template == existing_preamble + expected_alternate_config_contents
157+
assert len(result) == 1
158+
assert result[0].template == existing_preamble + expected_alternate_config_contents
159+
160+
161+
def test_prepare_for_hermetic_build_generates_git_config_for_git_deps(
162+
rooted_tmp_path: RootedPath,
163+
) -> None:
164+
git_paths = [
165+
("yabeda-activejob", "yabeda-activejob-1894d97a925d", "https://github.com/org/yabeda-activejob.git"),
166+
("my-gem", "my-gem-abc123def456", "https://github.com/org/my-gem.git"),
167+
]
168+
169+
result = _prepare_for_hermetic_build(rooted_tmp_path, rooted_tmp_path, git_paths=git_paths)
170+
171+
assert len(result) == 2
172+
git_config = result[1].template
173+
assert '[url "file://${output_dir}/deps/bundler/yabeda-activejob-1894d97a925d/"]' in git_config
174+
assert "\tinsteadOf = https://github.com/org/yabeda-activejob.git" in git_config
175+
assert '[url "file://${output_dir}/deps/bundler/my-gem-abc123def456/"]' in git_config
176+
assert "\tinsteadOf = https://github.com/org/my-gem.git" in git_config
156177

157178

158179
@mock.patch("hermeto.core.package_managers.bundler.main.get_repo_id")

0 commit comments

Comments
 (0)