Ruby/Rails

Let's Get Baked

By
published

I just made the same mistake I always make; I named a file containing some Rake tasks with the wrong file extension, .rb instead of .rake. This is made all the more embarassing because I’m not working on a Rails app. This is the Ruby/Rack app we’re building at Super Good and I’d just written the code that grabbed all the lib/tasks/*.rake files and loaded them. Old habits die hard.

When I posted about it, Sean Collins pointed me towards Bake, an alternative to Rake created by Samuel Williams. (That sentence had too many links.) There’s nothing wrong with Rake, but the Bake README explains that it improves on Rake in four main ways:

  • On demand loading of files following a standard convention.
  • This avoid loading all your rake tasks just to execute a single command.
  • Better argument handling including support for positional and optional arguments.
  • Focused on task execution not dependency resolution. Implementation is simpler and a bit more predictable.
  • Canonical structure for integration with gems.

I don’t think that last bullet is relevant to us, but the other features sound cool. I’m always curious to try new things, so let’s try moving our app over to Bake.

The Status Quo

This app doesn’t have very many tasks yet, only these five:

  1. environment: This one loads the application environment by using require_relative to load config/environment.rb, which does all the work of setting up the application.
  2. db:create: This creates the development and test databases if they don’t exist. (For reasons we don’t need to get into, we don’t need this in production.)
  3. db:drop: You’ll never guess what this does. This is configured so you can’t run it with RACK_ENV=production because we don’t want to drop our production database. We’re weird like that.
  4. db:new_migration: This takes a name argument and creates a new, blank migration in db/migrations, naming it by attaching a timestamp to the provided argument.
  5. db:migrate: If no arguments are given, this runs all the unrun migrations in the app. If you give it a version, it will migrate the database up or down to that version.

Our Rakefile is super minimal too. It’s just this:

# Load all tasks from the lib/tasks directory.
Dir[File.join(File.dirname(__FILE__), 'lib', 'tasks', '*.rake')].each { |file| load file }

The gives us a convenient mix of tasks that take arguments, don’t take arguments, and even one that takes an optional argument. Additionally, the db:migrate task (unlike the others) has a dependency on the environment task. This all makes for a pretty good test of Bake.

The Bake-sics

Bake provides a guide for getting started. The first step is to add it to the Gemfile. The simplest setup would be to define our tasks as methods in a bake.rb file at the root of our project. Tasks are defined as methods, like this:

# bake.rb

# Add the x and y values together and print the result.
# @parameter x [Integer]
# @parameter y [Integer]
def add(x, y)
    puts x + y
end

Bake detects not just the method (making it runnable as bundle exec bake add 3 5) but also the comments. It parses the string explaining what the task does as well as the types of the arguments, which is uses to perform type coercion for us. That’s handy.

If we want to write methods that aren’t exposed as tasks, we can just define them as private methods. (Defining private methods in your Rake tasks, while sometimes done, could technically caused unwanted side effects. I’ve written about that.)

Rake gives you a list of available (documented) commands with rake -T. bake list is the equivalent command, and you can even specify a specific command you want to see the docs for.

❯ bundle exec bake list add
Bake::Registry::BakefileLoader /Users/jardo/Codes/redacted/bake.rb

        add x y
                Add the x and y coordinate together and print the result.
                x [Integer]
                y [Integer]

I like it.

Baking Show

We’ve got a small number of tasks, but we want to keep them namespaced and we anticipate adding more tasks (and more namespaces), so we don’t want to define them all right in our bake.rb. Bake supports this through facilities for integrating it into projects and gems. I’m mostly only going to discuss the project integration, because I’ve not tried out the gem stuff (yet).

The tasks defined in your bake.rb are considered private to the project/gem. (If we were working on a gem, consumers of the gem wouldn’t be able to access them.) Since our environment task doesn’t need to be namespaced, let’s put it in there. That looks this:

# Load the application environment
def environment
  require_relative "config/environment"
end

We can test this by running bundle exec bake environment. This just prints “true”, but that’s what we expect. Perfect.

Do not bake the environment.

Climate change is serious and is baking the environment enough as it is. Don’t contribute to that any more. Instead bake cookies, quiches, and pot pies (and push for policy changes at all levels of government that will have real impact).

Bake looks for “recipes” in bake/. A file at bake/frog.rb that defines a method called ribbit will create a task called frog:ribbit. We’ll use that to define the tasks we need in bake/db.rb. The ones without arguments will look like this:

# Create the development and test databases
def create
  # The guts of the task, copied straight out of
  # lib/tasks/db.rake go here.
end

# Drop the development and test databases
def drop
  # The guts of the task, copied straight out of
  # lib/tasks/db.rake go here.
end

Next up, let’s do db:new_migration, the task with the required argument. In our Rake version, we have to work with Rake’s somewhat esoteric argument handling. (It’s not that bad, but it’s certainly not natural.) This looked something like this:

task :new_migration, [:name] do |_t, args|
  # code...

  migration_name = args[:name]

  if migration_name.nil?
    # provide a useful error message and exit
  end

  # more code...
end

The Bake version is much cleaner. Let’s take a look:

# Create a new migration
# @param name [String] the name of the migration
def new_migration(name)
  # code...

  # more code...
end

We don’t need to check if the argument was provided or not. We just use the value. If the user didn’t provide it, Bake would give them a reasonable error message:

  0.0s    error: Bake::Command [ec=0x260] [pid=9638] [2025-07-31 18:51:57 -0700]
               | wrong number of arguments (given 0, expected 1)
               |   ArgumentError: wrong number of arguments
               |   → bake/db.rb:55 in 'new_migration'
               |   [stack trace omitted]

This is a reasonable error, but I don’t love it. I bet Bake could be improved to provide much better errors in this situation. Still, it’s an improvement on Rake.

Our final task to migrate takes an optional argument. It’s short, so here’s the entire Rake version:

task :migrate, [:version] => :environment do |_t, args|
  Sequel.extension :migration
  version = args[:version].to_i if args[:version]
  Sequel::Migrator.run(DB, 'db/migrations', target: version)
end

Bake doesn’t seem to support optional positional arguments, only optional keyword arguments. I’m not sure why that is, but it’s not a problem. Edit: This is because it would be impossible to reliably differentiate additional tasks from optional arguments. (Rake doesn’t support keyword arguments at all.)

# Run migrations
# @param version [Integer] the version to migrate to (omit for latest)
def migrate(version: nil)
  call "environment"
  Sequel.extension :migration
  Sequel::Migrator.run(DB, 'db/migrations', target: version)
end

Let’s talk about the differences. Rather than handling the optionality of the argument and the type coercion ourselves, we rely on a combination of regular ol’ Ruby default keyword arguments and Bake’s type coercion. That’s a nice win.

Bake doesn’t provide a facility for expressing task dependencies like Rake does, so we use call to invoke the tasks this task depends on. (Rake also has an API for programmatically invoking tasks.) If you need to invoke a task with arguments, you can use lookup:

lookup("frog:speak").call("ribbit")

I like the Bake version. It feels more Ruby-y to me.

Wake and Bake

I wasn’t really thinking about the consequences of the fact that I’ve framed this post as a review of Bake. Should I finish the post off with a summary of my thoughts and a rating out of ten, like a music review?

If you’ve enjoyed Samuel Williams’ previous albums, like falcon, or his collaborations on with the Japanese supergroup, the Ruby commiters team, then you’ll probably find something to enjoy in this record. Williams really comes into his own on the record, developing his music style in new directions while staying true to his sonic roots.

I’m not going to bother trying to decide whether Rake or Bake is better, but I do like the features it adds. The automatic file loading is handy, and I imagine this would come in extremely handy in an ecosystem of gems using Bake.

While annotating the tasks with comments feels really natural, I’d love to see a couple small improvements to how arguments are handled. If there’s no technical reason we can’t use optional positional arguments, it would be nice them supported. Edit: There is a technical reason. Mostly I just think we could (leveraging the annotations) make argument errors really nice.

Most of all, I like that the task files feel like normal Ruby. I’m not anti-DSL, but I love that Bake was trivial to set up and gave me everything I needed in a task runner without making me learn a DSL. Rake’s not going anywhere and I don’t plan on forgetting how to use it, so this doesn’t really save me (or you) any brain-space, but still, I like it.

I’m not going to finish by giving Bake a star rate, but I am going to mess around with it on some future projects. Seems good to me.