Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## [Unreleased]

* Replace sbt-extras with direct sbt launcher installation. ([#291](https://github.com/heroku/heroku-buildpack-scala/pull/291))
* Remove build directory symlinking. Modern sbt versions no longer require a stable build path for caching. ([#290](https://github.com/heroku/heroku-buildpack-scala/pull/290))

## [v103] - 2025-10-27
Expand Down
147 changes: 70 additions & 77 deletions bin/compile
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ source "${BUILDPACK_DIR}/lib/metrics.sh"
source "${BUILDPACK_DIR}/lib/util.sh"
# shellcheck source=lib/openjdk.sh
source "${BUILDPACK_DIR}/lib/openjdk.sh"
# shellcheck source=lib/sbt.sh
source "${BUILDPACK_DIR}/lib/sbt.sh"

metrics::init "${CACHE_DIR}" "scala"
metrics::setup
Expand All @@ -51,27 +53,6 @@ done
# Install the JDK
openjdk::install_openjdk_via_jvm_common_buildpack "${BUILD_DIR}" "${BUILDPACK_DIR}"

#create the cache dir if it doesn't exist
mkdir -p "${CACHE_DIR}"

# home directory from perspective of SBT; we rename
# it because otherwise the project root and $HOME
# are the same, and by default .sbt has a (different)
# meaning in those two places
SBT_USER_HOME=".sbt_home"
SBT_USER_HOME_ABSOLUTE="${BUILD_DIR}/${SBT_USER_HOME}"
# where we put the SBT binaries
SBT_BINDIR="${SBT_USER_HOME}/bin"

# chdir as sbt expects
cd "${BUILD_DIR}"

# unpack cache
CACHED_DIRS="${SBT_USER_HOME} target project/target project/boot .coursier"
for DIR in ${CACHED_DIRS}; do
cache_copy "${DIR}" "${CACHE_DIR}" "${BUILD_DIR}"
done

sbt_version="$(java_properties::get "${BUILD_DIR}/project/build.properties" "sbt.version")"
metrics::set_string "sbt_version" "${sbt_version:-"unknown"}"

Expand Down Expand Up @@ -150,6 +131,15 @@ if has_old_preset_sbt_opts; then
EOF
fi

# Copy the target dir from cache to speed up compilation. This is legacy buildpack
# behavior that other JVM buildpacks don't implement. It can cause cache bloat, stale artifacts,
# and reduced build reproducibility. We've observed customers using SBT_CLEAN (which will remove these files)
# much more than expected (~10% of builds), presumably to work around issues.
# This will be removed in a future version.
target_dir_cache_restore_start_time=$(util::nowms)
util::cache_copy "target" "${CACHE_DIR}" "${BUILD_DIR}"
metrics::set_duration "target_dir_cache_restore_duration" "${target_dir_cache_restore_start_time}"

if [[ -n "${SBT_PROJECT}" ]]; then
SBT_TASKS="${SBT_PROJECT}/compile ${SBT_PROJECT}/stage"
else
Expand All @@ -166,27 +156,55 @@ fi
# See: https://devcenter.heroku.com/articles/scala-support#clean-builds
[[ "${SBT_CLEAN}" = "true" ]] && SBT_TASKS="clean ${SBT_TASKS}"

# Install the custom sbt script
install_sbt_extras "${BUILDPACK_DIR}/opt" "${SBT_BINDIR}"
# Disable log formatting (which would modify already printed lines...)
util::prepend_to_env "SBT_OPTS" "-Dsbt.log.noformat=true"
# Disable colored output
util::prepend_to_env "SBT_OPTS" "-Dsbt.color=false"

ivy_home_dir="${CACHE_DIR}/ivy_home"
util::prepend_to_env "SBT_OPTS" "-Dsbt.ivy.home=${ivy_home_dir}"
mkdir -p "${ivy_home_dir}"

coursier_home_dir="${CACHE_DIR}/coursier_home"
util::prepend_to_env "SBT_OPTS" "-Dsbt.coursier.home=${coursier_home_dir}"
mkdir -p "${coursier_home_dir}"

sbt_boot_dir="${CACHE_DIR}/sbt_boot"
util::prepend_to_env "SBT_OPTS" "-Dsbt.boot.directory=${sbt_boot_dir}"
mkdir -p "${sbt_boot_dir}"

# See: https://www.scala-sbt.org/1.x/docs/Command-Line-Reference.html
sbt_global_dir="${CACHE_DIR}/sbt_global"
util::prepend_to_env "SBT_OPTS" "-Dsbt.global.base=${sbt_global_dir}"
mkdir -p "${sbt_global_dir}"

# copy in heroku sbt plugin
# Install the Heroku sbt plugin if the exising version differs (or is missing). We do this conditionally to
# ensure we can cache the compiled class files of the plugin between builds, speeding up the build overall.
case "${sbt_version}" in
1.*)
HEROKU_PLUGIN="HerokuBuildpackPlugin_sbt1.scala"
plugin_source_path="${BUILDPACK_DIR}/opt/HerokuBuildpackPlugin_sbt1.scala"
;;
*)
HEROKU_PLUGIN="HerokuBuildpackPlugin.scala"
plugin_source_path="${BUILDPACK_DIR}/opt/HerokuBuildpackPlugin.scala"
;;
esac

mkdir -p "${SBT_USER_HOME}/plugins"
rm -f "${SBT_USER_HOME}/plugins/HerokuPlugin.scala" # remove the old ambiguously named plugin
rm -f "${SBT_USER_HOME}/plugins/HerokuBuildpackPlugin_sbt1.scala" # remove the old poorly named plugin
rm -f "${SBT_USER_HOME}/plugins/HerokuBuildpackPlugin.scala" # remove the old plugin
cp -p "${BUILDPACK_DIR}/opt/${HEROKU_PLUGIN}" "${SBT_USER_HOME}/plugins/HerokuBuildpackPlugin.scala"
plugins_dir="${sbt_global_dir}/plugins"
plugin_destination_path="${plugins_dir}/HerokuBuildpackPlugin.scala"
source_checksum="$(sha256sum "${plugin_source_path}" | awk '{print $1}')"

# Collect metrics
if [[ ! -f "${plugin_destination_path}" ]] || [[ "${source_checksum}" != "$(sha256sum "${plugin_destination_path}" | awk '{print $1}')" ]]; then
# We remove the whole directory since we also want to remove the (cached) compiled files
# for the older version of the plugin in the ./target directory.
rm -rf "${plugins_dir}"

mkdir -p "${plugins_dir}"
cp "${plugin_source_path}" "${plugin_destination_path}"
fi

sbt::install_sbt_launcher "${sbt_version}" "${CACHE_DIR}/sbt-launcher"

# Collect metrics
if is_sbt_native_packager "${BUILD_DIR}"; then
metrics::set_raw "uses_sbt_native_packager" "true"
else
Expand All @@ -199,66 +217,41 @@ else
metrics::set_raw "is_play_app" "false"
fi

# Manually pre-clean because sbt-native-packager doesn't clobber this dir
rm -rf "${BUILD_DIR}/target/universal/stage"

# build app
run_sbt "${SBT_USER_HOME_ABSOLUTE}" "${SBT_TASKS}"
cd "${BUILD_DIR}"

run_sbt "${SBT_TASKS}"

if [[ -z "${DISABLE_DEPENDENCY_CLASSPATH_LOG:-}" ]]; then
write_sbt_dependency_classpath_log "${SBT_USER_HOME_ABSOLUTE}"
write_sbt_dependency_classpath_log
fi

# repack cache
mkdir -p "${CACHE_DIR}"
for DIR in ${CACHED_DIRS}; do
cache_copy "${DIR}" "${BUILD_DIR}" "${CACHE_DIR}"
done

# drop useless directories from slug for play and sbt-native-launcher only
if is_sbt_native_packager "${BUILD_DIR}" || is_play "${BUILD_DIR}"; then
if [[ "${KEEP_SBT_CACHE:-}" != "true" ]]; then
if [[ "${KEEP_IVY_CACHE:-}" != "true" ]] && [[ -d "${SBT_USER_HOME}/.ivy2" ]]; then
output::step "Dropping ivy cache from the slug"
rm -rf "${SBT_USER_HOME:?}/.ivy2"
fi
if [[ "${KEEP_COURSIER_CACHE:-}" != "true" ]] && [[ -d "${SBT_USER_HOME}/.coursier" ]]; then
output::step "Dropping coursier cache from the slug"
rm -rf "${SBT_USER_HOME:?}/.coursier"
fi
if [[ -d "${SBT_USER_HOME}/boot" ]]; then
output::step "Dropping sbt boot dir from the slug"
rm -rf "${SBT_USER_HOME:?}/boot"
fi
if [[ -d "${SBT_USER_HOME}/.cache" ]]; then
output::step "Dropping sbt cache dir from the slug"
rm -rf "${SBT_USER_HOME:?}/.cache"
fi
if [[ -d "${BUILD_DIR}/project/boot" ]]; then
output::step "Dropping project boot dir from the slug"
rm -rf "${BUILD_DIR}/project/boot"
fi
if [[ -d "${BUILD_DIR}/target" ]]; then
output::step "Dropping compilation artifacts from the slug"
rm -rf "${BUILD_DIR}/target/scala-"*
rm -rf "${BUILD_DIR}/target/streams"
if [[ -d "${BUILD_DIR}/target/resolution-cache" ]]; then
find "${BUILD_DIR}/target/resolution-cache"/* ! -name "reports" ! -name "*-compile.xml" -print0 | xargs -0 rm -rf --
fi
fi
fi
# Copy the target dir back to cache for the next build (see cache restore comment above for context)
target_dir_cache_write_start_time=$(util::nowms)
util::cache_copy "target" "${BUILD_DIR}" "${CACHE_DIR}"
metrics::set_duration "target_dir_cache_write_duration" "${target_dir_cache_write_start_time}"

# When sbt-native-packager is used (either directly or implicitly by the Play framework), a standalone JAR file with
# all code and dependencies is created. This makes the other JAR and class files redundant since they are not used by
# the running application. We remove these files here to reduce the slug size. However, this is a broad assumption that
# is often but not always correct. Users might want to use these files to run the application differently or for other
# purposes. This is legacy behavior that we will not change at this point to avoid users running into slug size limits
# needlessly. Ideally customers would curate their builds to not include extraneous files.
if (is_sbt_native_packager "${BUILD_DIR}" || is_play "${BUILD_DIR}") && [[ "${KEEP_SBT_CACHE:-}" != "true" ]]; then
output::step "Dropping compilation artifacts from the slug"
rm -rf "${BUILD_DIR}/target/scala-"*
rm -rf "${BUILD_DIR}/target/streams"
find "${BUILD_DIR}/target/resolution-cache" -mindepth 1 ! -name "reports" ! -name "*-compile.xml" -exec rm -rf {} + 2>/dev/null || true
fi

# write profile.d script
profile_script="${BUILD_DIR}/.profile.d/scala.sh"
mkdir -p "$(dirname "${profile_script}")"
cat <<-EOF >"${profile_script}"
export SBT_HOME="\$HOME/${SBT_USER_HOME}"
export PATH="\$SBT_HOME/bin:\$PATH"
EOF

# write export script
cat <<-EOF >"${BASE_DIR}/export"
export SBT_HOME="${BUILD_DIR}/${SBT_USER_HOME}"
export PATH="\$SBT_HOME/bin:\$PATH"
EOF
39 changes: 4 additions & 35 deletions lib/common.sh
Original file line number Diff line number Diff line change
Expand Up @@ -50,54 +50,23 @@ output() {
esac
}

install_sbt_extras() {
local opt_dir="${1}"
local sbt_bin_dir="${2}"

rm -f "${sbt_bin_dir}"/sbt-launch*.jar #legacy launcher
mkdir -p "${sbt_bin_dir}"
cp -p "${opt_dir}"/sbt-extras.sh "${sbt_bin_dir}"/sbt-extras
cp -p "${opt_dir}"/sbt-wrapper.sh "${sbt_bin_dir}"/sbt

chmod 0755 "${sbt_bin_dir}"/sbt-extras
chmod 0755 "${sbt_bin_dir}"/sbt

export PATH="${sbt_bin_dir}:${PATH}"
}

run_sbt() {
local home="${1}"
local tasks="${2}"
local tasks="${1}"
local build_log_file=".heroku/sbt-build.log"

mkdir -p "$(dirname "${build_log_file}")"
echo "" >"${build_log_file}"

export SBT_EXTRAS_OPTS="${SBT_EXTRAS_OPTS:-}"

output::step "Running: sbt ${tasks}"
# shellcheck disable=SC2086 # We want word splitting for tasks
if ! SBT_HOME="${home}" sbt ${tasks} | output "${build_log_file}"; then
if ! sbt ${tasks} | output "${build_log_file}"; then
handle_sbt_errors "${build_log_file}"
exit 1
fi
}

write_sbt_dependency_classpath_log() {
local home="${1}"

export SBT_EXTRAS_OPTS="${SBT_EXTRAS_OPTS:-}"

output::step "Collecting dependency information"
SBT_HOME="${home}" sbt "show dependencyClasspath" | grep -o "Attributed\(.*\)" >.heroku/sbt-dependency-classpath.log || true
}

cache_copy() {
rel_dir="${1}"
from_dir="${2}"
to_dir="${3}"
rm -rf "${to_dir:?}/${rel_dir}"
if [[ -d "${from_dir}/${rel_dir}" ]]; then
mkdir -p "${to_dir}/${rel_dir}"
cp -pr "${from_dir}/${rel_dir}"/. "${to_dir}/${rel_dir}"
fi
sbt "show dependencyClasspath" | grep -o "Attributed\(.*\)" >.heroku/sbt-dependency-classpath.log || true
}
Loading
Loading