Five Build Commands to Make Project Onboarding Faster & Easier

New team members shouldn’t have to spend their time probing the subtle differences between their MacBook and yours while reading a lovingly worded project_treatise.md. They should be able to jump in and run the project in 10 minutes, flat.

So next time you start a new project, try writing your Makefile before you write the Readme. You’ll spend a bit more time upfront building automation, but it will pay off when collaborators join you. Here are five commands that I add to every project to make joining up fast and painless.

Setup

When teammates join your project, they have a lot to learn about project goals, history, and social relationships. Setting up a build environment shouldn’t be on their todo list. Your setup command should take a teammate’s machine from clean-install to ready-to-rock and require minimal interaction.

I like to go as far down the stack as possible. A little time invested in following this simple pattern will save you loads of time whenever someone joins the project. For my React Native projects, setup usually looks something like this:


setup: .homebrew_installed .yarn_installed .yarn_packages_installed .rn_packages_linked

.homebrew_installed:
  printf "⚛️ Installing Homebrew…"; \
  if $(install_homebrew) $(log_setup_output); then \
    echo "done."; \
    touch $@; \
    exit 0; \
  else \
    echo "failed."; \
    open $(setup_log); \
    exit 1; \
  fi
      
install_homebrew := ruby -e "$$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)"

.yarn_installed: .homebrew_installed
  printf "⚛️ Installing Yarn…"; \
  if brew install yarn $(log_setup_output); then \
    echo "done."; \
    touch $@; \
    exit 0; \
  else \
    echo "failed."; \
    open $(setup_log); \
    exit 1; \
  fi

.yarn_packages_installed: .yarn_installed package.json
  printf "⚛️ Installing Yarn packages…"; \
  if yarn $(log_setup_output); then \
    echo "done."; \
    touch $@; \
    exit 0; \
  else \
    echo "failed."; \
    open $(setup_log); \
    exit 1; \
  fi

.rn_packages_linked: .yarn_packages_installed
  printf "⚛️ Linking React Native packages…"; \
  if react-native link $(log_setup_output); then \
    echo "done."; \
    touch $@; \
    exit 0; \
  else \
    echo "failed."; \
    open $(setup_log); \
    exit 1; \
  fi

setup_log := .setup.log
log_setup_output := > $(setup_log) 2>&1

I use an emoji that looks sort of like our logo to make it easy to differentiate between output from our build script and any unsilenced output from commands that it runs. I also store an empty file to indicate when each has been installed, because checking for the the file is usually quicker than running a command like which homebrew.

Clean

Clean is especially valuable early in a project when you’re still establishing infrastructure and conventions. It’s super-helpful to have a command that will put you in a known good state. rm -r ./ works too, but it is a bit too scorched-earth for my taste. Instead, my make clean targets usually look something like this:


clean:
  rm -rf $(deadwood)

deadwood := \
  node_modules \
  ./tmp \
  ./android/build \
  ./android/dist \
  ./ios/build \
  ./ios/dist \

Putting any variable after rm -rf is incredibly dangerous, so be very careful what you put into deadwood.

Run_iOS and Run_Android

run_ios and run_android do exactly what you’d expect: run the app in an iOS simulator and run the app in an Android emulator. When joining a project, it’s really nice to, with one command, run the code that you’re about to jump into. It immediately puts you in a good place.

These commands are the most variable from project to project, but my recent React Native projects usually look something like this:


run_ios: start_packager
  printf "⚛️ Starting app in a simulator…"; \
  if react-native run-ios --no-packager $(log_ios_build_output); then \
    echo "done."; \
    exit 0; \
  else \
    echo "failed."; \
    open $(ios_build_log); \
    exit 1; \
  fi

ios_build_log := .ios_build.log
log_ios_build_output := > $(ios_build_log) 2>&1

start_packager: .yarn_packages_installed .react_native_packages_linked setup
  if $(packager_running); then \
    echo "⚛️ Using the React Native packager that’s already running on port 8081."; \
    exit 0; \
  else \
    printf "⚛️ Starting a React Native packager on port 8081..."; \
    yarn start $(log_packager_output) & \
    until $(packager_running); do \
      sleep 0.1; \
    done; \
    if $(packager_running); then \
      echo "done."; \
      exit 0; \
    else \
      echo "failed."; \
      open $(packager_log); \
      exit 1; \
    fi; \
  fi
  
packager_log := .react_native_packager_log
log_packager_output := > $(packager_log) 2>&1
packager_running = curl -s localhost:8081 | grep running $(mute)
mute := > /dev/null 2>&1

You don’t have to run the React Native packager as a separate step, but doing so lets you carefully tailor the experience that you present to new teammates. For the same reason, I redirect command output to hidden log files instead of spewing it into the Terminal. It may seem frivolous, but developer experience matters more than you might think.

Together, we’re going to spend an huge amount of time focusing on tiny details of user experience. Giving a shit about the developer experience early in a project helps everyone start with fewer low-level annoyances, and fewer annoyances mean more emotional capacity for empathy and building great experiences.

Test

Testing is crucial to software development, but most modern test frameworks do a terrible job at getting out of your way when everything is running smoothly. Knowing that the test suite passed is helpful, but there is no good reason for that to be drawn like this:

...............................................................
...............................................................
...............................................................
...............................................................
...............................................................
...............................................................
...............................................

The line of ants marching away from a picnic blanket has got to stop. To remove visual clutter, I often structure my test targets like this:


test: setup
  printf "⚛️ Running tests…"; \
  if yarn test $(log_test_results); then \
    echo "done."; \
    exit 0; \
  else \
    echo "failed. 🔥🔥🔥"; \
    open $(test_results); \
    exit 1; \
  fi

test_results := .test_results
log_test_results := > $(test_results) 2>&1

If the test suite passes, you see a simple “done,” not a line of full stops. Since test output only matters when something goes wrong, I only show it when something goes wrong. In that case, I use the standard macOS open utility to load the results in the default text editor.

Taking time to craft the developer experience of your next project is a great way to let your team know that you care about them and want them to have nice things. It speeds up the process of bringing on new developers.

Setting up one consistent build system for every aspect of the project helps give everyone a single place to look for answers to build-related questions. Automating these common tasks in a platform-agnostic way helps minimize the things that new developers need to know right away and leads to a happier, more productive team.

What about you? How do you use project automation to improve your developer experience?