diff --git a/doc/source/build-roles.rst b/doc/source/build-roles.rst
new file mode 100644
index 000000000..d8b7f6b58
--- /dev/null
+++ b/doc/source/build-roles.rst
@@ -0,0 +1,5 @@
+Build Roles
+============
+
+.. zuul:autorole:: bazel-build
+.. zuul:autorole:: ensure-bazel
diff --git a/doc/source/roles.rst b/doc/source/roles.rst
index 1c9e28d2b..fda09d100 100644
--- a/doc/source/roles.rst
+++ b/doc/source/roles.rst
@@ -10,6 +10,7 @@ Roles
    general-roles
    log-roles
    afs-roles
+   build-roles
    cloud-roles
    container-roles
    deprecated-roles
diff --git a/roles/bazel-build/README.rst b/roles/bazel-build/README.rst
new file mode 100644
index 000000000..d4379cf69
--- /dev/null
+++ b/roles/bazel-build/README.rst
@@ -0,0 +1,14 @@
+Build project using Bazel.
+
+**Role Variables**
+
+.. zuul:rolevar:: bazel_build_target
+
+   Build target to specify when invoking Bazel. See
+   `Bazel docs <https://docs.bazel.build/versions/master/guide.html#target-patterns>`_
+   for details
+
+.. zuul:rolevar:: zuul_work_dir
+   :default: {{ zuul.project.src_dir }}
+
+   Directory where project will be built.
diff --git a/roles/bazel-build/defaults/main.yaml b/roles/bazel-build/defaults/main.yaml
new file mode 100644
index 000000000..9739eb171
--- /dev/null
+++ b/roles/bazel-build/defaults/main.yaml
@@ -0,0 +1 @@
+zuul_work_dir: "{{ zuul.project.src_dir }}"
diff --git a/roles/bazel-build/tasks/main.yaml b/roles/bazel-build/tasks/main.yaml
new file mode 100644
index 000000000..b1cab83bb
--- /dev/null
+++ b/roles/bazel-build/tasks/main.yaml
@@ -0,0 +1,9 @@
+- name: Require bazel_build_target variable
+  fail:
+    msg: bazel_build_target is required for this role
+  when: bazel_build_target is not defined
+
+- name: Build bazel
+  command: bazel build {{ bazel_build_target }}
+  args:
+    chdir: '{{ zuul_work_dir }}'
diff --git a/roles/ensure-bazel/README.rst b/roles/ensure-bazel/README.rst
new file mode 100644
index 000000000..9cb15e206
--- /dev/null
+++ b/roles/ensure-bazel/README.rst
@@ -0,0 +1,13 @@
+Download and install Bazel, if the specified version is not already present.
+
+**Role Variables**
+
+.. zuul:rolevar:: bazel_version
+   :default: '3.1.0'
+
+   The version of Bazel required.
+
+.. zuul:rolevar:: bazel_release_url
+   :default: 'https://github.com/bazelbuild/bazel/releases/download'
+
+   The base URL to use when downloading Bazel releases.
diff --git a/roles/ensure-bazel/defaults/main.yaml b/roles/ensure-bazel/defaults/main.yaml
new file mode 100644
index 000000000..d47f0b9b6
--- /dev/null
+++ b/roles/ensure-bazel/defaults/main.yaml
@@ -0,0 +1,4 @@
+---
+bazel_version: '3.1.0'
+bazel_release_url: 'https://github.com/bazelbuild/bazel/releases/download'
+install_bazel_if_missing: true
diff --git a/roles/ensure-bazel/tasks/Debian.yaml b/roles/ensure-bazel/tasks/Debian.yaml
new file mode 100644
index 000000000..9f049d2e3
--- /dev/null
+++ b/roles/ensure-bazel/tasks/Debian.yaml
@@ -0,0 +1,20 @@
+- name: Install Bazel dependencies
+  become: true
+  package:
+    name:
+      - pkg-config
+      - zip
+      - g++
+      - zlib1g-dev
+      - unzip
+      - python3
+    state: present
+
+- name: Install bazel on Debian
+  become: true
+  shell: |
+    set -ex
+    {{ bazel_installer_tempdir.path }}/bazel-{{ bazel_version }}-installer-linux-x86_64.sh
+    bazel version
+  args:
+    executable: /bin/bash
diff --git a/roles/ensure-bazel/tasks/default.yaml b/roles/ensure-bazel/tasks/default.yaml
new file mode 100644
index 000000000..c8a8100d7
--- /dev/null
+++ b/roles/ensure-bazel/tasks/default.yaml
@@ -0,0 +1,5 @@
+- name: Warn about unsupported distribution
+  debug:
+    msg: >
+      WARNING: Installation of Bazel on {{ ansible_distribution }} is not
+      supported by this role yet.
diff --git a/roles/ensure-bazel/tasks/install-bazel.yaml b/roles/ensure-bazel/tasks/install-bazel.yaml
new file mode 100644
index 000000000..848b9fc84
--- /dev/null
+++ b/roles/ensure-bazel/tasks/install-bazel.yaml
@@ -0,0 +1,29 @@
+- name: Create temp directory
+  tempfile:
+    state: directory
+  register: bazel_installer_tempdir
+
+- name: Get installer checksum
+  uri:
+    url: "{{ bazel_release_url }}/{{ bazel_version }}/bazel-{{ bazel_version }}-installer-linux-x86_64.sh.sha256"
+    return_content: true
+  register: bazel_installer_checksum
+
+- debug: msg="Checksum is {{ bazel_installer_checksum.content.split(' ')[0] }}"
+
+- name: Download bazel installer
+  get_url:
+    url: "{{ bazel_release_url }}/{{ bazel_version }}/bazel-{{ bazel_version }}-installer-linux-x86_64.sh"
+    dest: "{{ bazel_installer_tempdir.path }}/bazel-{{ bazel_version }}-installer-linux-x86_64.sh"
+    mode: 0755
+    checksum: "sha256:{{ bazel_installer_checksum.content.split(' ')[0] }}"
+
+- debug: msg="Distribution is {{ ansible_distribution }}"
+- debug: msg="OS family is {{ ansible_os_family }}"
+
+- name: Install bazel and platform-specific dependencies
+  include: "{{ item }}"
+  with_first_found:
+    - "{{ ansible_distribution }}.yaml"
+    - "{{ ansible_os_family }}.yaml"
+    - "default.yaml"
diff --git a/roles/ensure-bazel/tasks/main.yaml b/roles/ensure-bazel/tasks/main.yaml
new file mode 100644
index 000000000..28f0d9d69
--- /dev/null
+++ b/roles/ensure-bazel/tasks/main.yaml
@@ -0,0 +1,12 @@
+- name: Check version of bazel
+  shell: bazel --version | grep -Eo '([0-9]+\.)+[0-9]+'
+  ignore_errors: yes
+  register: installed_bazel_version
+
+- debug: msg="Current installed Bazel version is {{ installed_bazel_version.stdout }}"
+
+- name: Download and install Bazel if needed
+  include_tasks: install-bazel.yaml
+  when:
+    - install_bazel_if_missing
+    - installed_bazel_version.stdout != bazel_version
diff --git a/test-playbooks/build-roles/ensure-bazel.yaml b/test-playbooks/build-roles/ensure-bazel.yaml
new file mode 100644
index 000000000..eb7633abe
--- /dev/null
+++ b/test-playbooks/build-roles/ensure-bazel.yaml
@@ -0,0 +1,24 @@
+- name: Test correct version of bazel installed
+  hosts: all
+  roles:
+    - role: ensure-bazel
+  post_tasks:
+    - name: Confirm correct version installed
+      shell: bazel --version | grep -Eo '([0-9]+\.)+[0-9]+'
+      ignore_errors: yes
+      register: confirmed_bazel_version
+      failed_when: bazel_version != confirmed_bazel_version.stdout
+
+- name: Test bazel not installed when install_bazel_if_missing is false
+  hosts: all
+  roles:
+    - role: ensure-bazel
+      vars:
+        bazel_version: '1.2.1'
+        install_bazel_if_missing: False
+  post_tasks:
+    - name: Confirm version not installed
+      shell: bazel --version | grep -Eo '([0-9]+\.)+[0-9]+'
+      ignore_errors: yes
+      register: confirmed_bazel_version
+      failed_when: bazel_version == confirmed_bazel_version.stdout
diff --git a/zuul-tests.d/build-roles-jobs.yaml b/zuul-tests.d/build-roles-jobs.yaml
new file mode 100644
index 000000000..5cfbcee5a
--- /dev/null
+++ b/zuul-tests.d/build-roles-jobs.yaml
@@ -0,0 +1,16 @@
+- job:
+    name: zuul-jobs-test-ensure-bazel
+    description: Test the ensure-bazel role
+    run: test-playbooks/build-roles/ensure-bazel.yaml
+
+# -* AUTOGENERATED *-
+#  The following project section is autogenerated by
+#    tools/update-test-platforms.py
+#  Please re-run to generate new job lists
+
+- project:
+    check:
+      jobs: &id001
+        - zuul-jobs-test-ensure-bazel
+    gate:
+      jobs: *id001