Skip to content

Upgrading from Sprockets to jsbundling‐rails

Janell Huyck edited this page Jul 1, 2024 · 27 revisions

Updating to jsbundling-rails

Background

The asset pipeline continues to evolve. There's a somewhat-helpful blog post here that talks about it. It looks like the new Rails 7 does allow for the use of Sprockets, but Sprockets has trouble handling modern JavaScript functionality, including the ES-6 format.

We needed to update how Javascript is handled on Treatment Database to add modern functionality like Stimulus and adding a WYSIWYG text editor. Initially, I had planned to update just to Webpacker from Sprockets, but Webpacker has a security issue that is fixed by upgrading from Webpacker to webpack with jsbundling-rails. Therefore, I decided it would be better to skip the migration to Webpacker and go straight to jsbundling-rails from Sprockets.

Quick note: "Webpack" is NOT the same thing as "Webpacker". It's really easy to get these two confused, and both ChatGPT and Google Searches will often assume you meant "Webpacker" when you type "Webpack" to look things up. Be on the lookout for this so you don't end up wandering down the wrong path. We are only using Webpack.

jsbundling-rails uses Webpack behind the scenes to handle files. The Webpack documentation is surprisingly readable and can be found at https://webpack.js.org/concepts/.

This wiki page will talk about what was involved in the migration and how the new jsbundling-rails set-up is laid out, what the different components are, and why I made the choices I did. The following sections are meant to be reviewed in order, but you may need to jump around and refer back to them if you're attempting to upgrade a different app.

In order for any of this to work, we need to be on an updated version of Node. The current server version 12.22.12 is not recent enough to handle the changes. Before any of this would work, we needed to get our server to run different versions of Node, and to have a .nvmrc file. This is discussed in the Node upgrade documentation

The following is based off what I did in the following PR: https://github.com/uclibs/treatment_database/pull/497


Installing Webpack and JSBundling Rails

Installing Webpack and JSBundling Rails

The next step in this migration is to install and configure Webpack since jsbundling-rails utilizes Webpack under the hood to manage JavaScript bundling.

Steps to Install

  • If you have the gem gem 'sprockets-rails' in your Gemfile, keep it - it's needed for deployments still.
  • Add gem 'jsbundling-rails' to your Gemfile and run bundle install.
  • Run yarn add webpack webpack-cli to add the Webpack and webpack-cli dependencies to your package.json file.
  • Run rails javascript:install:webpack to install and configure Webpack.

Updating the package.json file

Updating the package.json File

The package.json file is a manifest for your JavaScript dependencies and project metadata, similar to how a Gemfile works for Ruby gems, specifying which packages to install and how to configure scripts and tools for a project.

It needs to have all the dependencies that will handle loading and running the front end of the app. Here's how ours ended up, after including all the dependencies for handling JavaScript, css, and images:

{
  "name": "treatment_database",
  "private": true,
  "dependencies": {
    "@rails/activestorage": "^7.1.3-2",
    "@rails/ujs": "^7.1.3-2",
    "babel-loader": "^9.1.3",
    "bootstrap": "^4.3.1",
    "jquery": "^3.5.1",
    "mini-css-extract-plugin": "^2.9.0",
    "popper.js": "^1.16.1",
    "turbolinks": "^5.2.0",
    "webpack": "^5.91.0",
    "webpack-cli": "^5.1.4",
    "webpack-manifest-plugin": "^5.0.0",
    "@babel/core": "^7.24.5",
    "@babel/preset-env": "^7.24.5",
    "css-loader": "^7.1.1",
    "file-loader": "^6.2.0",
    "sass": "^1.77.0",
    "sass-loader": "^14.2.1",
    "style-loader": "^4.0.0"
  },
  "scripts": {
    "build": "webpack --mode production --config webpack.config.js"
  }
}

For any missing dependency that you want to add, run yarn add package-name@version and that will add it to the "dependencies" section. It's fine to copy/paste these dependencies into your package.json file. If you do this, you will need to run yarn install after changing the package.json file. That's similar to manually adding a gem to your Gemfile and then running bundle install.

When a dependency is added, either by yarn add or by adding manually to package.json and then running yarn install, it will create or update a yarn.lock file. You don't edit anything in the yarn.lock file. This is the equivalent of the Gemfile.lock file, but for your front-end (yarn-managed) dependencies.

If you think you've messed something up or if things aren't running right, it's fine to delete the yarn.lock file and run yarn install again to get a fresh installation of the dependencies.

Note:

In the sample package.json above, take a look at the section:

  "scripts": {
    "build": "webpack --mode production --config webpack.config.js"
  }

This sets up the yarn build command for you to run from the terminal. It says that when you run yarn build it should use production mode for webpack, and that the config file for webpack is "webpack.config.js".


Creating a Webpack Config File

Creating a Webpack Config File

Think of Webpack like a pipeline, where your JavaScript, CSS, and image files enter in their raw, development-friendly format. Through this pipeline, they are transformed, optimized, and output as fewer, optimized files that are easier and faster for browsers to load. The configuration file webpack.config.js tells Webpack how to conduct this process in terms of:

  • Where to start and end the process.
  • How to handle different file types.
  • How to name the output files for efficient caching.
  • How to keep track of everything via a manifest.

Webpack assumes everything is in production mode. It has a development mode that could be slightly faster for development, but I found with our small Treatment Database that trying to manage both a production mode and a development mode added needless complexity, and running Webpack in production mode all the time worked fine.

It could be different with a larger codebase. There, you might see noticeable performance improvements for development mode.

The webpack.config.js file looks intimidating to someone not familiar with that format. Let's take a look at it. Here's how ours ended up:

const path = require('path');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const { WebpackManifestPlugin } = require('webpack-manifest-plugin');

module.exports = (env, argv) => {
  const isProduction = argv.mode === 'production';
  const publicPath = isProduction ? '/treatment_database/build/' : '/build/';

  return {
    devtool: 'source-map',
    entry: {
      application: './app/javascript/application.js'
    },
    output: {
      path: path.resolve(__dirname, 'public', 'build'),
      publicPath: publicPath,
      filename: 'javascripts/[name]-[contenthash].js'
    },
    module: {
      rules: [
        {
          test: /\.css$/,
          use: [MiniCssExtractPlugin.loader, 'css-loader']
        },
        {
          test: /\.scss$/,
          use: [MiniCssExtractPlugin.loader, 'css-loader', 'sass-loader']
        },
        {
          test: /\.js$/,
          exclude: /node_modules/,
          use: {
            loader: 'babel-loader',
            options: {
              presets: ['@babel/preset-env']
            }
          }
        },
        {
          test: /\.(png|jpe?g|svg)$/i,
          use: [
            {
              loader: 'file-loader',
              options: {
                outputPath: 'assets/images/',
                publicPath: '/build/assets/images',
                name: '[name]-[hash].[ext]',
              },
            },
          ],
        },
      ]
    },
    plugins: [
      new MiniCssExtractPlugin({
        filename: 'stylesheets/[name]-[contenthash].css',
      }),
      new WebpackManifestPlugin({
        publicPath: '/build/',
        writeToFileEmit: true,
        generate: (seed, files) => {
          return files.reduce((manifest, file) => {
            const name = path.basename(file.name, path.extname(file.name));
            const ext = path.extname(file.name);
            manifest[name + ext] = file.path.replace(/^.*\/build\//, '');
            return manifest;
          }, seed);
        }
      })
    ],
  };
}

Let's break it down into chunks. Here's the main points:

Basic Configuration

  • entry: Specifies the starting point of your application. Webpack will begin its process here, in our case with ./app/javascript/application.js.

  • output: Defines where the bundled files will be placed. In this setup:

    • path: Where to output the files on the disk (under public/build in your project directory).
    • publicPath: The base path for all assets within the application. We configured ours with two different versions to handle Rails production mode and development mode.
    • filename: Pattern for naming the bundled JavaScript files, where [name] is replaced by the entry name and [contenthash] helps in cache busting by appending a unique hash generated from the file content.

Rules

module.rules: these are the instructions for how to process different types of files. When you see a list of things after the "use" line, it uses the last item first, then the next to last item, etc. So [MiniCssExtractPlugin.loader, 'css-loader'] gets executed first with 'css-loader' and then with the MiniCssExtractPlugin.loader. This is relevant if you're troubleshooting why something won't compile.

  • CSS: Uses MiniCssExtractPlugin.loader and css-loader to process .css files. The plugin extracts CSS into separate files.
  • Sass/SCSS: Similar to CSS but includes sass-loader to handle Sass files.
  • JavaScript: Uses babel-loader to transpile modern JavaScript to backward-compatible versions using Babel, particularly focusing on @babel/preset-env for handling modern JavaScript syntax.
  • Images: Uses file-loader for processing image files like PNG, JPEG, and SVG, placing them in a specific directory and modifying the file names to include a hash.

The end result of these rules is that we've told Webpack how to handle css, scss, javascript, and image files. These should not need to be handled through Sprockets anymore, and any reference to them in the old Sprockets setup (app/assets/javascripts/*) can be removed.

Plugins

  • MiniCssExtractPlugin: Extracts CSS into separate files named according to the [name]-[contenthash].css pattern for caching purposes.

  • WebpackManifestPlugin: Generates a manifest file, useful for managing assets. It maps the original file names to the output filenames, which can include a hash. This plugin is especially handy for integrating with Rails' asset management.


Creating a New application.js File

Creating a New application.js File

A new application.js file was created to serve as the main entry point for all JavaScript. This file integrates various libraries and modules. It needs to be located at `app/javascript/application.js`. After we run `yarn build` there will be a copy of the compiled code in the `public/build/javascripts` directory, but we only modify the one at app/javascript.

The main point of the application.js file is to load the javascript and the front-end libraries. This is how ours looks right now:

// Import Rails libraries
import Rails from '@rails/ujs';
import * as ActiveStorage from '@rails/activestorage';
import Turbolinks from 'turbolinks';

Rails.start();
ActiveStorage.start();
Turbolinks.start();

// Import jQuery and other libraries
import jQuery from 'jquery';
window.jQuery = jQuery;
window.$ = jQuery;

// Import Popper
import Popper from 'popper.js';
window.Popper = Popper;

// Import Bootstrap JavaScript
import 'bootstrap';

// Import custom CSS
import './stylesheets/application.scss';

// Import all the images in the images file:
function importAll(r) {
    r.keys().forEach(r);
}
importAll(require.context('./images/', false, /\.(png|jpe?g|svg)$/));

The order of the imports and starting matters. First we start rails, then active_storage, then turbolinks. Bootstrap also needs to be loaded before the stylesheets. If you're getting errors that something isn't available when you're sure you loaded it, it's worth your time to check the order that you are importing and loading your dependencies.


Some Notes About Bootstrap

Some Notes About Bootstrap

Our app is currently set up to use Bootstrap 4. We will need to upgrade to 5 soon, and some of this may not apply to Bootstrap 5. Bootstrap 4 needs to be imported in several places...

  • Bootstrap uses JavaScript for things like opening and closing modals and drop-down lists. If we don't import it like a javascript dependency, we will lose all functionality. In application.js:
// Import Bootstrap JavaScript
import 'bootstrap';

  • We need to load the node modules. In package.json:
  "dependencies": {
    "bootstrap": "^4.3.1"
  },

  • We import it from our node modules and need to use a relative path for the import. In app/javascript/stylesheets/application.scss:
@import "~bootstrap/scss/bootstrap";

Initially, I hadn't wanted to load our styling and images through Webpack. Unfortunately, the JavaScript portion of Bootstrap broke with our migration to Webpack, and I was forced to load it in the dual method described above. It does not work with Sprockets anymore in our setup.


Updating .circleci/config.yml

Updating .circleci/config.yml

To accommodate the changes in JavaScript management, the .circleci/config.yml file was updated to include steps for installing the Node.js and yarn dependencies and building assets using Webpack.

Changes: Because of the timing of this change, we were forced to update to Ruby from 3.3.0 to 3.3.1 for security updates. While this didn't change much for running it locally, we did have to make several changes to the .circleci/config.yml file on top of the changes needed to compile the code properly. Here are the changes made:

  • Update the Ruby version to 3.3.1
jobs:
  build:
    docker:
      # specify the version you desire here
      - image: cimg/ruby:3.3.1
  • Manually install node (using the .nvmrc file) and yarn
    steps:
      - run:
          name: Install NVM and Node.js
          command: |
            curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
            echo 'export NVM_DIR="$HOME/.nvm"' >> $BASH_ENV
            echo '[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" --install' >> $BASH_ENV
            echo '[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion"' >> $BASH_ENV
            source $BASH_ENV
            echo "Node Version Manager (NVM) installed successfully."
            echo "NVM version: $(nvm --version)"
            echo "Node.js version: $(node --version)"

      - run:
          name: Install Yarn
          command: |
            source $BASH_ENV
            nvm use
            npm install -g yarn
            echo "Yarn installed successfully."
            echo "Yarn version: $(yarn --version)"
  • Before the tests run, do a yarn install and yarn build.
      - run:
          name: Install Yarn Dependencies
          command: |
            echo "Installing Yarn dependencies..."
            source $BASH_ENV
            nvm use
            yarn install

      - run:
          name: Run Yarn Build
          command: |
            echo "Running Yarn build..."
            source $BASH_ENV
            nvm use
            yarn build

Note:

Each task of the CircleCI build is run as if it's in a new, separate terminal or shell. That means that you can set the node version in one place and it won't remain set unless you set it again in your newest task. That's why we keep repeating source $BASH_ENV and nvm use.


Updating the SCSS files

Updating the SCSS Files

Transitioning to jsbundling-rails and Webpack required updates to how SCSS files are managed:

  • Moved the stylesheets from app/assets/stylesheets to app/javascript/stylesheets.
  • We used to use a generic *.* import previously for the files now located at app/assets/stylesheets/partials. That type of import is no longer supported, so I made an "index" file at app/javascript/stylesheets/partials/_index.scss and imported that to aplication.scss directly after the new format bootstrap import:
# application.scss

@import "~bootstrap/scss/bootstrap";
@import "partials/index";
  • Configured Webpack to compile SCSS files into CSS, including setting up loaders such as sass-loader and css-loader. See the "rules" part in the section above on creating a webpack config file.

Updating the Image files

Updating the Image Files

Image files are now being served through jsbundling-rails and webpack as well.

  • Moved all image assets from app/assets/images to app/javascript/images.
  • Configured webpack to serve these files. See the "rules" part in the section above on the webpack config file.

Building the Front End & the Manifest

Building the Front End

Before we can launch the program with rails server, we need to build the front end. This is done through the console command yarn build. Once that has been successfully done, we can run rails server.

About the Manifest

When assets are compiled with yarn build, a compiled version of the code is saved in public/build/.

  • The images are now copied over to public/build/assets/images/.
  • The stylesheets are compiled at public/build/stylesheets/.
  • The application.js file is now at public/build/javascripts/.

The file names, instead of being something like application.js, will now be hashed like this: application-4585d1d8c24d86edd3f6.js. This is referred to as application-[hash].js. The hash is regenerated every time we run yarn build, which makes it tricky to try to link to the files. To solve this problem, yarn build will also create a manifest.

It looks like this:

{
  "application.css": "stylesheets/application-79e9558c20b88067884b.css",
  "application.js": "javascripts/application-4585d1d8c24d86edd3f6.js",
  "delete.svg": "assets/images/delete-5e9b43c1abd7ceb1ad6f82587130173e.svg",
  "delete.png": "assets/images/delete-72ccf5711928f0c4d59f5d285c0855c6.png",
  "application-css.map": "stylesheets/application-79e9558c20b88067884b.css.map",
  "application-js.map": "javascripts/application-4585d1d8c24d86edd3f6.js.map"
}

The manifest is used to help map out the locations of files. When we go to look for application.css, we use the manifest to locate the most recent hashed version at stylesheets/application-79e9558c20b88067884b.css. To make this process easier, we created several helper methods. (See the next section.)


Creating Helper Functions

Creating Helper Functions

There are three new helper functions at app/helpers/application_helper.rb: one to generate the path to application.js, one to generate the path to the stylesheet address, and one to dynamically get the addresses for images.

  # Creates and returns the path for an image being handled by webpack
  # Use like: <%= image_tag webpack_image_path('delete.png'), class: 'delete-icon', alt: 'Delete' %>
  # Exact image_name to be passed in can be determined from the manifest.json file in the public/build directory
  def webpack_image_path(image_name)
    # Determine the base path based on the environment
    base_path = Rails.env.production? ? '/treatment_database/build' : '/build'

    manifest_path = Rails.public_path.join('build/manifest.json')
    begin
      manifest = JSON.parse(File.read(manifest_path))
      "#{base_path}/#{manifest[image_name]}"
    rescue StandardError
      "#{base_path}/#{image_name}" # Fallback if there's an error reading the manifest or the key is not found
    end
  end

  # Creates and returns the path for a stylesheet file being handled by webpack
  def webpack_stylesheet_path
    # Determine the base path based on the environment
    base_path = Rails.env.production? ? '/treatment_database/build' : '/build'

    manifest_path = Rails.public_path.join('build/manifest.json')
    manifest = JSON.parse(File.read(manifest_path))
    "#{base_path}/#{manifest['application.css']}"
  rescue StandardError
    "#{base_path}/stylesheets/application.css" # Fallback if manifest is missing or key is not found
  end

  # Creates and returns the path for the javascript file being handled by webpack
  def webpack_javascript_path
    # Determine the base path based on the environment
    base_path = Rails.env.production? ? '/treatment_database/build' : '/build'

    manifest_path = Rails.public_path.join('build/manifest.json')
    manifest = JSON.parse(File.read(manifest_path))
    "#{base_path}/#{manifest['application.js']}"
  rescue StandardError
    "#{base_path}/javascripts/application.js" # Fallback if manifest is missing or key is not found
  end
end

We used to have these tag links in our application.html.erb file:

    <%= stylesheet_link_tag    'application', media: 'all', 'data-turbolinks-track': 'reload' %>
    <%= javascript_include_tag 'application', 'data-turbolinks-track': 'reload' %>

There are some new tags supported by webpack, but I couldn't get them to work. Instead, this solution is what works:

    <link rel="stylesheet" href="<%= webpack_stylesheet_path %>" media="all" data-turbolinks-track="reload">
    <script src="<%= webpack_javascript_path %>" type="module" data-turbo-track="reload"></script>

The image files are now linked in this fashion:

<%= image_tag webpack_image_path('delete.png'), class: 'delete-icon', alt: 'Delete' %>

Updating .gitignore

Updating .gitignore

The build folder should not be pushed up to Github. The following was added to .gitignore:

# Ignore everything in public/build
/public/build/*

Using Rake Tasks in Capistrano

What are Rake Tasks?

Rake is a Ruby-based build language, similar to make in UNIX. It allows you to write tasks in Ruby, which can be executed from the command line. Capistrano uses Rake tasks extensively to handle the automation of deployment processes. These tasks can include steps such as setting up directories on the server, updating the codebase from a version control system, restarting services, and much more.

How to Write and Run a Custom Rake Task

To write a custom Rake task for use with Capistrano, you typically define tasks in a file within the lib/capistrano/tasks directory of your project. Here’s a simple example of a custom task:

# lib/capistrano/tasks/my_custom_task.rake
namespace :deploy do
  desc 'My custom task description'
  task :my_custom_task do
    on roles(:all) do
      execute :echo, 'Hello from Capistrano'
    end
  end
end
  • namespace works kind of like a module, where you are going to describe 1 or more tasks within. When you are calling your custom task in a hook, this is the first part of the call. Use the :namespace format.
  • desc short description of the task's purpose. It is a string. This is shown if you list all tasks with the command cap --tasks.
  • task the name of the task, which you call in order to execute all code within this task's block. Use :task_name format.
More about roles in Capistrano Roles: In Capistrano, roles are used to define different responsibilities or types of services your servers provide in your deployment environment. Common roles include `:web` for servers that handle web traffic, `:app` for application servers, and `:db` for databases. These roles help target specific commands to only relevant servers, enhancing efficiency and security.

On roles(:all) do Usage: By using on roles(:all), you instruct Capistrano to execute the following commands on every server involved in the deployment, regardless of its specific role. This is particularly useful for tasks that need to be universally applied, such as setting environment variables, deploying code, or performing system-wide updates.

To run this custom task as part of a deploy, you can use Capistrano's hooks to execute it at a specific point in the deployment process.

A New Shell Script for Each Line

Capistrano executes each command as a separate shell script. This means every line in a Capistrano task that uses the execute method runs in a new shell session on the remote server. This isolation helps prevent errors from one command affecting others but requires careful management of environment settings and paths.

Using Hooks

Hooks in Capistrano allow you to execute tasks at specific points in the deployment process. For example, you can run a custom task before or after updating the code, restarting the application, or performing cleanup tasks. Here’s how you might add a hook to run a custom task before the assets are precompiled:

# config/deploy.rb
before 'deploy:compile_assets', 'deploy:my_custom_task'

This setup ensures that your custom task runs before Capistrano starts the asset compilation process.


Making the Node Version Available with NVM

During our deployment process, it's crucial to ensure that the correct version of Node.js is used. This is managed using an .nvmrc file, which specifies the Node.js version required for the project. We automate the use of this version by integrating NVM (Node Version Manager) directly into our deploy process.

Here is the Capistrano task configured to load NVM:

# lib/capistrano/tasks/nvm.rake
namespace :nvm do
  desc 'Load NVM and use the Node version specified in .nvmrc'
  task :load do
    on roles(:all) do
      within release_path do
        execute :echo, 'Sourcing NVM, installing Node version, and setting Node version'
        execute "source ~/.nvm/nvm.sh && nvm install $(cat #{release_path}/.nvmrc) && nvm use $(cat #{release_path}/.nvmrc)"
      end
    end
  end
end

This task is hooked after git:create_release, ensuring that the correct Node version is set up immediately after the new release is created. Because of how Capistrano executes each command as if it were in its own Shell script, we will need to re-source nvm and call nvm use to get the correct version of Node working when we need it.

Not every line of the installation needs the most recent Node versions. For our app, we only really need the most recent version of Node in three places:

  • yarn:install
  • deploy:assets:precompile
  • yarn:build

With our custom tasks yarn install and yarn build, we built in the sourcing of nvm and setting the Node version as part of the commands executed (See "Yarn Install and Yarn Build" below). Using the correct version of Node with deploy:assets:precompile was a little trickier and is discussed in another section below.


Yarn Install and Yarn Build

Two key tasks in our process involve managing Yarn dependencies: yarn install and yarn build.

Yarn Install:

This task installs all the front-end packages specified in our package.json file. Once executed, the installed modules are placed in the node_modules directory, where they can be accessed or 'required' by various parts of the application. The process also generates a yarn.lock file, which captures the exact versions of each dependency and sub-dependency that were installed. This is similar to the Gemfile.lock in Ruby, which records the specific versions of gems installed based on a Gemfile. The yarn.lock file ensures that the same versions of the packages are used in all environments, promoting consistency and preventing issues that might arise from version discrepancies.

namespace :yarn do
  desc 'Install yarn packages'
  task :install do
    on roles(:all) do
      within release_path do
        execute :echo, 'Sourcing NVM and running yarn install'
        execute [
          'source ~/.nvm/nvm.sh &&',
          "nvm use $(cat #{release_path}/.nvmrc) &&",
          "cd #{release_path} &&",
          'RAILS_ENV=production yarn cache clean &&',
          'yarn install'
        ].join(' ')
      end
    end
  end
end

Yarn Build:

The yarn build command prepares your web application for production by running scripts defined in package.json. These scripts typically compile, bundle, and minify JavaScript and CSS files to improve performance. The specific tools and tasks used, like Webpack or Babel, depend on your project's configuration.

After running, yarn build places the optimized files in a build or dist directory. These files are then moved to the public folder, ready to be served. This process is crucial for reducing load times and ensuring your application performs well for users.

namespace :yarn do
  desc 'Build yarn packages'
  task :build do
    on roles(:all) do
      within release_path do
        execute :echo, 'Sourcing NVM and running yarn build'
        execute "source ~/.nvm/nvm.sh && nvm use $(cat #{release_path}/.nvmrc) && cd #{release_path} && RAILS_ENV=production yarn build"
      end
    end
  end
end

Both yarn tasks make sure to source the NVM and set the Node version in the same execute statement to maintain the correct environment settings throughout the command execution. They are called like this:

before 'deploy:compile_assets', 'yarn:install'
after 'deploy:compile_assets', 'yarn:build'

Using Node with `deploy:assets:precompile`

Our deployment was failing at the task labeled deploy:assets:precompile, but when I ran cap --tasks, there WAS no Capistrano task labeled "deploy:assets:precompile". It turns out that there is a default task called deploy:compile_assets, and THAT task calls rake assets:precompile.

In theory, writing my own version of deploy:compile_assets should have overwritten the default version, but when I tried to do that, the default version was always called during the actual deployment. I ended up with a fairly complex solution to our problem, but only because I couldn't get any simpler solutions to work.

I created a new module that had a method that accepted a task name and then ran each of the commands from that task with our NVM sourcing and the correct node version. Then, I used that method to modify the deploy:compile_assets task.

It's called like this: NVMIntegration.wrap_with_nvm('deploy:compile_assets'). This statement is placed near the bottom of our config/deploy.rb file but above our hooks. It's not a hook, but instead modifies the task, which is then run normally but with the modified code.

Here's the code for the module:

#lib/capistrano/tasks/nvm_integration.rb

# frozen_string_literal: true

module NVMIntegration
  def self.wrap_with_nvm(task_name)
    original_task = Rake::Task[task_name]
    actions = original_task.actions.dup # Save the original actions
    original_task.clear_actions # Clear existing actions

    redefine_task_with_nvm(original_task, actions)
  end

  def self.redefine_task_with_nvm(original_task, actions)
    Rake::Task.define_task(original_task) do
      on roles(:all) do
        within release_path do
          NVMIntegration.setup_and_execute_actions(actions)
        end
      end
    end
  end

  def self.setup_and_execute_actions(actions)
    actions.each do |action|
      NVMIntegration.setup_nvm_environment
      instance_exec(&action)
      NVMIntegration.cleanup_nvm_environment
    end
  end

  def self.setup_nvm_environment
    command = "source ~/.nvm/nvm.sh && nvm use $(cat #{release_path}/.nvmrc) && cd #{release_path} && RAILS_ENV=production"
    SSHKit.config.command_map.prefix[:rake].unshift(command)
  end

  def self.cleanup_nvm_environment
    SSHKit.config.command_map.prefix[:rake].shift
  end
end

What it does:

  • Looks for the original task, notes what that task does, and modifies the original task so that it's blank to avoid overriding what we actually want it to do.
  • Takes the list of what the original task does (the "actions"), and for each action, prepends the following: "source ~/.nvm/nvm.sh && nvm use $(cat #{release_path}/.nvmrc) && cd #{release_path} && RAILS_ENV=production"
  • Runs that action in the same shell as the NVM sourcing, node version setting, folder traversal, and RAILS_ENV setting

Cleaning Up Old Assets
The deploy process was not removing the multiple hashed files created in public/assets and public/build with each deploy, and they were just piling up. The deployment will create a public, compressed version of the stylesheets and JavaScript files and create or use the public/build and public/assets directories. Nothing is needed to be kept from deployment to deployment. To prevent the public directory from becoming cluttered with outdated files, I created a task to clear old assets:
namespace :deploy do
  desc 'Remove old assets'
  task :clear_assets do
    on roles(:web) do
      execute :rm, '-rf', release_path.join('public/assets/*')
      execute :rm, '-rf', release_path.join('public/build/*')
    end
  end
end

This task is called after deploy:confirmation, ensuring that it runs only after deployment parameters have been confirmed: after 'deploy:confirmation', 'deploy:clear_assets'


Setting up Our Capistrano Deploy
Each environment (production, deploy, test) has its own config file that has specific instructions for setting up that particular environment's deploy. The files are at: `config/deploy/qa.rb`, etc. These files include some hooks for custom rake tasks already. I didn't modify anything about them.

The file at config/deploy.rb applies to each environment. It currently contains the custom hooks previously created, as well as some general set-up that applies to all environments. This file is where I put the new hooks to the new custom methods and where I modified the deploy:compile_assets task with my NVMIntegration module. The changes look like this:

# frozen_string_literal: true

# Custom module to integrate NVM with Capistrano
require_relative '../lib/capistrano/tasks/nvm_integration'

.
.
.
# Other setup previously present including rake tasks
.
.
.
# Use nvm on the default task to ensure the correct Node version is used
NVMIntegration.wrap_with_nvm('deploy:compile_assets')

after 'git:create_release', 'nvm:load'
before 'deploy:starting', 'deploy:confirmation'
after 'deploy:confirmation', 'deploy:clear_assets'
before 'deploy:compile_assets', 'yarn:install'
after 'deploy:compile_assets', 'yarn:build'

I moved Sean's confirmation task to lib/capistrano/tasks/deploy.rake because I had other tasks that belonged in the "deploy" namespace. I made sure it was still called before 'deploy:starting' - only the location of the file changed.

All custom rake tasks are at lib/capistrano/tasks/. This is the default location where Rails expects to find these tasks, and we had already set up Capistrano to look there for tasks in our Capfile with the line

# Load custom tasks from `lib/capistrano/tasks` if you have any defined
Dir.glob('lib/capistrano/tasks/*.rake').each { |r| import r }`

For review, here are the tasks we currently have:

deploy.rake
# frozen_string_literal: true

namespace :deploy do
  desc 'Confirmation before deploy'
  task :confirmation do
    stage = fetch(:stage).upcase
    branch = fetch(:branch)
    puts <<-WARN

    ========================================================================

      *** Deploying to branch `#{branch}` to #{stage} server ***

      WARNING: You're about to perform actions on #{stage} server(s)
      Please confirm that all your intentions are kind and friendly

    ========================================================================

    WARN
    ask :value, "Sure you want to continue deploying `#{branch}` on #{stage}? (Y or Yes)"

    unless fetch(:value).match?(/\A(?i:yes|y)\z/)
      puts "\nNo confirmation - deploy cancelled!"
      exit
    end
  end

  desc 'Remove old assets'
  task :clear_assets do
    on roles(:web) do
      execute :rm, '-rf', release_path.join('public/assets/*')
      execute :rm, '-rf', release_path.join('public/build/*')
    end
  end
end
nvm.rake
# frozen_string_literal: true

namespace :nvm do
  task :load do
    on roles(:all) do
      within release_path do
        execute :echo, 'Sourcing NVM, installing Node version, and setting Node version'
        execute "source ~/.nvm/nvm.sh && nvm install $(cat #{release_path}/.nvmrc) && nvm use $(cat #{release_path}/.nvmrc)"
      end
    end
  end
end
yarn.rake
#  frozen_string_literal: true

namespace :yarn do
  desc 'Install yarn packages'
  task :install do
    on roles(:all) do
      within release_path do
        execute :echo, 'Sourcing NVM and running yarn install'
        execute [
          'source ~/.nvm/nvm.sh &&',
          "nvm use $(cat #{release_path}/.nvmrc) &&",
          "cd #{release_path} &&",
          'RAILS_ENV=production yarn cache clean &&',
          'yarn install'
        ].join(' ')
      end
    end
  end

  task :build do
    on roles(:all) do
      within release_path do
        execute :echo, 'Sourcing NVM and running yarn build'
        execute "source ~/.nvm/nvm.sh && nvm use $(cat #{release_path}/.nvmrc) && cd #{release_path} && RAILS_ENV=production yarn build"
      end
    end
  end
end
These tasks are all mentioned and explained in other sections above.

Thanks for taking the time to read through this rather lengthy explanation. I hope I answered all your questions about how we migrated to jsbundling-rails. If anything needs clarification or expanding please let me know! -Janell

Clone this wiki locally