Note: The examples in this post were ripped out of a real production project, simplified and renamed where appropriate. I have not tested them in their current state.

I recently did a bit of work to package a simple static website as an RPM to make it easier to manage versions and deploy automatically. The site is built with Jekyll and gem dependency management is handled by bundler. I ran into a few challenges using bundler from the specfile so it seemed like a good idea to document the process here.

The basic project layout looks like this:

root/
    webapp/
        _includes/
            app.js
        _layouts/
            main.html
        _config.yml
        Gemfile
        index.html
    Makefile
    webapp.spec

The build process for the Jekyll site is as follows:

cd webapp
gem install bundler
bundle install
bundle exec jekyll build

I used a Makefile to drive the build. Here's a simplified version of it sufficient for building the Jekyll site:

NAME = webapp

# important files and directories
WEBAPPDIR = webapp
SITEDIR = $(WEBAPPDIR)/target
SPECFILE = $(NAME).spec

.PHONY: default all site clean

default: site
all: site

site: $(SITEDIR)

clean:
  @echo "==== making clean"
  rm -rf $(WEBAPPDIR)/target
  rm -f $(WEBAPPDIR)/Gemfile.lock
  @echo "==== making clean done"

$(SITEDIR):
  @echo "==== making jekyll site"
  cd $(WEBAPPDIR) && bundle install
  cd $(WEBAPPDIR) && bundle exec jekyll build
  @echo "==== making jekyll site done"

Building the RPM

I used the Makefile to create an RPM build root and copy the appropriate files in. This requires a few more targets to generate the intermediate files and the final package. I also added the VERSION and RELEASE variables to pass into rpmbuild later, making it a little simpler to control versioning from one file in the project root.

NAME = webapp
VERSION = 1.0
RELEASE = 1

# important files and directories
WEBAPPDIR = webapp
SITEDIR = $(WEBAPPDIR)/target
SPECFILE = $(NAME).spec
RPMBUILDDIR = $(CURDIR)/rpmbuild
SPECSDIR = $(RPMBUILDDIR)/SPECS
SOURCESDIR = $(RPMBUILDDIR)/SOURCES
BUILDDIR = $(RPMBUILDDIR)/BUILD
SRPMSDIR = $(RPMBUILDDIR)/SRPMS
RPMSDIR = $(RPMBUILDDIR)/RPMS/noarch
SPEC = $(SPECSDIR)/$(SPECFILE)
TARBALL = $(SOURCESDIR)/$(NAME).tgz
BUILDDIRS= $(SPECSDIR) $(SOURCESDIR) $(BUILDDIR) $(SRPMSDIR) $(RPMSDIR)

RPM_BASENAME = $(NAME)-$(VERSION)-$(RELEASE)
RPM = $(RPMSDIR)/$(RPM_BASENAME).rpm

.PHONY: default all site clean siteclean dist

default: dist
all: dist

site: $(SITEDIR)

clean: siteclean
  @echo "==== making clean"
  rm -rf $(RPMBUILDDIR)
  @echo "==== making clean done"

siteclean:
  @echo "==== making siteclean"
  rm -rf $(WEBAPPDIR)/target
  rm -f $(WEBAPPDIR)/Gemfile.lock
  @echo "==== making siteclean done"

dist: $(RPM)

$(SITEDIR):
  @echo "==== making jekyll site"
  cd $(WEBAPPDIR) && bundle install
  cd $(WEBAPPDIR) && bundle exec jekyll build
  @echo "==== making jekyll site done"

$(RPM): $(TARBALL) $(SPEC) $(BUILDDIRS)
  rpmbuild -v -ba $(SPEC) \
    --define="_topdir $(RPMBUILDDIR)" \
    --define="_version $(VERSION)" \
    --define="_release $(RELEASE)"

$(SPEC): $(SPECSDIR) $(SPECFILE)
  @echo "==== making specfile"
  cp $(SPECFILE) $@
  @echo "==== making specfile done"

$(TARBALL): $(SOURCESDIR)
  @echo "==== making tarball"
  tar -czf $@ $(WEBAPPDIR)
  @echo "==== making tarball done"

$(BUILDDIRS): $(RPMBUILDDIR)
  mkdir -p $@

$(RPMBUILDDIR):
  mkdir -p $@

That should be enough to get rpmbuild to run. The hard part was getting bundler to run in the context of the specfile. Fortunately, the package rubygems-devel provides the necessary macros to support this use case:

%global bundler bundler-1.7.11
%global url_bundler https://rubygems.org/downloads/%{bundler}.gem

Name: webapp
Summary: Example webapp
Version: %{?_version}
Release: %{?_release}
Group: Applications/Internet
Vendor: Ian Gilham
License: CC BY 3.0 (http://creativecommons.org/licenses/by/3.0/)
Buildarch: noarch
BuildRoot: %{_tmppath}/%{name}-%{version}-%{release}-root
Source0: %{name}.tgz
BuildRequires: ruby
BuildRequires: ruby-devel
BuildRequires: rubygems
BuildRequires: rubygems-devel
BuildRequires: gcc
BuildRequires: gcc-c++
BuildRequires: wget

%description
Example web app

%prep
%setup -n webapp

%build
wget %{url_bundler}
%gem_install -n %{bundler}.gem

export PATH=$PATH:./usr/bin
export GEM_PATH=./usr/lib/ruby/gems/1.8/
export GEM_HOME=$GEM_PATH
bundle install
bundle exec jekyll build

%install
INSTALL_PATH="home/server/webapp"
mkdir -p "%{buildroot}/${INSTALL_PATH}"
cp _site/index.html "%{buildroot}/${INSTALL_PATH}/"

%files
%defattr(644,dcabwww,dcabwww,755)
  "/home/server/webapp/index.html"

%clean
rm -rf %{buildroot}

%post

The BuildRequires dependencies had to be explicit to work in a chroot environment. ruby-devel, gcc and gcc-c++ are required to build Ruby native gems. wget is used to download a gem for local installation during RPM build. rubygems-devel provides a few useful rpmbuild macros needed to glue the build system together.

I knew from the Fedora Project Documentation that the %gem_install macro would allow me to install the prerequisite bundler gem locally, but it took a few attempts to figure out where it was installed and how to set up the local Ruby environment to use it correctly. This is particularly important when considering how to run the build in mock. In the end this was accomplished with the following snippet:

export PATH=$PATH:./usr/bin
export GEM_PATH=./usr/lib/ruby/gems/1.8/
export GEM_HOME=$GEM_PATH

Using mock to isolate rpmbuild from the host environment

At this point I had a workable build system, so the last step was to run it in our continuous integration environment without polluting the build agent. I used mock to isolate the package build-time dependencies from the host machine by running the RPM build inside a chroot jail.

With a working build, it is fairly easy to get mock working by adding a few things to the Makefile:

SRPM = $(SRPMSDIR)/$(RPM_BASENAME).src.rpm

# executables and their options
MOCK = mock
MOCK_OPTS = -q -D "_version $(VERSION)" -D "_release $(RELEASE)"
RPMBUILD_OPTS = --define="_topdir $(RPMBUILDDIR)" \
  --define="_version $(VERSION)" \
  --define="_release $(RELEASE)"

# ... skipped

clean: siteclean
  @echo "==== making clean"
  rm -rf $(RPMBUILDDIR)
  mock -q --clean
  @echo "==== making clean done"

# ... skipped

$(SRPM): $(TARBALL) $(SPEC) $(BUILDDIRS) siteclean
  @echo "==== making source rpm"
  rpmbuild -bs $(SPEC) $(RPMBUILD_OPTS)
  @echo "==== making source rpm done"

$(RPM): $(SRPM)
  @echo "==== making binary rpm"
  $(MOCK) $(MOCK_OPTS) --init
  $(MOCK) $(MOCK_OPTS) --installdeps $<
  $(MOCK) $(MOCK_OPTS) --offline --resultdir $(RPMSDIR) --rebuild $<
  @echo "==== making binary rpm done"

On the continuous integration server, I configured the build to run make RELEASE=$BUILD_NUMBER clean dist to make it easy to see where each version of the package came from. The top-level package version is edited manually in the Makefile and corresponds to our own tagging and versioning conventions.

The final complete Makefile follows:

NAME = webapp
VERSION = 1.0
RELEASE = 1

# important files and directories
WEBAPPDIR = webapp
SITEDIR = $(WEBAPPDIR)/target
SPECFILE = $(NAME).spec
RPMBUILDDIR = $(CURDIR)/rpmbuild
SPECSDIR = $(RPMBUILDDIR)/SPECS
SOURCESDIR = $(RPMBUILDDIR)/SOURCES
BUILDDIR = $(RPMBUILDDIR)/BUILD
SRPMSDIR = $(RPMBUILDDIR)/SRPMS
RPMSDIR = $(RPMBUILDDIR)/RPMS/noarch
SPEC = $(SPECSDIR)/$(SPECFILE)
TARBALL = $(SOURCESDIR)/$(NAME).tgz
BUILDDIRS= $(SPECSDIR) $(SOURCESDIR) $(BUILDDIR) $(SRPMSDIR) $(RPMSDIR)

RPM_BASENAME = $(NAME)-$(VERSION)-$(RELEASE)
SRPM = $(SRPMSDIR)/$(RPM_BASENAME).src.rpm
RPM = $(RPMSDIR)/$(RPM_BASENAME).rpm

# executables and their options
MOCK = mock
MOCK_OPTS = -q -D "_version $(VERSION)" -D "_release $(RELEASE)"
RPMBUILD_OPTS = --define="_topdir $(RPMBUILDDIR)" \
  --define="_version $(VERSION)" \
  --define="_release $(RELEASE)"

.PHONY: default all site clean siteclean dist

default: dist
all: dist

site: $(SITEDIR)

clean: siteclean
  @echo "==== making clean"
  rm -rf $(RPMBUILDDIR)
  mock -q --clean
  @echo "==== making clean done"

siteclean:
  @echo "==== making siteclean"
  rm -rf $(WEBAPPDIR)/target
    rm -f $(WEBAPPDIR)/Gemfile.lock
  @echo "==== making siteclean done"

dist: $(RPM)

$(SITEDIR):
  @echo "==== making jekyll site"
  cd $(WEBAPPDIR) && bundle install
  cd $(WEBAPPDIR) && bundle exec jekyll build
  @echo "==== making jekyll site done"

$(SRPM): $(TARBALL) $(SPEC) $(BUILDDIRS) siteclean
  @echo "==== making source rpm"
  rpmbuild -bs $(SPEC) $(RPMBUILD_OPTS)
  @echo "==== making source rpm done"

$(RPM): $(SRPM)
  @echo "==== making binary rpm"
  $(MOCK) $(MOCK_OPTS) --init
  $(MOCK) $(MOCK_OPTS) --installdeps $<
  $(MOCK) $(MOCK_OPTS) --offline --resultdir $(RPMSDIR) --rebuild $<
  @echo "==== making binary rpm done"

$(SPEC): $(SPECSDIR) $(SPECFILE)
  @echo "==== making specfile"
  cp $(SPECFILE) $@
  @echo "==== making specfile done"

$(TARBALL): $(SOURCESDIR)
  @echo "==== making tarball"
  tar -czf $@ $(WEBAPPDIR)
  @echo "==== making tarball done"

$(BUILDDIRS): $(RPMBUILDDIR)
  mkdir -p $@

$(RPMBUILDDIR):
  mkdir -p $@