Skip to content

Commit a800e1b

Browse files
schneemsedmorley
andauthored
Configure gem install location via GEM_* (#402)
* Configure gem install location via GEM_* The previous logic used the `BUNDLE_PATH` environment variable to direct bundler where to install gems. This had the side effect of adding additional paths to the directory structure, so the files wouldn't be in `<layer-path>` they would be in a `<layer-path>/ruby/<major>.<minor>.0/` directory such as `<layer-path>/ruby/3.4.0`. The classic buildpack handles this by shelling out to Ruby https://github.com/heroku/heroku-buildpack-ruby/blob/b3ccc41885135ae495c604a512b523c81241914d/lib/language_pack/ruby.rb#L157. This change diverges and takes an alternative approach, using the `GEM_HOME` and `GEM_PATH` environment variables to configure installation location rather than configuring bundler directly. When `bundle install` is run, it will install into the first `GEM_PATH` (there can be multiple). This install will be direct (without any additional directories under it). This is what we want. This also allows us to remove `BUNDLE_BIN` which is no longer needed (bundler will install binaries into `GEM_PATH/bin`). - `BUNDLE_DEPLOYMENT` does more than forces the `Gemfile.lock` to be frozen, it also installs gems into `vendor/bundle`, which we don't want. This has been changed to `BUNDLE_FROZEN=1`. Unfortunately, removing `BUNDLE_PATH` causes the automatic clean after install logic (`BUNDLE_CLEAN=1`) to error with a warning saying that it is unsafe because there's no explicit path. To work around this, I added a manual call to `bundle clean --force` after the `bundle install`. This requires I also remove the `BUNDLE_CLEAN=1` environment variable. Because this changes the structure of the underlying gems on disk, I need to clear the cache manually by updating the key to `v2`. * Clear old files Gems will now be installed directly into `<layer-path>` instead of `<layer-path>/ruby/X.Y.0`. This mechanism cleans previously installed gems left in the cache by checking the cache key of `v1`. This cache will also be evicted when the application changes Ruby version. * Apply suggestions from code review Co-authored-by: Ed Morley <501702+edmorley@users.noreply.github.com> --------- Co-authored-by: Ed Morley <501702+edmorley@users.noreply.github.com>
1 parent b5fd48a commit a800e1b

File tree

4 files changed

+71
-47
lines changed

4 files changed

+71
-47
lines changed

buildpacks/ruby/CHANGELOG.md

+8
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Changed
11+
12+
- Gem install behavior and configuration ([#402](https://github.com/heroku/buildpacks-ruby/pull/402))
13+
- Gem install path is now configured with `GEM_HOME` and `GEM_PATH` instead of `BUNDLE_PATH`.
14+
- Cleaning gems is now accomplished via running `bundle clean --force`. Previously it was accomplished by setting `BUNDLE_CLEAN=1`.
15+
- The `BUNDLE_DEPLOYMENT=1` environment variable is changed to `BUNDLE_FROZEN=1`.
16+
- The `BUNDLE_BIN` environment variable is no longer set.
17+
1018
## [5.1.0] - 2025-02-28
1119

1220
### Changed

buildpacks/ruby/src/layers/bundle_install_layer.rs

+46-28
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ const SKIP_DIGEST_ENV_KEY: &str = "HEROKU_SKIP_BUNDLE_DIGEST";
4242
/// A failsafe, if a programmer made a mistake in the caching logic, rev-ing this
4343
/// key will force a re-run of `bundle install` to ensure the cache is correct
4444
/// on the next build.
45-
pub(crate) const FORCE_BUNDLE_INSTALL_CACHE_KEY: &str = "v1";
45+
pub(crate) const FORCE_BUNDLE_INSTALL_CACHE_KEY: &str = "v2";
4646

4747
pub(crate) fn handle<W>(
4848
context: &libcnb::build::BuildContext<RubyBuildpack>,
@@ -99,6 +99,15 @@ where
9999
fun_run::map_which_problem(error, cmd.mut_cmd(), env.get("PATH").cloned())
100100
})
101101
.map_err(RubyBuildpackError::BundleInstallCommandError)?;
102+
103+
bullet
104+
.time_cmd(
105+
Command::new("bundle")
106+
.args(["clean", "--force"])
107+
.env_clear()
108+
.envs(&env),
109+
)
110+
.map_err(RubyBuildpackError::BundleInstallCommandError)?;
102111
}
103112
InstallState::Skip(checked) => {
104113
let bundle_install = style::value("bundle install");
@@ -144,6 +153,7 @@ pub(crate) struct MetadataV2 {
144153
}
145154

146155
#[derive(Deserialize, Serialize, Debug, Clone, Eq, PartialEq, CacheDiff, TryMigrate)]
156+
#[cache_diff(custom = clear_v1)]
147157
#[try_migrate(from = MetadataV2)]
148158
#[serde(deny_unknown_fields)]
149159
pub(crate) struct MetadataV3 {
@@ -171,6 +181,14 @@ pub(crate) struct MetadataV3 {
171181
pub(crate) digest: MetadataDigest, // Must be last for serde to be happy https://github.com/toml-rs/toml-rs/issues/142
172182
}
173183

184+
fn clear_v1(_new: &Metadata, old: &Metadata) -> Vec<String> {
185+
if &old.force_bundle_install_key == "v1" {
186+
vec!["Internal gem directory structure changed".to_string()]
187+
} else {
188+
Vec::new()
189+
}
190+
}
191+
174192
#[derive(thiserror::Error, Debug)]
175193
pub(crate) enum MetadataMigrateError {
176194
#[error("Could not migrate metadata {0}")]
@@ -252,20 +270,14 @@ fn layer_env(layer_path: &Path, app_dir: &Path, without_default: &BundleWithout)
252270
.chainable_insert(
253271
Scope::All,
254272
ModificationBehavior::Override,
255-
"BUNDLE_PATH", // Directs bundler to install gems to this path.
273+
"GEM_HOME", // Tells bundler where to install gems, along with GEM_PATH
256274
layer_path,
257275
)
258-
.chainable_insert(
259-
Scope::All,
260-
ModificationBehavior::Override,
261-
"BUNDLE_BIN", // Install executables for all gems into specified path.
262-
layer_path.join("bin"),
263-
)
264276
.chainable_insert(Scope::All, ModificationBehavior::Delimiter, "GEM_PATH", ":")
265277
.chainable_insert(
266278
Scope::All,
267279
ModificationBehavior::Prepend,
268-
"GEM_PATH", // Tells Ruby where gems are located. Should match `BUNDLE_PATH`.
280+
"GEM_PATH", // Tells Ruby where gems are located.
269281
layer_path,
270282
)
271283
.chainable_insert(
@@ -283,13 +295,7 @@ fn layer_env(layer_path: &Path, app_dir: &Path, without_default: &BundleWithout)
283295
.chainable_insert(
284296
Scope::All,
285297
ModificationBehavior::Override,
286-
"BUNDLE_CLEAN", // After successful `bundle install` bundler will automatically run `bundle clean`
287-
"1",
288-
)
289-
.chainable_insert(
290-
Scope::All,
291-
ModificationBehavior::Override,
292-
"BUNDLE_DEPLOYMENT", // Requires the `Gemfile.lock` to be in sync with the current `Gemfile`.
298+
"BUNDLE_FROZEN", // Requires the `Gemfile.lock` to be in sync with the current `Gemfile`.
293299
"1",
294300
);
295301
// CAREFUL: Changes to these ^^^^^^^ environment variables
@@ -306,14 +312,7 @@ fn display_name(cmd: &mut Command, env: &Env) -> String {
306312
fun_run::display_with_env_keys(
307313
cmd,
308314
env,
309-
[
310-
"BUNDLE_BIN",
311-
"BUNDLE_CLEAN",
312-
"BUNDLE_DEPLOYMENT",
313-
"BUNDLE_GEMFILE",
314-
"BUNDLE_PATH",
315-
"BUNDLE_WITHOUT",
316-
],
315+
["BUNDLE_FROZEN", "BUNDLE_GEMFILE", "BUNDLE_WITHOUT"],
317316
)
318317
}
319318

@@ -439,12 +438,10 @@ mod test {
439438

440439
let actual = commons::display::env_to_sorted_string(&env);
441440
let expected = r"
442-
BUNDLE_BIN=layer_path/bin
443-
BUNDLE_CLEAN=1
444-
BUNDLE_DEPLOYMENT=1
441+
BUNDLE_FROZEN=1
445442
BUNDLE_GEMFILE=app_path/Gemfile
446-
BUNDLE_PATH=layer_path
447443
BUNDLE_WITHOUT=development:test
444+
GEM_HOME=layer_path
448445
GEM_PATH=layer_path
449446
";
450447
assert_eq!(expected.trim(), actual.trim());
@@ -510,6 +507,27 @@ platform_env = "c571543beaded525b7ee46ceb0b42c0fb7b9f6bfc3a211b3bbcfe6956b69ace3
510507
let deserialized: Metadata = toml::from_str(&toml_string).unwrap();
511508

512509
assert_eq!(metadata, deserialized);
510+
511+
let old = Metadata {
512+
ruby_version: ResolvedRubyVersion("3.5.3".to_string()),
513+
os_distribution: OsDistribution {
514+
name: "ubuntu".to_string(),
515+
version: "20.04".to_string(),
516+
},
517+
cpu_architecture: "amd64".to_string(),
518+
force_bundle_install_key: "v1".to_string(),
519+
digest: MetadataDigest::new_env_files(
520+
&context.platform,
521+
&[&context.app_path.join("Gemfile")],
522+
)
523+
.unwrap(),
524+
};
525+
526+
let diff = old.diff(&old);
527+
assert_eq!(
528+
vec!["Internal gem directory structure changed".to_string()],
529+
diff
530+
);
513531
}
514532

515533
#[test]

buildpacks/ruby/tests/integration_test.rs

+15-14
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,8 @@ fn test_default_app_ubuntu20() {
8181
assert_contains!(context.pack_stderr, "# Heroku Ruby Buildpack");
8282
assert_contains!(
8383
context.pack_stderr,
84-
r#"`BUNDLE_BIN="/layers/heroku_ruby/gems/bin" BUNDLE_CLEAN="1" BUNDLE_DEPLOYMENT="1" BUNDLE_GEMFILE="/workspace/Gemfile" BUNDLE_PATH="/layers/heroku_ruby/gems" BUNDLE_WITHOUT="development:test" bundle install`"#);
84+
r#"`BUNDLE_FROZEN="1" BUNDLE_GEMFILE="/workspace/Gemfile" BUNDLE_WITHOUT="development:test" bundle install`"#
85+
);
8586

8687
assert_contains!(context.pack_stderr, "Installing puma");
8788

@@ -90,7 +91,7 @@ fn test_default_app_ubuntu20() {
9091
let command_output = context.run_shell_command(
9192
indoc! {"
9293
set -euo pipefail
93-
printenv | sort | grep -vE '(_|HOME|HOSTNAME|OLDPWD|PWD|SHLVL|SECRET_KEY_BASE)='
94+
printenv | sort | grep -vE '(_|^HOME|HOSTNAME|OLDPWD|PWD|SHLVL|SECRET_KEY_BASE)='
9495
9596
# Output command + output to stdout
9697
export BASH_XTRACEFD=1; set -o xtrace
@@ -101,13 +102,11 @@ fn test_default_app_ubuntu20() {
101102
assert_empty!(command_output.stderr);
102103
assert_eq!(
103104
formatdoc! {"
104-
BUNDLE_BIN=/layers/heroku_ruby/gems/bin
105-
BUNDLE_CLEAN=1
106-
BUNDLE_DEPLOYMENT=1
105+
BUNDLE_FROZEN=1
107106
BUNDLE_GEMFILE=/workspace/Gemfile
108-
BUNDLE_PATH=/layers/heroku_ruby/gems
109107
BUNDLE_WITHOUT=development:test
110108
DISABLE_SPRING=1
109+
GEM_HOME=/layers/heroku_ruby/gems
111110
GEM_PATH=/layers/heroku_ruby/gems:/layers/heroku_ruby/bundler
112111
JRUBY_OPTS=-Xcompile.invokedynamic=false
113112
LD_LIBRARY_PATH=/layers/heroku_ruby/binruby/lib
@@ -190,11 +189,10 @@ fn test_default_app_ubuntu20() {
190189
assert_eq!(
191190
r"
192191
$ echo $PATH
193-
/layers/heroku_ruby/gems/ruby/<x.y.z>/bin:/workspace/bin:/layers/heroku_ruby/gems/bin:/layers/heroku_ruby/bundler/bin:/layers/heroku_ruby/binruby/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
192+
/layers/heroku_ruby/gems/bin:/workspace/bin:/layers/heroku_ruby/bundler/bin:/layers/heroku_ruby/binruby/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
194193
$ which -a rake
195-
/layers/heroku_ruby/gems/ruby/<x.y.z>/bin/rake
196-
/workspace/bin/rake
197194
/layers/heroku_ruby/gems/bin/rake
195+
/workspace/bin/rake
198196
/layers/heroku_ruby/binruby/bin/rake
199197
/usr/bin/rake
200198
/bin/rake
@@ -203,7 +201,7 @@ fn test_default_app_ubuntu20() {
203201
/usr/bin/ruby
204202
/bin/ruby
205203
".trim(),
206-
Regex::new(r"/layers/heroku_ruby/gems/ruby/\d+\.\d+\.\d+/bin").unwrap().replace_all(&rake_output, "/layers/heroku_ruby/gems/ruby/<x.y.z>/bin").trim()
204+
rake_output.trim()
207205
);
208206

209207
let command_output = rebuild_context.run_shell_command(
@@ -240,7 +238,8 @@ fn test_default_app_ubuntu22() {
240238
assert_contains!(context.pack_stderr, "# Heroku Ruby Buildpack");
241239
assert_contains!(
242240
context.pack_stderr,
243-
r#"`BUNDLE_BIN="/layers/heroku_ruby/gems/bin" BUNDLE_CLEAN="1" BUNDLE_DEPLOYMENT="1" BUNDLE_GEMFILE="/workspace/Gemfile" BUNDLE_PATH="/layers/heroku_ruby/gems" BUNDLE_WITHOUT="development:test" bundle install`"#);
241+
r#"`BUNDLE_FROZEN="1" BUNDLE_GEMFILE="/workspace/Gemfile" BUNDLE_WITHOUT="development:test" bundle install`"#
242+
);
244243

245244
assert_contains!(context.pack_stderr, "Installing puma");
246245
},
@@ -259,7 +258,8 @@ fn test_default_app_latest_distro() {
259258
assert_contains!(context.pack_stderr, "# Heroku Ruby Buildpack");
260259
assert_contains!(
261260
context.pack_stderr,
262-
r#"`BUNDLE_BIN="/layers/heroku_ruby/gems/bin" BUNDLE_CLEAN="1" BUNDLE_DEPLOYMENT="1" BUNDLE_GEMFILE="/workspace/Gemfile" BUNDLE_PATH="/layers/heroku_ruby/gems" BUNDLE_WITHOUT="development:test" bundle install`"#);
261+
r#"`BUNDLE_FROZEN="1" BUNDLE_GEMFILE="/workspace/Gemfile" BUNDLE_WITHOUT="development:test" bundle install`"#
262+
);
263263

264264
assert_contains!(context.pack_stderr, "Installing puma");
265265

@@ -340,7 +340,7 @@ DEPENDENCIES
340340
assert_contains!(context.pack_stderr, "# Heroku Ruby Buildpack");
341341
assert_contains!(
342342
context.pack_stderr,
343-
r#"`BUNDLE_BIN="/layers/heroku_ruby/gems/bin" BUNDLE_CLEAN="1" BUNDLE_DEPLOYMENT="1" BUNDLE_GEMFILE="/workspace/Gemfile" BUNDLE_PATH="/layers/heroku_ruby/gems" BUNDLE_WITHOUT="development:test" bundle install`"#
343+
r#"`BUNDLE_FROZEN="1" BUNDLE_GEMFILE="/workspace/Gemfile" BUNDLE_WITHOUT="development:test" bundle install`"#
344344
);
345345
assert_contains!(context.pack_stderr, "Ruby version `3.1.4-jruby-9.4.8.0` from `Gemfile.lock`");
346346
});
@@ -361,7 +361,8 @@ fn test_ruby_app_with_yarn_app() {
361361
assert_contains!(context.pack_stderr, "# Heroku Ruby Buildpack");
362362
assert_contains!(
363363
context.pack_stderr,
364-
r#"`BUNDLE_BIN="/layers/heroku_ruby/gems/bin" BUNDLE_CLEAN="1" BUNDLE_DEPLOYMENT="1" BUNDLE_GEMFILE="/workspace/Gemfile" BUNDLE_PATH="/layers/heroku_ruby/gems" BUNDLE_WITHOUT="development:test" bundle install`"#);
364+
r#"`BUNDLE_FROZEN="1" BUNDLE_GEMFILE="/workspace/Gemfile" BUNDLE_WITHOUT="development:test" bundle install`"#
365+
);
365366
}
366367
);
367368
}

docs/application_contract.md

+2-5
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ Once an application has passed the detect phase, the build phase will execute to
3838
- `Gemfile.lock`
3939
- User configurable environment variables.
4040
-To always run `bundle install` even if there are changes if the environment variable `HEROKU_SKIP_BUNDLE_DIGEST=1` is found.
41-
- We will always run `bundle clean` after a successful `bundle install` via setting `BUNDLE_CLEAN=1` environment variable.
41+
- We will always run `bundle clean` after a successful `bundle install`.
4242
- We will always cache the contents of your gem dependencies.
4343
- We will always invalidate the dependency cache if your distribution name or version (operating system) changes.
4444
- We will always invalidate the dependency cache if your CPU architecture (i.e. amd64) changes.
@@ -71,11 +71,8 @@ Once an application has passed the detect phase, the build phase will execute to
7171
- `SECRET_KEY_BASE=${SECRET_KEY_BASE:-<generate a secret key>}` - In Rails 4.1+ apps a value is needed to generate cryptographic tokens used for a variety of things. Notably this value is used in generating user sessions so modifying it between builds will have the effect of logging out all users. This buildpack provides a default generated value. You can override this value.
7272
- `BUNDLE_WITHOUT=development:test` - Tells bundler to not install `development` or `test` groups during `bundle install`. You can override this value.
7373
- Environment variables modified - In addition to the default list this is a list of environment variables that the buildpack modifies:
74-
- `BUNDLE_BIN=<bundle-path-dir>/bin` - Install executables for all gems into specified path.
75-
- `BUNDLE_CLEAN=1` - After successful `bundle install` bundler will automatically run `bundle clean` to remove all stale gems from previous builds that are no longer specified in the `Gemfile.lock`.
76-
- `BUNDLE_DEPLOYMENT=1` - Requires `Gemfile.lock` to be in sync with the current `Gemfile`.
74+
- `BUNDLE_FROZEN=1` - Requires `Gemfile.lock` to be in sync with the current `Gemfile`.
7775
- `BUNDLE_GEMFILE=<app-dir>/Gemfile` - Tells bundler where to find the `Gemfile`.
78-
- `BUNDLE_PATH=<bundle-path-dir>` - Directs bundler to install gems to this path
7976
- `DISABLE_SPRING="1"` - Spring is a library that attempts to cache application state by forking and manipulating processes with the goal of decreasing development boot time. Disabling it in production removes significant problems [details](https://devcenter.heroku.com/changelog-items/1826).
8077
- `GEM_PATH=<bundle-path-dir>` - Tells Ruby where gems are located.
8178
- `MALLOC_ARENA_MAX=2` - Controls glibc memory allocation behavior with the goal of decreasing overall memory allocated by Ruby [details](https://devcenter.heroku.com/changelog-items/1683).

0 commit comments

Comments
 (0)