Merge "Collect logging information into ara callback" into feature/zuulv3
diff --git a/.gitignore b/.gitignore
index a2dd0a3..d7e2fac 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,6 +3,7 @@
 *.egg-info
 *.pyc
 .idea
+.mypy_cache
 .test
 .testrepository
 .tox
diff --git a/.zuul.yaml b/.zuul.yaml
index 8095733..e2628cb 100644
--- a/.zuul.yaml
+++ b/.zuul.yaml
@@ -3,7 +3,13 @@
     check:
       jobs:
         - tox-docs
-        - tox-cover
+        - tox-cover:
+            voting: false
         - tox-pep8
         - tox-py35
         - tox-tarball
+    gate:
+      jobs:
+        - tox-docs
+        - tox-pep8
+        - tox-py35
diff --git a/bindep.txt b/bindep.txt
index 8dffd0f..85254b4 100644
--- a/bindep.txt
+++ b/bindep.txt
@@ -8,6 +8,7 @@
 zookeeperd [platform:dpkg]
 build-essential [platform:dpkg]
 gcc [platform:rpm]
+graphviz [test]
 libssl-dev [platform:dpkg]
 openssl-devel [platform:rpm]
 libffi-dev [platform:dpkg]
diff --git a/doc/source/admin/client.rst b/doc/source/admin/client.rst
index 6b62360..961b205 100644
--- a/doc/source/admin/client.rst
+++ b/doc/source/admin/client.rst
@@ -22,6 +22,14 @@
 
 The following subcommands are supported:
 
+Autohold
+^^^^^^^^
+.. program-output:: zuul autohold --help
+
+Example::
+
+  zuul autohold --tenant openstack --project example_project --job example_job --reason "reason text" --count 1
+
 Enqueue
 ^^^^^^^
 .. program-output:: zuul enqueue --help
diff --git a/doc/source/admin/components.rst b/doc/source/admin/components.rst
index cc9d181..aa6d8c8 100644
--- a/doc/source/admin/components.rst
+++ b/doc/source/admin/components.rst
@@ -6,16 +6,62 @@
 ==========
 
 Zuul is a distributed system consisting of several components, each of
-which is described below.  All Zuul processes read the
-**/etc/zuul/zuul.conf** file (an alternate location may be supplied on
-the command line) which uses an INI file syntax.  Each component may
-have its own configuration file, though you may find it simpler to use
-the same file for all components.
+which is described below.
 
-A minimal Zuul system may consist of a *scheduler* and *executor* both
-running on the same host.  Larger installations should consider
-running multiple executors, each on a dedicated host, and running
-mergers on dedicated hosts as well.
+
+.. graphviz::
+   :align: center
+
+   graph  {
+      node [shape=box]
+      Gearman [shape=ellipse]
+      Gerrit [fontcolor=grey]
+      Zookeeper [shape=ellipse]
+      Nodepool
+      GitHub [fontcolor=grey]
+
+      Merger -- Gearman
+      Executor -- Gearman
+      Web -- Gearman
+
+      Gearman -- Scheduler;
+      Scheduler -- Gerrit;
+      Scheduler -- Zookeeper;
+      Zookeeper -- Nodepool;
+      Scheduler -- GitHub;
+   }
+
+
+
+All Zuul processes read the ``/etc/zuul/zuul.conf`` file (an alternate
+location may be supplied on the command line) which uses an INI file
+syntax.  Each component may have its own configuration file, though
+you may find it simpler to use the same file for all components.
+
+An example ``zuul.conf``:
+
+.. code-block:: ini
+
+   [gearman]
+   server=localhost
+
+   [gearman_server]
+   start=true
+   log_config=/etc/zuul/gearman-logging.yaml
+
+   [zookeeper]
+   hosts=zk1.example.com,zk2.example.com,zk3.example.com
+
+   [webapp]
+   status_url=https://zuul.example.com/status
+
+   [scheduler]
+   log_config=/etc/zuul/scheduler-logging.yaml
+
+A minimal Zuul system may consist of a :ref:`scheduler` and
+:ref:`executor` both running on the same host.  Larger installations
+should consider running multiple executors, each on a dedicated host,
+and running mergers on dedicated hosts as well.
 
 Common
 ------
@@ -25,46 +71,52 @@
 Configuration
 ~~~~~~~~~~~~~
 
-The following sections of **zuul.conf** are used by all Zuul components:
+The following sections of ``zuul.conf`` are used by all Zuul components:
 
-gearman
-"""""""
 
-Client connection information for gearman.
+.. attr:: gearman
 
-**server** (required)
-  Hostname or IP address of the Gearman server::
+   Client connection information for Gearman.
 
-     server=gearman.example.com
+   .. attr:: server
+      :required:
 
-**port**
-  Port on which the Gearman server is listening::
+      Hostname or IP address of the Gearman server.
 
-     port=4730
+   .. attr:: port
+      :default: 4730
 
-**ssl_ca**
-  An openssl file containing a set of concatenated “certification
-  authority” certificates in PEM formet.
+      Port on which the Gearman server is listening.
 
-**ssl_cert**
-  An openssl file containing the client public certificate in PEM format.
+   .. attr:: ssl_ca
 
-**ssl_key**
-  An openssl file containing the client private key in PEM format.
+      An openssl file containing a set of concatenated “certification
+      authority” certificates in PEM formet.
 
-zookeeper
-"""""""""
+   .. attr:: ssl_cert
+
+      An openssl file containing the client public certificate in PEM format.
+
+   .. attr:: ssl_key
+
+      An openssl file containing the client private key in PEM format.
 
 .. NOTE: this is a white lie at this point, since only the scheduler
    uses this, however, we expect other components to use it later, so
    it's reasonable for admins to plan for this now.
 
-**hosts**
-  A list of zookeeper hosts for Zuul to use when communicating with
-  Nodepool::
+.. attr:: zookeeper
 
-     hosts=zk1.example.com,zk2.example.com,zk3.example.com
+   Client connection information for ZooKeeper
 
+   .. attr:: hosts
+      :required:
+
+      A list of zookeeper hosts for Zuul to use when communicating
+      with Nodepool.
+
+
+.. _scheduler:
 
 Scheduler
 ---------
@@ -79,85 +131,86 @@
 Configuration
 ~~~~~~~~~~~~~
 
-The following sections of **zuul.conf** are used by the scheduler:
+The following sections of ``zuul.conf`` are used by the scheduler:
 
-gearman_server
-""""""""""""""
 
-The builtin gearman server. Zuul can fork a gearman process from itself rather
-than connecting to an external one.
+.. attr:: gearman_server
 
-**start**
-  Whether to start the internal Gearman server (default: False)::
+   The builtin gearman server. Zuul can fork a gearman process from
+   itself rather than connecting to an external one.
 
-     start=true
+   .. attr:: start
+      :default: false
 
-**listen_address**
-  IP address or domain name on which to listen (default: all addresses)::
+      Whether to start the internal Gearman server.
 
-     listen_address=127.0.0.1
+   .. attr:: listen_address
+      :default: all addresses
 
-**log_config**
-  Path to log config file for internal Gearman server::
+      IP address or domain name on which to listen.
 
-     log_config=/etc/zuul/gearman-logging.yaml
+   .. attr:: log_config
 
-**ssl_ca**
-  An openssl file containing a set of concatenated “certification authority”
-  certificates in PEM formet.
+      Path to log config file for internal Gearman server.
 
-**ssl_cert**
-  An openssl file containing the server public certificate in PEM format.
+   .. attr:: ssl_ca
 
-**ssl_key**
-  An openssl file containing the server private key in PEM format.
+      An openssl file containing a set of concatenated “certification
+      authority” certificates in PEM formet.
 
-webapp
-""""""
+   .. attr:: ssl_cert
 
-**listen_address**
-  IP address or domain name on which to listen (default: 0.0.0.0)::
+      An openssl file containing the server public certificate in PEM
+      format.
 
-     listen_address=127.0.0.1
+   .. attr:: ssl_key
 
-**port**
-  Port on which the webapp is listening (default: 8001)::
+      An openssl file containing the server private key in PEM format.
 
-     port=8008
+.. attr:: webapp
 
-**status_expiry**
-  Zuul will cache the status.json file for this many seconds (default: 1)::
+   .. attr:: listen_address
+      :default: all addresses
 
-     status_expiry=1
+      IP address or domain name on which to listen.
 
-**status_url**
-  URL that will be posted in Zuul comments made to changes when
-  starting jobs for a change.  Used by zuul-scheduler only::
+   .. attr:: port
+      :default: 8001
 
-     status_url=https://zuul.example.com/status
+      Port on which the webapp is listening.
 
-scheduler
-"""""""""
+   .. attr:: status_expiry
+      :default: 1
 
-**tenant_config**
-  Path to tenant config file::
+      Zuul will cache the status.json file for this many seconds.
 
-     layout_config=/etc/zuul/tenant.yaml
+   .. attr:: status_url
 
-**log_config**
-  Path to log config file::
+      URL that will be posted in Zuul comments made to changes when
+      starting jobs for a change.
 
-     log_config=/etc/zuul/scheduler-logging.yaml
+      .. TODO: is this effectively required?
 
-**pidfile**
-  Path to PID lock file::
+.. attr:: scheduler
 
-     pidfile=/var/run/zuul/scheduler.pid
+   .. attr:: tenant_config
+      :required:
 
-**state_dir**
-  Path to directory that Zuul should save state to::
+      Path to :ref:`tenant-config` file.
 
-     state_dir=/var/lib/zuul
+   .. attr:: log_config
+
+      Path to log config file.
+
+   .. attr:: pidfile
+      :default: /var/run/zuul-schedurecr/zuul-scheduler.pid
+
+      Path to PID lock file.
+
+   .. attr:: state_dir
+      :default: /var/lib/zuul
+
+      Path to directory in which Zuul should save its state.
 
 Operation
 ~~~~~~~~~
@@ -169,7 +222,8 @@
 the repositories which contain it are merged.  However, Zuul must be
 explicitly notified of changes to the tenant config file, since it is
 not read from a git repository.  To do so, send the scheduler PID
-(saved in the pidfile specified in the configuration) a SIGHUP signal.
+(saved in the pidfile specified in the configuration) a `SIGHUP`
+signal.
 
 Merger
 ------
@@ -193,35 +247,32 @@
 Configuration
 ~~~~~~~~~~~~~
 
-The following section of **zuul.conf** is used by the merger:
+The following section of ``zuul.conf`` is used by the merger:
 
-merger
-""""""
+.. attr:: merger
 
-**git_dir**
-  Directory that Zuul should clone local git repositories to::
+   .. attr:: git_dir
 
-     git_dir=/var/lib/zuul/git
+      Directory in which Zuul should clone git repositories.
 
-**git_user_email**
-  Value to pass to `git config user.email`::
+   .. attr:: git_user_email
 
-     git_user_email=zuul@example.com
+      Value to pass to `git config user.email
+      <https://git-scm.com/book/en/v2/Getting-Started-First-Time-Git-Setup>`_.
 
-**git_user_name**
-  Value to pass to `git config user.name`::
+   .. attr:: git_user_name
 
-     git_user_name=zuul
+      Value to pass to `git config user.name
+      <https://git-scm.com/book/en/v2/Getting-Started-First-Time-Git-Setup>`_.
 
-**log_config**
-  Path to log config file for the merger process::
+   .. attr:: log_config
 
-     log_config=/etc/zuul/logging.yaml
+      Path to log config file for the merger process.
 
-**pidfile**
-  Path to PID lock file for the merger process::
+   .. attr:: pidfile
+      :default: /var/run/zuul-merger/zuul-merger.pid
 
-     pidfile=/var/run/zuul-merger/merger.pid
+      Path to PID lock file for the merger process.
 
 Operation
 ~~~~~~~~~
@@ -229,6 +280,8 @@
 To start the merger, run ``zuul-merger``.  To stop it, kill the
 PID which was saved in the pidfile specified in the configuration.
 
+.. _executor:
+
 Executor
 --------
 
@@ -252,10 +305,11 @@
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
 The executor runs playbooks in one of two execution contexts depending
-on whether the project containing the playbook is a *config project*
-or an *untrusted project*.  If the playbook is in a *config project*,
-the executor runs the playbook in the *trusted* execution context,
-otherwise, it is run in the *untrusted* execution context.
+on whether the project containing the playbook is a
+:term:`config-project` or an :term:`untrusted-project`.  If the
+playbook is in a config project, the executor runs the playbook in the
+*trusted* execution context, otherwise, it is run in the *untrusted*
+execution context.
 
 Both execution contexts use `bubblewrap`_ to create a namespace to
 ensure that playbook executions are isolated and are unable to access
@@ -263,7 +317,7 @@
 configure additional local directories on the executor to be made
 available to the restricted environment.
 
-The *trusted* execution context has access to all Ansible features,
+The trusted execution context has access to all Ansible features,
 including the ability to load custom Ansible modules.  Needless to
 say, extra scrutiny should be given to code that runs in a trusted
 context as it could be used to compromise other jobs running on the
@@ -271,64 +325,129 @@
 granted additional access through bubblewrap, or a method of escaping
 the restricted environment created by bubblewrap is found.
 
-Playbooks run in the *untrusted* execution context are not permitted
-to load additional Ansible modules or access files outside of the
+Playbooks run in the untrusted execution context are not permitted to
+load additional Ansible modules or access files outside of the
 restricted environment prepared for them by the executor.  In addition
 to the bubblewrap environment applied to both execution contexts, in
-the *untrusted* context some standard Ansible modules are replaced
-with versions which prohibit some actions, including attempts to
-access files outside of the restricted execution context.  These
-redundant protections are made as part of a defense-in-depth strategy.
+the untrusted context some standard Ansible modules are replaced with
+versions which prohibit some actions, including attempts to access
+files outside of the restricted execution context.  These redundant
+protections are made as part of a defense-in-depth strategy.
 
 .. _bubblewrap: https://github.com/projectatomic/bubblewrap
 
 Configuration
 ~~~~~~~~~~~~~
 
-The following sections of **zuul.conf** are used by the executor:
+The following sections of ``zuul.conf`` are used by the executor:
 
-executor
-""""""""
+.. attr:: executor
 
-**finger_port**
-  Port to use for finger log streamer::
+   .. attr:: finger_port
+      :default: 79
 
-     finger_port=79
+      Port to use for finger log streamer.
 
-**git_dir**
-  Directory that Zuul should clone local git repositories to::
+   .. attr:: git_dir
+      :default: /var/lib/zuul/git
 
-     git_dir=/var/lib/zuul/git
+      Directory that Zuul should clone local git repositories to.  The
+      executor keeps a local copy of every git repository it works
+      with to speed operations and perform speculative merging.
 
-**log_config**
-  Path to log config file for the executor process::
+      This should be on the same filesystem as
+      :attr:`executor.job_dir` so that when git repos are cloned into
+      the job workspaces, they can be hard-linked to the local git
+      cache.
 
-     log_config=/etc/zuul/logging.yaml
+   .. attr:: job_dir
+      :default: /tmp
 
-**private_key_file**
-  SSH private key file to be used when logging into worker nodes::
+      Directory that Zuul should use to hold temporary job directories.
+      When each job is run, a new entry will be created under this
+      directory to hold the configuration and scratch workspace for
+      that job.  It will be deleted at the end of the job (unless the
+      `--keep-jobdir` command line option is specified).
 
-     private_key_file=~/.ssh/id_rsa
+      This should be on the same filesystem as :attr:`executor.git_dir`
+      so that when git repos are cloned into the job workspaces, they
+      can be hard-linked to the local git cache.
 
-**user**
-  User ID for the zuul-executor process. In normal operation as a daemon,
-  the executor should be started as the ``root`` user, but it will drop
-  privileges to this user during startup::
+   .. attr:: log_config
 
-     user=zuul
+      Path to log config file for the executor process.
 
-merger
-""""""
+   .. attr:: pidfile
+      :default: /var/run/zuul-executor/zuul-executor.pid
 
-**git_user_email**
-  Value to pass to `git config user.email`::
+      Path to PID lock file for the executor process.
 
-     git_user_email=zuul@example.com
+   .. attr:: private_key_file
+      :default: ~/.ssh/id_rsa
 
-**git_user_name**
-  Value to pass to `git config user.name`::
+      SSH private key file to be used when logging into worker nodes.
 
-     git_user_name=zuul
+   .. attr:: user
+      :default: zuul
+
+      User ID for the zuul-executor process. In normal operation as a
+      daemon, the executor should be started as the ``root`` user, but
+      it will drop privileges to this user during startup.
+
+   .. _admin_sitewide_variables:
+
+   .. attr:: variables
+
+      Path to an Ansible variables file to supply site-wide variables.
+      This should be a YAML-formatted file consisting of a single
+      dictionary.  The contents will be made available to all jobs as
+      Ansible variables.  These variables take precedence over all
+      other forms (job variables and secrets).  Care should be taken
+      when naming these variables to avoid potential collisions with
+      those used by jobs.  Prefixing variable names with a
+      site-specific identifier is recommended.  The default is not to
+      add any site-wide variables.  See the :ref:`User's Guide
+      <user_sitewide_variables>` for more information.
+
+   .. attr:: disk_limit_per_job
+      :default: 250
+
+      This integer is the maximum number of megabytes that any one job
+      is allowed to consume on disk while it is running. If a job's
+      scratch space has more than this much space consumed, it will be
+      aborted.
+
+   .. attr:: trusted_ro_paths
+
+      List of paths, separated by ``:`` to read-only bind mount into
+      trusted bubblewrap contexts.
+
+   .. attr:: trusted_rw_paths
+
+      List of paths, separated by ``:`` to read-write bind mount into
+      trusted bubblewrap contexts.
+
+   .. attr:: untrusted_ro_paths
+
+      List of paths, separated by ``:`` to read-only bind mount into
+      untrusted bubblewrap contexts.
+
+   .. attr:: untrusted_rw_paths
+
+      List of paths, separated by ``:`` to read-write bind mount into
+      untrusted bubblewrap contexts.
+
+.. attr:: merger
+
+   .. attr:: git_user_email
+
+      Value to pass to `git config user.email
+      <https://git-scm.com/book/en/v2/Getting-Started-First-Time-Git-Setup>`_.
+
+   .. attr:: git_user_name
+
+      Value to pass to `git config user.name
+      <https://git-scm.com/book/en/v2/Getting-Started-First-Time-Git-Setup>`_.
 
 Operation
 ~~~~~~~~~
@@ -345,9 +464,9 @@
 To request that the executor stop executing new jobs and exit when all
 currently running jobs have completed, run ``zuul-executor graceful``.
 
-To enable or disable running Ansible in verbose mode (with the '-vvv'
-argument to ansible-playbook) run ``zuul-executor verbose`` and
-``zuul-executor unverbose``.
+To enable or disable running Ansible in verbose mode (with the
+``-vvv`` argument to ansible-playbook) run ``zuul-executor verbose``
+and ``zuul-executor unverbose``.
 
 Web Server
 ----------
@@ -359,35 +478,34 @@
 Configuration
 ~~~~~~~~~~~~~
 
-In addition to the ``gearman`` common configuration section, the following
-sections of **zuul.conf** are used by the web server:
+In addition to the common configuration sections, the following
+sections of ``zuul.conf`` are used by the web server:
 
-web
-"""
+.. attr:: web
 
-**listen_address**
-  IP address or domain name on which to listen (default: 127.0.0.1)::
+   .. attr:: listen_address
+      :default: 127.0.0.1
 
-     listen_address=127.0.0.1
+      IP address or domain name on which to listen.
 
-**log_config**
-  Path to log config file for the web server process::
+   .. attr:: log_config
 
-     log_config=/etc/zuul/logging.yaml
+      Path to log config file for the web server process.
 
-**pidfile**
-  Path to PID lock file for the web server process::
+   .. attr:: pidfile
+      :default: /var/run/zuul-web/zuul-web.pid
 
-     pidfile=/var/run/zuul-web/zuul-web.pid
+      Path to PID lock file for the web server process.
 
-**port**
-  Port to use for web server process::
+   .. attr:: port
+      :default: 9000
 
-     port=9000
+      Port to use for web server process.
 
-**websocket_url**
-  Base URL on which the websocket service is exposed, if different than the
-  base URL of the web app.
+   .. attr:: websocket_url
+
+      Base URL on which the websocket service is exposed, if different
+      than the base URL of the web app.
 
 Operation
 ~~~~~~~~~
diff --git a/doc/source/admin/connections.rst b/doc/source/admin/connections.rst
index 5b40e5b..29ca3be 100644
--- a/doc/source/admin/connections.rst
+++ b/doc/source/admin/connections.rst
@@ -23,9 +23,11 @@
 driver.
 
 To configure a connection in Zuul, select a unique name for the
-connection and add a section to **zuul.conf** with the form
-"[connection NAME]".  For example, a connection to a gerrit server may
-appear as::
+connection and add a section to ``zuul.conf`` with the form
+``[connection NAME]``.  For example, a connection to a gerrit server
+may appear as:
+
+.. code-block:: ini
 
   [connection mygerritserver]
   driver=gerrit
diff --git a/doc/source/admin/drivers/gerrit.rst b/doc/source/admin/drivers/gerrit.rst
index 29e136b..ac42bd3 100644
--- a/doc/source/admin/drivers/gerrit.rst
+++ b/doc/source/admin/drivers/gerrit.rst
@@ -24,44 +24,57 @@
 Connection Configuration
 ------------------------
 
-The supported options in zuul.conf connections are:
+The supported options in ``zuul.conf`` connections are:
 
-**driver=gerrit**
+.. attr:: <gerrit connection>
 
-**server**
-  FQDN of Gerrit server.
-  ``server=review.example.com``
+   .. attr:: driver
+      :required:
 
-**canonical_hostname**
-  The canonical hostname associated with the git repos on the Gerrit
-  server.  Defaults to the value of **server**.  This is used to
-  identify projects from this connection by name and in preparing
-  repos on the filesystem for use by jobs.  Note that Zuul will still
-  only communicate with the Gerrit server identified by **server**;
-  this option is useful if users customarily use a different hostname
-  to clone or pull git repos so that when Zuul places them in the
-  job's working directory, they appear under this directory name.
-  ``canonical_hostname=git.example.com``
+      .. value:: gerrit
 
-**port**
-  Optional: Gerrit server port.
-  ``port=29418``
+         The connection must set ``driver=gerrit`` for Gerrit connections.
 
-**baseurl**
-  Optional: path to Gerrit web interface. Defaults to ``https://<value
-  of server>/``. ``baseurl=https://review.example.com/review_site/``
+   .. attr:: server
 
-**user**
-  User name to use when logging into above server via ssh.
-  ``user=zuul``
+      Fully qualified domain name of Gerrit server.
 
-**sshkey**
-  Path to SSH key to use when logging into above server.
-  ``sshkey=/home/zuul/.ssh/id_rsa``
+   .. attr:: canonical_hostname
 
-**keepalive**
-  Optional: Keepalive timeout, 0 means no keepalive.
-  ``keepalive=60``
+      The canonical hostname associated with the git repos on the
+      Gerrit server.  Defaults to the value of
+      :attr:`<gerrit connection>.server`.  This is used to identify
+      projects from this connection by name and in preparing repos on
+      the filesystem for use by jobs.  Note that Zuul will still only
+      communicate with the Gerrit server identified by ``server``;
+      this option is useful if users customarily use a different
+      hostname to clone or pull git repos so that when Zuul places
+      them in the job's working directory, they appear under this
+      directory name.
+
+   .. attr:: port
+      :default: 29418
+
+      Gerrit server port.
+
+   .. attr:: baseurl
+
+      Path to Gerrit web interface.
+
+   .. attr:: user
+      :default: zuul
+
+      User name to use when logging into Gerrit via ssh.
+
+   .. attr:: sshkey
+      :default: ~zuul/.ssh/id_rsa
+
+      Path to SSH key to use when logging into Gerrit.
+
+   .. attr:: keepalive
+      :default: 60
+
+      SSH connection keepalive timeout; ``0`` disables.
 
 Trigger Configuration
 ---------------------
@@ -74,85 +87,88 @@
 that is granted the ``Stream Events`` permission, otherwise it will not
 be able to invoke the ``gerrit stream-events`` command over SSH.
 
-The supported pipeline trigger options are:
+.. attr:: pipeline.trigger.<gerrit source>
 
-**event**
-  The event name from gerrit.  Examples: ``patchset-created``,
-  ``comment-added``, ``ref-updated``.  This field is treated as a
-  regular expression.
+   The dictionary passed to the Gerrit pipeline ``trigger`` attribute
+   supports the following attributes:
 
-**branch**
-  The branch associated with the event.  Example: ``master``.  This
-  field is treated as a regular expression, and multiple branches may
-  be listed.
+   .. attr:: event
+      :required:
 
-**ref**
-  On ref-updated events, the branch parameter is not used, instead the
-  ref is provided.  Currently Gerrit has the somewhat idiosyncratic
-  behavior of specifying bare refs for branch names (e.g.,
-  ``master``), but full ref names for other kinds of refs (e.g.,
-  ``refs/tags/foo``).  Zuul matches what you put here exactly against
-  what Gerrit provides.  This field is treated as a regular
-  expression, and multiple refs may be listed.
+      The event name from gerrit.  Examples: ``patchset-created``,
+      ``comment-added``, ``ref-updated``.  This field is treated as a
+      regular expression.
 
-**ignore-deletes**
-  When a branch is deleted, a ref-updated event is emitted with a
-  newrev of all zeros specified. The ``ignore-deletes`` field is a
-  boolean value that describes whether or not these newrevs trigger
-  ref-updated events.  The default is True, which will not trigger
-  ref-updated events.
+   .. attr:: branch
 
-**approval**
-  This is only used for ``comment-added`` events.  It only matches if
-  the event has a matching approval associated with it.  Example:
-  ``code-review: 2`` matches a ``+2`` vote on the code review
-  category.  Multiple approvals may be listed.
+      The branch associated with the event.  Example: ``master``.
+      This field is treated as a regular expression, and multiple
+      branches may be listed.
 
-**email**
-  This is used for any event.  It takes a regex applied on the
-  performer email, i.e. Gerrit account email address.  If you want to
-  specify several email filters, you must use a YAML list.  Make sure
-  to use non greedy matchers and to escapes dots!  Example: ``email:
-  ^.*?@example\.org$``.
+   .. attr:: ref
 
-**email_filter** (deprecated)
-  A deprecated alternate spelling of *email*.  Only one of *email* or
-  *email_filter* should be used.
+      On ref-updated events, the branch parameter is not used, instead
+      the ref is provided.  Currently Gerrit has the somewhat
+      idiosyncratic behavior of specifying bare refs for branch names
+      (e.g., ``master``), but full ref names for other kinds of refs
+      (e.g., ``refs/tags/foo``).  Zuul matches this value exactly
+      against what Gerrit provides.  This field is treated as a
+      regular expression, and multiple refs may be listed.
 
-**username**
-  This is used for any event.  It takes a regex applied on the
-  performer username, i.e. Gerrit account name.  If you want to
-  specify several username filters, you must use a YAML list.  Make
-  sure to use non greedy matchers and to escapes dots!  Example:
-  ``username: ^jenkins$``.
+   .. attr:: ignore-deletes
+      :default: true
 
-**username_filter** (deprecated)
-  A deprecated alternate spelling of *username*.  Only one of
-  *username* or *username_filter* should be used.
+      When a branch is deleted, a ref-updated event is emitted with a
+      newrev of all zeros specified. The ``ignore-deletes`` field is a
+      boolean value that describes whether or not these newrevs
+      trigger ref-updated events.
 
-**comment**
-  This is only used for ``comment-added`` events.  It accepts a list
-  of regexes that are searched for in the comment string. If any of
-  these regexes matches a portion of the comment string the trigger is
-  matched. ``comment: retrigger`` will match when comments containing
-  'retrigger' somewhere in the comment text are added to a change.
+   .. attr:: approval
 
-**comment_filter** (deprecated)
-  A deprecated alternate spelling of *comment*.  Only one of *comment*
-  or *comment_filter* should be used.
+      This is only used for ``comment-added`` events.  It only matches
+      if the event has a matching approval associated with it.
+      Example: ``Code-Review: 2`` matches a ``+2`` vote on the code
+      review category.  Multiple approvals may be listed.
 
-**require-approval**
-  This may be used for any event.  It requires that a certain kind of
-  approval be present for the current patchset of the change (the
-  approval could be added by the event in question).  It follows the
-  same syntax as the :ref:`"approval" pipeline requirement
-  <pipeline-require-approval>`. For each specified criteria there must
-  exist a matching approval.
+   .. attr:: email
 
-**reject-approval**
-  This takes a list of approvals in the same format as
-  *require-approval* but will fail to enter the pipeline if there is a
-  matching approval.
+      This is used for any event.  It takes a regex applied on the
+      performer email, i.e. Gerrit account email address.  If you want
+      to specify several email filters, you must use a YAML list.
+      Make sure to use non greedy matchers and to escapes dots!
+      Example: ``email: ^.*?@example\.org$``.
+
+   .. attr:: username
+
+      This is used for any event.  It takes a regex applied on the
+      performer username, i.e. Gerrit account name.  If you want to
+      specify several username filters, you must use a YAML list.
+      Make sure to use non greedy matchers and to escapes dots.
+      Example: ``username: ^zuul$``.
+
+   .. attr:: comment
+
+      This is only used for ``comment-added`` events.  It accepts a
+      list of regexes that are searched for in the comment string. If
+      any of these regexes matches a portion of the comment string the
+      trigger is matched. ``comment: retrigger`` will match when
+      comments containing ``retrigger`` somewhere in the comment text
+      are added to a change.
+
+   .. attr:: require-approval
+
+      This may be used for any event.  It requires that a certain kind
+      of approval be present for the current patchset of the change
+      (the approval could be added by the event in question).  It
+      follows the same syntax as :attr:`pipeline.require.<gerrit
+      source>.approval`. For each specified criteria there must exist
+      a matching approval.
+
+   .. attr:: reject-approval
+
+      This takes a list of approvals in the same format as
+      :attr:`pipeline.trigger.<gerrit source>.require-approval` but
+      will fail to enter the pipeline if there is a matching approval.
 
 Reporter Configuration
 ----------------------
@@ -170,3 +186,103 @@
 
 A :ref:`connection<connections>` that uses the gerrit driver must be
 supplied to the trigger.
+
+Requirements Configuration
+--------------------------
+
+As described in :attr:`pipeline.require` and :attr:`pipeline.reject`,
+pipelines may specify that items meet certain conditions in order to
+be enqueued into the pipeline.  These conditions vary according to the
+source of the project in question.  To supply requirements for changes
+from a Gerrit source named ``my-gerrit``, create a configuration such
+as the following:
+
+.. code-block:: yaml
+
+   pipeline:
+     require:
+       my-gerrit:
+         approval:
+           - Code-Review: 2
+
+This indicates that changes originating from the Gerrit connection
+named ``my-gerrit`` must have a ``Code-Review`` vote of ``+2`` in
+order to be enqueued into the pipeline.
+
+.. attr:: pipeline.require.<gerrit source>
+
+   The dictionary passed to the Gerrit pipeline `require` attribute
+   supports the following attributes:
+
+   .. attr:: approval
+
+      This requires that a certain kind of approval be present for the
+      current patchset of the change (the approval could be added by
+      the event in question).  It takes several sub-parameters, all of
+      which are optional and are combined together so that there must
+      be an approval matching all specified requirements.
+
+      .. attr:: username
+
+         If present, an approval from this username is required.  It is
+         treated as a regular expression.
+
+      .. attr:: email
+
+         If present, an approval with this email address is required.  It is
+         treated as a regular expression.
+
+      .. attr:: older-than
+
+         If present, the approval must be older than this amount of time
+         to match.  Provide a time interval as a number with a suffix of
+         "w" (weeks), "d" (days), "h" (hours), "m" (minutes), "s"
+         (seconds).  Example ``48h`` or ``2d``.
+
+      .. attr:: newer-than
+
+         If present, the approval must be newer than this amount
+         of time to match.  Same format as "older-than".
+
+      Any other field is interpreted as a review category and value
+      pair.  For example ``Verified: 1`` would require that the
+      approval be for a +1 vote in the "Verified" column.  The value
+      may either be a single value or a list: ``Verified: [1, 2]``
+      would match either a +1 or +2 vote.
+
+   .. attr:: open
+
+      A boolean value (``true`` or ``false``) that indicates whether
+      the change must be open or closed in order to be enqueued.
+
+   .. attr:: current-patchset
+
+      A boolean value (``true`` or ``false``) that indicates whether the
+      change must be the current patchset in order to be enqueued.
+
+   .. attr:: status
+
+      A string value that corresponds with the status of the change
+      reported by the trigger.
+
+.. attr:: pipeline.reject.<gerrit source>
+
+   The `reject` attribute is the mirror of the `require` attribute.  It
+   also accepts a dictionary under the connection name.  This
+   dictionary supports the following attributes:
+
+   .. attr:: approval
+
+      This takes a list of approvals. If an approval matches the
+      provided criteria the change can not be entered into the
+      pipeline. It follows the same syntax as
+      :attr:`pipeline.require.<gerrit source>.approval`.
+
+      Example to reject a change with any negative vote:
+
+      .. code-block:: yaml
+
+         reject:
+           my-gerrit:
+             approval:
+               - Code-Review: [-1, -2]
diff --git a/doc/source/admin/drivers/github.rst b/doc/source/admin/drivers/github.rst
index 9740292..7eebbdc 100644
--- a/doc/source/admin/drivers/github.rst
+++ b/doc/source/admin/drivers/github.rst
@@ -22,118 +22,196 @@
 Connection Configuration
 ------------------------
 
-The supported options in zuul.conf connections are:
+There are two forms of operation. Either the Zuul installation can be
+configured as a `Github App`_ or it can be configured as a Webhook.
 
-**driver=github**
+If the `Github App`_ approach is taken, the config settings ``app_id`` and
+``app_key`` are required. If the Webhook approach is taken, the ``api_token``
+setting is required.
 
-**api_token**
-  API token for accessing GitHub.
-  See `Creating an access token for command-line use
-  <https://help.github.com/articles/creating-an-access-token-for-command-line-use/>`_.
+The supported options in ``zuul.conf`` connections are:
 
-**webhook_token**
-  Optional: Token for validating the webhook event payloads.
-  If not specified, payloads are not validated.
-  See `Securing your webhooks
-  <https://developer.github.com/webhooks/securing/>`_.
+.. attr:: <github connection>
 
-**sshkey**
-  Path to SSH key to use when cloning github repositories.
-  ``sshkey=/home/zuul/.ssh/id_rsa``
+   .. attr:: driver
+      :required:
 
-**server**
-  Optional: Hostname of the github install (such as a GitHub Enterprise)
-  If not specified, defaults to ``github.com``
-  ``server=github.myenterprise.com``
+      .. value:: github
 
-**canonical_hostname**
-  The canonical hostname associated with the git repos on the GitHub
-  server.  Defaults to the value of **server**.  This is used to
-  identify projects from this connection by name and in preparing
-  repos on the filesystem for use by jobs.  Note that Zuul will still
-  only communicate with the GitHub server identified by **server**;
-  this option is useful if users customarily use a different hostname
-  to clone or pull git repos so that when Zuul places them in the
-  job's working directory, they appear under this directory name.
-  ``canonical_hostname=git.example.com``
+         The connection must set ``driver=github`` for GitHub connections.
+
+   .. attr:: app_id
+
+      App ID if you are using a *GitHub App*. Can be found under the
+      **Public Link** on the right hand side labeled **ID**.
+
+   .. attr:: app_key
+
+      Path to a file containing the secret key Zuul will use to create
+      tokens for the API interactions. In Github this is known as
+      **Private key** and must be collected when generated.
+
+   .. attr:: api_token
+
+      API token for accessing GitHub if Zuul is configured with
+      Webhooks.  See `Creating an access token for command-line use
+      <https://help.github.com/articles/creating-an-access-token-for-command-line-use/>`_.
+
+   .. attr:: webhook_token
+
+      Required token for validating the webhook event payloads.  In
+      the GitHub App Configuration page, this is called **Webhook
+      secret**.  See `Securing your webhooks
+      <https://developer.github.com/webhooks/securing/>`_.
+
+   .. attr:: sshkey
+      :default: ~/.ssh/id_rsa
+
+      Path to SSH key to use when cloning github repositories.
+
+   .. attr:: server
+      :default: github.com
+
+      Hostname of the github install (such as a GitHub Enterprise).
+
+   .. attr:: canonical_hostname
+
+      The canonical hostname associated with the git repos on the
+      GitHub server.  Defaults to the value of :attr:`<github
+      connection>.server`.  This is used to identify projects from
+      this connection by name and in preparing repos on the filesystem
+      for use by jobs.  Note that Zuul will still only communicate
+      with the GitHub server identified by **server**; this option is
+      useful if users customarily use a different hostname to clone or
+      pull git repos so that when Zuul places them in the job's
+      working directory, they appear under this directory name.
+
+   .. attr:: verify_ssl
+      :default: true
+
+      Enable or disable ssl verification for GitHub Enterprise.  This
+      is useful for a connection to a test installation.
 
 Trigger Configuration
 ---------------------
 GitHub webhook events can be configured as triggers.
 
-A connection name with the github driver can take multiple events with the
-following options.
+A connection name with the GitHub driver can take multiple events with
+the following options.
 
-**event**
-  The event from github. Supported events are ``pull_request``,
-  ``pull_request_review``, and ``push``.
+.. attr:: pipeline.trigger.<github source>
 
-  A ``pull_request`` event will have associated action(s) to trigger
-  from. The supported actions are:
+   The dictionary passed to the GitHub pipeline ``trigger`` attribute
+   supports the following attributes:
 
-    *opened* - pull request opened
+   .. attr:: event
+      :required:
 
-    *changed* - pull request synchronized
+      The event from github. Supported events are:
 
-    *closed* - pull request closed
+      .. value:: pull_request
 
-    *reopened* - pull request reopened
+      .. value:: pull_request_review
 
-    *comment* - comment added on pull request
+      .. value:: push
 
-    *labeled* - label added on pull request
+   .. attr:: action
 
-    *unlabeled* - label removed from pull request
+      A :value:`pipeline.trigger.<github source>.event.pull_request`
+      event will have associated action(s) to trigger from. The
+      supported actions are:
 
-    *status* - status set on commit
+      .. value:: opened
 
-  A ``pull_request_review`` event will
-  have associated action(s) to trigger from. The supported actions are:
+         Pull request opened.
 
-    *submitted* - pull request review added
+      .. value:: changed
 
-    *dismissed* - pull request review removed
+         Pull request synchronized.
 
-**branch**
-  The branch associated with the event. Example: ``master``.  This
-  field is treated as a regular expression, and multiple branches may
-  be listed. Used for ``pull_request`` and ``pull_request_review``
-  events.
+      .. value:: closed
 
-**comment**
-  This is only used for ``pull_request`` ``comment`` actions.  It
-  accepts a list of regexes that are searched for in the comment
-  string. If any of these regexes matches a portion of the comment
-  string the trigger is matched.  ``comment: retrigger`` will match
-  when comments containing 'retrigger' somewhere in the comment text
-  are added to a pull request.
+         Pull request closed.
 
-**label**
-  This is only used for ``labeled`` and ``unlabeled`` ``pull_request``
-  actions.  It accepts a list of strings each of which matches the
-  label name in the event literally.  ``label: recheck`` will match a
-  ``labeled`` action when pull request is labeled with a ``recheck``
-  label. ``label: 'do not test'`` will match a ``unlabeled`` action
-  when a label with name ``do not test`` is removed from the pull
-  request.
+      .. value:: reopened
 
-**state**
-  This is only used for ``pull_request_review`` events.  It accepts a
-  list of strings each of which is matched to the review state, which
-  can be one of ``approved``, ``comment``, or ``request_changes``.
+         Pull request reopened.
 
-**status**
-  This is used for ``pull-request`` and ``status`` actions. It accepts
-  a list of strings each of which matches the user setting the status,
-  the status context, and the status itself in the format of
-  ``user:context:status``.  For example,
-  ``zuul_github_ci_bot:check_pipeline:success``.
+      .. value:: comment
 
-**ref**
-  This is only used for ``push`` events. This field is treated as a
-  regular expression and multiple refs may be listed. GitHub always
-  sends full ref name, eg. ``refs/tags/bar`` and this string is
-  matched against the regexp.
+         Comment added to pull request.
+
+      .. value:: labeled
+
+         Label added to pull request.
+
+      .. value:: unlabeled
+
+         Label removed from pull request.
+
+      .. value:: status
+
+         Status set on commit.
+
+      A :value:`pipeline.trigger.<github
+      source>.event.pull_request_review` event will have associated
+      action(s) to trigger from. The supported actions are:
+
+      .. value:: submitted
+
+         Pull request review added.
+
+      .. value:: dismissed
+
+         Pull request review removed.
+
+   .. attr:: branch
+
+      The branch associated with the event. Example: ``master``.  This
+      field is treated as a regular expression, and multiple branches
+      may be listed. Used for ``pull_request`` and
+      ``pull_request_review`` events.
+
+   .. attr:: comment
+
+      This is only used for ``pull_request`` ``comment`` actions.  It
+      accepts a list of regexes that are searched for in the comment
+      string. If any of these regexes matches a portion of the comment
+      string the trigger is matched.  ``comment: retrigger`` will
+      match when comments containing 'retrigger' somewhere in the
+      comment text are added to a pull request.
+
+   .. attr:: label
+
+      This is only used for ``labeled`` and ``unlabeled``
+      ``pull_request`` actions.  It accepts a list of strings each of
+      which matches the label name in the event literally.  ``label:
+      recheck`` will match a ``labeled`` action when pull request is
+      labeled with a ``recheck`` label. ``label: 'do not test'`` will
+      match a ``unlabeled`` action when a label with name ``do not
+      test`` is removed from the pull request.
+
+   .. attr:: state
+
+      This is only used for ``pull_request_review`` events.  It
+      accepts a list of strings each of which is matched to the review
+      state, which can be one of ``approved``, ``comment``, or
+      ``request_changes``.
+
+   .. attr:: status
+
+      This is used for ``pull-request`` and ``status`` actions. It
+      accepts a list of strings each of which matches the user setting
+      the status, the status context, and the status itself in the
+      format of ``user:context:status``.  For example,
+      ``zuul_github_ci_bot:check_pipeline:success``.
+
+   .. attr:: ref
+
+      This is only used for ``push`` events. This field is treated as
+      a regular expression and multiple refs may be listed. GitHub
+      always sends full ref name, eg. ``refs/tags/bar`` and this
+      string is matched against the regular expression.
 
 Reporter Configuration
 ----------------------
@@ -142,35 +220,154 @@
 failure, an issue label addition/removal on the PR, and a merge of the PR
 itself. Status name, description, and context is taken from the pipeline.
 
-A :ref:`connection<connections>` that uses the github driver must be
-supplied to the reporter. It has the following options:
+.. attr:: pipeline.<reporter>.<github source>
 
-**status**
-  String value (``pending``, ``success``, ``failure``) that the
-  reporter should set as the commit status on github.  ``status:
-  'success'``
+   To report to GitHub, the dictionaries passed to any of the pipeline
+   :ref:`reporter<reporters>` attributes support the following
+   attributes:
 
-**status-url**
-  String value for a link url to set in the github status. Defaults to
-  the zuul server status_url, or the empty string if that is unset.
+   .. attr:: status
 
-**comment**
-  Boolean value (``true`` or ``false``) that determines if the
-  reporter should add a comment to the pipeline status to the github
-  pull request. Defaults to ``true``. Only used for Pull Request based
-  events.  ``comment: false``
+      String value (``pending``, ``success``, ``failure``) that the
+      reporter should set as the commit status on github.
 
-**merge**
-  Boolean value (``true`` or ``false``) that determines if the
-  reporter should merge the pull reqeust. Defaults to ``false``. Only
-  used for Pull Request based events.  ``merge=true``
+   .. TODO support role markup in :default: so we can xref
+      :attr:`webapp.status_url` below
 
-**label**
-  List of strings each representing an exact label name which should
-  be added to the pull request by reporter. Only used for Pull Request
-  based events.  ``label: 'test successful'``
+   .. attr:: status-url
+      :default: webapp.status_url or the empty string
 
-**unlabel**
-  List of strings each representing an exact label name which should
-  be removed from the pull request by reporter. Only used for Pull
-  Request based events.  ``unlabel: 'test failed'``
+      String value for a link url to set in the github
+      status. Defaults to the zuul server status_url, or the empty
+      string if that is unset.
+
+   .. attr:: comment
+      :default: true
+
+      Boolean value that determines if the reporter should add a
+      comment to the pipeline status to the github pull request. Only
+      used for Pull Request based items.
+
+   .. attr:: merge
+      :default: false
+
+      Boolean value that determines if the reporter should merge the
+      pull reqeust. Only used for Pull Request based items.
+
+   .. attr:: label
+
+      List of strings each representing an exact label name which
+      should be added to the pull request by reporter. Only used for
+      Pull Request based items.
+
+   .. attr:: unlabel
+
+      List of strings each representing an exact label name which
+      should be removed from the pull request by reporter. Only used
+      for Pull Request based items.
+
+.. _Github App: https://developer.github.com/apps/
+
+Requirements Configuration
+--------------------------
+
+As described in :attr:`pipeline.require` and :attr:`pipeline.reject`,
+pipelines may specify that items meet certain conditions in order to
+be enqueued into the pipeline.  These conditions vary according to the
+source of the project in question.  To supply requirements for changes
+from a GitHub source named ``my-github``, create a congfiguration such
+as the following::
+
+  pipeline:
+    require:
+      my-github:
+        review:
+          - type: approval
+
+This indicates that changes originating from the GitHub connection
+named ``my-github`` must have an approved code review in order to be
+enqueued into the pipeline.
+
+.. attr:: pipeline.require.<github source>
+
+   The dictionary passed to the GitHub pipeline `require` attribute
+   supports the following attributes:
+
+   .. attr:: review
+
+      This requires that a certain kind of code review be present for
+      the pull request (it could be added by the event in question).
+      It takes several sub-parameters, all of which are optional and
+      are combined together so that there must be a code review
+      matching all specified requirements.
+
+      .. attr:: username
+
+         If present, a code review from this username is required.  It
+         is treated as a regular expression.
+
+      .. attr:: email
+
+         If present, a code review with this email address is
+         required.  It is treated as a regular expression.
+
+      .. attr:: older-than
+
+         If present, the code review must be older than this amount of
+         time to match.  Provide a time interval as a number with a
+         suffix of "w" (weeks), "d" (days), "h" (hours), "m"
+         (minutes), "s" (seconds).  Example ``48h`` or ``2d``.
+
+      .. attr:: newer-than
+
+         If present, the code review must be newer than this amount of
+         time to match.  Same format as "older-than".
+
+      .. attr:: type
+
+         If present, the code review must match this type (or types).
+
+         .. TODO: what types are valid?
+
+      .. attr:: permission
+
+         If present, the author of the code review must have this
+         permission (or permissions).  The available values are
+         ``read``, ``write``, and ``admin``.
+
+   .. attr:: open
+
+      A boolean value (``true`` or ``false``) that indicates whether
+      the change must be open or closed in order to be enqueued.
+
+   .. attr:: current-patchset
+
+      A boolean value (``true`` or ``false``) that indicates whether
+      the item must be associated with the latest commit in the pull
+      request in order to be enqueued.
+
+      .. TODO: this could probably be expanded upon -- under what
+         circumstances might this happen with github
+
+   .. attr:: status
+
+      A string value that corresponds with the status of the pull
+      request.  The syntax is ``user:status:value``.
+
+   .. attr:: label
+
+      A string value indicating that the pull request must have the
+      indicated label (or labels).
+
+.. attr:: pipeline.reject.<github source>
+
+   The `reject` attribute is the mirror of the `require` attribute.  It
+   also accepts a dictionary under the connection name.  This
+   dictionary supports the following attributes:
+
+   .. attr:: review
+
+      This takes a list of code reviews.  If a code review matches the
+      provided criteria the pull request can not be entered into the
+      pipeline.  It follows the same syntax as
+      :attr:`pipeline.require.<github source>.review`
diff --git a/doc/source/admin/drivers/smtp.rst b/doc/source/admin/drivers/smtp.rst
index 6f24355..11c0624 100644
--- a/doc/source/admin/drivers/smtp.rst
+++ b/doc/source/admin/drivers/smtp.rst
@@ -9,25 +9,36 @@
 Connection Configuration
 ------------------------
 
-**driver=smtp**
+.. attr:: <smtp connection>
 
-**server**
-  SMTP server hostname or address to use.
-  ``server=localhost``
+   .. attr:: driver
+      :required:
 
-**port**
-  Optional: SMTP server port.
-  ``port=25``
+      .. value:: smtp
 
-**default_from**
-  Who the email should appear to be sent from when emailing the report.
-  This can be overridden by individual pipelines.
-  ``default_from=zuul@example.com``
+         The connection must set ``driver=smtp`` for SMTP connections.
 
-**default_to**
-  Who the report should be emailed to by default.
-  This can be overridden by individual pipelines.
-  ``default_to=you@example.com``
+   .. attr:: server
+      :default: localhost
+
+      SMTP server hostname or address to use.
+
+   .. attr:: port
+      :default: 25
+
+      SMTP server port.
+
+   .. attr:: default_from
+      :default: zuul
+
+      Who the email should appear to be sent from when emailing the report.
+      This can be overridden by individual pipelines.
+
+   .. attr:: default_to
+      :default: zuul
+
+      Who the report should be emailed to by default.
+      This can be overridden by individual pipelines.
 
 Reporter Configuration
 ----------------------
@@ -39,15 +50,38 @@
 address.
 
 Each pipeline can overwrite the ``subject`` or the ``to`` or ``from`` address by
-providing alternatives as arguments to the reporter. For example, ::
+providing alternatives as arguments to the reporter. For example:
 
-  - pipeline:
-      name: post-merge
-      success:
-        outgoing_smtp:
-          to: you@example.com
-      failure:
-        internal_smtp:
-          to: you@example.com
-          from: alternative@example.com
-          subject: Change {change} failed
+.. code-block:: yaml
+
+   - pipeline:
+       name: post-merge
+       success:
+         outgoing_smtp:
+           to: you@example.com
+       failure:
+         internal_smtp:
+           to: you@example.com
+           from: alternative@example.com
+           subject: Change {change} failed
+
+.. attr:: pipeline.<reporter>.<smtp source>
+
+   To report via email, the dictionaries passed to any of the pipeline
+   :ref:`reporter<reporters>` attributes support the following
+   attributes:
+
+   .. attr:: to
+
+      The SMTP recipient address for the report.  Multiple addresses
+      may be specified as one value separated by commas.
+
+   .. attr:: from
+
+      The SMTP sender address for the report.
+
+   .. attr:: subject
+
+      The Subject of the report email.
+
+      .. TODO: document subject string formatting.
diff --git a/doc/source/admin/drivers/sql.rst b/doc/source/admin/drivers/sql.rst
index b890f08..e208467 100644
--- a/doc/source/admin/drivers/sql.rst
+++ b/doc/source/admin/drivers/sql.rst
@@ -4,14 +4,29 @@
 ===
 
 The SQL driver supports reporters only.  Only one connection per
-database is permitted.  The connection options are:
+database is permitted.
 
-**driver=sql**
+Connection Configuration
+------------------------
 
-**dburi**
-  Database connection information in the form of a URI understood by
-  sqlalchemy. eg http://docs.sqlalchemy.org/en/rel_1_0/core/engines.html#database-urls
-  ``dburi=mysql://user:pass@localhost/db``
+The connection options for the SQL driver are:
+
+.. attr:: <sql connection>
+
+   .. attr:: driver
+      :required:
+
+      .. value:: sql
+
+         The connection must set ``driver=sql`` for SQL connections.
+
+   .. attr:: dburi
+      :required:
+
+      Database connection information in the form of a URI understood
+      by SQLAlchemy.  See `The SQLAlchemy manual
+      <http://docs.sqlalchemy.org/en/rel_1_0/core/engines.html#database-urls>`_
+      for more information.
 
 Reporter Configuration
 ----------------------
@@ -21,24 +36,27 @@
 A :ref:`connection<connections>` that uses the sql driver must be
 supplied to the reporter.
 
-zuul.conf contains the database connection and credentials. To store different
-reports in different databases you'll need to create a new connection per
-database.
+``zuul.conf`` contains the database connection and credentials. To
+store different reports in different databases you'll need to create a
+new connection per database.
 
-The SQL reporter does nothing on "start" or "merge-failure"; it only
-acts on "success" or "failure" reporting stages.
+The SQL reporter does nothing on :attr:`pipeline.start` or
+:attr:`pipeline.merge-failure`; it only acts on
+:attr:`pipeline.success` or :attr:`pipeline.failure` reporting stages.
 
-**score**
-  A score to store for the result of the build. eg: -1 might indicate a failed
-  build.
+For example:
 
-For example ::
+.. code-block:: yaml
 
-  - pipeline:
-      name: post-merge
-      success:
-        mydb_conn:
-            score: 1
-      failure:
-        mydb_conn:
-            score: -1
+   - pipeline:
+       name: post-merge
+       success:
+         mydb_conn:
+       failure:
+         mydb_conn:
+
+.. attr:: pipeline.<reporter>.<sql source>
+
+   To report to a database, add a key with the connection name and an
+   empty value to the desired pipeline :ref:`reporter<reporters>`
+   attributes.
diff --git a/doc/source/admin/drivers/timer.rst b/doc/source/admin/drivers/timer.rst
index c8afdd7..3b38c99 100644
--- a/doc/source/admin/drivers/timer.rst
+++ b/doc/source/admin/drivers/timer.rst
@@ -11,14 +11,20 @@
 --------------------
 
 Timers don't require a special connection or driver. Instead they can
-simply be used by listing **timer** as the trigger.
+simply be used by listing ``timer`` as the trigger.
 
-This trigger will run based on a cron-style time specification.
-It will enqueue an event into its pipeline for every project
-defined in the configuration.  Any job associated with the
-pipeline will run in response to that event.
+This trigger will run based on a cron-style time specification.  It
+will enqueue an event into its pipeline for every project defined in
+the configuration.  Any job associated with the pipeline will run in
+response to that event.
 
-**time**
-  The time specification in cron syntax.  Only the 5 part syntax is
-  supported, not the symbolic names.  Example: ``0 0 * * *`` runs at
-  midnight. The first weekday is Monday.
+.. attr:: pipeline.trigger.timer
+
+   The timer trigger supports the following attributes:
+
+   .. attr:: time
+      :required:
+
+      The time specification in cron syntax.  Only the 5 part syntax
+      is supported, not the symbolic names.  Example: ``0 0 * * *``
+      runs at midnight. The first weekday is Monday.
diff --git a/doc/source/admin/drivers/zuul.rst b/doc/source/admin/drivers/zuul.rst
index a23c875..d95dffc 100644
--- a/doc/source/admin/drivers/zuul.rst
+++ b/doc/source/admin/drivers/zuul.rst
@@ -10,31 +10,29 @@
 ---------------------
 
 Zuul events don't require a special connection or driver. Instead they
-can simply be used by listing **zuul** as the trigger.
+can simply be used by listing ``zuul`` as the trigger.
 
-**event**
-  The event name.  Currently supported:
+.. attr:: pipeline.trigger.zuul
 
-  *project-change-merged* when Zuul merges a change to a project, it
-  generates this event for every open change in the project.
+   The Zuul trigger supports the following attributes:
 
-  *parent-change-enqueued* when Zuul enqueues a change into any
-  pipeline, it generates this event for every child of that
-  change.
+   .. attr:: event
+      :required:
 
-**pipeline**
-  Only available for ``parent-change-enqueued`` events.  This is the
-  name of the pipeline in which the parent change was enqueued.
+      The event name.  Currently supported events:
 
-**require-approval**
-  This may be used for any event.  It requires that a certain kind of
-  approval be present for the current patchset of the change (the
-  approval could be added by the event in question).  It follows the
-  same syntax as the :ref:`"approval" pipeline requirement
-  <pipeline-require-approval>`. For each specified criteria there must
-  exist a matching approval.
+      .. value:: project-change-merged
 
-**reject-approval**
-  This takes a list of approvals in the same format as
-  *require-approval* but will fail to enter the pipeline if there is a
-  matching approval.
+         When Zuul merges a change to a project, it generates this
+         event for every open change in the project.
+
+      .. value:: parent-change-enqueued
+
+         When Zuul enqueues a change into any pipeline, it generates
+         this event for every child of that change.
+
+   .. attr:: pipeline
+
+      Only available for ``parent-change-enqueued`` events.  This is
+      the name of the pipeline in which the parent change was
+      enqueued.
diff --git a/doc/source/admin/monitoring.rst b/doc/source/admin/monitoring.rst
index 2a6c959..9c69960 100644
--- a/doc/source/admin/monitoring.rst
+++ b/doc/source/admin/monitoring.rst
@@ -31,63 +31,101 @@
 Metrics
 ~~~~~~~
 
-The metrics are emitted by the Zuul scheduler (`zuul/scheduler.py`):
+The metrics are emitted by the Zuul :ref:`scheduler`:
 
-**gerrit.event.<type> (counters)**
-  Gerrit emits different kind of message over its `stream-events`
-  interface.  Zuul will report counters for each type of event it
-  receives from Gerrit.
+.. stat:: gerrit.event.<type>
+   :type: counter
 
-  Some of the events emitted are:
+   Gerrit emits different kind of message over its `stream-events`
+   interface.  Zuul will report counters for each type of event it
+   receives from Gerrit.
 
-    * patchset-created
-    * draft-published
-    * change-abandonned
-    * change-restored
-    * change-merged
-    * merge-failed
-    * comment-added
-    * ref-updated
-    * reviewer-added
+   Refer to your Gerrit installation documentation for a complete
+   list of Gerrit event types.
 
-  Refer to your Gerrit installation documentation for an exhaustive list of
-  Gerrit event types.
+.. stat:: zuul.pipeline
 
-**zuul.pipeline.**
-  Holds metrics specific to jobs. The hierarchy is:
+   Holds metrics specific to jobs. This hierarchy includes:
 
-    #. **<pipeline name>** as defined in your `layout.yaml` file (ex: `gate`,
-                         `test`, `publish`). It contains:
+   .. stat:: <pipeline name>
 
-      #. **all_jobs** counter of jobs triggered by the pipeline.
-      #. **current_changes** A gauge for the number of Gerrit changes being
-               processed by this pipeline.
-      #. **job** subtree detailing per jobs statistics:
+      A set of metrics for each pipeline named as defined in the Zuul
+      config.
 
-        #. **<jobname>** The triggered job name.
-        #. **<build result>** Result as defined in your triggering system. For
-                 Jenkins that would be SUCCESS, FAILURE, UNSTABLE, LOST.  The
-                 metrics holds both an increasing counter and a timing
-                 reporting the duration of the build. Whenever the result is a
-                 SUCCESS or FAILURE, Zuul will additionally report the duration
-                 of the build as a timing event.
+      .. stat:: all_jobs
+         :type: counter
 
-      #. **resident_time** timing representing how long the Change has been
-               known by Zuul (which includes build time and Zuul overhead).
-      #. **total_changes** counter of the number of change proceeding since
-               Zuul started.
-      #. **wait_time** counter and timer of the wait time, with the difference
-               of the job start time and the execute time, in milliseconds.
+         Number of jobs triggered by the pipeline.
 
-  Additionally, the `zuul.pipeline.<pipeline name>` hierarchy contains
-  `current_changes` (gauge), `resident_time` (timing) and `total_changes`
-  (counter) metrics for each projects. The slash separator used in Gerrit name
-  being replaced by dots.
+      .. stat:: current_changes
+         :type: gauge
 
-  As an example, given a job named `myjob` triggered by the `gate` pipeline
-  which took 40 seconds to build, the Zuul scheduler will emit the following
-  statsd events:
+         The number of items currently being processed by this
+         pipeline.
 
-    * `zuul.pipeline.gate.job.myjob.SUCCESS` +1
-    * `zuul.pipeline.gate.job.myjob`  40 seconds
-    * `zuul.pipeline.gate.all_jobs` +1
+      .. stat:: job
+
+         Subtree detailing per jobs statistics:
+
+         .. stat:: <jobname>
+
+            The triggered job name.
+
+            .. stat:: <result>
+               :type: counter, timer
+
+               A counter for each type of result (e.g., ``SUCCESS`` or
+               ``FAILURE``, ``ERROR``, etc.) for the job.  If the
+               result is ``SUCCESS`` or ``FAILURE``, Zuul will
+               additionally report the duration of the build as a
+               timer.
+
+      .. stat:: resident_time
+         :type: timer
+
+         A timer metric reporting how long each item has been in the
+         pipeline.
+
+      .. stat:: total_changes
+         :type: counter
+
+         The number of change processed by the pipeline since Zuul
+         started.
+
+      .. stat:: wait_time
+         :type: timer
+
+         How long each item spent in the pipeline before its first job
+         started.
+
+      .. stat:: <project>
+
+         This hierarchy holds more specific metrics for each project
+         participating in the pipeline.  If the project name contains
+         a ``/`` character, it will be replaced with a ``.``.
+
+         .. stat:: current_changes
+            :type: gauge
+
+            The number of items of this project currently being
+            processed by this pipeline.
+
+         .. stat:: resident_time
+            :type: timer
+
+            A timer metric reporting how long each item for this
+            project has been in the pipeline.
+
+         .. stat:: total_changes
+            :type: counter
+
+            The number of change for this project processed by the
+            pipeline since Zuul started.
+
+As an example, given a job named `myjob` triggered by the `gate` pipeline
+which took 40 seconds to build, the Zuul scheduler will emit the following
+statsd events:
+
+  * ``zuul.pipeline.gate.job.myjob.SUCCESS`` +1
+  * ``zuul.pipeline.gate.job.myjob``  40 seconds
+  * ``zuul.pipeline.gate.all_jobs`` +1
diff --git a/doc/source/admin/tenants.rst b/doc/source/admin/tenants.rst
index 60873a9..a1709a6 100644
--- a/doc/source/admin/tenants.rst
+++ b/doc/source/admin/tenants.rst
@@ -5,107 +5,172 @@
 Tenant Configuration
 ====================
 
-After *zuul.conf* is configured, Zuul component servers will be able
+After ``zuul.conf`` is configured, Zuul component servers will be able
 to start, but a tenant configuration is required in order for Zuul to
 perform any actions.  The tenant configuration file specifies upon
-which projects Zuul should operate.  These repositories are
-grouped into tenants.  The configuration of each tenant is separate
-from the rest (no pipelines, jobs, etc are shared between them).
+which projects Zuul should operate.  These repositories are grouped
+into tenants.  The configuration of each tenant is separate from the
+rest (no pipelines, jobs, etc are shared between them).
 
 A project may appear in more than one tenant; this may be useful if
 you wish to use common job definitions across multiple tenants.
 
-The tenant configuration file is specified by the *tenant_config*
-setting in the *scheduler* section of *zuul.yaml*.  It is a YAML file
-which, like other Zuul configuration files, is a list of configuration
-objects, though only one type of object is supported, *tenant*.
+The tenant configuration file is specified by the
+:attr:`scheduler.tenant_config` setting in ``zuul.conf``.  It is a
+YAML file which, like other Zuul configuration files, is a list of
+configuration objects, though only one type of object is supported:
+``tenant``.
 
 Tenant
 ------
 
 A tenant is a collection of projects which share a Zuul
-configuration.  An example tenant definition is::
+configuration.  An example tenant definition is:
 
-  - tenant:
-      name: my-tenant
-      source:
-        gerrit:
-          config-projects:
-            - common-config
-            - shared-jobs:
-                include: jobs
-          untrusted-projects:
-            - zuul-jobs:
-                shadow: common-config
-            - project1
-            - project2
+.. code-block:: yaml
 
-The following attributes are supported:
+   - tenant:
+       name: my-tenant
+       max-nodes-per-job: 5
+       exclude-unprotected-branches: false
+       source:
+         gerrit:
+           config-projects:
+             - common-config
+             - shared-jobs:
+                 include: job
+           untrusted-projects:
+             - zuul-jobs:
+                 shadow: common-config
+             - project1
+             - project2:
+                 exclude-unprotected-branches: true
 
-**name** (required)
-  The name of the tenant.  This may appear in URLs, paths, and
-  monitoring fields, and so should be restricted to URL friendly
-  characters (ASCII letters, numbers, hyphen and underscore) and you
-  should avoid changing it unless necessary.
+.. attr:: tenant
 
-**source** (required)
-  A dictionary of sources to consult for projects.  A tenant may
-  contain projects from multiple sources; each of those sources must
-  be listed here, along with the projects it supports.  The name of a
-  :ref:`connection<connections>` is used as the dictionary key
-  (e.g. `gerrit` in the example above), and the value is a further
-  dictionary containing the keys below.
+   The following attributes are supported:
 
-  **config-projects**
-    A list of projects to be treated as config projects in this
-    tenant.  The jobs in a config project are trusted, which means
-    they run with extra privileges, do not have their configuration
-    dynamically loaded for proposed changes, and zuul.yaml files are
-    only searched for in the master branch.
+   .. attr:: name
+      :required:
 
-  **untrusted-projects**
-    A list of projects to be treated as untrusted in this tenant.  An
-    untrusted project is the typical project operated on by Zuul.
-    Their jobs run in a more restrictive environment, they may not
-    define pipelines, their configuration dynamically changes in
-    response to proposed changes, Zuul will read configuration files
-    in all of their branches.
+      The name of the tenant.  This may appear in URLs, paths, and
+      monitoring fields, and so should be restricted to URL friendly
+      characters (ASCII letters, numbers, hyphen and underscore) and
+      you should avoid changing it unless necessary.
 
-  Each of the projects listed may be either a simple string value, or
-  it may be a dictionary with the following keys:
+   .. attr:: source
+      :required:
 
-    **include**
-    Normally Zuul will load all of the configuration classes
-    appropriate for the type of project (config or untrusted) in
-    question.  However, if you only want to load some items, the
-    *include* attribute can be used to specify that *only* the
-    specified classes should be loaded.  Supplied as a string, or a
-    list of strings.
+      A dictionary of sources to consult for projects.  A tenant may
+      contain projects from multiple sources; each of those sources
+      must be listed here, along with the projects it supports.  The
+      name of a :ref:`connection<connections>` is used as the
+      dictionary key (e.g. ``gerrit`` in the example above), and the
+      value is a further dictionary containing the keys below.
 
-    **exclude**
-    A list of configuration classes that should not be loaded.
+   The next two attributes, **config-projects** and
+   **untrusted-projects** provide the bulk of the information for
+   tenant configuration.  They list all of the projects upon which
+   Zuul will act.
 
-    **shadow**
-    A list of projects which this project is permitted to shadow.
-    Normally, only one project in Zuul may contain definitions for a
-    given job.  If a project earlier in the configuration defines a
-    job which a later project redefines, the later definition is
-    considered an error and is not permitted.  The "shadow" attribute
-    of a project indicates that job definitions in this project which
-    conflict with the named projects should be ignored, and those in
-    the named project should be used instead.  The named projects must
-    still appear earlier in the configuration.  In the example above,
-    if a job definition appears in both the "common-config" and
-    "zuul-jobs" projects, the definition in "common-config" will be
-    used.
+   The order of the projects listed in a tenant is important.  A job
+   which is defined in one project may not be redefined in another
+   project; therefore, once a job appears in one project, a project
+   listed later will be unable to define a job with that name.
+   Further, some aspects of project configuration (such as the merge
+   mode) may only be set on the first appearance of a project
+   definition.
 
-  The order of the projects listed in a tenant is important.  A job
-  which is defined in one project may not be redefined in another
-  project; therefore, once a job appears in one project, a project
-  listed later will be unable to define a job with that name.
-  Further, some aspects of project configuration (such as the merge
-  mode) may only be set on the first appearance of a project
-  definition.
+   Zuul loads the configuration from all **config-projects** in the
+   order listed, followed by all **untrusted-projects** in order.
 
-  Zuul loads the configuration from all *config-projects* in the order
-  listed, followed by all *trusted-projects* in order.
+   .. attr:: config-projects
+
+      A list of projects to be treated as :term:`config projects
+      <config-project>` in this tenant.  The jobs in a config project
+      are trusted, which means they run with extra privileges, do not
+      have their configuration dynamically loaded for proposed
+      changes, and Zuul config files are only searched for in the
+      ``master`` branch.
+
+      The items in the list follow the same format described in
+      **untrusted-projects**.
+
+   .. attr:: untrusted-projects
+
+      A list of projects to be treated as untrusted in this tenant.
+      An :term:`untrusted-project` is the typical project operated on
+      by Zuul.  Their jobs run in a more restrictive environment, they
+      may not define pipelines, their configuration dynamically
+      changes in response to proposed changes, and Zuul will read
+      configuration files in all of their branches.
+
+      .. attr:: <project>:
+
+         The items in the list may either be simple string values of
+         the project names, or a dictionary with the project name as
+         key and the following values:
+
+         .. attr:: include
+
+            Normally Zuul will load all of the configuration classes
+            appropriate for the type of project (config or untrusted)
+            in question.  However, if you only want to load some
+            items, the **include** attribute can be used to specify
+            that *only* the specified classes should be loaded.
+            Supplied as a string, or a list of strings.
+
+         .. attr:: exclude
+
+            A list of configuration classes that should not be loaded.
+
+         .. attr:: shadow
+
+            A list of projects which this project is permitted to
+            shadow.  Normally, only one project in Zuul may contain
+            definitions for a given job.  If a project earlier in the
+            configuration defines a job which a later project
+            redefines, the later definition is considered an error and
+            is not permitted.  The **shadow** attribute of a project
+            indicates that job definitions in this project which
+            conflict with the named projects should be ignored, and
+            those in the named project should be used instead.  The
+            named projects must still appear earlier in the
+            configuration.  In the example above, if a job definition
+            appears in both the ``common-config`` and ``zuul-jobs``
+            projects, the definition in ``common-config`` will be
+            used.
+
+         .. attr:: exclude-unprotected-branches
+
+            Define if unprotected github branches should be
+            processed. Defaults to the tenant wide setting of
+            exclude-unprotected-branches.
+
+   .. attr:: max-nodes-per-job
+      :default: 5
+
+      The maximum number of nodes a job can request.  A value of
+      '-1' value removes the limit.
+
+   .. attr:: exclude-unprotected-branches
+      :default: false
+
+      When using a branch and pull model on a shared GitHub repository
+      there are usually one or more protected branches which are gated
+      and a dynamic number of personal/feature branches which are the
+      source for the pull requests. These branches can potentially
+      include broken Zuul config and therefore break the global tenant
+      wide configuration. In order to deal with this Zuul's operations
+      can be limited to the protected branches which are gated. This
+      is a tenant wide setting and can be overridden per project.
+      This currently only affects GitHub projects.
+
+   .. attr:: default-parent
+      :default: base
+
+      If a job is defined without an explicit :attr:`job.parent`
+      attribute, this job will be configured as the job's parent.
+      This allows an administrator to configure a default base job to
+      implement local policies such as node setup and artifact
+      publishing.
diff --git a/doc/source/conf.py b/doc/source/conf.py
index 71c7697..85fcdc6 100644
--- a/doc/source/conf.py
+++ b/doc/source/conf.py
@@ -27,13 +27,19 @@
 # coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
 extensions = [
     'sphinx.ext.autodoc',
+    'sphinx_autodoc_typehints',
+    'sphinx.ext.graphviz',
     'sphinxcontrib.blockdiag',
     'sphinxcontrib.programoutput',
-    'oslosphinx'
+    'zuul_sphinx',
+    'zuul.sphinx.ansible',
+    'zuul.sphinx.zuul',
 ]
 #extensions = ['sphinx.ext.intersphinx']
 #intersphinx_mapping = {'python': ('http://docs.python.org/2.7', None)}
 
+primary_domain = 'zuuldoc'
+
 # Add any paths that contain templates here, relative to this directory.
 templates_path = ['_templates']
 
@@ -89,12 +95,14 @@
 
 # The theme to use for HTML and HTML Help pages.  See the documentation for
 # a list of builtin themes.
-html_theme = 'default'
+#html_theme = 'alabaster'
 
 # Theme options are theme-specific and customize the look and feel of a theme
 # further.  For a list of options available for each theme, see the
 # documentation.
-#html_theme_options = {}
+html_theme_options = {
+    'show_related': True
+}
 
 # Add any paths that contain custom themes here, relative to this directory.
 #html_theme_path = []
diff --git a/doc/source/developer/ansible.rst b/doc/source/developer/ansible.rst
new file mode 100644
index 0000000..e3ebca7
--- /dev/null
+++ b/doc/source/developer/ansible.rst
@@ -0,0 +1,66 @@
+Ansible Integration
+===================
+
+Zuul contains Ansible modules and plugins to control the execution of Ansible
+Job content. These break down into two basic categories.
+
+* Restricted Execution on Executors
+* Build Log Support
+
+Restricted Execution
+--------------------
+
+Zuul runs ``ansible-playbook`` on executors to run job content on nodes. While
+the intent is that content is run on the remote nodes, Ansible is a flexible
+system that allows delegating actions to ``localhost``, and also reading and
+writing files. These actions can be desirable and necessary for actions such
+as fetching log files or build artifacts, but could also be used as a vector
+to attack the executor.
+
+For that reason Zuul implements a set of Ansible action plugins and lookup
+plugins that override and intercept task execution during untrusted playbook
+execution to ensure local actions are not executed or that for operations that
+are desirable to allow locally that they only interact with files in the zuul
+work directory.
+
+.. autoclass:: zuul.ansible.action.normal.ActionModule
+   :members:
+
+Build Log Support
+-----------------
+
+Zuul provides realtime build log streaming to end users so that users can
+watch long-running jobs in progress. As jobs may be written that execute a
+shell script that could run for a long time, additional effort is expended
+to stream stdout and stderr of shell tasks as they happen rather than waiting
+for the command to finish.
+
+Zuul contains a modified version of the :ansible:module:`command`
+that starts a log streaming daemon on the build node.
+
+.. automodule:: zuul.ansible.library.command
+
+All jobs run with the :py:mod:`zuul.ansible.callback.zuul_stream` callback
+plugin enabled, which writes the build log to a file so that the
+:py:class:`zuul.lib.log_streamer.LogStreamer` can provide the data on demand
+over the finger protocol. Finally, :py:class:`zuul.web.LogStreamingHandler`
+exposes that log stream over a websocket connection as part of
+:py:class:`zuul.web.ZuulWeb`.
+
+.. autoclass:: zuul.ansible.callback.zuul_stream.CallbackModule
+   :members:
+
+.. autoclass:: zuul.lib.log_streamer.LogStreamer
+.. autoclass:: zuul.web.LogStreamingHandler
+.. autoclass:: zuul.web.ZuulWeb
+
+In addition to real-time streaming, Zuul also installs another callback module,
+:py:mod:`zuul.ansible.callback.zuul_json.CallbackModule` that collects all
+of the information about a given run into a json file which is written to the
+work dir so that it can be published along with build logs. Since the streaming
+log is by necessity a single text stream, choices have to be made for
+readability about what data is shown and what is not shown. The json log file
+is intended to allow for a richer more interactive set of data to be displayed
+to the user.
+
+.. autoclass:: zuul.ansible.callback.zuul_json.CallbackModule
diff --git a/doc/source/developer/docs.rst b/doc/source/developer/docs.rst
new file mode 100644
index 0000000..6a7256e
--- /dev/null
+++ b/doc/source/developer/docs.rst
@@ -0,0 +1,225 @@
+Documentation
+=============
+
+This is a brief style guide for Zuul documentation.
+
+ReStructuredText Conventions
+----------------------------
+
+Code Blocks
+~~~~~~~~~~~
+
+When showing a YAML example, use the ``.. code-block:: yaml``
+directive so that the sample appears as a code block with the correct
+syntax highlighting.
+
+Literal Values
+~~~~~~~~~~~~~~
+
+Filenames and literal values (such as when we instruct a user to type
+a specific string into a configuration file) should use the RST
+````literal```` syntax.
+
+YAML supports boolean values expressed with or without an initial
+capital letter.  In examples and documentation, use ``true`` and
+``false`` in lowercase type because the resulting YAML is easier for
+users to type and read.
+
+Terminology
+~~~~~~~~~~~
+
+Zuul employs some specialized terminology.  To help users become
+acquainted with it, we employ a glossary.  Observe the following:
+
+* Specialized terms should have entries in the glossary.
+
+* If the term is being defined in the text, don't link to the glossary
+  (that would be redundant), but do emphasize it with ``*italics*``
+  the first time it appears in that definition.  Subsequent uses
+  within the same subsection should be in regular type.
+
+* If it's being used (but not defined) in the text, link the first
+  usage within a subsection to the glossary using the ``:term:`` role,
+  but subsequent uses should be in regular type.
+
+* Be cognizant of how readers may jump to link targets within the
+  text, so be liberal in considering that once you cross a link
+  target, you may be in a new "subsection" for the above guideline.
+
+
+Zuul Sphinx Directives
+----------------------
+
+The following extra Sphinx directives are available in the ``zuul``
+domain.  The ``zuul`` domain is configured as the default domain, so the
+``zuul:`` prefix may be omitted.
+
+zuul:attr::
+~~~~~~~~~~~
+
+This should be used when documenting Zuul configuration attributes.
+Zuul configuration is heavily hierarchical, and this directive
+facilitates documenting these by emphasising the hierarchy as
+appropriate.  It will annotate each configuration attribute with a
+nice header with its own unique hyperlink target.  It displays the
+entire hierarchy of the attribute, but emphasises the last portion
+(i.e., the field being documented).
+
+To use the hierarchical features, simply nest with indendtation in the
+normal RST manner.
+
+It supports the ``required`` and ``default`` options and will annotate
+the header appropriately.  Example:
+
+.. code-block:: rst
+
+   .. attr:: foo
+
+      Some text about ``foo``.
+
+      .. attr:: bar
+         :required:
+         :default: 42
+
+         Text about ``foo.bar``.
+
+.. attr:: foo
+   :noindex:
+
+   Some text about ``foo``.
+
+   .. attr:: bar
+      :noindex:
+      :required:
+      :default: 42
+
+      Text about ``foo.bar``.
+
+zuul:value::
+~~~~~~~~~~~~
+
+Similar to zuul:attr, but used when documenting a literal value of an
+attribute.
+
+.. code-block:: rst
+
+   .. attr:: foo
+
+      Some text about foo.  It supports the following values:
+
+      .. value:: bar
+
+         One of the supported values for ``foo`` is ``bar``.
+
+      .. value:: baz
+
+         Another supported values for ``foo`` is ``baz``.
+
+.. attr:: foo
+   :noindex:
+
+   Some text about foo.  It supports the following values:
+
+   .. value:: bar
+      :noindex:
+
+      One of the supported values for ``foo`` is ``bar``.
+
+   .. value:: baz
+      :noindex:
+
+      Another supported values for ``foo`` is ``baz``.
+
+zuul:var::
+~~~~~~~~~~
+
+Also similar to zuul:attr, but used when documenting an Ansible
+variable which is available to a job's playbook.  In these cases, it's
+often necessary to indicate the variable may be an element of a list
+or dictionary, so this directive supports a ``type`` option.  It also
+supports the ``hidden`` option so that complex data structure
+definitions may continue across sections.  To use this, set the hidden
+option on a ``zuul:var::`` directive with the root of the data
+structure as the name.  Example:
+
+.. code-block:: rst
+
+   .. var:: foo
+
+      Foo is a dictionary with the following keys:
+
+      .. var:: items
+         :type: list
+
+         Items is a list of dictionaries with the following keys:
+
+         .. var:: bar
+
+            Text about bar
+
+   Section Boundary
+
+   .. var:: foo
+      :hidden:
+
+      .. var:: baz
+
+         Text about baz
+
+.. End of code block; start example
+
+.. var:: foo
+   :noindex:
+
+   Foo is a dictionary with the following keys:
+
+   .. var:: items
+      :noindex:
+      :type: list
+
+      Items is a list of dictionaries with the following keys:
+
+      .. var:: bar
+         :noindex:
+
+         Text about bar
+
+Section Boundary
+
+.. var:: foo
+   :noindex:
+   :hidden:
+
+   .. var:: baz
+      :noindex:
+
+      Text about baz
+
+.. End of example
+
+Zuul Sphinx Roles
+-----------------
+
+The following extra Sphinx roles are available.  Use these within the
+text when referring to attributes, values, and variables defined with
+the directives above.  Use these roles for the first appearance of an
+object within a subsection, but use the ````literal```` role in
+subsequent uses.
+
+:zuul:attr:
+~~~~~~~~~~~
+
+This creates a reference to the named attribute.  Provide the fully
+qualified name (e.g., ``:attr:`pipeline.manager```)
+
+:zuul:value:
+~~~~~~~~~~~~
+
+This creates a reference to the named value.  Provide the fully
+qualified name (e.g., ``:attr:`pipeline.manager.dependent```)
+
+:zuul:var:
+~~~~~~~~~~
+
+This creates a reference to the named variable.  Provide the fully
+qualified name (e.g., ``:var:`zuul.executor.name```)
diff --git a/doc/source/developer/index.rst b/doc/source/developer/index.rst
index 986bbe4..360dcd5 100644
--- a/doc/source/developer/index.rst
+++ b/doc/source/developer/index.rst
@@ -14,3 +14,5 @@
    drivers
    triggers
    testing
+   docs
+   ansible
diff --git a/doc/source/glossary.rst b/doc/source/glossary.rst
new file mode 100644
index 0000000..d4dbf01
--- /dev/null
+++ b/doc/source/glossary.rst
@@ -0,0 +1,85 @@
+.. _glossary:
+
+Glossary
+========
+
+.. glossary::
+   :sorted:
+
+   base job
+
+      A job with no parent.  A base job may only be defined in a
+      :term:`config-project`.  Multiple base jobs may be defined, but
+      each tenant has a single default job which will be used as the
+      parent of any job which does not specify one explicitly.
+
+   check
+
+      By convention, the name of a pipeline which performs pre-merge
+      tests.  Such a pipeline might be triggered by creating a new
+      change or pull request.  It may run with changes which have not
+      yet seen any human review, so care must be taken in selecting
+      the kinds of jobs to run, and what resources will be available
+      to them in order to avoid misuse of the system or credential
+      compromise.
+
+   config-project
+
+      One of two types of projects which may be specified by the
+      administrator in the tenant config file.  A config-project is
+      primarily tasked with holding configuration information and job
+      content for Zuul.  Jobs which are defined in a config-project
+      are run with elevated privileges, and all Zuul configuration
+      items are available for use.  It is expected that changes to
+      config-projects will undergo careful scrutiny before being
+      merged.
+
+   gate
+
+      By convention, the name of a pipeline which performs project
+      gating.  Such a pipeline might be triggered by a core team
+      member approving a change or pull request.  It should have a
+      :value:`dependent <pipeline.manager.dependent>` pipeline manager
+      so that it can combine and sequence changes as they are
+      approved.
+
+   reporter
+
+      A reporter is a :ref:`pipeline attribute <reporters>` which
+      describes the action performed when an item is dequeued after
+      its jobs complete.  Reporters are implemented by :ref:`drivers`
+      so their actions may be quite varied.  For example, a reporter
+      might leave feedback in a remote system on a proposed change,
+      send email, or store information in a database.
+
+   trusted execution context
+
+      Playbooks defined in a :term:`config-project` run in the
+      *trusted* execution context.  The trusted execution context has
+      access to all Ansible features, including the ability to load
+      custom Ansible modules.
+
+   untrusted execution context
+
+      Playbooks defined in an :term:`untrusted-project` run in the
+      *untrusted* execution context.  Playbooks run in the untrusted
+      execution context are not permitted to load additional Ansible
+      modules or access files outside of the restricted environment
+      prepared for them by the executor.  In addition to the
+      bubblewrap environment applied to both execution contexts, in
+      the untrusted context some standard Ansible modules are replaced
+      with versions which prohibit some actions, including attempts to
+      access files outside of the restricted execution context.  These
+      redundant protections are made as part of a defense-in-depth
+      strategy.
+
+   untrusted-project
+
+      One of two types of projects which may be specified by the
+      administrator in the tenant config file.  An untrusted-project
+      is one whose primary focus is not to operate Zuul, but rather it
+      is one of the projects being tested or deployed.  The Zuul
+      configuration language available to these projects is somewhat
+      restricted, and jobs defined in these projects run in a
+      restricted execution environment since they may be operating on
+      changes which have not yet undergone review.
diff --git a/doc/source/index.rst b/doc/source/index.rst
index 24ab31b..677e958 100644
--- a/doc/source/index.rst
+++ b/doc/source/index.rst
@@ -21,10 +21,15 @@
    admin/index
    developer/index
 
+.. toctree::
+   :hidden:
+
+   glossary
+
 Indices and tables
 ==================
 
 * :ref:`genindex`
-* :ref:`modindex`
 * :ref:`search`
+* :ref:`glossary`
 
diff --git a/doc/source/user/concepts.rst b/doc/source/user/concepts.rst
index 6197396..318de09 100644
--- a/doc/source/user/concepts.rst
+++ b/doc/source/user/concepts.rst
@@ -40,7 +40,8 @@
 configured with any number of reporters.  See :ref:`drivers` for a
 full list of available reporters.
 
-The items enqueued into a pipeline are each associated with a git ref.
+The items enqueued into a pipeline are each associated with a
+`git ref <https://git-scm.com/book/en/v2/Git-Internals-Git-References>`_.
 That ref may point to a proposed change, or it may be the tip of a
 branch or a tag.  The triggering event determines the ref, and whether
 it represents a proposed or merged commit.  Zuul prepares the ref for
@@ -67,7 +68,7 @@
 change appears.
 
 Jobs specify the type and quantity of nodes which they require.
-Before executing each job, Zuul will contact it's companion program,
+Before executing each job, Zuul will contact its companion program,
 Nodepool, to supply them.  Nodepool may be configured to supply static
 nodes or contact cloud providers to create or delete nodes as
 necessary.  The types of nodes available to Zuul are determined by the
@@ -80,6 +81,6 @@
 script) or sophisticated deployment scenarios.  When Zuul runs
 Ansible, it attempts to do so in a manner most similar to the way that
 Ansible might be used to orchestrate remote systems.  Ansible itself
-is run on the executor and acts remotely upon the test nodes supplied
-to a job.  This facilitates continuous delivery by making it possible
-to use the same Ansible playbooks in testing and production.
+is run on the :ref:`executor <executor>` and acts remotely upon the test
+nodes supplied to a job.  This facilitates continuous delivery by making it
+possible to use the same Ansible playbooks in testing and production.
diff --git a/doc/source/user/config.rst b/doc/source/user/config.rst
index c137918..7ff7106 100644
--- a/doc/source/user/config.rst
+++ b/doc/source/user/config.rst
@@ -16,15 +16,16 @@
 they specify one of two security contexts for that project.  A
 *config-project* is one which is primarily tasked with holding
 configuration information and job content for Zuul.  Jobs which are
-defined in a *config-project* are run with elevated privileges, and
-all Zuul configuration items are available for use.  It is expected
-that changes to *config-projects* will undergo careful scrutiny before
-being merged.
+defined in a config-project are run with elevated privileges, and all
+Zuul configuration items are available for use.  Base jobs (that is,
+jobs without a parent) may only be defined in config-projects.  It is
+expected that changes to config-projects will undergo careful scrutiny
+before being merged.
 
 An *untrusted-project* is a project whose primary focus is not to
 operate Zuul, but rather it is one of the projects being tested or
 deployed.  The Zuul configuration language available to these projects
-is somewhat restricted (as detailed in individual section below), and
+is somewhat restricted (as detailed in individual sections below), and
 jobs defined in these projects run in a restricted execution
 environment since they may be operating on changes which have not yet
 undergone review.
@@ -33,23 +34,23 @@
 ---------------------
 
 When Zuul starts, it examines all of the git repositories which are
-specified by the system administrator in :ref:`tenant-config` and searches
-for files in the root of each repository. Zuul looks first for a file named
-`zuul.yaml` or a directory named `zuul.d`, and if they are not found,
-`.zuul.yaml` or `.zuul.d` (with a leading dot). In the case of an
-*untrusted-project*, the configuration from every branch is included,
-however, in the case of a *config-project*, only the `master` branch is
-examined.
+specified by the system administrator in :ref:`tenant-config` and
+searches for files in the root of each repository. Zuul looks first
+for a file named ``zuul.yaml`` or a directory named ``zuul.d``, and if
+they are not found, ``.zuul.yaml`` or ``.zuul.d`` (with a leading
+dot). In the case of an :term:`untrusted-project`, the configuration
+from every branch is included, however, in the case of a
+:term:`config-project`, only the ``master`` branch is examined.
 
 When a change is proposed to one of these files in an
-*untrusted-project*, the configuration proposed in the change is
-merged into the running configuration so that any changes to Zuul's
+untrusted-project, the configuration proposed in the change is merged
+into the running configuration so that any changes to Zuul's
 configuration are self-testing as part of that change.  If there is a
 configuration error, no jobs will be run and the error will be
 reported by any applicable pipelines.  In the case of a change to a
-*config-project*, the new configuration is parsed and examined for
+config-project, the new configuration is parsed and examined for
 errors, but the new configuration is not used in testing the change.
-This is because configuration in *config-projects* is able to access
+This is because configuration in config-projects is able to access
 elevated privileges and should always be reviewed before being merged.
 
 As soon as a change containing a Zuul configuration change merges to
@@ -59,14 +60,15 @@
 Configuration Items
 -------------------
 
-The `zuul.yaml` and `.zuul.yaml` configuration files are
+The ``zuul.yaml`` and ``.zuul.yaml`` configuration files are
 YAML-formatted and are structured as a series of items, each of which
 is described below.
 
-In the case of a `zuul.d` directory, Zuul recurses the directory and extends
-the configuration using all the .yaml files in the sorted path order.
-For example, to keep job's variants in a separate file, it needs to be loaded
-after the main entries, for example using number prefixes in file's names::
+In the case of a ``zuul.d`` directory, Zuul recurses the directory and
+extends the configuration using all the .yaml files in the sorted path
+order.  For example, to keep job's variants in a separate file, it
+needs to be loaded after the main entries, for example using number
+prefixes in file's names::
 
 * zuul.d/pipelines.yaml
 * zuul.d/projects.yaml
@@ -87,320 +89,333 @@
 projects.
 
 By way of example, one of the primary uses of Zuul is to perform
-project gating.  To do so, one can create a *gate* pipeline which
-tells Zuul that when a certain event (such as approval by a code
+project gating.  To do so, one can create a :term:`gate` pipeline
+which tells Zuul that when a certain event (such as approval by a code
 reviewer) occurs, the corresponding change or pull request should be
 enqueued into the pipeline.  When that happens, the jobs which have
-been configured to run for that project in the *gate* pipeline are
-run, and when they complete, the pipeline reports the results to the
-user.
+been configured to run for that project in the gate pipeline are run,
+and when they complete, the pipeline reports the results to the user.
 
-Pipeline configuration items may only appear in *config-projects*.
+Pipeline configuration items may only appear in :term:`config-projects
+<config-project>`.
 
 Generally, a Zuul administrator would define a small number of
 pipelines which represent the workflow processes used in their
 environment.  Each project can then be added to the available
 pipelines as appropriate.
 
-Here is an example *check* pipeline, which runs whenever a new
+Here is an example :term:`check` pipeline, which runs whenever a new
 patchset is created in Gerrit.  If the associated jobs all report
-success, the pipeline reports back to Gerrit with a *Verified* vote of
-+1, or if at least one of them fails, a -1::
+success, the pipeline reports back to Gerrit with ``Verified`` vote of
++1, or if at least one of them fails, a -1:
 
-  - pipeline:
-      name: check
-      manager: independent
-      trigger:
-        my_gerrit:
-          - event: patchset-created
-      success:
-        my_gerrit:
-          verified: 1
-      failure:
-        my_gerrit
-          verified: -1
+.. code-block:: yaml
+
+   - pipeline:
+       name: check
+       manager: independent
+       trigger:
+         my_gerrit:
+           - event: patchset-created
+       success:
+         my_gerrit:
+           Verified: 1
+       failure:
+         my_gerrit
+           Verified: -1
 
 .. TODO: See TODO for more annotated examples of common pipeline configurations.
 
-The attributes available on a pipeline are as follows (all are
-optional unless otherwise specified):
+.. attr:: pipeline
 
-**name** (required)
-  This is used later in the project definition to indicate what jobs
-  should be run for events in the pipeline.
+   The attributes available on a pipeline are as follows (all are
+   optional unless otherwise specified):
 
-**manager** (required)
-  There are currently two schemes for managing pipelines:
+   .. attr:: name
+      :required:
 
-  .. _independent_pipeline_manager:
+      This is used later in the project definition to indicate what jobs
+      should be run for events in the pipeline.
 
-  *independent*
-    Every event in this pipeline should be treated as independent of
-    other events in the pipeline.  This is appropriate when the order of
-    events in the pipeline doesn't matter because the results of the
-    actions this pipeline performs can not affect other events in the
-    pipeline.  For example, when a change is first uploaded for review,
-    you may want to run tests on that change to provide early feedback
-    to reviewers.  At the end of the tests, the change is not going to
-    be merged, so it is safe to run these tests in parallel without
-    regard to any other changes in the pipeline.  They are independent.
+   .. attr:: manager
+      :required:
 
-    Another type of pipeline that is independent is a post-merge
-    pipeline. In that case, the changes have already merged, so the
-    results can not affect any other events in the pipeline.
+      There are currently two schemes for managing pipelines:
 
-  .. _dependent_pipeline_manager:
+      .. value:: independent
 
-  *dependent*
-    The dependent pipeline manager is designed for gating.  It ensures
-    that every change is tested exactly as it is going to be merged
-    into the repository.  An ideal gating system would test one change
-    at a time, applied to the tip of the repository, and only if that
-    change passed tests would it be merged.  Then the next change in
-    line would be tested the same way.  In order to achieve parallel
-    testing of changes, the dependent pipeline manager performs
-    speculative execution on changes.  It orders changes based on
-    their entry into the pipeline.  It begins testing all changes in
-    parallel, assuming that each change ahead in the pipeline will pass
-    its tests.  If they all succeed, all the changes can be tested and
-    merged in parallel.  If a change near the front of the pipeline
-    fails its tests, each change behind it ignores whatever tests have
-    been completed and are tested again without the change in front.
-    This way gate tests may run in parallel but still be tested
-    correctly, exactly as they will appear in the repository when
-    merged.
+         Every event in this pipeline should be treated as independent
+         of other events in the pipeline.  This is appropriate when
+         the order of events in the pipeline doesn't matter because
+         the results of the actions this pipeline performs can not
+         affect other events in the pipeline.  For example, when a
+         change is first uploaded for review, you may want to run
+         tests on that change to provide early feedback to reviewers.
+         At the end of the tests, the change is not going to be
+         merged, so it is safe to run these tests in parallel without
+         regard to any other changes in the pipeline.  They are
+         independent.
 
-    For more detail on the theory and operation of Zuul's dependent
-    pipeline manager, see: :doc:`gating`.
+         Another type of pipeline that is independent is a post-merge
+         pipeline. In that case, the changes have already merged, so
+         the results can not affect any other events in the pipeline.
 
-**allow-secrets**
-  This is a boolean which can be used to prevent jobs which require
-  secrets from running in this pipeline.  Some pipelines run on
-  proposed changes and therefore execute code which has not yet been
-  reviewed.  In such a case, allowing a job to use a secret could
-  result in that secret being exposed.  The default is False, meaning
-  that in order to run jobs with secrets, this must be explicitly
-  enabled on each Pipeline where that is safe.
+      .. value:: dependent
 
-  For more information, see :ref:`secret`.
+         The dependent pipeline manager is designed for gating.  It
+         ensures that every change is tested exactly as it is going to
+         be merged into the repository.  An ideal gating system would
+         test one change at a time, applied to the tip of the
+         repository, and only if that change passed tests would it be
+         merged.  Then the next change in line would be tested the
+         same way.  In order to achieve parallel testing of changes,
+         the dependent pipeline manager performs speculative execution
+         on changes.  It orders changes based on their entry into the
+         pipeline.  It begins testing all changes in parallel,
+         assuming that each change ahead in the pipeline will pass its
+         tests.  If they all succeed, all the changes can be tested
+         and merged in parallel.  If a change near the front of the
+         pipeline fails its tests, each change behind it ignores
+         whatever tests have been completed and are tested again
+         without the change in front.  This way gate tests may run in
+         parallel but still be tested correctly, exactly as they will
+         appear in the repository when merged.
 
-**description**
-  This field may be used to provide a textual description of the
-  pipeline.  It may appear in the status page or in documentation.
+         For more detail on the theory and operation of Zuul's
+         dependent pipeline manager, see: :doc:`gating`.
 
-**success-message**
-  The introductory text in reports when all the voting jobs are
-  successful.  Defaults to "Build successful."
+   .. attr:: post-review
+      :default: false
 
-**failure-message**
-  The introductory text in reports when at least one voting job fails.
-  Defaults to "Build failed."
+      This is a boolean which indicates that this pipeline executes
+      code that has been reviewed.  Some jobs perform actions which
+      should not be permitted with unreviewed code.  When this value
+      is ``false`` those jobs will not be permitted to run in the
+      pipeline.  If a pipeline is designed only to be used after
+      changes are reviewed or merged, set this value to ``true`` to
+      permit such jobs.
 
-**merge-failure-message**
-  The introductory text in the message reported when a change fails to
-  merge with the current state of the repository.  Defaults to "Merge
-  failed."
+      For more information, see :ref:`secret` and
+      :attr:`job.post-review`.
 
-**footer-message**
-  Supplies additional information after test results.  Useful for
-  adding information about the CI system such as debugging and contact
-  details.
+   .. attr:: description
 
-**trigger**
-  At least one trigger source must be supplied for each pipeline.
-  Triggers are not exclusive -- matching events may be placed in
-  multiple pipelines, and they will behave independently in each of
-  the pipelines they match.
+      This field may be used to provide a textual description of the
+      pipeline.  It may appear in the status page or in documentation.
 
-  Triggers are loaded from their connection name. The driver type of
-  the connection will dictate which options are available.
-  See :ref:`drivers`.
+   .. attr:: success-message
+      :default: Build successful.
 
-**require**
-  If this section is present, it established pre-requisites for any
-  kind of item entering the Pipeline.  Regardless of how the item is
-  to be enqueued (via any trigger or automatic dependency resolution),
-  the conditions specified here must be met or the item will not be
-  enqueued.
+      The introductory text in reports when all the voting jobs are
+      successful.
 
-.. _pipeline-require-approval:
+   .. attr:: failure-message
+      :default: Build failed.
 
-  **approval**
-  This requires that a certain kind of approval be present for the
-  current patchset of the change (the approval could be added by the
-  event in question).  It takes several sub-parameters, all of which
-  are optional and are combined together so that there must be an
-  approval matching all specified requirements.
+      The introductory text in reports when at least one voting job
+      fails.
 
-    *username*
-    If present, an approval from this username is required.  It is
-    treated as a regular expression.
+   .. attr:: merge-failure-message
+      :default: Merge failed.
 
-    *email*
-    If present, an approval with this email address is required.  It
-    is treated as a regular expression.
+      The introductory text in the message reported when a change
+      fails to merge with the current state of the repository.
+      Defaults to "Merge failed."
 
-    *email-filter* (deprecated)
-    A deprecated alternate spelling of *email*.  Only one of *email* or
-    *email_filter* should be used.
+   .. attr:: footer-message
 
-    *older-than*
-    If present, the approval must be older than this amount of time
-    to match.  Provide a time interval as a number with a suffix of
-    "w" (weeks), "d" (days), "h" (hours), "m" (minutes), "s"
-    (seconds).  Example ``48h`` or ``2d``.
+      Supplies additional information after test results.  Useful for
+      adding information about the CI system such as debugging and
+      contact details.
 
-    *newer-than*
-    If present, the approval must be newer than this amount of time
-    to match.  Same format as "older-than".
+   .. attr:: trigger
 
-    Any other field is interpreted as a review category and value
-    pair.  For example ``verified: 1`` would require that the approval
-    be for a +1 vote in the "Verified" column.  The value may either
-    be a single value or a list: ``verified: [1, 2]`` would match
-    either a +1 or +2 vote.
+      At least one trigger source must be supplied for each pipeline.
+      Triggers are not exclusive -- matching events may be placed in
+      multiple pipelines, and they will behave independently in each
+      of the pipelines they match.
 
-  **open**
-  A boolean value (``true`` or ``false``) that indicates whether the change
-  must be open or closed in order to be enqueued.
+      Triggers are loaded from their connection name. The driver type
+      of the connection will dictate which options are available.  See
+      :ref:`drivers`.
 
-  **current-patchset**
-  A boolean value (``true`` or ``false``) that indicates whether the change
-  must be the current patchset in order to be enqueued.
+   .. attr:: require
 
-  **status**
-  A string value that corresponds with the status of the change
-  reported by the trigger.
+      If this section is present, it establishes prerequisites for
+      any kind of item entering the Pipeline.  Regardless of how the
+      item is to be enqueued (via any trigger or automatic dependency
+      resolution), the conditions specified here must be met or the
+      item will not be enqueued.  These requirements may vary
+      depending on the source of the item being enqueued.
 
-**reject**
-  If this section is present, it establishes pre-requisites that can
-  block an item from being enqueued. It can be considered a negative
-  version of **require**.
+      Requirements are loaded from their connection name. The driver
+      type of the connection will dictate which options are available.
+      See :ref:`drivers`.
 
-  **approval**
-  This takes a list of approvals. If an approval matches the provided
-  criteria the change can not be entered into the pipeline. It follows
-  the same syntax as the :ref:`"require approval" pipeline above
-  <pipeline-require-approval>`.
+   .. attr:: reject
 
-  Example to reject a change with any negative vote::
+      If this section is present, it establishes prerequisites that
+      can block an item from being enqueued. It can be considered a
+      negative version of :attr:`pipeline.require`.
 
-    reject:
-      approval:
-        - code-review: [-1, -2]
+      Requirements are loaded from their connection name. The driver
+      type of the connection will dictate which options are available.
+      See :ref:`drivers`.
 
-**dequeue-on-new-patchset**
-  Normally, if a new patchset is uploaded to a change that is in a
-  pipeline, the existing entry in the pipeline will be removed (with
-  jobs canceled and any dependent changes that can no longer merge as
-  well.  To suppress this behavior (and allow jobs to continue
-  running), set this to ``false``.  Default: ``true``.
+   .. attr:: dequeue-on-new-patchset
+      :default: true
 
-**ignore-dependencies**
-  In any kind of pipeline (dependent or independent), Zuul will
-  attempt to enqueue all dependencies ahead of the current change so
-  that they are tested together (independent pipelines report the
-  results of each change regardless of the results of changes ahead).
-  To ignore dependencies completely in an independent pipeline, set
-  this to ``true``.  This option is ignored by dependent pipelines.
-  The default is: ``false``.
+      Normally, if a new patchset is uploaded to a change that is in a
+      pipeline, the existing entry in the pipeline will be removed
+      (with jobs canceled and any dependent changes that can no longer
+      merge as well.  To suppress this behavior (and allow jobs to
+      continue running), set this to ``false``.
 
-**precedence**
-  Indicates how the build scheduler should prioritize jobs for
-  different pipelines.  Each pipeline may have one precedence, jobs
-  for pipelines with a higher precedence will be run before ones with
-  lower.  The value should be one of ``high``, ``normal``, or ``low``.
-  Default: ``normal``.
+   .. attr:: ignore-dependencies
+      :default: false
 
-The following options configure *reporters*.  Reporters are
-complementary to triggers; where a trigger is an event on a connection
-which causes Zuul to enqueue an item, a reporter is the action
-performed on a connection when an item is dequeued after its jobs
-complete.  The actual syntax for a reporter is defined by the driver
-which implements it.  See :ref:`drivers` for more information.
+      In any kind of pipeline (dependent or independent), Zuul will
+      attempt to enqueue all dependencies ahead of the current change
+      so that they are tested together (independent pipelines report
+      the results of each change regardless of the results of changes
+      ahead).  To ignore dependencies completely in an independent
+      pipeline, set this to ``true``.  This option is ignored by
+      dependent pipelines.
 
-**success**
-  Describes where Zuul should report to if all the jobs complete
-  successfully.  This section is optional; if it is omitted, Zuul will
-  run jobs and do nothing on success -- it will not report at all.  If
-  the section is present, the listed reporters will be asked to report
-  on the jobs.  The reporters are listed by their connection name. The
-  options available depend on the driver for the supplied connection.
+   .. attr:: precedence
+      :default: normal
 
-**failure**
-  These reporters describe what Zuul should do if at least one job
-  fails.
+      Indicates how the build scheduler should prioritize jobs for
+      different pipelines.  Each pipeline may have one precedence,
+      jobs for pipelines with a higher precedence will be run before
+      ones with lower.  The value should be one of ``high``,
+      ``normal``, or ``low``.  Default: ``normal``.
 
-**merge-failure**
-  These reporters describe what Zuul should do if it is unable to
-  merge in the patchset. If no merge-failure reporters are listed then
-  the ``failure`` reporters will be used to notify of unsuccessful
-  merges.
+   .. _reporters:
 
-**start**
-  These reporters describe what Zuul should do when a change is added
-  to the pipeline.  This can be used, for example, to reset a
-  previously reported result.
+   The following options configure :term:`reporters <reporter>`.
+   Reporters are complementary to triggers; where a trigger is an
+   event on a connection which causes Zuul to enqueue an item, a
+   reporter is the action performed on a connection when an item is
+   dequeued after its jobs complete.  The actual syntax for a reporter
+   is defined by the driver which implements it.  See :ref:`drivers`
+   for more information.
 
-**disabled**
-  These reporters describe what Zuul should do when a pipeline is
-  disabled.  See ``disable-after-consecutive-failures``.
+   .. attr:: success
 
-The following options can be used to alter Zuul's behavior to mitigate
-situations in which jobs are failing frequently (perhaps due to a
-problem with an external dependency, or unusually high
-non-deterministic test failures).
+      Describes where Zuul should report to if all the jobs complete
+      successfully.  This section is optional; if it is omitted, Zuul
+      will run jobs and do nothing on success -- it will not report at
+      all.  If the section is present, the listed :term:`reporters
+      <reporter>` will be asked to report on the jobs.  The reporters
+      are listed by their connection name. The options available
+      depend on the driver for the supplied connection.
 
-**disable-after-consecutive-failures**
-  If set, a pipeline can enter a ''disabled'' state if too many changes
-  in a row fail. When this value is exceeded the pipeline will stop
-  reporting to any of the ``success``, ``failure`` or ``merge-failure``
-  reporters and instead only report to the ``disabled`` reporters.
-  (No ``start`` reports are made when a pipeline is disabled).
+   .. attr:: failure
 
-**window**
-  Dependent pipeline managers only. Zuul can rate limit dependent
-  pipelines in a manner similar to TCP flow control.  Jobs are only
-  started for items in the queue if they are within the actionable
-  window for the pipeline. The initial length of this window is
-  configurable with this value. The value given should be a positive
-  integer value. A value of ``0`` disables rate limiting on the
-  DependentPipelineManager.  Default: ``20``.
+      These reporters describe what Zuul should do if at least one job
+      fails.
 
-**window-floor**
-  Dependent pipeline managers only. This is the minimum value for the
-  window described above. Should be a positive non zero integer value.
-  Default: ``3``.
+   .. attr:: merge-failure
 
-**window-increase-type**
-  Dependent pipeline managers only. This value describes how the window
-  should grow when changes are successfully merged by zuul. A value of
-  ``linear`` indicates that ``window-increase-factor`` should be added
-  to the previous window value. A value of ``exponential`` indicates
-  that ``window-increase-factor`` should be multiplied against the
-  previous window value and the result will become the window size.
-  Default: ``linear``.
+      These reporters describe what Zuul should do if it is unable to
+      merge in the patchset. If no merge-failure reporters are listed
+      then the ``failure`` reporters will be used to notify of
+      unsuccessful merges.
 
-**window-increase-factor**
-  Dependent pipeline managers only. The value to be added or multiplied
-  against the previous window value to determine the new window after
-  successful change merges.
-  Default: ``1``.
+   .. attr:: start
 
-**window-decrease-type**
-  Dependent pipeline managers only. This value describes how the window
-  should shrink when changes are not able to be merged by Zuul. A value
-  of ``linear`` indicates that ``window-decrease-factor`` should be
-  subtracted from the previous window value. A value of ``exponential``
-  indicates that ``window-decrease-factor`` should be divided against
-  the previous window value and the result will become the window size.
-  Default: ``exponential``.
+      These reporters describe what Zuul should do when a change is
+      added to the pipeline.  This can be used, for example, to reset
+      a previously reported result.
 
-**window-decrease-factor**
-  Dependent pipline managers only. The value to be subtracted or divided
-  against the previous window value to determine the new window after
-  unsuccessful change merges.
-  Default: ``2``.
+   .. attr:: disabled
+
+      These reporters describe what Zuul should do when a pipeline is
+      disabled.  See ``disable-after-consecutive-failures``.
+
+   The following options can be used to alter Zuul's behavior to
+   mitigate situations in which jobs are failing frequently (perhaps
+   due to a problem with an external dependency, or unusually high
+   non-deterministic test failures).
+
+   .. attr:: disable-after-consecutive-failures
+
+      If set, a pipeline can enter a *disabled* state if too many
+      changes in a row fail. When this value is exceeded the pipeline
+      will stop reporting to any of the **success**, **failure** or
+      **merge-failure** reporters and instead only report to the
+      **disabled** reporters.  (No **start** reports are made when a
+      pipeline is disabled).
+
+   .. attr:: window
+      :default: 20
+
+      Dependent pipeline managers only. Zuul can rate limit dependent
+      pipelines in a manner similar to TCP flow control.  Jobs are
+      only started for items in the queue if they are within the
+      actionable window for the pipeline. The initial length of this
+      window is configurable with this value. The value given should
+      be a positive integer value. A value of ``0`` disables rate
+      limiting on the :value:`dependent pipeline manager
+      <pipeline.manager.dependent>`.
+
+   .. attr:: window-floor
+      :default: 3
+
+      Dependent pipeline managers only. This is the minimum value for
+      the window described above. Should be a positive non zero
+      integer value.
+
+   .. attr:: window-increase-type
+      :default: linear
+
+      Dependent pipeline managers only. This value describes how the
+      window should grow when changes are successfully merged by zuul.
+
+      .. value:: linear
+
+         Indicates that **window-increase-factor** should be added to
+         the previous window value.
+
+      .. value:: exponential
+
+         Indicates that **window-increase-factor** should be
+         multiplied against the previous window value and the result
+         will become the window size.
+
+   .. attr:: window-increase-factor
+      :default: 1
+
+      Dependent pipeline managers only. The value to be added or
+      multiplied against the previous window value to determine the
+      new window after successful change merges.
+
+   .. attr:: window-decrease-type
+      :default: exponential
+
+      Dependent pipeline managers only. This value describes how the
+      window should shrink when changes are not able to be merged by
+      Zuul.
+
+      .. value:: linear
+
+         Indicates that **window-decrease-factor** should be
+         subtracted from the previous window value.
+
+      .. value:: exponential
+
+         Indicates that **window-decrease-factor** should be divided
+         against the previous window value and the result will become
+         the window size.
+
+   .. attr:: window-decrease-factor
+      :default: 2
+
+      :value:`Dependent pipeline managers
+      <pipeline.manager.dependent>` only. The value to be subtracted
+      or divided against the previous window value to determine the
+      new window after unsuccessful change merges.
 
 
 .. _job:
@@ -423,7 +438,13 @@
 jobs on the system should have, progressing through stages of
 specialization before arriving at a particular job.  A job may inherit
 from any other job in any project (however, if the other job is marked
-as `final`, some attributes may not be overidden).
+as :attr:`job.final`, jobs may not inherit from it).
+
+A job with no parent is called a *base job* and may only be defined in
+a :term:`config-project`.  Every other job must have a parent, and so
+ultimately, all jobs must have an inheritance path which terminates at
+a base job.  Each tenant has a default parent job which will be used
+if no explicit parent is specified.
 
 Jobs also support a concept called variance.  The first time a job
 definition appears is called the reference definition of the job.
@@ -431,7 +452,8 @@
 These may have different selection criteria which indicate to Zuul
 that, for instance, the job should behave differently on a different
 git branch.  Unlike inheritance, all job variants must be defined in
-the same project.
+the same project.  Some attributes of jobs marked :attr:`job.final`
+may not be overidden
 
 When Zuul decides to run a job, it performs a process known as
 freezing the job.  Because any number of job variants may be
@@ -461,366 +483,430 @@
 
 Further inheritance would nest even deeper.
 
-Here is an example of two job definitions::
+Here is an example of two job definitions:
 
-  - job:
-      name: base
-      pre-run: copy-git-repos
-      post-run: copy-logs
+.. code-block:: yaml
 
-  - job:
-      name: run-tests
-      parent: base
-      nodes:
-        - name: test-node
-	  image: fedora
+   - job:
+       name: base
+       pre-run: copy-git-repos
+       post-run: copy-logs
 
-The following attributes are available on a job; all are optional
-unless otherwise specified:
+   - job:
+       name: run-tests
+       parent: base
+       nodes:
+         - name: test-node
+           label: fedora
 
-**name** (required)
-  The name of the job.  By default, Zuul looks for a playbook with
-  this name to use as the main playbook for the job.  This name is
-  also referenced later in a project pipeline configuration.
+.. attr:: job
 
-**parent**
-  Specifies a job to inherit from.  The parent job can be defined in
-  this or any other project.  Any attributes not specified on a job
-  will be collected from its parent.
+   The following attributes are available on a job; all are optional
+   unless otherwise specified:
 
-**description**
-  A textual description of the job.  Not currently used directly by
-  Zuul, but it is used by the zuul-sphinx extension to Sphinx to
-  auto-document Zuul jobs (in which case it is interpreted as
-  ReStructuredText.
+   .. attr:: name
+      :required:
 
-**success-message**
-  Normally when a job succeeds, the string "SUCCESS" is reported as
-  the result for the job.  If set, this option may be used to supply a
-  different string.  Default: "SUCCESS".
+      The name of the job.  By default, Zuul looks for a playbook with
+      this name to use as the main playbook for the job.  This name is
+      also referenced later in a project pipeline configuration.
 
-**failure-message**
-  Normally when a job fails, the string "FAILURE" is reported as
-  the result for the job.  If set, this option may be used to supply a
-  different string.  Default: "FAILURE".
+   .. TODO: figure out how to link the parent default to tenant.default.parent
 
-**success-url**
-  When a job succeeds, this URL is reported along with the result.  If
-  this value is not supplied, Zuul uses the content of the job
-  :ref:`return value <return_values>` **zuul.log_url**.  This is
-  recommended as it allows the code which stores the URL to the job
-  artifacts to report exactly where they were stored.  To override
-  this value, or if it is not set, supply an absolute URL in this
-  field.  If a relative URL is supplied in this field, and
-  **zuul.log_url** is set, then the two will be combined to produce
-  the URL used for the report.  This can be used to specify that
-  certain jobs should "deep link" into the stored job artifacts.
-  Default: none.
+   .. attr:: parent
+      :default: Tenant default-parent
 
-**failure-url**
-  When a job fails, this URL is reported along with the result.
-  Otherwise behaves the same as **success-url**.
+      Specifies a job to inherit from.  The parent job can be defined
+      in this or any other project.  Any attributes not specified on a
+      job will be collected from its parent.  If no value is supplied
+      here, the job specified by :attr:`tenant.default-parent` will be
+      used.  If **parent** is set to ``null`` (which is only valid in
+      a :term:`config-project`), this is a :term:`base job`.
 
-**hold-following-changes**
-  In a dependent pipeline, this option may be used to indicate that no
-  jobs should start on any items which depend on the current item
-  until this job has completed successfully.  This may be used to
-  conserve build resources, at the expense of inhibiting the
-  parallelization which speeds the processing of items in a dependent
-  pipeline.  A boolean value, default: false.
+   .. attr:: description
 
-**voting**
-  Indicates whether the result of this job should be used in
-  determining the overall result of the item.  A boolean value,
-  default: true.
+      A textual description of the job.  Not currently used directly
+      by Zuul, but it is used by the zuul-sphinx extension to Sphinx
+      to auto-document Zuul jobs (in which case it is interpreted as
+      ReStructuredText.
 
-**semaphore**
-  The name of a :ref:`semaphore` which should be acquired and released
-  when the job begins and ends.  If the semaphore is at maximum
-  capacity, then Zuul will wait until it can be acquired before
-  starting the job.  Default: none.
+   .. attr:: final
+      :default: false
 
-**tags**
-  Metadata about this job.  Tags are units of information attached to
-  the job; they do not affect Zuul's behavior, but they can be used
-  within the job to characterize the job.  For example, a job which
-  tests a certain subsystem could be tagged with the name of that
-  subsystem, and if the job's results are reported into a database,
-  then the results of all jobs affecting that subsystem could be
-  queried.  This attribute is specified as a list of strings, and when
-  inheriting jobs or applying variants, tags accumulate in a set, so
-  the result is always a set of all the tags from all the jobs and
-  variants used in constructing the frozen job, with no duplication.
-  Default: none.
+      To prevent other jobs from inheriting from this job, and also to
+      prevent changing execution-related attributes when this job is
+      specified in a project's pipeline, set this attribute to
+      ``true``.
 
-**branches**
-  A regular expression (or list of regular expressions) which describe
-  on what branches a job should run (or in the case of variants: to
-  alter the behavior of a job for a certain branch).
+   .. attr:: success-message
+      :default: SUCCESS
 
-  If there is no job definition for a given job which matches the
-  branch of an item, then that job is not run for the item.
-  Otherwise, all of the job variants which match that branch (and any
-  other selection criteria) are used when freezing the job.
+      Normally when a job succeeds, the string ``SUCCESS`` is reported
+      as the result for the job.  If set, this option may be used to
+      supply a different string.
 
-  This example illustrates a job called *run-tests* which uses a
-  nodeset based on the current release of an operating system to
-  perform its tests, except when testing changes to the stable/2.0
-  branch, in which case it uses an older release::
+   .. attr:: failure-message
+      :default: FAILURE
 
-    - job:
-        name: run-tests
-        nodes: current-release
+      Normally when a job fails, the string ``FAILURE`` is reported as
+      the result for the job.  If set, this option may be used to
+      supply a different string.
 
-    - job:
-        name: run-tests
-        branch: stable/2.0
-        nodes: old-release
+   .. attr:: success-url
 
-  In some cases, Zuul uses an implied value for the branch specifier
-  if none is supplied:
+      When a job succeeds, this URL is reported along with the result.
+      If this value is not supplied, Zuul uses the content of the job
+      :ref:`return value <return_values>` **zuul.log_url**.  This is
+      recommended as it allows the code which stores the URL to the
+      job artifacts to report exactly where they were stored.  To
+      override this value, or if it is not set, supply an absolute URL
+      in this field.  If a relative URL is supplied in this field, and
+      **zuul.log_url** is set, then the two will be combined to
+      produce the URL used for the report.  This can be used to
+      specify that certain jobs should "deep link" into the stored job
+      artifacts.
 
-  * For a job definition in a *config-project*, no implied branch
-    specifier is used.  If no branch specifier appears, the job
-    applies to all branches.
+   .. attr:: failure-url
 
-  * In the case of an *untrusted-project*, no implied branch specifier
-    is applied to the reference definition of a job.  That is to say,
-    that if the first appearance of the job definition appears without
-    a branch specifier, then it will apply to all branches.  Note that
-    when collecting its configuration, Zuul reads the `master` branch
-    of a given project first, then other branches in alphabetical
-    order.
+      When a job fails, this URL is reported along with the result.
+      Otherwise behaves the same as **success-url**.
 
-  * Any further job variants other than the reference definition in an
-    *untrusted-project* will, if they do not have a branch specifier,
-    will have an implied branch specifier for the current branch
-    applied.
+   .. attr:: hold-following-changes
+      :default: false
 
-  This allows for the very simple and expected workflow where if a
-  project defines a job on the master branch with no branch specifier,
-  and then creates a new branch based on master, any changes to that
-  job definition within the new branch only affect that branch.
+      In a dependent pipeline, this option may be used to indicate
+      that no jobs should start on any items which depend on the
+      current item until this job has completed successfully.  This
+      may be used to conserve build resources, at the expense of
+      inhibiting the parallelization which speeds the processing of
+      items in a dependent pipeline.
 
-**files**
-  This attribute indicates that the job should only run on changes
-  where the specified files are modified.  This is a regular
-  expression or list of regular expressions.  Default: none.
+   .. attr:: voting
+      :default: true
 
-**irrelevant-files**
-  This is a negative complement of `files`.  It indicates that the job
-  should run unless *all* of the files changed match this list.  In
-  other words, if the regular expression `docs/.*` is supplied, then
-  this job will not run if the only files changed are in the docs
-  directory.  A regular expression or list of regular expressions.
-  Default: none.
+      Indicates whether the result of this job should be used in
+      determining the overall result of the item.
 
-**auth**
-  Authentication information to be made available to the job.  This is
-  a dictionary with two potential keys:
+   .. attr:: semaphore
 
-  **inherit**
-  A boolean indicating that the authentication information referenced
-  by this job should be able to be inherited by child jobs.  Normally
-  when a job inherits from another job, the auth section is not
-  included.  This permits jobs to inherit the same basic structure and
-  playbook, but ensures that secret information is unable to be
-  exposed by a child job which may alter the job's behavior.  If it is
-  safe for the contents of the authentication section to be used by
-  child jobs, set this to ``true``.  Default: ``false``.
+      The name of a :ref:`semaphore` which should be acquired and
+      released when the job begins and ends.  If the semaphore is at
+      maximum capacity, then Zuul will wait until it can be acquired
+      before starting the job.
 
-  **secrets**
-  A list of secrets which may be used by the job.  A :ref:`secret` is
-  a named collection of private information defined separately in the
-  configuration.  The secrets that appear here must be defined in the
-  same project as this job definition.
+   .. attr:: tags
 
-  In the future, other types of authentication information may be
-  added.
+      Metadata about this job.  Tags are units of information attached
+      to the job; they do not affect Zuul's behavior, but they can be
+      used within the job to characterize the job.  For example, a job
+      which tests a certain subsystem could be tagged with the name of
+      that subsystem, and if the job's results are reported into a
+      database, then the results of all jobs affecting that subsystem
+      could be queried.  This attribute is specified as a list of
+      strings, and when inheriting jobs or applying variants, tags
+      accumulate in a set, so the result is always a set of all the
+      tags from all the jobs and variants used in constructing the
+      frozen job, with no duplication.
 
-**nodes**
-  A list of nodes which should be supplied to the job.  This parameter
-  may be supplied either as a string, in which case it references a
-  :ref:`nodeset` definition which appears elsewhere in the
-  configuration, or a list, in which case it is interpreted in the
-  same way as a Nodeset definition (in essence, it is an anonymous
-  Node definition unique to this job).  See the :ref:`nodeset`
-  reference for the syntax to use in that case.
+   .. attr:: branches
 
-  If a job has an empty or no node definition, it will still run and
-  may be able to perform actions on the Zuul executor.
+      A regular expression (or list of regular expressions) which
+      describe on what branches a job should run (or in the case of
+      variants: to alter the behavior of a job for a certain branch).
 
-**override-branch**
-  When Zuul runs jobs for a proposed change, it normally checks out
-  the branch associated with that change on every project present in
-  the job.  If jobs are running on a ref (such as a branch tip or
-  tag), then that ref is normally checked out.  This attribute is used
-  to override that behavior and indicate that this job should,
-  regardless of the branch for the queue item, use the indicated
-  branch instead.  This can be used, for example, to run a previous
-  version of the software (from a stable maintenance branch) under
-  test even if the change being tested applies to a different branch
-  (this is only likely to be useful if there is some cross-branch
-  interaction with some component of the system being tested).  See
-  also the project-specific **override-branch** attribute under
-  **required-projects** to apply this behavior to a subset of a job's
-  projects.
+      If there is no job definition for a given job which matches the
+      branch of an item, then that job is not run for the item.
+      Otherwise, all of the job variants which match that branch (and
+      any other selection criteria) are used when freezing the job.
 
-**timeout**
-  The time in minutes that the job should be allowed to run before it
-  is automatically aborted and failure is reported.  If no timeout is
-  supplied, the job may run indefinitely.  Supplying a timeout is
-  highly recommended.
+      This example illustrates a job called *run-tests* which uses a
+      nodeset based on the current release of an operating system to
+      perform its tests, except when testing changes to the stable/2.0
+      branch, in which case it uses an older release:
 
-**attempts**
-  When Zuul encounters an error running a job's pre-run playbook, Zuul
-  will stop and restart the job.  Errors during the main or
-  post-run -playbook phase of a job are not affected by this parameter
-  (they are reported immediately).  This parameter controls the number
-  of attempts to make before an error is reported.  Default: 3.
+      .. code-block:: yaml
 
-**pre-run**
-  The name of a playbook or list of playbooks without file extension
-  to run before the main body of a job.  The full path to the playbook
-  in the repo where the job is defined is expected.
+         - job:
+             name: run-tests
+             nodes: current-release
 
-  When a job inherits from a parent, the child's pre-run playbooks are
-  run after the parent's.  See :ref:`job` for more information.
+         - job:
+             name: run-tests
+             branch: stable/2.0
+             nodes: old-release
 
-**post-run**
-  The name of a playbook or list of playbooks without file extension
-  to run after the main body of a job.  The full path to the playbook
-  in the repo where the job is defined is expected.
+      In some cases, Zuul uses an implied value for the branch
+      specifier if none is supplied:
 
-  When a job inherits from a parent, the child's post-run playbooks
-  are run before the parent's.  See :ref:`job` for more information.
+      * For a job definition in a :term:`config-project`, no implied
+        branch specifier is used.  If no branch specifier appears, the
+        job applies to all branches.
 
-**run**
-  The name of the main playbook for this job.  This parameter is
-  not normally necessary, as it defaults to a playbook with the
-  same name as the job inside of the `playbooks/` directory (e.g.,
-  the `foo` job would default to `playbooks/foo`.  However, if a
-  playbook with a different name is needed, it can be specified
-  here.  The file extension is not required, but the full path
-  within the repo is.  When a child inherits from a parent, a
-  playbook with the name of the child job is implicitly searched
-  first, before falling back on the playbook used by the parent
-  job (unless the child job specifies a ``run`` attribute, in which
-  case that value is used).  Example::
+      * In the case of an :term:`untrusted-project`, no implied branch
+        specifier is applied to the reference definition of a job.
+        That is to say, that if the first appearance of the job
+        definition appears without a branch specifier, then it will
+        apply to all branches.  Note that when collecting its
+        configuration, Zuul reads the ``master`` branch of a given
+        project first, then other branches in alphabetical order.
 
-     run: playbooks/<name of the job>
+      * Any further job variants other than the reference definition
+        in an untrusted-project will, if they do not have a branch
+        specifier, will have an implied branch specifier for the
+        current branch applied.
 
-**roles**
-  A list of Ansible roles to prepare for the job.  Because a job runs
-  an Ansible playbook, any roles which are used by the job must be
-  prepared and installed by Zuul before the job begins.  This value is
-  a list of dictionaries, each of which indicates one of two types of
-  roles: a Galaxy role, which is simply a role that is installed from
-  Ansible Galaxy, or a Zuul role, which is a role provided by a
-  project managed by Zuul.  Zuul roles are able to benefit from
-  speculative merging and cross-project dependencies when used by
-  playbooks in untrusted projects.  Roles are added to the Ansible
-  role path in the order they appear on the job -- roles earlier in
-  the list will take precedence over those which follow.
+      This allows for the very simple and expected workflow where if a
+      project defines a job on the ``master`` branch with no branch
+      specifier, and then creates a new branch based on ``master``,
+      any changes to that job definition within the new branch only
+      affect that branch.
 
-  In the case of job inheritance or variance, the roles used for each
-  of the playbooks run by the job will be only those which were
-  defined along with that playbook.  If a child job inherits from a
-  parent which defines a pre and post playbook, then the pre and post
-  playbooks it inherits from the parent job will run only with the
-  roles that were defined on the parent.  If the child adds its own
-  pre and post playbooks, then any roles added by the child will be
-  available to the child's playbooks.  This is so that a job which
-  inherits from a parent does not inadvertantly alter the behavior of
-  the parent's playbooks by the addition of conflicting roles.  Roles
-  added by a child will appear before those it inherits from its
-  parent.
+   .. attr:: files
 
-  A project which supplies a role may be structured in one of two
-  configurations: a bare role (in which the role exists at the root of
-  the project), or a contained role (in which the role exists within
-  the `roles/` directory of the project, perhaps along with other
-  roles).  In the case of a contained role, the `roles/` directory of
-  the project is added to the role search path.  In the case of a bare
-  role, the project itself is added to the role search path.  In case
-  the name of the project is not the name under which the role should
-  be installed (and therefore referenced from Ansible), the `name`
-  attribute may be used to specify an alternate.
+      This attribute indicates that the job should only run on changes
+      where the specified files are modified.  This is a regular
+      expression or list of regular expressions.
 
-  A job automatically has the project in which it is defined added to
-  the roles path if that project appears to contain a role or `roles/`
-  directory.  By default, the project is added to the path under its
-  own name, however, that may be changed by explicitly listing the
-  project in the roles list in the usual way.
+   .. attr:: irrelevant-files
 
-  .. note:: galaxy roles are not yet implemented
+      This is a negative complement of **files**.  It indicates that
+      the job should run unless *all* of the files changed match this
+      list.  In other words, if the regular expression ``docs/.*`` is
+      supplied, then this job will not run if the only files changed
+      are in the docs directory.  A regular expression or list of
+      regular expressions.
 
-  **galaxy**
-    The name of the role in Ansible Galaxy.  If this attribute is
-    supplied, Zuul will search Ansible Galaxy for a role by this name
-    and install it.  Mutually exclusive with ``zuul``; either
-    ``galaxy`` or ``zuul`` must be supplied.
+   .. attr:: secrets
 
-  **zuul**
-    The name of a Zuul project which supplies the role.  Mutually
-    exclusive with ``galaxy``; either ``galaxy`` or ``zuul`` must be
-    supplied.
+      A list of secrets which may be used by the job.  A
+      :ref:`secret` is a named collection of private information
+      defined separately in the configuration.  The secrets that
+      appear here must be defined in the same project as this job
+      definition.
 
-  **name**
-    The installation name of the role.  In the case of a bare role,
-    the role will be made available under this name.  Ignored in the
-    case of a contained role.
+   .. attr:: nodes
 
-**required-projects**
-  A list of other projects which are used by this job.  Any Zuul
-  projects specified here will also be checked out by Zuul into the
-  working directory for the job.  Speculative merging and cross-repo
-  dependencies will be honored.
+      A list of nodes which should be supplied to the job.  This
+      parameter may be supplied either as a string, in which case it
+      references a :ref:`nodeset` definition which appears elsewhere
+      in the configuration, or a list, in which case it is interpreted
+      in the same way as a Nodeset definition (in essence, it is an
+      anonymous Node definition unique to this job).  See the
+      :ref:`nodeset` reference for the syntax to use in that case.
 
-  The format for this attribute is either a list of strings or
-  dictionaries.  Strings are interpreted as project names,
-  dictionaries may have the following attributes:
+      If a job has an empty or no node definition, it will still run
+      and may be able to perform actions on the Zuul executor.
 
-  **name**
-    The name of the required project.
+   .. attr:: override-branch
 
-  **override-branch**
-    When Zuul runs jobs for a proposed change, it normally checks out
-    the branch associated with that change on every project present in
-    the job.  If jobs are running on a ref (such as a branch tip or
-    tag), then that ref is normally checked out.  This attribute is
-    used to override that behavior and indicate that this job should,
-    regardless of the branch for the queue item, use the indicated
-    branch instead, for only this project.  See also the
-    **override-branch** attribute of jobs to apply the same behavior
-    to all projects in a job.
+      When Zuul runs jobs for a proposed change, it normally checks
+      out the branch associated with that change on every project
+      present in the job.  If jobs are running on a ref (such as a
+      branch tip or tag), then that ref is normally checked out.  This
+      attribute is used to override that behavior and indicate that
+      this job should, regardless of the branch for the queue item,
+      use the indicated branch instead.  This can be used, for
+      example, to run a previous version of the software (from a
+      stable maintenance branch) under test even if the change being
+      tested applies to a different branch (this is only likely to be
+      useful if there is some cross-branch interaction with some
+      component of the system being tested).  See also the
+      project-specific :attr:`job.required-projects.override-branch`
+      attribute to apply this behavior to a subset of a job's
+      projects.
 
-**vars**
+   .. attr:: timeout
 
-A dictionary of variables to supply to Ansible.  When inheriting from
-a job (or creating a variant of a job) vars are merged with previous
-definitions.  This means a variable definition with the same name will
-override a previously defined variable, but new variable names will be
-added to the set of defined variables.
+      The time in minutes that the job should be allowed to run before
+      it is automatically aborted and failure is reported.  If no
+      timeout is supplied, the job may run indefinitely.  Supplying a
+      timeout is highly recommended.
 
-**dependencies**
-  A list of other jobs upon which this job depends.  Zuul will not
-  start executing this job until all of its dependencies have
-  completed successfully, and if one or more of them fail, this job
-  will not be run.
+   .. attr:: attempts
+      :default: 3
 
-**allowed-projects**
-  A list of Zuul projects which may use this job.  By default, a job
-  may be used by any other project known to Zuul, however, some jobs
-  use resources or perform actions which are not appropriate for other
-  projects.  In these cases, a list of projects which are allowed to
-  use this job may be supplied.  If this list is not empty, then it
-  must be an exhaustive list of all projects permitted to use the job.
-  The current project (where the job is defined) is not automatically
-  included, so if it should be able to run this job, then it must be
-  explicitly listed.  Default: the empty list (all projects may use
-  the job).
+      When Zuul encounters an error running a job's pre-run playbook,
+      Zuul will stop and restart the job.  Errors during the main or
+      post-run -playbook phase of a job are not affected by this
+      parameter (they are reported immediately).  This parameter
+      controls the number of attempts to make before an error is
+      reported.
 
+   .. attr:: pre-run
+
+      The name of a playbook or list of playbooks without file
+      extension to run before the main body of a job.  The full path
+      to the playbook in the repo where the job is defined is
+      expected.
+
+      When a job inherits from a parent, the child's pre-run playbooks
+      are run after the parent's.  See :ref:`job` for more
+      information.
+
+   .. attr:: post-run
+
+      The name of a playbook or list of playbooks without file
+      extension to run after the main body of a job.  The full path to
+      the playbook in the repo where the job is defined is expected.
+
+      When a job inherits from a parent, the child's post-run
+      playbooks are run before the parent's.  See :ref:`job` for more
+      information.
+
+   .. attr:: run
+
+      The name of the main playbook for this job.  This parameter is
+      not normally necessary, as it defaults to a playbook with the
+      same name as the job inside of the ``playbooks/`` directory
+      (e.g., the ``foo`` job would default to ``playbooks/foo``.
+      However, if a playbook with a different name is needed, it can
+      be specified here.  The file extension is not required, but the
+      full path within the repo is.  When a child inherits from a
+      parent, a playbook with the name of the child job is implicitly
+      searched first, before falling back on the playbook used by the
+      parent job (unless the child job specifies a ``run`` attribute,
+      in which case that value is used).  Example:
+
+      .. code-block:: yaml
+
+         run: playbooks/<name of the job>
+
+   .. attr:: roles
+
+      A list of Ansible roles to prepare for the job.  Because a job
+      runs an Ansible playbook, any roles which are used by the job
+      must be prepared and installed by Zuul before the job begins.
+      This value is a list of dictionaries, each of which indicates
+      one of two types of roles: a Galaxy role, which is simply a role
+      that is installed from Ansible Galaxy, or a Zuul role, which is
+      a role provided by a project managed by Zuul.  Zuul roles are
+      able to benefit from speculative merging and cross-project
+      dependencies when used by playbooks in untrusted projects.
+      Roles are added to the Ansible role path in the order they
+      appear on the job -- roles earlier in the list will take
+      precedence over those which follow.
+
+      In the case of job inheritance or variance, the roles used for
+      each of the playbooks run by the job will be only those which
+      were defined along with that playbook.  If a child job inherits
+      from a parent which defines a pre and post playbook, then the
+      pre and post playbooks it inherits from the parent job will run
+      only with the roles that were defined on the parent.  If the
+      child adds its own pre and post playbooks, then any roles added
+      by the child will be available to the child's playbooks.  This
+      is so that a job which inherits from a parent does not
+      inadvertently alter the behavior of the parent's playbooks by
+      the addition of conflicting roles.  Roles added by a child will
+      appear before those it inherits from its parent.
+
+      A project which supplies a role may be structured in one of two
+      configurations: a bare role (in which the role exists at the
+      root of the project), or a contained role (in which the role
+      exists within the ``roles/`` directory of the project, perhaps
+      along with other roles).  In the case of a contained role, the
+      ``roles/`` directory of the project is added to the role search
+      path.  In the case of a bare role, the project itself is added
+      to the role search path.  In case the name of the project is not
+      the name under which the role should be installed (and therefore
+      referenced from Ansible), the ``name`` attribute may be used to
+      specify an alternate.
+
+      A job automatically has the project in which it is defined added
+      to the roles path if that project appears to contain a role or
+      ``roles/`` directory.  By default, the project is added to the
+      path under its own name, however, that may be changed by
+      explicitly listing the project in the roles list in the usual
+      way.
+
+      .. note:: Galaxy roles are not yet implemented.
+
+      .. attr:: galaxy
+
+         The name of the role in Ansible Galaxy.  If this attribute is
+         supplied, Zuul will search Ansible Galaxy for a role by this
+         name and install it.  Mutually exclusive with ``zuul``;
+         either ``galaxy`` or ``zuul`` must be supplied.
+
+      .. attr:: zuul
+
+         The name of a Zuul project which supplies the role.  Mutually
+         exclusive with ``galaxy``; either ``galaxy`` or ``zuul`` must
+         be supplied.
+
+      .. attr:: name
+
+         The installation name of the role.  In the case of a bare
+         role, the role will be made available under this name.
+         Ignored in the case of a contained role.
+
+   .. attr:: required-projects
+
+      A list of other projects which are used by this job.  Any Zuul
+      projects specified here will also be checked out by Zuul into
+      the working directory for the job.  Speculative merging and
+      cross-repo dependencies will be honored.
+
+      The format for this attribute is either a list of strings or
+      dictionaries.  Strings are interpreted as project names,
+      dictionaries, if used, may have the following attributes:
+
+      .. attr:: name
+         :required:
+
+         The name of the required project.
+
+      .. attr:: override-branch
+
+         When Zuul runs jobs for a proposed change, it normally checks
+         out the branch associated with that change on every project
+         present in the job.  If jobs are running on a ref (such as a
+         branch tip or tag), then that ref is normally checked out.
+         This attribute is used to override that behavior and indicate
+         that this job should, regardless of the branch for the queue
+         item, use the indicated branch instead, for only this
+         project.  See also the :attr:`job.override-branch` attribute
+         to apply the same behavior to all projects in a job.
+
+   .. attr:: vars
+
+      A dictionary of variables to supply to Ansible.  When inheriting
+      from a job (or creating a variant of a job) vars are merged with
+      previous definitions.  This means a variable definition with the
+      same name will override a previously defined variable, but new
+      variable names will be added to the set of defined variables.
+
+   .. attr:: dependencies
+
+      A list of other jobs upon which this job depends.  Zuul will not
+      start executing this job until all of its dependencies have
+      completed successfully, and if one or more of them fail, this
+      job will not be run.
+
+   .. attr:: allowed-projects
+
+      A list of Zuul projects which may use this job.  By default, a
+      job may be used by any other project known to Zuul, however,
+      some jobs use resources or perform actions which are not
+      appropriate for other projects.  In these cases, a list of
+      projects which are allowed to use this job may be supplied.  If
+      this list is not empty, then it must be an exhaustive list of
+      all projects permitted to use the job.  The current project
+      (where the job is defined) is not automatically included, so if
+      it should be able to run this job, then it must be explicitly
+      listed.  By default, all projects may use the job.
+
+   .. attr:: post-review
+      :default: false
+
+      A boolean value which indicates whether this job may only be
+      used in pipelines where :attr:`pipeline.post-review` is
+      ``true``.  This is automatically set to ``true`` if this job is
+      defined in a :term:`untrusted-project`.  It may be explicitly
+      set to obtain the same behavior for jobs defined in
+      :term:`config projects <config-project>`.  Once this is set to
+      ``true`` anywhere in the inheritance hierarchy for a job, it
+      will remain set for all child jobs and variants (it can not be
+      set to ``false``).
 
 .. _project:
 
@@ -828,14 +914,14 @@
 ~~~~~~~
 
 A project corresponds to a source code repository with which Zuul is
-configured to interact.  The main responsibility of the `Project`
+configured to interact.  The main responsibility of the project
 configuration item is to specify which jobs should run in which
-pipelines for a given project.  Within each `Project` definition, a
-section for each `Pipeline` may appear.  This project-pipeline
-definition is what determines how a project participates in a
-pipeline.
+pipelines for a given project.  Within each project definition, a
+section for each :ref:`pipeline <pipeline>` may appear.  This
+project-pipeline definition is what determines how a project
+participates in a pipeline.
 
-Consider the following `Project` definition::
+Consider the following project definition::
 
   - project:
       name: yoyodyne
@@ -849,13 +935,13 @@
           - unit-tests
           - integration-tests
 
-The project has two project-pipeline stanzas, one for the `check`
-pipeline, and one for `gate`.  Each specifies which jobs shuld run
-when a change for that project enteres the respective pipeline -- when
-a change enters `check`, the `check-syntax` and `unit-test` jobs are
-run.
+The project has two project-pipeline stanzas, one for the ``check``
+pipeline, and one for ``gate``.  Each specifies which jobs should run
+when a change for that project enters the respective pipeline -- when
+a change enters ``check``, the ``check-syntax`` and ``unit-test`` jobs
+are run.
 
-Pipelines which use the dependent pipeline manager (e.g., the `gate`
+Pipelines which use the dependent pipeline manager (e.g., the ``gate``
 example shown earlier) maintain separate queues for groups of
 projects.  When Zuul serializes a set of changes which represent
 future potential project states, it must know about all of the
@@ -875,24 +961,80 @@
 for a dependent pipeline, set the ``queue`` parameter to the same
 value for those projects.
 
-The `gate` project-pipeline definition above specifies that this
-project participates in the `integrated` shared queue for that
+The ``gate`` project-pipeline definition above specifies that this
+project participates in the ``integrated`` shared queue for that
 pipeline.
 
-In addition to a project-pipeline definition for one or more
-`Pipelines`, the following attributes may appear in a Project:
+.. attr:: project
 
-**name** (required)
-  The name of the project.  If Zuul is configured with two or more
-  unique projects with the same name, the canonical hostname for the
-  project should be included (e.g., `git.example.com/foo`).
+   In addition to a project-pipeline definition for one or more
+   pipelines, the following attributes may appear in a project:
 
-**templates**
-  A list of :ref:`project-template` references; the project-pipeline
-  definitions of each Project Template will be applied to this
-  project.  If more than one template includes jobs for a given
-  pipeline, they will be combined, as will any jobs specified in
-  project-pipeline definitions on the project itself.
+   .. attr:: name
+      :required:
+
+      The name of the project.  If Zuul is configured with two or more
+      unique projects with the same name, the canonical hostname for
+      the project should be included (e.g., `git.example.com/foo`).
+
+   .. attr:: templates
+
+      A list of :ref:`project-template` references; the
+      project-pipeline definitions of each Project Template will be
+      applied to this project.  If more than one template includes
+      jobs for a given pipeline, they will be combined, as will any
+      jobs specified in project-pipeline definitions on the project
+      itself.
+
+   .. attr:: merge-mode
+      :default: merge-resolve
+
+      The merge mode which is used by Git for this project.  Be sure
+      this matches what the remote system which performs merges (i.e.,
+      Gerrit or GitHub).  Must be one of the following values:
+
+      .. value:: merge
+
+         Uses the default git merge strategy (recursive).
+
+      .. value:: merge-resolve
+
+         Uses the resolve git merge strategy.  This is a very
+         conservative merge strategy which most closely matches the
+         behavior of Gerrit.
+
+      .. value:: cherry-pick
+
+         Cherry-picks each change onto the branch rather than
+         performing any merges.
+
+   .. attr:: <pipeline>
+
+      Each pipeline that the project participates in should have an
+      entry in the project.  The value for this key should be a
+      dictionary with the following format:
+
+      .. attr:: jobs
+         :required:
+
+         A list of jobs that should be run when items for this project
+         are enqueued into the pipeline.  Each item of this list may
+         be a string, in which case it is treated as a job name, or it
+         may be a dictionary, in which case it is treated as a job
+         variant local to this project and pipeline.  In that case,
+         the format of the dictionary is the same as the top level
+         :attr:`job` definition.  Any attributes set on the job here
+         will override previous versions of the job.
+
+      .. attr:: queue
+
+         If this pipeline is a :value:`dependent
+         <pipeline.manager.dependent>` pipeline, this specifies the
+         name of the shared queue this project is in.  Any projects
+         which interact with each other in tests should be part of the
+         same shared queue in order to ensure that they don't merge
+         changes which break the others.  This is a free-form string;
+         just set the same value for each group of projects.
 
 .. _project-template:
 
@@ -903,9 +1045,10 @@
 which can be re-used by multiple projects.
 
 A Project Template uses the same syntax as a :ref:`project`
-definition, however, in the case of a template, the ``name`` attribute
-does not refer to the name of a project, but rather names the template
-so that it can be referenced in a `Project` definition.
+definition, however, in the case of a template, the
+:attr:`project.name` attribute does not refer to the name of a
+project, but rather names the template so that it can be referenced in
+a `Project` definition.
 
 .. _secret:
 
@@ -918,34 +1061,60 @@
 unencrypted as well for convenience.
 
 A Secret may only be used by jobs defined within the same project.  To
-use a secret, a :ref:`job` must specify the secret within its `auth`
-section.  To protect against jobs in other repositories declaring a
-job with a secret as a parent and then exposing that secret, jobs
-which inherit from a job with secrets will not inherit the secrets
-themselves.  To alter that behavior, see the `inherit` job attribute.
-Further, jobs which do not permit children to inherit secrets (the
-default) are also automatically marked `final`, meaning that their
-execution related attributes may not be changed in a project-pipeline
-stanza.  This is to protect against a job with secrets defined in one
-project being used by another project in a way which might expose the
-secrets.  If a job with secrets is unsafe to be used by other
-projects, the `allowed-projects` job attribute can be used to restrict
-the projects which can invoke that job.  Finally, pipelines which are
-used to execute proposed but unreviewed changes can set the
-`allow-secrets` attribute to indicate that they should not supply
-secrets at all in order to protect against someone proposing a change
-which exposes a secret.
+use a secret, a :ref:`job` must specify the secret in
+:attr:`job.secrets`.  Secrets are bound to the playbooks associated
+with the specific job definition where they were declared.  Additional
+pre or post playbooks which appear in child jobs will not have access
+to the secrets, nor will playbooks which override the main playbook
+(if any) of the job which declared the secret.  This protects against
+jobs in other repositories declaring a job with a secret as a parent
+and then exposing that secret.
 
-The following attributes are required:
+It is possible to use secrets for jobs defined in :term:`config
+projects <config-project>` as well as :term:`untrusted projects
+<untrusted-project>`, however their use differs slightly.  Because
+playbooks in a config project which use secrets run in the
+:term:`trusted execution context` where proposed changes are not used
+in executing jobs, it is safe for those secrets to be used in all
+types of pipelines.  However, because playbooks defined in an
+untrusted project are run in the :term:`untrusted execution context`
+where proposed changes are used in job execution, it is dangerous to
+allow those secrets to be used in pipelines which are used to execute
+proposed but unreviewed changes.  By default, pipelines are considered
+`pre-review` and will refuse to run jobs which have playbooks that use
+secrets in the untrusted execution context to protect against someone
+proposing a change which exposes a secret.  To permit this (for
+instance, in a pipeline which only runs after code review), the
+:attr:`pipeline.post-review` attribute may be explicitly set to
+``true``.
 
-**name** (required)
-  The name of the secret, used in a :ref:`Job` definition to request
-  the secret.
+In some cases, it may be desirable to prevent a job which is defined
+in a config project from running in a pre-review pipeline (e.g., a job
+used to publish an artifact).  In these cases, the
+:attr:`job.post-review` attribute may be explicitly set to ``true`` to
+indicate the job should only run in post-review pipelines.
 
-**data** (required)
-  A dictionary which will be added to the Ansible variables available
-  to the job.  The values can either be plain text strings, or
-  encrypted values.  See :ref:`encryption` for more information.
+If a job with secrets is unsafe to be used by other projects, the
+`allowed-projects` job attribute can be used to restrict the projects
+which can invoke that job.
+
+.. attr:: secret
+
+   The following attributes must appear on a secret:
+
+   .. attr:: name
+      :required:
+
+      The name of the secret, used in a :ref:`Job` definition to
+      request the secret.
+
+   .. attr:: data
+      :required:
+
+      A dictionary which will be added to the Ansible variables
+      available to the job.  The values can either be plain text
+      strings, or encrypted values.  See :ref:`encryption` for more
+      information.
 
 .. _nodeset:
 
@@ -957,21 +1126,68 @@
 groups of node types once and referring to them by name, job
 configuration may be simplified.
 
-A Nodeset requires two attributes:
+.. code-block:: yaml
 
-**name** (required)
-  The name of the Nodeset, to be referenced by a :ref:`job`.
+   - nodeset:
+       name: nodeset1
+       nodes:
+         - name: controller
+           label: controller-label
+         - name: compute1
+           label: compute-label
+         - name: compute2
+           label: compute-label
+       groups:
+         - name: ceph-osd
+           nodes:
+             - controller
+         - name: ceph-monitor
+           nodes:
+             - controller
+             - compute1
+             - compute2
 
-**nodes** (required)
-  A list of node definitions, each of which has the following format:
+.. attr:: nodeset
 
-  **name** (required)
-    The name of the node.  This will appear in the Ansible inventory
-    for the job.
+   A Nodeset requires two attributes:
 
-  **label** (required)
-    The Nodepool label for the node.  Zuul will request a node with
-    this label.
+   .. attr:: name
+      :required:
+
+      The name of the Nodeset, to be referenced by a :ref:`job`.
+
+   .. attr:: nodes
+      :required:
+
+      A list of node definitions, each of which has the following format:
+
+      .. attr:: name
+         :required:
+
+         The name of the node.  This will appear in the Ansible inventory
+         for the job.
+
+      .. attr:: label
+         :required:
+
+         The Nodepool label for the node.  Zuul will request a node with
+         this label.
+
+   .. attr:: groups
+
+      Additional groups can be defined which are accessible from the ansible
+      playbooks.
+
+      .. attr:: name
+         :required:
+
+         The name of the group to be referenced by an ansible playbook.
+
+      .. attr:: nodes
+         :required:
+
+         The nodes that shall be part of the group. This is specified as a list
+         of strings.
 
 .. _semaphore:
 
@@ -986,20 +1202,27 @@
 
 Semaphores are never subject to dynamic reconfiguration.  If the value
 of a semaphore is changed, it will take effect only when the change
-where it is updated is merged.  An example follows::
+where it is updated is merged.  An example follows:
 
-  - semaphore:
-      name: semaphore-foo
-      max: 5
-  - semaphore:
-      name: semaphore-bar
-      max: 3
+.. code-block:: yaml
 
-The following attributes are available:
+   - semaphore:
+       name: semaphore-foo
+       max: 5
+   - semaphore:
+       name: semaphore-bar
+       max: 3
 
-**name** (required)
-  The name of the semaphore, referenced by jobs.
+.. attr:: semaphore
 
-**max**
-  The maximum number of running jobs which can use this semaphore.
-  Defaults to 1.
+   The following attributes are available:
+
+   .. attr:: name
+      :required:
+
+      The name of the semaphore, referenced by jobs.
+
+   .. attr:: max
+      :default: 1
+
+      The maximum number of running jobs which can use this semaphore.
diff --git a/doc/source/user/encryption.rst b/doc/source/user/encryption.rst
index fdf2c5a..7ced589 100644
--- a/doc/source/user/encryption.rst
+++ b/doc/source/user/encryption.rst
@@ -20,24 +20,41 @@
 the main Zuul configuration file.
 
 Zuul currently supports one encryption scheme, PKCS#1 with OAEP, which
-can not store secrets longer than the key length, 4096 bits.  The
-padding used by this scheme ensures that someone examining the
-encrypted data can not determine the length of the plaintext version
-of the data, except to know that it is not longer than 4096 bits.
+can not store secrets longer than the 3760 bits (derived from the key
+length of 4096 bits minus 336 bits of overhead).  The padding used by
+this scheme ensures that someone examining the encrypted data can not
+determine the length of the plaintext version of the data, except to
+know that it is not longer than 3760 bits (or some multiple thereof).
 
 In the config files themselves, Zuul uses an extensible method of
 specifying the encryption scheme used for a secret so that other
 schemes may be added later.  To specify a secret, use the
 ``!encrypted/pkcs1-oaep`` YAML tag along with the base64 encoded
-value.  For example::
+value.  For example:
+
+.. code-block:: yaml
 
   - secret:
       name: test_secret
       data:
         password: !encrypted/pkcs1-oaep |
-          BFhtdnm8uXx7kn79RFL/zJywmzLkT1GY78P3bOtp4WghUFWobkifSu7ZpaV4NeO0s71YUsi1wGZZ
+          BFhtdnm8uXx7kn79RFL/zJywmzLkT1GY78P3bOtp4WghUFWobkifSu7ZpaV4NeO0s71YUsi
           ...
 
+To support secrets longer than 3760 bits, the value after the
+encryption tag may be a list rather than a scalar.  For example:
+
+.. code-block:: yaml
+
+  - secret:
+      name: long_secret
+      data:
+        password: !encrypted/pkcs1-oaep
+          - er1UXNOD3OqtsRJaP0Wvaqiqx0ZY2zzRt6V9vqIsRaz1R5C4/AEtIad/DERZHwk3Nk+KV
+            ...
+          - HdWDS9lCBaBJnhMsm/O9tpzCq+GKRELpRzUwVgU5k822uBwhZemeSrUOLQ8hQ7q/vVHln
+            ...
+
 Zuul provides a standalone script to make encrypting values easy; it
 can be found at `tools/encrypt_secret.py` in the Zuul source
 directory.
diff --git a/doc/source/user/gating.rst b/doc/source/user/gating.rst
index 3398892..795df72 100644
--- a/doc/source/user/gating.rst
+++ b/doc/source/user/gating.rst
@@ -41,7 +41,7 @@
 developers to create changes at a rate faster than they can be tested
 and merged.
 
-Zuul's :ref:`dependent pipeline manager<dependent_pipeline_manager>`
+Zuul's :value:`dependent pipeline manager<pipeline.manager.dependent>`
 allows for parallel execution of test jobs for gating while ensuring
 changes are tested correctly, exactly as if they had been tested one
 at a time.  It does this by performing speculative execution of test
@@ -227,7 +227,8 @@
 
 A given dependent pipeline may have as many shared change queues as
 necessary, so groups of related projects may share a change queue
-without interfering with unrelated projects.  Independent pipelines do
+without interfering with unrelated projects.
+:value:`Independent pipelines <pipeline.manager.independent>` do
 not use shared change queues, however, they may still be used to test
 changes across projects using cross-project dependencies.
 
diff --git a/doc/source/user/index.rst b/doc/source/user/index.rst
index 3eca04b..8c7308b 100644
--- a/doc/source/user/index.rst
+++ b/doc/source/user/index.rst
@@ -4,7 +4,7 @@
 This guide is for all users of Zuul.  If you work on a project where
 Zuul is used to drive automation (whether that's testing proposed
 changes, building artifacts, or deploying builds), this guide will
-help you understand the concepts that underly Zuul, and how to
+help you understand the concepts that underlie Zuul, and how to
 configure it to meet your needs.
 
 
diff --git a/doc/source/user/jobs.rst b/doc/source/user/jobs.rst
index a367aa0..4e1880a 100644
--- a/doc/source/user/jobs.rst
+++ b/doc/source/user/jobs.rst
@@ -3,7 +3,7 @@
 Job Content
 ===========
 
-Zuul jobs are implemneted as Ansible playbooks.  Zuul prepares the
+Zuul jobs are implemented as Ansible playbooks.  Zuul prepares the
 repositories used for a job, installs any required Ansible roles, and
 then executes the job's playbooks.  Any setup or artifact collection
 required is the responsibility of the job itself.  While this flexible
@@ -73,23 +73,43 @@
 Variables
 ---------
 
-Any variables specified in the job definition are available as Ansible
-host variables.  They are added to the `vars` section of the inventory
-file under the `all` hosts group, so they are available to all hosts.
-Simply refer to them by the name specified in the job's `vars`
-section.
+There are several sources of variables which are available to Ansible:
+variables defined in jobs, secrets, and site-wide variables.  The
+order of precedence is:
+
+* Site-wide variables
+
+* Secrets
+
+* Job variables
+
+Meaning that a site-wide variable with the same name as any other will
+override its value, and similarly, secrets override job variables of
+the same name.  Each of the three sources is described below.
+
+
+Job Variables
+~~~~~~~~~~~~~
+
+Any variables specified in the job definition (using the
+:attr:`job.vars` attribute) are available as Ansible host variables.
+They are added to the ``vars`` section of the inventory file under the
+``all`` hosts group, so they are available to all hosts.  Simply refer
+to them by the name specified in the job's ``vars`` section.
 
 Secrets
 ~~~~~~~
 
-Secrets also appear as variables available to Ansible.  Unlike job
-variables, these are not added to the inventory file (so that the
-inventory file may be kept for debugging purposes without revealing
-secrets).  But they are still available to Ansible as normal
+:ref:`Secrets <secret>` also appear as variables available to Ansible.
+Unlike job variables, these are not added to the inventory file (so
+that the inventory file may be kept for debugging purposes without
+revealing secrets).  But they are still available to Ansible as normal
 variables.  Because secrets are groups of variables, they will appear
 as a dictionary structure in templates, with the dictionary itself
 being the name of the secret, and its members the individual items in
-the secret.  For example, a secret defined as::
+the secret.  For example, a secret defined as:
+
+.. code-block:: yaml
 
   - secret:
       name: credentials
@@ -101,13 +121,15 @@
 
  {{ credentials.username }} {{ credentials.password }}
 
-.. TODO: xref job vars
+Secrets are only available to playbooks associated with the job
+definition which uses the secret; they are not available to playbooks
+associated with child jobs or job variants.
 
 Zuul Variables
 ~~~~~~~~~~~~~~
 
 Zuul supplies not only the variables specified by the job definition
-to Ansible, but also some variables from the Zuul itself.
+to Ansible, but also some variables from Zuul itself.
 
 When a pipeline is triggered by an action, it enqueues items which may
 vary based on the pipeline's configuration.  For example, when a new
@@ -119,82 +141,136 @@
 attributes in common.  But other attributes may vary based on the type
 of item.
 
-All items provide the following information as Ansible variables:
+.. var:: zuul
 
-**zuul.build**
-  The UUID of the build.  A build is a single execution of a job.
-  When an item is enqueued into a pipeline, this usually results in
-  one build of each job configured for that item's project.  However,
-  items may be re-enqueued in which case another build may run.  In
-  dependent pipelines, the same job may run multiple times for the
-  same item as circumstances change ahead in the queue.  Each time a
-  job is run, for whatever reason, it is acompanied with a new
-  unique id.
+   All items provide the following information as Ansible variables
+   under the ``zuul`` key:
 
-**zuul.buildset**
-  The build set UUID.  When Zuul runs jobs for an item, the collection
-  of those jobs is known as a buildset.  If the configuration of items
-  ahead in a dependent pipeline changes, Zuul creates a new buildset
-  and restarts all of the jobs.
+   .. var:: build
 
-**zuul.ref**
-  The git ref of the item.  This will be the full path (e.g.,
-  'refs/heads/master' or 'refs/changes/...').
+      The UUID of the build.  A build is a single execution of a job.
+      When an item is enqueued into a pipeline, this usually results
+      in one build of each job configured for that item's project.
+      However, items may be re-enqueued in which case another build
+      may run.  In dependent pipelines, the same job may run multiple
+      times for the same item as circumstances change ahead in the
+      queue.  Each time a job is run, for whatever reason, it is
+      acompanied with a new unique id.
 
-**zuul.pipeline**
-  The name of the pipeline in which the job is being run.
+   .. var:: buildset
 
-**zuul.job**
-  The name of the job being run.
+      The build set UUID.  When Zuul runs jobs for an item, the
+      collection of those jobs is known as a buildset.  If the
+      configuration of items ahead in a dependent pipeline changes,
+      Zuul creates a new buildset and restarts all of the jobs.
 
-**zuul.project**
-  The item's project.  This is a data structure with the following
-  fields:
+   .. var:: ref
 
-**zuul.project.name**
-  The name of the project, excluding hostname.  E.g., `org/project`.
+      The git ref of the item.  This will be the full path (e.g.,
+      `refs/heads/master` or `refs/changes/...`).
 
-**zuul.project.canonical_hostname**
-  The canonical hostname where the project lives.  E.g.,
-  `git.example.com`.
+   .. var:: pipeline
 
-**zuul.project.canonical_name**
-  The full canonical name of the project including hostname.  E.g.,
-  `git.example.com/org/project`.
+      The name of the pipeline in which the job is being run.
 
-**zuul.tenant**
-  The name of the current Zuul tenant.
+   .. var:: job
 
-**zuul.jobtags**
-  A list of tags associated with the job.  Not to be confused with git
-  tags, these are simply free-form text fields that can be used by the
-  job for reporting or classification purposes.
+      The name of the job being run.
 
-**zuul.items**
+   .. var:: voting
 
-  A list of dictionaries, each representing an item being tested with
-  this change with the format:
+      A boolean indicating whether the job is voting.
 
-  **project.name**
-    The name of the project, excluding hostname.  E.g., `org/project`.
-  
-  **project.canonical_hostname**
-    The canonical hostname where the project lives.  E.g.,
-    `git.example.com`.
-  
-  **project.canonical_name**
-    The full canonical name of the project including hostname.  E.g.,
-    `git.example.com/org/project`.
-  
-  **branch**
-    The target branch of the change (without the `refs/heads/` prefix).
-  
-  **change**
-    The identifier for the change.
-  
-  **patchset**
-    The patchset identifier for the change.  If a change is revised,
-    this will have a different value.
+   .. var:: project
+
+      The item's project.  This is a data structure with the following
+      fields:
+
+      .. var:: name
+
+         The name of the project, excluding hostname.  E.g., `org/project`.
+
+      .. var:: short_name
+
+         The name of the project, excluding directories or
+         organizations.  E.g., `project`.
+
+      .. var:: canonical_hostname
+
+         The canonical hostname where the project lives.  E.g.,
+         `git.example.com`.
+
+      .. var:: canonical_name
+
+         The full canonical name of the project including hostname.
+         E.g., `git.example.com/org/project`.
+
+      .. var:: src_dir
+
+         The path to the source code on the remote host, relative
+         to the home dir of the remote user.
+         E.g., `src/git.example.com/org/project`.
+
+
+   .. var:: tenant
+
+      The name of the current Zuul tenant.
+
+   .. var:: jobtags
+
+      A list of tags associated with the job.  Not to be confused with
+      git tags, these are simply free-form text fields that can be
+      used by the job for reporting or classification purposes.
+
+   .. var:: items
+      :type: list
+
+      A list of dictionaries, each representing an item being tested
+      with this change with the format:
+
+      .. var:: project
+
+         The item's project.  This is a data structure with the
+         following fields:
+
+         .. var:: name
+
+            The name of the project, excluding hostname.  E.g.,
+            `org/project`.
+
+         .. var:: short_name
+
+            The name of the project, excluding directories or
+            organizations.  E.g., `project`.
+
+         .. var:: canonical_hostname
+
+            The canonical hostname where the project lives.  E.g.,
+            `git.example.com`.
+
+         .. var:: canonical_name
+
+            The full canonical name of the project including hostname.
+            E.g., `git.example.com/org/project`.
+
+         .. var:: src_dir
+
+            The path to the source code on the remote host, relative
+            to the home dir of the remote user.
+            E.g., `src/git.example.com/org/project`.
+
+      .. var:: branch
+
+         The target branch of the change (without the `refs/heads/` prefix).
+
+      .. var:: change
+
+         The identifier for the change.
+
+      .. var:: patchset
+
+         The patchset identifier for the change.  If a change is
+         revised, this will have a different value.
 
 Change Items
 ++++++++++++
@@ -204,15 +280,21 @@
 change or a GitHub pull request).  The following additional variables
 are available:
 
-**zuul.branch**
-  The target branch of the change (without the `refs/heads/` prefix).
+.. var:: zuul
+   :hidden:
 
-**zuul.change**
-  The identifier for the change.
+   .. var:: branch
 
-**zuul.patchset**
-  The patchset identifier for the change.  If a change is revised,
-  this will have a different value.
+      The target branch of the change (without the `refs/heads/` prefix).
+
+   .. var:: change
+
+      The identifier for the change.
+
+   .. var:: patchset
+
+      The patchset identifier for the change.  If a change is revised,
+      this will have a different value.
 
 Branch Items
 ++++++++++++
@@ -223,18 +305,25 @@
 of verifying the current condition of the branch.  The following
 additional variables are available:
 
-**zuul.branch**
-  The name of the item's branch (without the `refs/heads/` prefix).
+.. var:: zuul
+   :hidden:
 
-**zuul.oldrev**
-  If the item was enqueued as the result of a change merging or being
-  pushed to the branch, the git sha of the old revision will be
-  included here.  Otherwise, this variable will be undefined.
+   .. var:: branch
 
-**zuul.newrev**
-  If the item was enqueued as the result of a change merging or being
-  pushed to the branch, the git sha of the new revision will be
-  included here.  Otherwise, this variable will be undefined.
+      The name of the item's branch (without the `refs/heads/`
+      prefix).
+
+   .. var:: oldrev
+
+      If the item was enqueued as the result of a change merging or
+      being pushed to the branch, the git sha of the old revision will
+      be included here.  Otherwise, this variable will be undefined.
+
+   .. var:: newrev
+
+      If the item was enqueued as the result of a change merging or
+      being pushed to the branch, the git sha of the new revision will
+      be included here.  Otherwise, this variable will be undefined.
 
 Tag Items
 +++++++++
@@ -243,20 +332,24 @@
 tag was created or deleted.  The following additional variables are
 available:
 
-**zuul.tag**
-  The name of the item's tag (without the `refs/tags/` prefix).
+.. var:: zuul
+   :hidden:
 
-**zuul.oldrev**
-  If the item was enqueued as the result of a tag being deleted, the
-  previous git sha of the tag will be included here.  If the tag was
-  created, this will be set to the value
-  0000000000000000000000000000000000000000.
+   .. var:: tag
 
-**zuul.newrev**
-  If the item was enqueued as the result of a tag being created, the
-  new git sha of the tag will be included here.  If the tag was
-  deleted, this will be set to the value
-  0000000000000000000000000000000000000000.
+      The name of the item's tag (without the `refs/tags/` prefix).
+
+   .. var:: oldrev
+
+      If the item was enqueued as the result of a tag being deleted,
+      the previous git sha of the tag will be included here.  If the
+      tag was created, this variable will be undefined.
+
+   .. var:: newrev
+
+      If the item was enqueued as the result of a tag being created,
+      the new git sha of the tag will be included here.  If the tag
+      was deleted, this variable will be undefined.
 
 Ref Items
 +++++++++
@@ -266,17 +359,20 @@
 to identify the ref.  The following additional variables are
 available:
 
-**zuul.oldrev**
-  If the item was enqueued as the result of a ref being deleted, the
-  previous git sha of the ref will be included here.  If the ref was
-  created, this will be set to the value
-  0000000000000000000000000000000000000000.
+.. var:: zuul
+   :hidden:
 
-**zuul.newrev**
-  If the item was enqueued as the result of a ref being created, the
-  new git sha of the ref will be included here.  If the ref was
-  deleted, this will be set to the value
-  0000000000000000000000000000000000000000.
+   .. var:: oldrev
+
+      If the item was enqueued as the result of a ref being deleted,
+      the previous git sha of the ref will be included here.  If the
+      ref was created, this variable will be undefined.
+
+   .. var:: newrev
+
+      If the item was enqueued as the result of a ref being created,
+      the new git sha of the ref will be included here.  If the ref
+      was deleted, this variable will be undefined.
 
 Working Directory
 +++++++++++++++++
@@ -284,14 +380,41 @@
 Additionally, some information about the working directory and the
 executor running the job is available:
 
-**zuul.executor.hostname**
-  The hostname of the executor.
+.. var:: zuul
+   :hidden:
 
-**zuul.executor.src_root**
-  The path to the source directory.
+   .. var:: executor
 
-**zuul.executor.log_root**
-  The path to the logs directory.
+      A number of values related to the executor running the job are
+      available:
+
+      .. var:: hostname
+
+         The hostname of the executor.
+
+      .. var:: src_root
+
+         The path to the source directory.
+
+      .. var:: log_root
+
+         The path to the logs directory.
+
+      .. var:: work_root
+
+         The path to the working directory.
+
+.. _user_sitewide_variables:
+
+Site-wide Variables
+~~~~~~~~~~~~~~~~~~~
+
+The Zuul administrator may define variables which will be available to
+all jobs running in the system.  These are statically defined and may
+not be altered by jobs.  See the :ref:`Administrator's Guide
+<admin_sitewide_variables>` for information on how a site
+administrator may define these variables.
+
 
 SSH Keys
 --------
@@ -315,7 +438,9 @@
 
 The job may return some values to Zuul to affect its behavior.  To
 return a value, use the *zuul_return* Ansible module in a job
-playbook.  For example::
+playbook.  For example:
+
+.. code-block:: yaml
 
   tasks:
     - zuul_return:
@@ -328,7 +453,9 @@
 
 Several uses of these values are planned, but the only currently
 implemented use is to set the log URL for a build.  To do so, set the
-**zuul.log_url** value.  For example::
+**zuul.log_url** value.  For example:
+
+.. code-block:: yaml
 
   tasks:
     - zuul_return:
diff --git a/etc/layout.yaml-sample b/etc/layout.yaml-sample
index 53f6ba1..b84be11 100644
--- a/etc/layout.yaml-sample
+++ b/etc/layout.yaml-sample
@@ -6,10 +6,10 @@
         - event: patchset-created
     success:
       gerrit:
-        verified: 1
+        Verified: 1
     failure:
      gerrit:
-        verified: -1
+        Verified: -1
 
   - name: tests
     manager: IndependentPipelineManager
@@ -19,10 +19,10 @@
           email_filter: ^.*@example.org$
     success:
       gerrit:
-        verified: 1
+        Verified: 1
     failure:
       gerrit:
-        verified: -1
+        Verified: -1
 
   - name: post
     manager: IndependentPipelineManager
@@ -38,16 +38,16 @@
       gerrit:
         - event: comment-added
           approval:
-            - approved: 1
+            - Approved: 1
     start:
       gerrit:
-        verified: 0
+        Verified: 0
     success:
       gerrit:
-        verified: 1
+        Verified: 1
     failure:
       gerrit:
-        verified: -1
+        Verified: -1
 
 jobs:
   - name: ^.*-merge$
diff --git a/etc/zuul.conf-sample b/etc/zuul.conf-sample
index e6375a5..6e79f9b 100644
--- a/etc/zuul.conf-sample
+++ b/etc/zuul.conf-sample
@@ -23,12 +23,11 @@
 git_dir=/var/lib/zuul/git
 ;git_user_email=zuul@example.com
 ;git_user_name=zuul
-zuul_url=http://zuul.example.com/p
 
 [executor]
 default_username=zuul
-trusted_ro_dirs=/opt/zuul-scripts:/var/cache
-trusted_rw_dirs=/opt/zuul-logs
+trusted_ro_paths=/opt/zuul-scripts:/var/cache
+trusted_rw_paths=/opt/zuul-logs
 
 [web]
 listen_address=127.0.0.1
diff --git a/test-requirements.txt b/test-requirements.txt
index eea1d69..b444297 100644
--- a/test-requirements.txt
+++ b/test-requirements.txt
@@ -11,6 +11,8 @@
 testrepository>=0.0.17
 testtools>=0.9.32
 sphinxcontrib-programoutput
-oslosphinx
+sphinx-autodoc-typehints
 mock
 PyMySQL
+mypy
+zuul-sphinx
diff --git a/tests/base.py b/tests/base.py
index 2c478ad..480db83 100755
--- a/tests/base.py
+++ b/tests/base.py
@@ -16,6 +16,7 @@
 # under the License.
 
 import configparser
+from contextlib import contextmanager
 import datetime
 import gc
 import hashlib
@@ -133,12 +134,13 @@
 
 
 class FakeGerritChange(object):
-    categories = {'approved': ('Approved', -1, 1),
-                  'code-review': ('Code-Review', -2, 2),
-                  'verified': ('Verified', -2, 2)}
+    categories = {'Approved': ('Approved', -1, 1),
+                  'Code-Review': ('Code-Review', -2, 2),
+                  'Verified': ('Verified', -2, 2)}
 
     def __init__(self, gerrit, number, project, branch, subject,
-                 status='NEW', upstream_root=None, files={}):
+                 status='NEW', upstream_root=None, files={},
+                 parent=None):
         self.gerrit = gerrit
         self.source = gerrit
         self.reported = 0
@@ -173,16 +175,18 @@
             'url': 'https://hostname/%s' % number}
 
         self.upstream_root = upstream_root
-        self.addPatchset(files=files)
+        self.addPatchset(files=files, parent=parent)
         self.data['submitRecords'] = self.getSubmitRecords()
         self.open = status == 'NEW'
 
-    def addFakeChangeToRepo(self, msg, files, large):
+    def addFakeChangeToRepo(self, msg, files, large, parent):
         path = os.path.join(self.upstream_root, self.project)
         repo = git.Repo(path)
+        if parent is None:
+            parent = 'refs/tags/init'
         ref = GerritChangeReference.create(
             repo, '1/%s/%s' % (self.number, self.latest_patchset),
-            'refs/tags/init')
+            parent)
         repo.head.reference = ref
         zuul.merger.merger.reset_repo_to_head(repo)
         repo.git.clean('-x', '-f', '-d')
@@ -210,7 +214,7 @@
         repo.heads['master'].checkout()
         return r
 
-    def addPatchset(self, files=None, large=False):
+    def addPatchset(self, files=None, large=False, parent=None):
         self.latest_patchset += 1
         if not files:
             fn = '%s-%s' % (self.branch.replace('/', '_'), self.number)
@@ -218,7 +222,7 @@
                     (self.branch, self.number, self.latest_patchset))
             files = {fn: data}
         msg = self.subject + '-' + str(self.latest_patchset)
-        c = self.addFakeChangeToRepo(msg, files, large)
+        c = self.addFakeChangeToRepo(msg, files, large, parent)
         ps_files = [{'file': '/COMMIT_MSG',
                      'type': 'ADDED'},
                     {'file': 'README',
@@ -291,7 +295,7 @@
                             "url": "https://hostname/3"},
                  "patchSet": self.patchsets[patchset - 1],
                  "author": {"name": "User Name"},
-                 "approvals": [{"type": "code-review",
+                 "approvals": [{"type": "Code-Review",
                                 "description": "Code-Review",
                                 "value": "0"}],
                  "comment": "This is a comment"}
@@ -468,12 +472,12 @@
         self.upstream_root = upstream_root
 
     def addFakeChange(self, project, branch, subject, status='NEW',
-                      files=None):
+                      files=None, parent=None):
         """Add a change to the fake Gerrit."""
         self.change_number += 1
         c = FakeGerritChange(self, self.change_number, project, branch,
                              subject, upstream_root=self.upstream_root,
-                             status=status, files=files)
+                             status=status, files=files, parent=parent)
         self.changes[self.change_number] = c
         return c
 
@@ -550,6 +554,98 @@
     _points_to_commits_only = True
 
 
+class FakeGithub(object):
+
+    class FakeUser(object):
+        def __init__(self, login):
+            self.login = login
+            self.name = "Github User"
+            self.email = "github.user@example.com"
+
+    class FakeBranch(object):
+        def __init__(self, branch='master'):
+            self.name = branch
+
+    class FakeStatus(object):
+        def __init__(self, state, url, description, context, user):
+            self._state = state
+            self._url = url
+            self._description = description
+            self._context = context
+            self._user = user
+
+        def as_dict(self):
+            return {
+                'state': self._state,
+                'url': self._url,
+                'description': self._description,
+                'context': self._context,
+                'creator': {
+                    'login': self._user
+                }
+            }
+
+    class FakeCommit(object):
+        def __init__(self):
+            self._statuses = []
+
+        def set_status(self, state, url, description, context, user):
+            status = FakeGithub.FakeStatus(
+                state, url, description, context, user)
+            # always insert a status to the front of the list, to represent
+            # the last status provided for a commit.
+            self._statuses.insert(0, status)
+
+        def statuses(self):
+            return self._statuses
+
+    class FakeRepository(object):
+        def __init__(self):
+            self._branches = [FakeGithub.FakeBranch()]
+            self._commits = {}
+
+        def branches(self, protected=False):
+            if protected:
+                # simulate there is no protected branch
+                return []
+            return self._branches
+
+        def create_status(self, sha, state, url, description, context,
+                          user='zuul'):
+            # Since we're bypassing github API, which would require a user, we
+            # default the user as 'zuul' here.
+            commit = self._commits.get(sha, None)
+            if commit is None:
+                commit = FakeGithub.FakeCommit()
+                self._commits[sha] = commit
+            commit.set_status(state, url, description, context, user)
+
+        def commit(self, sha):
+            commit = self._commits.get(sha, None)
+            if commit is None:
+                commit = FakeGithub.FakeCommit()
+                self._commits[sha] = commit
+            return commit
+
+    def __init__(self):
+        self._repos = {}
+
+    def user(self, login):
+        return self.FakeUser(login)
+
+    def repository(self, owner, proj):
+        return self._repos.get((owner, proj), None)
+
+    def repo_from_project(self, project):
+        # This is a convenience method for the tests.
+        owner, proj = project.split('/')
+        return self.repository(owner, proj)
+
+    def addProject(self, project):
+        owner, proj = project.name.split('/')
+        self._repos[(owner, proj)] = self.FakeRepository()
+
+
 class FakeGithubPullRequest(object):
 
     def __init__(self, github, number, project, branch,
@@ -862,6 +958,13 @@
         }
         return (name, data)
 
+    def setMerged(self, commit_message):
+        self.is_merged = True
+        self.merge_message = commit_message
+
+        repo = self._getRepo()
+        repo.heads[self.branch].commit = repo.commit(self.head_sha)
+
 
 class FakeGithubConnection(githubconnection.GithubConnection):
     log = logging.getLogger("zuul.test.FakeGithubConnection")
@@ -878,6 +981,13 @@
         self.merge_failure = False
         self.merge_not_allowed_count = 0
         self.reports = []
+        self.github_client = FakeGithub()
+
+    def getGithubClient(self,
+                        project=None,
+                        user_id=None,
+                        use_app=True):
+        return self.github_client
 
     def openFakePullRequest(self, project, branch, subject, files=[],
                             body=None):
@@ -891,7 +1001,7 @@
     def getPushEvent(self, project, ref, old_rev=None, new_rev=None,
                      added_files=[], removed_files=[], modified_files=[]):
         if not old_rev:
-            old_rev = '00000000000000000000000000000000'
+            old_rev = '0' * 40
         if not new_rev:
             new_rev = random_sha1()
         name = 'push'
@@ -917,13 +1027,21 @@
         port = self.webapp.server.socket.getsockname()[1]
         name, data = event
         payload = json.dumps(data).encode('utf8')
-        headers = {'X-Github-Event': name}
+        secret = self.connection_config['webhook_token']
+        signature = githubconnection._sign_request(payload, secret)
+        headers = {'X-Github-Event': name, 'X-Hub-Signature': signature}
         req = urllib.request.Request(
             'http://localhost:%s/connection/%s/payload'
             % (port, self.connection_name),
             data=payload, headers=headers)
         return urllib.request.urlopen(req)
 
+    def addProject(self, project):
+        # use the original method here and additionally register it in the
+        # fake github
+        super(FakeGithubConnection, self).addProject(project)
+        self.getGithubClient(project).addProject(project)
+
     def getPull(self, project, number):
         pr = self.pull_requests[number - 1]
         data = {
@@ -962,14 +1080,6 @@
         pr = self.pull_requests[number - 1]
         return pr.reviews
 
-    def getUser(self, login):
-        data = {
-            'username': login,
-            'name': 'Github User',
-            'email': 'github.user@example.com'
-        }
-        return data
-
     def getRepoPermission(self, project, login):
         owner, proj = project.split('/')
         for pr in self.pull_requests:
@@ -986,12 +1096,6 @@
     def real_getGitUrl(self, project):
         return super(FakeGithubConnection, self).getGitUrl(project)
 
-    def getProjectBranches(self, project):
-        """Masks getProjectBranches since we don't have a real github"""
-
-        # just returns master for now
-        return ['master']
-
     def commentPull(self, project, pr_number, message):
         # record that this got reported
         self.reports.append((project, pr_number, 'comment'))
@@ -1008,30 +1112,15 @@
             self.merge_not_allowed_count -= 1
             raise MergeFailure('Merge was not successful due to mergeability'
                                ' conflict')
-        pull_request.is_merged = True
-        pull_request.merge_message = commit_message
-
-    def getCommitStatuses(self, project, sha):
-        return self.statuses.get(project, {}).get(sha, [])
+        pull_request.setMerged(commit_message)
 
     def setCommitStatus(self, project, sha, state, url='', description='',
                         context='default', user='zuul'):
-        # record that this got reported
+        # record that this got reported and call original method
         self.reports.append((project, sha, 'status', (user, context, state)))
-        # always insert a status to the front of the list, to represent
-        # the last status provided for a commit.
-        # Since we're bypassing github API, which would require a user, we
-        # default the user as 'zuul' here.
-        self.statuses.setdefault(project, {}).setdefault(sha, [])
-        self.statuses[project][sha].insert(0, {
-            'state': state,
-            'url': url,
-            'description': description,
-            'context': context,
-            'creator': {
-                'login': user
-            }
-        })
+        super(FakeGithubConnection, self).setCommitStatus(
+            project, sha, state,
+            url=url, description=description, context=context)
 
     def labelPull(self, project, pr_number, label):
         # record that this got reported
@@ -1121,9 +1210,9 @@
         self.node = None
         if len(self.parameters.get('nodes')) == 1:
             self.node = self.parameters['nodes'][0]['label']
-        self.unique = self.parameters['ZUUL_UUID']
-        self.pipeline = self.parameters['ZUUL_PIPELINE']
-        self.project = self.parameters['ZUUL_PROJECT']
+        self.unique = self.parameters['zuul']['build']
+        self.pipeline = self.parameters['zuul']['pipeline']
+        self.project = self.parameters['zuul']['project']['name']
         self.name = self.parameters['job']
         self.wait_condition = threading.Condition()
         self.waiting = False
@@ -1131,8 +1220,9 @@
         self.requeue = False
         self.created = time.time()
         self.changes = None
-        if 'ZUUL_CHANGE_IDS' in self.parameters:
-            self.changes = self.parameters['ZUUL_CHANGE_IDS']
+        items = self.parameters['zuul']['items']
+        self.changes = ' '.join(['%s,%s' % (x['change'], x['patchset'])
+                                for x in items if 'change' in x])
 
     def __repr__(self):
         waiting = ''
@@ -1180,7 +1270,7 @@
         self.log.debug("Build %s continuing" % self.unique)
 
         result = (RecordingAnsibleJob.RESULT_NORMAL, 0)  # Success
-        if (('ZUUL_REF' in self.parameters) and self.shouldFail()):
+        if self.shouldFail():
             result = (RecordingAnsibleJob.RESULT_NORMAL, 1)  # Failure
         if self.aborted:
             result = (RecordingAnsibleJob.RESULT_ABORTED, None)
@@ -1215,8 +1305,7 @@
             except NoSuchPathError as e:
                 self.log.debug('%s' % e)
                 return False
-            ref = self.parameters['ZUUL_REF']
-            repo_messages = [c.message.strip() for c in repo.iter_commits(ref)]
+            repo_messages = [c.message.strip() for c in repo.iter_commits()]
             commit_message = '%s-1' % change.subject
             self.log.debug("Checking if build %s has changes; commit_message "
                            "%s; repo_messages %s" % (self, commit_message,
@@ -1296,11 +1385,11 @@
         for build in builds:
             if not regex or re.match(regex, build.name):
                 self.log.debug("Releasing build %s" %
-                               (build.parameters['ZUUL_UUID']))
+                               (build.parameters['zuul']['build']))
                 build.release()
             else:
                 self.log.debug("Not releasing build %s" %
-                               (build.parameters['ZUUL_UUID']))
+                               (build.parameters['zuul']['build']))
         self.log.debug("Done releasing builds %s (%s)" %
                        (regex, len(self.running_builds)))
 
@@ -1348,7 +1437,7 @@
                          node=build.node, uuid=build.unique,
                          ref=build.parameters['zuul']['ref'],
                          parameters=build.parameters, jobdir=build.jobdir,
-                         pipeline=build.parameters['ZUUL_PIPELINE'])
+                         pipeline=build.parameters['zuul']['pipeline'])
         )
         self.executor_server.running_builds.remove(build)
         del self.executor_server.job_builds[self.job.unique]
@@ -1362,12 +1451,12 @@
         self.recordResult(result)
         return result
 
-    def runAnsible(self, cmd, timeout, config_file, trusted):
+    def runAnsible(self, cmd, timeout, playbook):
         build = self.executor_server.job_builds[self.job.unique]
 
         if self.executor_server._run_ansible:
             result = super(RecordingAnsibleJob, self).runAnsible(
-                cmd, timeout, config_file, trusted)
+                cmd, timeout, playbook)
         else:
             result = build.run()
         return result
@@ -1914,7 +2003,6 @@
                             self.config.get('scheduler', 'tenant_config')))
         self.config.set('scheduler', 'state_dir', self.state_root)
         self.config.set('merger', 'git_dir', self.merger_src_root)
-        self.config.set('merger', 'state_dir', self.merger_state_root)
         self.config.set('executor', 'git_dir', self.executor_src_root)
         self.config.set('executor', 'private_key_file', self.private_key_file)
         self.config.set('executor', 'state_dir', self.executor_state_root)
@@ -2077,7 +2165,7 @@
                         self.copyDirToRepo(project,
                                            os.path.join(git_path, reponame))
         # Make test_root persist after ansible run for .flag test
-        self.config.set('executor', 'trusted_rw_dirs', self.test_root)
+        self.config.set('executor', 'trusted_rw_paths', self.test_root)
         self.setupAllProjectKeys()
 
     def setupSimpleLayout(self):
@@ -2119,15 +2207,15 @@
         config = [{'tenant':
                    {'name': 'tenant-one',
                     'source': {driver:
-                               {'config-projects': ['common-config'],
+                               {'config-projects': ['org/common-config'],
                                 'untrusted-projects': untrusted_projects}}}}]
         f.write(yaml.dump(config).encode('utf8'))
         f.close()
         self.config.set('scheduler', 'tenant_config',
                         os.path.join(FIXTURE_DIR, f.name))
 
-        self.init_repo('common-config')
-        self.addCommitToRepo('common-config', 'add content from fixture',
+        self.init_repo('org/common-config')
+        self.addCommitToRepo('org/common-config', 'add content from fixture',
                              files, branch='master', tag='init')
 
         return True
@@ -2222,19 +2310,12 @@
         # Make sure no jobs are running
         self.assertEqual({}, self.executor_server.job_workers)
         # Make sure that git.Repo objects have been garbage collected.
-        repos = []
         gc.disable()
         gc.collect()
         for obj in gc.get_objects():
             if isinstance(obj, git.Repo):
                 self.log.debug("Leaked git repo object: 0x%x %s" %
                                (id(obj), repr(obj)))
-                for ref in gc.get_referrers(obj):
-                    self.log.debug("  Referrer %s" % (repr(ref)))
-                repos.append(obj)
-        if repos:
-            for obj in gc.garbage:
-                self.log.debug("  Garbage %s" % (repr(obj)))
         gc.enable()
         self.assertEmptyQueues()
         self.assertNodepoolState()
@@ -2511,7 +2592,7 @@
         for job in self.history:
             if (job.name == name and
                 (project is None or
-                 job.parameters['ZUUL_PROJECT'] == project)):
+                 job.parameters['zuul']['project']['name'] == project)):
                 return job
         raise Exception("Unable to find job %s in history" % name)
 
@@ -2769,6 +2850,24 @@
     """ZuulTestCase but with an actual ansible executor running"""
     run_ansible = True
 
+    @contextmanager
+    def jobLog(self, build):
+        """Print job logs on assertion errors
+
+        This method is a context manager which, if it encounters an
+        ecxeption, adds the build log to the debug output.
+
+        :arg Build build: The build that's being asserted.
+        """
+        try:
+            yield
+        except Exception:
+            path = os.path.join(self.test_root, build.uuid,
+                                'work', 'logs', 'job-output.txt')
+            with open(path) as f:
+                self.log.debug(f.read())
+            raise
+
 
 class SSLZuulTestCase(ZuulTestCase):
     """ZuulTestCase but using SSL when possible"""
diff --git a/tests/fixtures/config/ansible/git/common-config/playbooks/check-vars.yaml b/tests/fixtures/config/ansible/git/common-config/playbooks/check-vars.yaml
index ce392a4..f3ad414 100644
--- a/tests/fixtures/config/ansible/git/common-config/playbooks/check-vars.yaml
+++ b/tests/fixtures/config/ansible/git/common-config/playbooks/check-vars.yaml
@@ -13,6 +13,7 @@
           - zuul.executor.hostname is defined
           - zuul.executor.src_root is defined
           - zuul.executor.log_root is defined
+          - zuul.executor.work_root is defined
 
     - name: Assert zuul.project variables are valid.
       assert:
@@ -20,3 +21,14 @@
           - zuul.project.name == 'org/project'
           - zuul.project.canonical_hostname == 'review.example.com'
           - zuul.project.canonical_name == 'review.example.com/org/project'
+          - zuul.project.src_dir == 'src/review.example.com/org/project'
+
+    - debug:
+        msg: "vartest secret {{ vartest_secret }}"
+
+    - name: Assert variable precedence.
+      assert:
+        that:
+          - vartest_job == 'vartest_job'
+          - vartest_secret.value == 'vartest_secret'
+          - vartest_site == 'vartest_site'
diff --git a/tests/fixtures/config/ansible/git/common-config/zuul.yaml b/tests/fixtures/config/ansible/git/common-config/zuul.yaml
index 1a1b22f..d90f5e2 100644
--- a/tests/fixtures/config/ansible/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/ansible/git/common-config/zuul.yaml
@@ -1,16 +1,16 @@
 - pipeline:
     name: check
     manager: independent
-    allow-secrets: true
+    post-review: true
     trigger:
       gerrit:
         - event: patchset-created
     success:
       gerrit:
-        verified: 1
+        Verified: 1
     failure:
       gerrit:
-        verified: -1
+        Verified: -1
 
 - pipeline:
     name: gate
@@ -20,17 +20,17 @@
       gerrit:
         - event: comment-added
           approval:
-            - approved: 1
+            - Approved: 1
     success:
       gerrit:
-        verified: 2
+        Verified: 2
         submit: true
     failure:
       gerrit:
-        verified: -2
+        Verified: -2
     start:
       gerrit:
-        verified: 0
+        Verified: 0
     precedence: high
 
 - secret:
@@ -48,6 +48,31 @@
         Z3QSO1NjbBxWnaHKZYT7nkrJm8AMCgZU0ZArFLpaufKCeiK5ECSsDxic4FIsY1OkWT42qEUfL0Wd
         +150AKGNZpPJnnP3QYY4W/MWcKH/zdO400+zWN52WevbSqZy90tqKDJrBkMl1ydqbuw1E4ZHvIs=
 
+# This is used by the check-vars job to evaluate variable precedence.
+# The name of this secret conflicts with a site variable.
+- secret:
+    name: vartest_site
+    data:
+      value: vartest_secret
+
+# This is used by the check-vars job to evaluate variable precedence.
+# The name of this secret conflicts with a job variable.
+- secret:
+    name: vartest_job
+    data:
+      value: vartest_secret
+
+# This is used by the check-vars job to evaluate variable precedence.
+# The name of this secret should not conflict.
+- secret:
+    name: vartest_secret
+    data:
+      value: vartest_secret
+
+- job:
+    name: base
+    parent: null
+
 - job:
     name: base-urls
     success-url: https://success.example.com/zuul-logs/{build.uuid}/
@@ -62,9 +87,8 @@
       flagpath: '{{zuul._test.test_root}}/{{zuul.build}}.flag'
     roles:
       - zuul: bare-role
-    auth:
-      secrets:
-        - test_secret
+    secrets:
+      - test_secret
 
 - job:
     parent: python27
@@ -77,6 +101,13 @@
     nodes:
       - name: ubuntu-xenial
         label: ubuntu-xenial
+    vars:
+      vartest_job: vartest_job
+      vartest_secret: vartest_job
+      vartest_site: vartest_job
+    secrets:
+      - vartest_site
+      - vartest_secret
 
 - job:
     parent: base-urls
diff --git a/tests/fixtures/config/ansible/git/org_plugin-project/.zuul.yaml b/tests/fixtures/config/ansible/git/org_plugin-project/.zuul.yaml
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/tests/fixtures/config/ansible/git/org_plugin-project/.zuul.yaml
diff --git a/tests/fixtures/config/ansible/git/org_plugin-project/README b/tests/fixtures/config/ansible/git/org_plugin-project/README
new file mode 100644
index 0000000..9daeafb
--- /dev/null
+++ b/tests/fixtures/config/ansible/git/org_plugin-project/README
@@ -0,0 +1 @@
+test
diff --git a/tests/fixtures/config/ansible/git/org_plugin-project/playbooks/cartesian.yaml b/tests/fixtures/config/ansible/git/org_plugin-project/playbooks/cartesian.yaml
new file mode 100644
index 0000000..a2e92a2
--- /dev/null
+++ b/tests/fixtures/config/ansible/git/org_plugin-project/playbooks/cartesian.yaml
@@ -0,0 +1,5 @@
+- hosts: all
+  vars:
+    value: "{{ lookup('cartesian', [1, 2], [3, 4]) }}"
+  tasks:
+    - debug: msg="value is {{ value }}"
diff --git a/tests/fixtures/config/ansible/git/org_plugin-project/playbooks/consul_kv.yaml b/tests/fixtures/config/ansible/git/org_plugin-project/playbooks/consul_kv.yaml
new file mode 100644
index 0000000..8cfee2e
--- /dev/null
+++ b/tests/fixtures/config/ansible/git/org_plugin-project/playbooks/consul_kv.yaml
@@ -0,0 +1,5 @@
+- hosts: all
+  tasks:
+    - debug: msg='key contains {{item}}'
+      with_consul_kv:
+        - 'key/to/retrieve'
diff --git a/tests/fixtures/config/ansible/git/org_plugin-project/playbooks/credstash.yaml b/tests/fixtures/config/ansible/git/org_plugin-project/playbooks/credstash.yaml
new file mode 100644
index 0000000..1a59281
--- /dev/null
+++ b/tests/fixtures/config/ansible/git/org_plugin-project/playbooks/credstash.yaml
@@ -0,0 +1,5 @@
+- hosts: all
+  tasks:
+    - debug: msg='key contains {{item}}'
+      with_credstash:
+        - 'key'
diff --git a/tests/fixtures/config/ansible/git/org_plugin-project/playbooks/csvfile_bad.yaml b/tests/fixtures/config/ansible/git/org_plugin-project/playbooks/csvfile_bad.yaml
new file mode 100644
index 0000000..66e1d84
--- /dev/null
+++ b/tests/fixtures/config/ansible/git/org_plugin-project/playbooks/csvfile_bad.yaml
@@ -0,0 +1,5 @@
+- hosts: all
+  vars:
+    value: "{{ lookup('csvfile', 'a file=/etc/passwd') }}"
+  tasks:
+    - debug: msg="value is {{ value }}"
diff --git a/tests/fixtures/config/ansible/git/org_plugin-project/playbooks/csvfile_good.yaml b/tests/fixtures/config/ansible/git/org_plugin-project/playbooks/csvfile_good.yaml
new file mode 100644
index 0000000..74ef51e
--- /dev/null
+++ b/tests/fixtures/config/ansible/git/org_plugin-project/playbooks/csvfile_good.yaml
@@ -0,0 +1,5 @@
+- hosts: all
+  vars:
+    value: "{{ lookup('csvfile', 'a file=test.csv delimiter=,') }}"
+  tasks:
+    - debug: msg="value is {{ value }}"
diff --git a/tests/fixtures/config/ansible/git/org_plugin-project/playbooks/passwd.yaml b/tests/fixtures/config/ansible/git/org_plugin-project/playbooks/passwd.yaml
new file mode 100644
index 0000000..cc74802
--- /dev/null
+++ b/tests/fixtures/config/ansible/git/org_plugin-project/playbooks/passwd.yaml
@@ -0,0 +1,5 @@
+- hosts: all
+  vars:
+    value: "{{ lookup('file', '/etc/passwd') }}"
+  tasks:
+    - debug: msg="value is {{ value }}"
diff --git a/tests/fixtures/config/ansible/git/org_plugin-project/playbooks/test.csv b/tests/fixtures/config/ansible/git/org_plugin-project/playbooks/test.csv
new file mode 100644
index 0000000..b2ffb02
--- /dev/null
+++ b/tests/fixtures/config/ansible/git/org_plugin-project/playbooks/test.csv
@@ -0,0 +1 @@
+a,b,c
diff --git a/tests/fixtures/config/ansible/git/org_plugin-project/playbooks/uri_bad_path.yaml b/tests/fixtures/config/ansible/git/org_plugin-project/playbooks/uri_bad_path.yaml
new file mode 100644
index 0000000..523aab7
--- /dev/null
+++ b/tests/fixtures/config/ansible/git/org_plugin-project/playbooks/uri_bad_path.yaml
@@ -0,0 +1,6 @@
+- hosts: localhost
+  tasks:
+    - uri:
+        method: GET
+        url: https://example.com
+        path: /tmp/example.out
diff --git a/tests/fixtures/config/ansible/git/org_plugin-project/playbooks/uri_bad_scheme.yaml b/tests/fixtures/config/ansible/git/org_plugin-project/playbooks/uri_bad_scheme.yaml
new file mode 100644
index 0000000..5d71793
--- /dev/null
+++ b/tests/fixtures/config/ansible/git/org_plugin-project/playbooks/uri_bad_scheme.yaml
@@ -0,0 +1,5 @@
+- hosts: localhost
+  tasks:
+    - uri:
+        method: GET
+        url: file:///etc/passwd
diff --git a/tests/fixtures/config/ansible/main.yaml b/tests/fixtures/config/ansible/main.yaml
index 9ccece9..a4e32c2 100644
--- a/tests/fixtures/config/ansible/main.yaml
+++ b/tests/fixtures/config/ansible/main.yaml
@@ -6,4 +6,5 @@
           - common-config
         untrusted-projects:
           - org/project
+          - org/plugin-project
           - bare-role
diff --git a/tests/fixtures/config/ansible/variables.yaml b/tests/fixtures/config/ansible/variables.yaml
new file mode 100644
index 0000000..692eb7f
--- /dev/null
+++ b/tests/fixtures/config/ansible/variables.yaml
@@ -0,0 +1 @@
+vartest_site: vartest_site
diff --git a/tests/fixtures/config/base-jobs/git/common-config/zuul.yaml b/tests/fixtures/config/base-jobs/git/common-config/zuul.yaml
new file mode 100644
index 0000000..4cef200
--- /dev/null
+++ b/tests/fixtures/config/base-jobs/git/common-config/zuul.yaml
@@ -0,0 +1,24 @@
+- pipeline:
+    name: check
+    manager: independent
+    trigger:
+      gerrit:
+        - event: patchset-created
+    success:
+      gerrit:
+        Verified: 1
+    failure:
+      gerrit:
+        Verified: -1
+
+- job:
+    name: my-base
+    parent: null
+    tags:
+      - mybase
+
+- job:
+    name: other-base
+    parent: null
+    tags:
+      - otherbase
diff --git a/tests/fixtures/config/base-jobs/git/org_project/.zuul.yaml b/tests/fixtures/config/base-jobs/git/org_project/.zuul.yaml
new file mode 100644
index 0000000..9844c14
--- /dev/null
+++ b/tests/fixtures/config/base-jobs/git/org_project/.zuul.yaml
@@ -0,0 +1,13 @@
+- job:
+    name: my-job
+
+- job:
+    name: other-job
+    parent: other-base
+
+- project:
+    name: org/project
+    check:
+      jobs:
+        - my-job
+        - other-job
diff --git a/tests/fixtures/config/base-jobs/git/org_project/README b/tests/fixtures/config/base-jobs/git/org_project/README
new file mode 100644
index 0000000..9daeafb
--- /dev/null
+++ b/tests/fixtures/config/base-jobs/git/org_project/README
@@ -0,0 +1 @@
+test
diff --git a/tests/fixtures/config/push-reqs/git/common-config/playbooks/job1.yaml b/tests/fixtures/config/base-jobs/git/org_project/playbooks/my-job.yaml
similarity index 100%
copy from tests/fixtures/config/push-reqs/git/common-config/playbooks/job1.yaml
copy to tests/fixtures/config/base-jobs/git/org_project/playbooks/my-job.yaml
diff --git a/tests/fixtures/config/push-reqs/git/common-config/playbooks/job1.yaml b/tests/fixtures/config/base-jobs/git/org_project/playbooks/other-job.yaml
similarity index 100%
copy from tests/fixtures/config/push-reqs/git/common-config/playbooks/job1.yaml
copy to tests/fixtures/config/base-jobs/git/org_project/playbooks/other-job.yaml
diff --git a/tests/fixtures/config/base-jobs/main.yaml b/tests/fixtures/config/base-jobs/main.yaml
new file mode 100644
index 0000000..3ab6dca
--- /dev/null
+++ b/tests/fixtures/config/base-jobs/main.yaml
@@ -0,0 +1,9 @@
+- tenant:
+    name: tenant-one
+    default-parent: my-base
+    source:
+      gerrit:
+        config-projects:
+          - common-config
+        untrusted-projects:
+          - org/project
diff --git a/tests/fixtures/config/broken/git/common-config/zuul.yaml b/tests/fixtures/config/broken/git/common-config/zuul.yaml
index 162a982..91b1b2d 100644
--- a/tests/fixtures/config/broken/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/broken/git/common-config/zuul.yaml
@@ -6,10 +6,10 @@
         - event: patchset-created
     success:
       gerrit:
-        verified: 1
+        Verified: 1
     failure:
       gerrit:
-        verified: -1
+        Verified: -1
 
 - project:
 error: true
diff --git a/tests/fixtures/config/conflict-config/git/common-config/zuul.yaml b/tests/fixtures/config/conflict-config/git/common-config/zuul.yaml
index 792fc8f..469dd7e 100644
--- a/tests/fixtures/config/conflict-config/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/conflict-config/git/common-config/zuul.yaml
@@ -1,2 +1,6 @@
 - job:
+    name: base
+    parent: null
+
+- job:
     name: trusted-zuul.yaml-job
diff --git a/tests/fixtures/config/data-return/git/common-config/zuul.yaml b/tests/fixtures/config/data-return/git/common-config/zuul.yaml
index 8d602f1..906dc5b 100644
--- a/tests/fixtures/config/data-return/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/data-return/git/common-config/zuul.yaml
@@ -1,16 +1,20 @@
 - pipeline:
     name: check
     manager: independent
-    allow-secrets: true
+    post-review: true
     trigger:
       gerrit:
         - event: patchset-created
     success:
       gerrit:
-        verified: 1
+        Verified: 1
     failure:
       gerrit:
-        verified: -1
+        Verified: -1
+
+- job:
+    name: base
+    parent: null
 
 - job:
     name: data-return
diff --git a/tests/fixtures/config/dependency-graph/git/common-config/zuul.yaml b/tests/fixtures/config/dependency-graph/git/common-config/zuul.yaml
index cdf989e..4179226 100644
--- a/tests/fixtures/config/dependency-graph/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/dependency-graph/git/common-config/zuul.yaml
@@ -6,20 +6,24 @@
       gerrit:
         - event: comment-added
           approval:
-            - approved: 1
+            - Approved: 1
     success:
       gerrit:
-        verified: 2
+        Verified: 2
         submit: true
     failure:
       gerrit:
-        verified: -2
+        Verified: -2
     start:
       gerrit:
-        verified: 0
+        Verified: 0
     precedence: high
 
 - job:
+    name: base
+    parent: null
+
+- job:
     name: A
 
 - job:
diff --git a/tests/fixtures/config/disk-accountant/git/common-config/playbooks/dd-big-empty-file.yaml b/tests/fixtures/config/disk-accountant/git/common-config/playbooks/dd-big-empty-file.yaml
new file mode 100644
index 0000000..95ab870
--- /dev/null
+++ b/tests/fixtures/config/disk-accountant/git/common-config/playbooks/dd-big-empty-file.yaml
@@ -0,0 +1,6 @@
+- hosts: localhost
+  tasks:
+    - command: dd if=/dev/zero of=toobig bs=1M count=2
+    - wait_for:
+        delay: 10
+        path: /
diff --git a/tests/fixtures/config/disk-accountant/git/common-config/zuul.yaml b/tests/fixtures/config/disk-accountant/git/common-config/zuul.yaml
new file mode 100644
index 0000000..893ea05
--- /dev/null
+++ b/tests/fixtures/config/disk-accountant/git/common-config/zuul.yaml
@@ -0,0 +1,26 @@
+- pipeline:
+    name: check
+    manager: independent
+    post-review: true
+    trigger:
+      gerrit:
+        - event: patchset-created
+    success:
+      gerrit:
+        verified: 1
+    failure:
+      gerrit:
+        verified: -1
+
+- job:
+    name: base
+    parent: null
+
+- job:
+    name: dd-big-empty-file
+
+- project:
+    name: org/project
+    check:
+      jobs:
+        - dd-big-empty-file
diff --git a/tests/fixtures/config/disk-accountant/git/org_project/README b/tests/fixtures/config/disk-accountant/git/org_project/README
new file mode 100644
index 0000000..9daeafb
--- /dev/null
+++ b/tests/fixtures/config/disk-accountant/git/org_project/README
@@ -0,0 +1 @@
+test
diff --git a/tests/fixtures/config/disk-accountant/main.yaml b/tests/fixtures/config/disk-accountant/main.yaml
new file mode 100644
index 0000000..208e274
--- /dev/null
+++ b/tests/fixtures/config/disk-accountant/main.yaml
@@ -0,0 +1,8 @@
+- tenant:
+    name: tenant-one
+    source:
+      gerrit:
+        config-projects:
+          - common-config
+        untrusted-projects:
+          - org/project
diff --git a/tests/fixtures/config/duplicate-pipeline/git/common-config/zuul.yaml b/tests/fixtures/config/duplicate-pipeline/git/common-config/zuul.yaml
index 60d7363..117e381 100755
--- a/tests/fixtures/config/duplicate-pipeline/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/duplicate-pipeline/git/common-config/zuul.yaml
@@ -7,10 +7,10 @@
         - event: change-restored
     success:
       gerrit:
-        verified: 1
+        Verified: 1
     failure:
       gerrit:
-        verified: -1
+        Verified: -1
 
 - pipeline:
     name: dup2
@@ -21,10 +21,14 @@
         - event: change-restored
     success:
       gerrit:
-        verified: 1
+        Verified: 1
     failure:
       gerrit:
-        verified: -1
+        Verified: -1
+
+- job:
+    name: base
+    parent: null
 
 - job:
     name: project-test1
diff --git a/tests/fixtures/config/push-reqs/git/common-config/playbooks/job1.yaml b/tests/fixtures/config/final/git/common-config/playbooks/job-final.yaml
similarity index 100%
copy from tests/fixtures/config/push-reqs/git/common-config/playbooks/job1.yaml
copy to tests/fixtures/config/final/git/common-config/playbooks/job-final.yaml
diff --git a/tests/fixtures/config/final/git/common-config/zuul.yaml b/tests/fixtures/config/final/git/common-config/zuul.yaml
new file mode 100644
index 0000000..f08d66e
--- /dev/null
+++ b/tests/fixtures/config/final/git/common-config/zuul.yaml
@@ -0,0 +1,28 @@
+- pipeline:
+    name: check
+    manager: independent
+    trigger:
+      gerrit:
+        - event: patchset-created
+    success:
+      gerrit:
+        Verified: 1
+    failure:
+      gerrit:
+        Verified: -1
+
+- job:
+    name: base
+    parent: null
+
+- job:
+    name: job-final
+    final: true
+    vars:
+      dont_override_this: dummy
+
+- project:
+    name: org/project
+    check:
+      jobs: []
+
diff --git a/tests/fixtures/config/final/git/org_project/README b/tests/fixtures/config/final/git/org_project/README
new file mode 100644
index 0000000..9daeafb
--- /dev/null
+++ b/tests/fixtures/config/final/git/org_project/README
@@ -0,0 +1 @@
+test
diff --git a/tests/fixtures/config/final/git/org_project/playbooks/placeholder b/tests/fixtures/config/final/git/org_project/playbooks/placeholder
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/tests/fixtures/config/final/git/org_project/playbooks/placeholder
diff --git a/tests/fixtures/config/final/main.yaml b/tests/fixtures/config/final/main.yaml
new file mode 100644
index 0000000..208e274
--- /dev/null
+++ b/tests/fixtures/config/final/main.yaml
@@ -0,0 +1,8 @@
+- tenant:
+    name: tenant-one
+    source:
+      gerrit:
+        config-projects:
+          - common-config
+        untrusted-projects:
+          - org/project
diff --git a/tests/fixtures/config/git-driver/git/common-config/zuul.yaml b/tests/fixtures/config/git-driver/git/common-config/zuul.yaml
index 8fe8749..34d1136 100644
--- a/tests/fixtures/config/git-driver/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/git-driver/git/common-config/zuul.yaml
@@ -6,10 +6,14 @@
         - event: patchset-created
     success:
       gerrit:
-        verified: 1
+        Verified: 1
     failure:
       gerrit:
-        verified: -1
+        Verified: -1
+
+- job:
+    name: base
+    parent: null
 
 - job:
     name: project-test1
diff --git a/tests/fixtures/config/implicit-roles/git/common-config/zuul.yaml b/tests/fixtures/config/implicit-roles/git/common-config/zuul.yaml
index ba91fb5..c941573 100644
--- a/tests/fixtures/config/implicit-roles/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/implicit-roles/git/common-config/zuul.yaml
@@ -6,7 +6,11 @@
         - event: patchset-created
     success:
       gerrit:
-        verified: 1
+        Verified: 1
     failure:
       gerrit:
-        verified: -1
+        Verified: -1
+
+- job:
+    name: base
+    parent: null
diff --git a/tests/fixtures/config/in-repo/git/common-config/zuul.yaml b/tests/fixtures/config/in-repo/git/common-config/zuul.yaml
index fce086e..ff4268b 100644
--- a/tests/fixtures/config/in-repo/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/in-repo/git/common-config/zuul.yaml
@@ -6,10 +6,10 @@
         - event: patchset-created
     success:
       gerrit:
-        verified: 1
+        Verified: 1
     failure:
       gerrit:
-        verified: -1
+        Verified: -1
 
 - pipeline:
     name: tenant-one-gate
@@ -19,17 +19,17 @@
       gerrit:
         - event: comment-added
           approval:
-            - approved: 1
+            - Approved: 1
     success:
       gerrit:
-        verified: 2
+        Verified: 2
         submit: true
     failure:
       gerrit:
-        verified: -2
+        Verified: -2
     start:
       gerrit:
-        verified: 0
+        Verified: 0
     precedence: high
 
 - pipeline:
@@ -39,19 +39,40 @@
       gerrit:
         - event: comment-added
           approval:
-            - code-review: 2
+            - Code-Review: 2
     success:
       gerrit:
-        verified: 2
+        Verified: 2
         submit: true
     failure:
       gerrit:
-        verified: -2
+        Verified: -2
     start:
       gerrit:
-        verified: 0
+        Verified: 0
     precedence: high
 
+# This pipeline is there to ensure that dynamic pipeline copy operations also
+# work with regex approval filters.
+- pipeline:
+    name: pipeline-with-regex
+    manager: independent
+    require:
+      gerrit:
+        approval:
+          - Code-Review: 2
+            username: maintainer
+    require:
+      github:
+        review:
+          - username: '^(herp|derp)$'
+            type: approved
+    trigger: {}
+
+- job:
+    name: base
+    parent: null
+
 - job:
     name: common-config-test
 
diff --git a/tests/fixtures/config/inventory/git/common-config/zuul.yaml b/tests/fixtures/config/inventory/git/common-config/zuul.yaml
index e147b98..7809c5d 100644
--- a/tests/fixtures/config/inventory/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/inventory/git/common-config/zuul.yaml
@@ -1,16 +1,16 @@
 - pipeline:
     name: check
     manager: independent
-    allow-secrets: true
+    post-review: true
     trigger:
       gerrit:
         - event: patchset-created
     success:
       gerrit:
-        verified: 1
+        Verified: 1
     failure:
       gerrit:
-        verified: -1
+        Verified: -1
 
 - nodeset:
     name: nodeset1
@@ -32,6 +32,10 @@
           - compute2
 
 - job:
+    name: base
+    parent: null
+
+- job:
     name: single-inventory
     nodes:
       - name: ubuntu-xenial
diff --git a/tests/fixtures/config/merges/git/common-config/zuul.yaml b/tests/fixtures/config/merges/git/common-config/zuul.yaml
index 1309b3f..1ea5048 100644
--- a/tests/fixtures/config/merges/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/merges/git/common-config/zuul.yaml
@@ -6,10 +6,10 @@
         - event: patchset-created
     success:
       gerrit:
-        verified: 1
+        Verified: 1
     failure:
       gerrit:
-        verified: -1
+        Verified: -1
 
 - pipeline:
     name: gate
@@ -19,20 +19,24 @@
       gerrit:
         - event: comment-added
           approval:
-            - approved: 1
+            - Approved: 1
     success:
       gerrit:
-        verified: 2
+        Verified: 2
         submit: true
     failure:
       gerrit:
-        verified: -2
+        Verified: -2
     start:
       gerrit:
-        verified: 0
+        Verified: 0
     precedence: high
 
 - job:
+    name: base
+    parent: null
+
+- job:
     name: project-test1
 
 - job:
diff --git a/tests/fixtures/config/multi-driver/git/common-config/playbooks/project-gerrit.yaml b/tests/fixtures/config/multi-driver/git/org_common-config/playbooks/project-gerrit.yaml
similarity index 100%
rename from tests/fixtures/config/multi-driver/git/common-config/playbooks/project-gerrit.yaml
rename to tests/fixtures/config/multi-driver/git/org_common-config/playbooks/project-gerrit.yaml
diff --git a/tests/fixtures/config/multi-driver/git/common-config/playbooks/project1-github.yaml b/tests/fixtures/config/multi-driver/git/org_common-config/playbooks/project1-github.yaml
similarity index 100%
rename from tests/fixtures/config/multi-driver/git/common-config/playbooks/project1-github.yaml
rename to tests/fixtures/config/multi-driver/git/org_common-config/playbooks/project1-github.yaml
diff --git a/tests/fixtures/config/multi-driver/git/common-config/zuul.yaml b/tests/fixtures/config/multi-driver/git/org_common-config/zuul.yaml
similarity index 74%
rename from tests/fixtures/config/multi-driver/git/common-config/zuul.yaml
rename to tests/fixtures/config/multi-driver/git/org_common-config/zuul.yaml
index 2dab845..7a5c190 100644
--- a/tests/fixtures/config/multi-driver/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/multi-driver/git/org_common-config/zuul.yaml
@@ -1,5 +1,5 @@
 - pipeline:
-    name: check_github
+    name: check
     manager: independent
     trigger:
       github:
@@ -8,39 +8,42 @@
             - opened
             - changed
             - reopened
-    success:
-      github:
-        status: 'success'
-    failure:
-      github:
-        status: 'failure'
-
-- pipeline:
-    name: check_gerrit
-    manager: independent
-    trigger:
       gerrit:
         - event: patchset-created
     success:
+      github:
+        status: 'success'
       gerrit:
-        verify: 1
+        Verified: 1
     failure:
+      github:
+        status: 'failure'
       gerrit:
-        verify: 1
+        Verified: 1
+    start:
+      github:
+        comment: true
+      gerrit:
+        Verified: 0
+
+- job:
+    name: base
+    parent: null
 
 - job:
     name: project-gerrit
+
 - job:
     name: project1-github
 
 - project:
     name: org/project
-    check_gerrit:
+    check:
       jobs:
         - project-gerrit
 
 - project:
     name: org/project1
-    check_github:
+    check:
       jobs:
         - project1-github
diff --git a/tests/fixtures/config/multi-driver/main.yaml b/tests/fixtures/config/multi-driver/main.yaml
index 301df38..4eed523 100644
--- a/tests/fixtures/config/multi-driver/main.yaml
+++ b/tests/fixtures/config/multi-driver/main.yaml
@@ -3,7 +3,7 @@
     source:
       github:
         config-projects:
-          - common-config
+          - org/common-config
         untrusted-projects:
           - org/project1
       gerrit:
diff --git a/tests/fixtures/config/multi-tenant-semaphore/git/common-config/zuul.yaml b/tests/fixtures/config/multi-tenant-semaphore/git/common-config/zuul.yaml
index ba91fb5..4abe532 100644
--- a/tests/fixtures/config/multi-tenant-semaphore/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/multi-tenant-semaphore/git/common-config/zuul.yaml
@@ -6,7 +6,11 @@
         - event: patchset-created
     success:
       gerrit:
-        verified: 1
+        Verified: 1
     failure:
       gerrit:
-        verified: -1
+        Verified: -1
+
+- job:
+    name: base
+    parent: null
\ No newline at end of file
diff --git a/tests/fixtures/config/multi-tenant/git/common-config/zuul.yaml b/tests/fixtures/config/multi-tenant/git/common-config/zuul.yaml
index 362434e..27f2fd5 100644
--- a/tests/fixtures/config/multi-tenant/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/multi-tenant/git/common-config/zuul.yaml
@@ -6,10 +6,14 @@
         - event: patchset-created
     success:
       gerrit:
-        verified: 1
+        Verified: 1
     failure:
       gerrit:
-        verified: -1
+        Verified: -1
+
+- job:
+    name: base
+    parent: null
 
 - job:
     name: python27
diff --git a/tests/fixtures/config/multi-tenant/git/tenant-one-config/zuul.yaml b/tests/fixtures/config/multi-tenant/git/tenant-one-config/zuul.yaml
index 347bc53..9a1b928 100644
--- a/tests/fixtures/config/multi-tenant/git/tenant-one-config/zuul.yaml
+++ b/tests/fixtures/config/multi-tenant/git/tenant-one-config/zuul.yaml
@@ -6,17 +6,17 @@
       gerrit:
         - event: comment-added
           approval:
-            - approved: 1
+            - Approved: 1
     success:
       gerrit:
-        verified: 2
+        Verified: 2
         submit: true
     failure:
       gerrit:
-        verified: -2
+        Verified: -2
     start:
       gerrit:
-        verified: 0
+        Verified: 0
     precedence: high
 
 - nodeset:
diff --git a/tests/fixtures/config/multi-tenant/git/tenant-two-config/zuul.yaml b/tests/fixtures/config/multi-tenant/git/tenant-two-config/zuul.yaml
index 5ea803e..9496a49 100644
--- a/tests/fixtures/config/multi-tenant/git/tenant-two-config/zuul.yaml
+++ b/tests/fixtures/config/multi-tenant/git/tenant-two-config/zuul.yaml
@@ -6,17 +6,17 @@
       gerrit:
         - event: comment-added
           approval:
-            - approved: 1
+            - Approved: 1
     success:
       gerrit:
-        verified: 2
+        Verified: 2
         submit: true
     failure:
       gerrit:
-        verified: -2
+        Verified: -2
     start:
       gerrit:
-        verified: 0
+        Verified: 0
     precedence: high
 
 - nodeset:
diff --git a/tests/fixtures/config/multi-tenant/main.yaml b/tests/fixtures/config/multi-tenant/main.yaml
index 3ae7756..4916905 100644
--- a/tests/fixtures/config/multi-tenant/main.yaml
+++ b/tests/fixtures/config/multi-tenant/main.yaml
@@ -10,6 +10,7 @@
 
 - tenant:
     name: tenant-two
+    max-nodes-per-job: 10
     source:
       gerrit:
         config-projects:
diff --git a/tests/fixtures/config/openstack/git/project-config/zuul.yaml b/tests/fixtures/config/openstack/git/project-config/zuul.yaml
index 2ad600c..2506db0 100644
--- a/tests/fixtures/config/openstack/git/project-config/zuul.yaml
+++ b/tests/fixtures/config/openstack/git/project-config/zuul.yaml
@@ -7,10 +7,10 @@
         - event: patchset-created
     success:
       gerrit:
-        verified: 1
+        Verified: 1
     failure:
       gerrit:
-        verified: -1
+        Verified: -1
 
 - pipeline:
     name: gate
@@ -20,21 +20,22 @@
       gerrit:
         - event: comment-added
           approval:
-            - approved: 1
+            - Approved: 1
     success:
       gerrit:
-        verified: 2
+        Verified: 2
         submit: true
     failure:
       gerrit:
-        verified: -2
+        Verified: -2
     start:
       gerrit:
-        verified: 0
+        Verified: 0
     precedence: high
 
 - job:
     name: base
+    parent: null
     timeout: 30
     nodes:
       - name: controller
diff --git a/tests/fixtures/config/pre-playbook/git/common-config/zuul.yaml b/tests/fixtures/config/pre-playbook/git/common-config/zuul.yaml
index 3de0d6d..16d1966 100644
--- a/tests/fixtures/config/pre-playbook/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/pre-playbook/git/common-config/zuul.yaml
@@ -1,16 +1,20 @@
 - pipeline:
     name: check
     manager: independent
-    allow-secrets: true
+    post-review: true
     trigger:
       gerrit:
         - event: patchset-created
     success:
       gerrit:
-        verified: 1
+        Verified: 1
     failure:
       gerrit:
-        verified: -1
+        Verified: -1
+
+- job:
+    name: base
+    parent: null
 
 - job:
     name: python27
diff --git a/tests/fixtures/config/push-reqs/git/common-config/playbooks/job1.yaml b/tests/fixtures/config/push-reqs/git/org_common-config/playbooks/job1.yaml
similarity index 100%
rename from tests/fixtures/config/push-reqs/git/common-config/playbooks/job1.yaml
rename to tests/fixtures/config/push-reqs/git/org_common-config/playbooks/job1.yaml
diff --git a/tests/fixtures/config/push-reqs/git/common-config/zuul.yaml b/tests/fixtures/config/push-reqs/git/org_common-config/zuul.yaml
similarity index 97%
rename from tests/fixtures/config/push-reqs/git/common-config/zuul.yaml
rename to tests/fixtures/config/push-reqs/git/org_common-config/zuul.yaml
index 6569966..63af1c9 100644
--- a/tests/fixtures/config/push-reqs/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/push-reqs/git/org_common-config/zuul.yaml
@@ -77,6 +77,10 @@
         - event: ref-updated
 
 - job:
+    name: base
+    parent: null
+
+- job:
     name: job1
 
 - project:
diff --git a/tests/fixtures/config/push-reqs/main.yaml b/tests/fixtures/config/push-reqs/main.yaml
index d9f1a42..b58db73 100644
--- a/tests/fixtures/config/push-reqs/main.yaml
+++ b/tests/fixtures/config/push-reqs/main.yaml
@@ -3,7 +3,7 @@
     source:
       github:
         config-projects:
-          - common-config
+          - org/common-config
         untrusted-projects:
           - org/project1
       gerrit:
diff --git a/tests/fixtures/config/requirements/email/git/common-config/zuul.yaml b/tests/fixtures/config/requirements/email/git/common-config/zuul.yaml
index efc3b32..90c9ac2 100644
--- a/tests/fixtures/config/requirements/email/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/requirements/email/git/common-config/zuul.yaml
@@ -6,10 +6,10 @@
         - event: comment-added
     success:
       gerrit:
-        verified: 1
+        Verified: 1
     failure:
       gerrit:
-        verified: -1
+        Verified: -1
     require:
       gerrit:
         approval:
@@ -25,10 +25,14 @@
             - email: jenkins@example.com
     success:
       gerrit:
-        verified: 1
+        Verified: 1
     failure:
       gerrit:
-        verified: -1
+        Verified: -1
+
+- job:
+    name: base
+    parent: null
 
 - job:
     name: project1-job
diff --git a/tests/fixtures/config/requirements/newer-than/git/common-config/zuul.yaml b/tests/fixtures/config/requirements/newer-than/git/common-config/zuul.yaml
index 6f0601d..5f266a4 100644
--- a/tests/fixtures/config/requirements/newer-than/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/requirements/newer-than/git/common-config/zuul.yaml
@@ -6,10 +6,10 @@
         - event: comment-added
     success:
       gerrit:
-        verified: 1
+        Verified: 1
     failure:
       gerrit:
-        verified: -1
+        Verified: -1
     require:
       gerrit:
         approval:
@@ -27,10 +27,14 @@
               newer-than: 48h
     success:
       gerrit:
-        verified: 1
+        Verified: 1
     failure:
       gerrit:
-        verified: -1
+        Verified: -1
+
+- job:
+    name: base
+    parent: null
 
 - job:
     name: project1-job
diff --git a/tests/fixtures/config/requirements/older-than/git/common-config/zuul.yaml b/tests/fixtures/config/requirements/older-than/git/common-config/zuul.yaml
index 77ee388..4287a94 100644
--- a/tests/fixtures/config/requirements/older-than/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/requirements/older-than/git/common-config/zuul.yaml
@@ -6,10 +6,10 @@
         - event: comment-added
     success:
       gerrit:
-        verified: 1
+        Verified: 1
     failure:
       gerrit:
-        verified: -1
+        Verified: -1
     require:
       gerrit:
         approval:
@@ -27,10 +27,14 @@
               older-than: 48h
     success:
       gerrit:
-        verified: 1
+        Verified: 1
     failure:
       gerrit:
-        verified: -1
+        Verified: -1
+
+- job:
+    name: base
+    parent: null
 
 - job:
     name: project1-job
diff --git a/tests/fixtures/config/requirements/reject-username/git/common-config/zuul.yaml b/tests/fixtures/config/requirements/reject-username/git/common-config/zuul.yaml
index 9e9d000..aabfb6a 100644
--- a/tests/fixtures/config/requirements/reject-username/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/requirements/reject-username/git/common-config/zuul.yaml
@@ -10,10 +10,10 @@
         - event: comment-added
     success:
       gerrit:
-        verified: 1
+        Verified: 1
     failure:
       gerrit:
-        verified: -1
+        Verified: -1
 
 - pipeline:
     name: trigger
@@ -25,10 +25,14 @@
             - username: jenkins
     success:
       gerrit:
-        verified: 1
+        Verified: 1
     failure:
       gerrit:
-        verified: -1
+        Verified: -1
+
+- job:
+    name: base
+    parent: null
 
 - job:
     name: project1-job
diff --git a/tests/fixtures/config/requirements/reject/git/common-config/zuul.yaml b/tests/fixtures/config/requirements/reject/git/common-config/zuul.yaml
index b08a105..2661eed 100644
--- a/tests/fixtures/config/requirements/reject/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/requirements/reject/git/common-config/zuul.yaml
@@ -5,13 +5,13 @@
       gerrit:
         approval:
           - username: jenkins
-            verified:
+            Verified:
               - 1
               - 2
     reject:
       gerrit:
         approval:
-          - verified:
+          - Verified:
               - -1
               - -2
     trigger:
@@ -19,10 +19,10 @@
         - event: comment-added
     success:
       gerrit:
-        verified: 1
+        Verified: 1
     failure:
       gerrit:
-        verified: -1
+        Verified: -1
 
 - pipeline:
     name: trigger
@@ -32,19 +32,23 @@
         - event: comment-added
           require-approval:
             - username: jenkins
-              verified:
+              Verified:
                 - 1
                 - 2
           reject-approval:
-            - verified:
+            - Verified:
                 - -1
                 - -2
     success:
       gerrit:
-        verified: 1
+        Verified: 1
     failure:
       gerrit:
-        verified: -1
+        Verified: -1
+
+- job:
+    name: base
+    parent: null
 
 - job:
     name: project1-job
diff --git a/tests/fixtures/config/requirements/state/git/common-config/zuul.yaml b/tests/fixtures/config/requirements/state/git/common-config/zuul.yaml
index bd9dc8f..715b89f 100644
--- a/tests/fixtures/config/requirements/state/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/requirements/state/git/common-config/zuul.yaml
@@ -10,10 +10,10 @@
         - event: comment-added
     success:
       gerrit:
-        verified: 1
+        Verified: 1
     failure:
       gerrit:
-        verified: -1
+        Verified: -1
 
 - pipeline:
     name: open-check
@@ -27,10 +27,10 @@
         - event: comment-added
     success:
       gerrit:
-        verified: 1
+        Verified: 1
     failure:
       gerrit:
-        verified: -1
+        Verified: -1
 
 - pipeline:
     name: status-check
@@ -44,10 +44,14 @@
         - event: comment-added
     success:
       gerrit:
-        verified: 1
+        Verified: 1
     failure:
       gerrit:
-        verified: -1
+        Verified: -1
+
+- job:
+    name: base
+    parent: null
 
 - job:
     name: project-job
diff --git a/tests/fixtures/config/requirements/username/git/common-config/zuul.yaml b/tests/fixtures/config/requirements/username/git/common-config/zuul.yaml
index 455d9de..778ac16 100644
--- a/tests/fixtures/config/requirements/username/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/requirements/username/git/common-config/zuul.yaml
@@ -6,10 +6,10 @@
         - event: comment-added
     success:
       gerrit:
-        verified: 1
+        Verified: 1
     failure:
       gerrit:
-        verified: -1
+        Verified: -1
     require:
       gerrit:
         approval:
@@ -25,10 +25,14 @@
             - username: jenkins
     success:
       gerrit:
-        verified: 1
+        Verified: 1
     failure:
       gerrit:
-        verified: -1
+        Verified: -1
+
+- job:
+    name: base
+    parent: null
 
 - job:
     name: project1-job
diff --git a/tests/fixtures/config/requirements/vote1/git/common-config/zuul.yaml b/tests/fixtures/config/requirements/vote1/git/common-config/zuul.yaml
index 799282d..b5d7498 100644
--- a/tests/fixtures/config/requirements/vote1/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/requirements/vote1/git/common-config/zuul.yaml
@@ -5,16 +5,16 @@
       gerrit:
         approval:
           - username: jenkins
-            verified: 1
+            Verified: 1
     trigger:
       gerrit:
         - event: comment-added
     success:
       gerrit:
-        verified: 1
+        Verified: 1
     failure:
       gerrit:
-        verified: -1
+        Verified: -1
 
 - pipeline:
     name: trigger
@@ -24,13 +24,17 @@
         - event: comment-added
           require-approval:
             - username: jenkins
-              verified: 1
+              Verified: 1
     success:
       gerrit:
-        verified: 1
+        Verified: 1
     failure:
       gerrit:
-        verified: -1
+        Verified: -1
+
+- job:
+    name: base
+    parent: null
 
 - job:
     name: project1-job
diff --git a/tests/fixtures/config/requirements/vote2/git/common-config/zuul.yaml b/tests/fixtures/config/requirements/vote2/git/common-config/zuul.yaml
index f337371..3f41868 100644
--- a/tests/fixtures/config/requirements/vote2/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/requirements/vote2/git/common-config/zuul.yaml
@@ -5,7 +5,7 @@
       gerrit:
         approval:
           - username: jenkins
-            verified:
+            Verified:
               - 1
               - 2
     trigger:
@@ -13,10 +13,10 @@
         - event: comment-added
     success:
       gerrit:
-        verified: 1
+        Verified: 1
     failure:
       gerrit:
-        verified: -1
+        Verified: -1
 
 - pipeline:
     name: trigger
@@ -26,15 +26,19 @@
         - event: comment-added
           require-approval:
             - username: jenkins
-              verified:
+              Verified:
                 - 1
                 - 2
     success:
       gerrit:
-        verified: 1
+        Verified: 1
     failure:
       gerrit:
-        verified: -1
+        Verified: -1
+
+- job:
+    name: base
+    parent: null
 
 - job:
     name: project1-job
diff --git a/tests/fixtures/config/roles/git/common-config/zuul.yaml b/tests/fixtures/config/roles/git/common-config/zuul.yaml
index 1fdaf2e..7ae6263 100644
--- a/tests/fixtures/config/roles/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/roles/git/common-config/zuul.yaml
@@ -6,10 +6,10 @@
         - event: patchset-created
     success:
       gerrit:
-        verified: 1
+        Verified: 1
     failure:
       gerrit:
-        verified: -1
+        Verified: -1
 
 - pipeline:
     name: tenant-one-gate
@@ -19,20 +19,24 @@
       gerrit:
         - event: comment-added
           approval:
-            - approved: 1
+            - Approved: 1
     success:
       gerrit:
-        verified: 2
+        Verified: 2
         submit: true
     failure:
       gerrit:
-        verified: -2
+        Verified: -2
     start:
       gerrit:
-        verified: 0
+        Verified: 0
     precedence: high
 
 - job:
+    name: base
+    parent: null
+
+- job:
     name: common-config-test
 
 - project:
diff --git a/tests/fixtures/config/secret-leaks/git/common-config/playbooks/secret-file-fail.yaml b/tests/fixtures/config/secret-leaks/git/common-config/playbooks/secret-file-fail.yaml
new file mode 100644
index 0000000..4984411
--- /dev/null
+++ b/tests/fixtures/config/secret-leaks/git/common-config/playbooks/secret-file-fail.yaml
@@ -0,0 +1,6 @@
+- hosts: all
+  tasks:
+    - copy:
+        content: "{{test_secret.username}} {{test_secret.password}}"
+        dest: "{{zuul.executor.work_root}}/failure-file.txt"
+        group: "hopefullythisgroupdoesnotexist"
\ No newline at end of file
diff --git a/tests/fixtures/config/secret-leaks/git/common-config/playbooks/secret-file.yaml b/tests/fixtures/config/secret-leaks/git/common-config/playbooks/secret-file.yaml
new file mode 100644
index 0000000..24bb61f
--- /dev/null
+++ b/tests/fixtures/config/secret-leaks/git/common-config/playbooks/secret-file.yaml
@@ -0,0 +1,5 @@
+- hosts: all
+  tasks:
+    - copy:
+        content: "{{test_secret.username}} {{test_secret.password}}"
+        dest: "{{zuul.executor.work_root}}/secret-file.txt"
diff --git a/tests/fixtures/config/secret-leaks/git/common-config/zuul.yaml b/tests/fixtures/config/secret-leaks/git/common-config/zuul.yaml
new file mode 100644
index 0000000..4ab198f
--- /dev/null
+++ b/tests/fixtures/config/secret-leaks/git/common-config/zuul.yaml
@@ -0,0 +1,70 @@
+- pipeline:
+    name: check
+    manager: independent
+    post-review: true
+    trigger:
+      gerrit:
+        - event: patchset-created
+    success:
+      gerrit:
+        Verified: 1
+    failure:
+      gerrit:
+        Verified: -1
+
+- pipeline:
+    name: gate
+    manager: dependent
+    success-message: Build succeeded (gate).
+    trigger:
+      gerrit:
+        - event: comment-added
+          approval:
+            - Approved: 1
+    success:
+      gerrit:
+        Verified: 2
+        submit: true
+    failure:
+      gerrit:
+        Verified: -2
+    start:
+      gerrit:
+        Verified: 0
+    precedence: high
+
+- secret:
+    name: test_secret
+    data:
+      username: test-username
+      password: !encrypted/pkcs1-oaep |
+        BFhtdnm8uXx7kn79RFL/zJywmzLkT1GY78P3bOtp4WghUFWobkifSu7ZpaV4NeO0s71YUsi1wGZZ
+        L0LveZjUN0t6OU1VZKSG8R5Ly7urjaSo1pPVIq5Rtt/H7W14Lecd+cUeKb4joeusC9drN3AA8a4o
+        ykcVpt1wVqUnTbMGC9ARMCQP6eopcs1l7tzMseprW4RDNhIuz3CRgd0QBMPl6VDoFgBPB8vxtJw+
+        3m0rqBYZCLZgCXekqlny8s2s92nJMuUABbJOEcDRarzibDsSXsfJt1y+5n7yOURsC7lovMg4GF/v
+        Cl/0YMKjBO5bpv9EM5fToeKYyPGSKQoHOnCYceb3cAVcv5UawcCic8XjhEhp4K7WPdYf2HVAC/qt
+        xhbpjTxG4U5Q/SoppOJ60WqEkQvbXs6n5Dvy7xmph6GWmU/bAv3eUK3pdD3xa2Ue1lHWz3U+rsYr
+        aI+AKYsMYx3RBlfAmCeC1ve2BXPrqnOo7G8tnUvfdYPbK4Aakk0ds/AVqFHEZN+S6hRBmBjLaRFW
+        Z3QSO1NjbBxWnaHKZYT7nkrJm8AMCgZU0ZArFLpaufKCeiK5ECSsDxic4FIsY1OkWT42qEUfL0Wd
+        +150AKGNZpPJnnP3QYY4W/MWcKH/zdO400+zWN52WevbSqZy90tqKDJrBkMl1ydqbuw1E4ZHvIs=
+
+- job:
+    name: base
+    parent: null
+
+- job:
+    parent: base
+    name: secret-file
+    secrets:
+      - test_secret
+
+- job:
+    parent: base
+    name: secret-file-fail
+    secrets:
+      - test_secret
+
+- project:
+    name: org/project
+    check:
+      jobs: []
diff --git a/tests/fixtures/config/secret-leaks/git/org_project/README b/tests/fixtures/config/secret-leaks/git/org_project/README
new file mode 100644
index 0000000..9daeafb
--- /dev/null
+++ b/tests/fixtures/config/secret-leaks/git/org_project/README
@@ -0,0 +1 @@
+test
diff --git a/tests/fixtures/config/secret-leaks/main.yaml b/tests/fixtures/config/secret-leaks/main.yaml
new file mode 100644
index 0000000..208e274
--- /dev/null
+++ b/tests/fixtures/config/secret-leaks/main.yaml
@@ -0,0 +1,8 @@
+- tenant:
+    name: tenant-one
+    source:
+      gerrit:
+        config-projects:
+          - common-config
+        untrusted-projects:
+          - org/project
diff --git a/tests/fixtures/config/semaphore/git/common-config/zuul.yaml b/tests/fixtures/config/semaphore/git/common-config/zuul.yaml
index 9d1cacf..c8bd322 100644
--- a/tests/fixtures/config/semaphore/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/semaphore/git/common-config/zuul.yaml
@@ -6,10 +6,10 @@
         - event: patchset-created
     success:
       gerrit:
-        verified: 1
+        Verified: 1
     failure:
       gerrit:
-        verified: -1
+        Verified: -1
 
 # TODOv3(jeblair, tobiash): make semaphore definitions required, which
 # will cause these tests to fail until we define test-semaphore
@@ -20,6 +20,10 @@
     max: 2
 
 - job:
+    name: base
+    parent: null
+
+- job:
     name: project-test1
 
 - job:
diff --git a/tests/fixtures/config/semaphore/zuul-reconfiguration.yaml b/tests/fixtures/config/semaphore/zuul-reconfiguration.yaml
index 8fe8749..34d1136 100644
--- a/tests/fixtures/config/semaphore/zuul-reconfiguration.yaml
+++ b/tests/fixtures/config/semaphore/zuul-reconfiguration.yaml
@@ -6,10 +6,14 @@
         - event: patchset-created
     success:
       gerrit:
-        verified: 1
+        Verified: 1
     failure:
       gerrit:
-        verified: -1
+        Verified: -1
+
+- job:
+    name: base
+    parent: null
 
 - job:
     name: project-test1
diff --git a/tests/fixtures/config/shadow/git/local-config/zuul.yaml b/tests/fixtures/config/shadow/git/local-config/zuul.yaml
index 756e843..87f46b7 100644
--- a/tests/fixtures/config/shadow/git/local-config/zuul.yaml
+++ b/tests/fixtures/config/shadow/git/local-config/zuul.yaml
@@ -6,13 +6,14 @@
         - event: patchset-created
     success:
       gerrit:
-        verified: 1
+        Verified: 1
     failure:
       gerrit:
-        verified: -1
+        Verified: -1
 
 - job:
     name: base
+    parent: null
 
 - job:
     name: test2
diff --git a/tests/fixtures/config/single-tenant/git/common-config/zuul.yaml b/tests/fixtures/config/single-tenant/git/common-config/zuul.yaml
index 27454d3..9796fe2 100644
--- a/tests/fixtures/config/single-tenant/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/single-tenant/git/common-config/zuul.yaml
@@ -6,10 +6,10 @@
         - event: patchset-created
     success:
       gerrit:
-        verified: 1
+        Verified: 1
     failure:
       gerrit:
-        verified: -1
+        Verified: -1
 
 - pipeline:
     name: gate
@@ -19,17 +19,17 @@
       gerrit:
         - event: comment-added
           approval:
-            - approved: 1
+            - Approved: 1
     success:
       gerrit:
-        verified: 2
+        Verified: 2
         submit: true
     failure:
       gerrit:
-        verified: -2
+        Verified: -2
     start:
       gerrit:
-        verified: 0
+        Verified: 0
     precedence: high
 
 - pipeline:
@@ -41,8 +41,15 @@
           ref: ^(?!refs/).*$
 
 - job:
+    name: base
+    parent: null
+
+- job:
     name: project-merge
     hold-following-changes: true
+    nodes:
+      - name: controller
+        label: label1
 
 - job:
     name: project-test1
@@ -66,9 +73,15 @@
 
 - job:
     name: project-test2
+    nodes:
+      - name: controller
+        label: label1
 
 - job:
     name: project1-project2-integration
+    nodes:
+      - name: controller
+        label: label1
 
 - job:
     name: project-testfile
diff --git a/tests/fixtures/config/split-config/git/common-config/zuul.d/jobs.yaml b/tests/fixtures/config/split-config/git/common-config/zuul.d/jobs.yaml
index 280342c..9d15599 100644
--- a/tests/fixtures/config/split-config/git/common-config/zuul.d/jobs.yaml
+++ b/tests/fixtures/config/split-config/git/common-config/zuul.d/jobs.yaml
@@ -1,2 +1,6 @@
 - job:
+    name: base
+    parent: null
+
+- job:
     name: project-test1
diff --git a/tests/fixtures/config/split-config/git/common-config/zuul.d/pipelines.yaml b/tests/fixtures/config/split-config/git/common-config/zuul.d/pipelines.yaml
index ba91fb5..5b2636f 100644
--- a/tests/fixtures/config/split-config/git/common-config/zuul.d/pipelines.yaml
+++ b/tests/fixtures/config/split-config/git/common-config/zuul.d/pipelines.yaml
@@ -6,7 +6,7 @@
         - event: patchset-created
     success:
       gerrit:
-        verified: 1
+        Verified: 1
     failure:
       gerrit:
-        verified: -1
+        Verified: -1
diff --git a/tests/fixtures/config/sql-driver/git/common-config/zuul.yaml b/tests/fixtures/config/sql-driver/git/common-config/zuul.yaml
index dd80d08..b8f4d67 100644
--- a/tests/fixtures/config/sql-driver/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/sql-driver/git/common-config/zuul.yaml
@@ -6,16 +6,17 @@
         - event: patchset-created
     success:
       gerrit:
-        verified: 1
+        Verified: 1
       resultsdb:
-        score: 1
     failure:
       gerrit:
-        verified: -1
+        Verified: -1
       resultsdb:
-        score: -1
       resultsdb_failures:
-        score: -1
+
+- job:
+    name: base
+    parent: null
 
 - job:
     name: project-merge
diff --git a/tests/fixtures/config/streamer/git/common-config/zuul.yaml b/tests/fixtures/config/streamer/git/common-config/zuul.yaml
index 6f4fa7e..f9925fe 100644
--- a/tests/fixtures/config/streamer/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/streamer/git/common-config/zuul.yaml
@@ -6,10 +6,14 @@
         - event: patchset-created
     success:
       gerrit:
-        verified: 1
+        Verified: 1
     failure:
       gerrit:
-        verified: -1
+        Verified: -1
+
+- job:
+    name: base
+    parent: null
 
 - job:
     name: python27
diff --git a/tests/fixtures/config/success-url/git/common-config/zuul.yaml b/tests/fixtures/config/success-url/git/common-config/zuul.yaml
index 7082b8c..8929240 100644
--- a/tests/fixtures/config/success-url/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/success-url/git/common-config/zuul.yaml
@@ -9,12 +9,16 @@
         to: alternative_me@example.com
     success:
       gerrit:
-        verified: 1
+        Verified: 1
       smtp:
         to: alternative_me@example.com
     failure:
       gerrit:
-        verified: -1
+        Verified: -1
+
+- job:
+    name: base
+    parent: null
 
 - job:
     name: docs-draft-test
diff --git a/tests/fixtures/config/templated-project/git/common-config/zuul.d/jobs.yaml b/tests/fixtures/config/templated-project/git/common-config/zuul.d/jobs.yaml
index e051871..f9de1ad 100644
--- a/tests/fixtures/config/templated-project/git/common-config/zuul.d/jobs.yaml
+++ b/tests/fixtures/config/templated-project/git/common-config/zuul.d/jobs.yaml
@@ -1,4 +1,8 @@
 - job:
+    name: base
+    parent: null
+
+- job:
     name: project-test1
 
 - job:
diff --git a/tests/fixtures/config/templated-project/git/common-config/zuul.d/pipelines.yaml b/tests/fixtures/config/templated-project/git/common-config/zuul.d/pipelines.yaml
index 4a19796..3f01e8a 100644
--- a/tests/fixtures/config/templated-project/git/common-config/zuul.d/pipelines.yaml
+++ b/tests/fixtures/config/templated-project/git/common-config/zuul.d/pipelines.yaml
@@ -6,10 +6,10 @@
         - event: patchset-created
     success:
       gerrit:
-        verified: 1
+        Verified: 1
     failure:
       gerrit:
-        verified: -1
+        Verified: -1
 
 - pipeline:
     name: gate
@@ -19,17 +19,17 @@
       gerrit:
         - event: comment-added
           approval:
-            - approved: 1
+            - Approved: 1
     success:
       gerrit:
-        verified: 2
+        Verified: 2
         submit: true
     failure:
       gerrit:
-        verified: -2
+        Verified: -2
     start:
       gerrit:
-        verified: 0
+        Verified: 0
     precedence: high
 
 - pipeline:
diff --git a/tests/fixtures/config/tenant-parser/git/common-config/zuul.yaml b/tests/fixtures/config/tenant-parser/git/common-config/zuul.yaml
index 9e52187..e21f967 100644
--- a/tests/fixtures/config/tenant-parser/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/tenant-parser/git/common-config/zuul.yaml
@@ -6,10 +6,14 @@
         - event: patchset-created
     success:
       gerrit:
-        verified: 1
+        Verified: 1
     failure:
       gerrit:
-        verified: -1
+        Verified: -1
+
+- job:
+    name: base
+    parent: null
 
 - job:
     name: common-config-job
diff --git a/tests/fixtures/config/tenant-parser/unprotected-branches.yaml b/tests/fixtures/config/tenant-parser/unprotected-branches.yaml
new file mode 100644
index 0000000..bf2feef
--- /dev/null
+++ b/tests/fixtures/config/tenant-parser/unprotected-branches.yaml
@@ -0,0 +1,11 @@
+- tenant:
+    name: tenant-one
+    exclude-unprotected-branches: true
+    source:
+      gerrit:
+        config-projects:
+          - common-config:
+              exclude-unprotected-branches: false
+        untrusted-projects:
+          - org/project1
+          - org/project2
diff --git a/tests/fixtures/config/unprotected-branches/git/org_common-config/zuul.yaml b/tests/fixtures/config/unprotected-branches/git/org_common-config/zuul.yaml
new file mode 100644
index 0000000..b16683f
--- /dev/null
+++ b/tests/fixtures/config/unprotected-branches/git/org_common-config/zuul.yaml
@@ -0,0 +1,23 @@
+- pipeline:
+    name: check
+    manager: independent
+    trigger:
+      github:
+        - event: pull_request
+          action:
+            - opened
+            - changed
+            - reopened
+    success:
+      github:
+        status: 'success'
+    failure:
+      github:
+        status: 'failure'
+    start:
+      github:
+        comment: true
+
+- job:
+    name: base
+    parent: null
diff --git a/tests/fixtures/config/unprotected-branches/git/org_project1/README b/tests/fixtures/config/unprotected-branches/git/org_project1/README
new file mode 100644
index 0000000..9daeafb
--- /dev/null
+++ b/tests/fixtures/config/unprotected-branches/git/org_project1/README
@@ -0,0 +1 @@
+test
diff --git a/tests/fixtures/config/multi-driver/git/common-config/playbooks/project-gerrit.yaml b/tests/fixtures/config/unprotected-branches/git/org_project1/playbooks/project-test.yaml
similarity index 100%
copy from tests/fixtures/config/multi-driver/git/common-config/playbooks/project-gerrit.yaml
copy to tests/fixtures/config/unprotected-branches/git/org_project1/playbooks/project-test.yaml
diff --git a/tests/fixtures/config/unprotected-branches/git/org_project1/zuul.yaml b/tests/fixtures/config/unprotected-branches/git/org_project1/zuul.yaml
new file mode 100644
index 0000000..31abadf
--- /dev/null
+++ b/tests/fixtures/config/unprotected-branches/git/org_project1/zuul.yaml
@@ -0,0 +1,8 @@
+- job:
+    name: project-test
+
+- project:
+    name: org/project1
+    check:
+      jobs:
+        - project-test
diff --git a/tests/fixtures/config/unprotected-branches/git/org_project2/README b/tests/fixtures/config/unprotected-branches/git/org_project2/README
new file mode 100644
index 0000000..9daeafb
--- /dev/null
+++ b/tests/fixtures/config/unprotected-branches/git/org_project2/README
@@ -0,0 +1 @@
+test
diff --git a/tests/fixtures/config/unprotected-branches/git/org_project2/zuul.yaml b/tests/fixtures/config/unprotected-branches/git/org_project2/zuul.yaml
new file mode 100644
index 0000000..64d316d
--- /dev/null
+++ b/tests/fixtures/config/unprotected-branches/git/org_project2/zuul.yaml
@@ -0,0 +1 @@
+This zuul.yaml is intentionally broken and should not be loaded on startup.
diff --git a/tests/fixtures/config/unprotected-branches/main.yaml b/tests/fixtures/config/unprotected-branches/main.yaml
new file mode 100644
index 0000000..8078d37
--- /dev/null
+++ b/tests/fixtures/config/unprotected-branches/main.yaml
@@ -0,0 +1,10 @@
+- tenant:
+    name: tenant-one
+    source:
+      github:
+        config-projects:
+          - org/common-config
+        untrusted-projects:
+          - org/project1
+          - org/project2:
+              exclude-unprotected-branches: true
diff --git a/tests/fixtures/config/zuul-connections-multiple-gerrits/git/common-config/zuul.yaml b/tests/fixtures/config/zuul-connections-multiple-gerrits/git/common-config/zuul.yaml
index 8f858cd..d70a384 100644
--- a/tests/fixtures/config/zuul-connections-multiple-gerrits/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/zuul-connections-multiple-gerrits/git/common-config/zuul.yaml
@@ -6,10 +6,10 @@
         - event: patchset-created
     success:
       review_gerrit:
-        verified: 1
+        Verified: 1
     failure:
       review_gerrit:
-        verified: -1
+        Verified: -1
 
 - pipeline:
     name: another_check
@@ -19,10 +19,10 @@
         - event: patchset-created
     success:
       another_gerrit:
-        verified: 1
+        Verified: 1
     failure:
       another_gerrit:
-        verified: -1
+        Verified: -1
 
 - pipeline:
     name: common_check
@@ -34,14 +34,18 @@
         - event: patchset-created
     success:
       review_gerrit:
-        verified: 1
+        Verified: 1
       another_gerrit:
-        verified: 1
+        Verified: 1
     failure:
       review_gerrit:
-        verified: -1
+        Verified: -1
       another_gerrit:
-        verified: -1
+        Verified: -1
+
+- job:
+    name: base
+    parent: null
 
 - job:
     name: project-test1
diff --git a/tests/fixtures/config/zuul-connections-same-gerrit/git/common-config/zuul.yaml b/tests/fixtures/config/zuul-connections-same-gerrit/git/common-config/zuul.yaml
index adc61a3..eb65279 100644
--- a/tests/fixtures/config/zuul-connections-same-gerrit/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/zuul-connections-same-gerrit/git/common-config/zuul.yaml
@@ -6,10 +6,14 @@
         - event: patchset-created
     success:
       review_gerrit:
-        verified: 1
+        Verified: 1
     failure:
       alt_voting_gerrit:
-        verified: -1
+        Verified: -1
+
+- job:
+    name: base
+    parent: null
 
 - job:
     name: project-test1
diff --git a/tests/fixtures/config/zuultrigger/parent-change-enqueued/git/common-config/zuul.yaml b/tests/fixtures/config/zuultrigger/parent-change-enqueued/git/common-config/zuul.yaml
index 351092c..3dd8324 100644
--- a/tests/fixtures/config/zuultrigger/parent-change-enqueued/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/zuultrigger/parent-change-enqueued/git/common-config/zuul.yaml
@@ -4,7 +4,7 @@
     require:
       gerrit:
         approval:
-          - verified: -1
+          - Verified: -1
     trigger:
       gerrit:
         - event: patchset-created
@@ -13,10 +13,10 @@
           pipeline: gate
     success:
       gerrit:
-        verified: 1
+        Verified: 1
     failure:
       gerrit:
-        verified: -1
+        Verified: -1
 
 - pipeline:
     name: gate
@@ -24,28 +24,32 @@
     require:
       gerrit:
         approval:
-          - verified: 1
+          - Verified: 1
     trigger:
       gerrit:
         - event: comment-added
           approval:
-            - approved: 1
+            - Approved: 1
       zuul:
         - event: parent-change-enqueued
           pipeline: gate
     success:
       gerrit:
-        verified: 2
+        Verified: 2
         submit: true
     failure:
       gerrit:
-        verified: -2
+        Verified: -2
     start:
       gerrit:
-        verified: 0
+        Verified: 0
     precedence: high
 
 - job:
+    name: base
+    parent: null
+
+- job:
     name: project-check
 
 - job:
diff --git a/tests/fixtures/config/zuultrigger/project-change-merged/git/common-config/zuul.yaml b/tests/fixtures/config/zuultrigger/project-change-merged/git/common-config/zuul.yaml
index 48fdffe..a5c5a1c 100644
--- a/tests/fixtures/config/zuultrigger/project-change-merged/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/zuultrigger/project-change-merged/git/common-config/zuul.yaml
@@ -6,10 +6,10 @@
         - event: patchset-created
     success:
       gerrit:
-        verified: 1
+        Verified: 1
     failure:
       gerrit:
-        verified: -1
+        Verified: -1
 
 - pipeline:
     name: gate
@@ -19,17 +19,17 @@
       gerrit:
         - event: comment-added
           approval:
-            - approved: 1
+            - Approved: 1
     success:
       gerrit:
-        verified: 2
+        Verified: 2
         submit: true
     failure:
       gerrit:
-        verified: -2
+        Verified: -2
     start:
       gerrit:
-        verified: 0
+        Verified: 0
     precedence: high
 
 - pipeline:
@@ -41,7 +41,11 @@
         - event: project-change-merged
     merge-failure:
       gerrit:
-        verified: -1
+        Verified: -1
+
+- job:
+    name: base
+    parent: null
 
 - job:
     name: project-check
diff --git a/tests/fixtures/layout-cloner.yaml b/tests/fixtures/layout-cloner.yaml
index e8b5dde..0d51129 100644
--- a/tests/fixtures/layout-cloner.yaml
+++ b/tests/fixtures/layout-cloner.yaml
@@ -6,10 +6,10 @@
         - event: patchset-created
     success:
       gerrit:
-        verified: 1
+        Verified: 1
     failure:
       gerrit:
-        verified: -1
+        Verified: -1
 
   - name: gate
     manager: DependentPipelineManager
@@ -18,17 +18,17 @@
       gerrit:
         - event: comment-added
           approval:
-            - approved: 1
+            - Approved: 1
     start:
       gerrit:
-        verified: 0
+        Verified: 0
     success:
       gerrit:
-        verified: 2
+        Verified: 2
         submit: true
     failure:
       gerrit:
-        verified: -2
+        Verified: -2
 
   - name: post
     manager: IndependentPipelineManager
diff --git a/tests/fixtures/layout-delayed-repo-init.yaml b/tests/fixtures/layout-delayed-repo-init.yaml
index 6caf622..04dc010 100644
--- a/tests/fixtures/layout-delayed-repo-init.yaml
+++ b/tests/fixtures/layout-delayed-repo-init.yaml
@@ -6,10 +6,10 @@
         - event: patchset-created
     success:
       gerrit:
-        verified: 1
+        Verified: 1
     failure:
       gerrit:
-        verified: -1
+        Verified: -1
 
   - name: post
     manager: IndependentPipelineManager
@@ -25,17 +25,17 @@
       gerrit:
         - event: comment-added
           approval:
-            - approved: 1
+            - Approved: 1
     success:
       gerrit:
-        verified: 2
+        Verified: 2
         submit: true
     failure:
       gerrit:
-        verified: -2
+        Verified: -2
     start:
       gerrit:
-        verified: 0
+        Verified: 0
     precedence: high
 
 projects:
diff --git a/tests/fixtures/layout-disable-at.yaml b/tests/fixtures/layout-disable-at.yaml
index a2b2526..0ec8257 100644
--- a/tests/fixtures/layout-disable-at.yaml
+++ b/tests/fixtures/layout-disable-at.yaml
@@ -6,10 +6,10 @@
         - event: patchset-created
     success:
       gerrit:
-        verified: 1
+        Verified: 1
     failure:
       gerrit:
-        verified: -1
+        Verified: -1
     disabled:
       smtp:
         to: you@example.com
diff --git a/tests/fixtures/layout-live-reconfiguration-functions.yaml b/tests/fixtures/layout-live-reconfiguration-functions.yaml
index b22b3ab..ef2186e 100644
--- a/tests/fixtures/layout-live-reconfiguration-functions.yaml
+++ b/tests/fixtures/layout-live-reconfiguration-functions.yaml
@@ -9,17 +9,17 @@
       gerrit:
         - event: comment-added
           approval:
-            - approved: 1
+            - Approved: 1
     success:
       gerrit:
-        verified: 2
+        Verified: 2
         submit: true
     failure:
       gerrit:
-        verified: -2
+        Verified: -2
     start:
       gerrit:
-        verified: 0
+        Verified: 0
     precedence: high
 
 jobs:
diff --git a/tests/fixtures/layout-merge-queues.yaml b/tests/fixtures/layout-merge-queues.yaml
index be39a1c..b328341 100644
--- a/tests/fixtures/layout-merge-queues.yaml
+++ b/tests/fixtures/layout-merge-queues.yaml
@@ -6,7 +6,7 @@
       gerrit:
         - event: comment-added
           approval:
-            - approved: 1
+            - Approved: 1
 
 projects:
   - name: projectA
diff --git a/tests/fixtures/layout-requirement-vote.yaml b/tests/fixtures/layout-requirement-vote.yaml
index 7ccadff..01aa97d 100644
--- a/tests/fixtures/layout-requirement-vote.yaml
+++ b/tests/fixtures/layout-requirement-vote.yaml
@@ -4,16 +4,16 @@
     require:
       approval:
         - username: jenkins
-          verified: 1
+          Verified: 1
     trigger:
       gerrit:
         - event: comment-added
     success:
       gerrit:
-        verified: 1
+        Verified: 1
     failure:
       gerrit:
-        verified: -1
+        Verified: -1
 
   - name: trigger
     manager: IndependentPipelineManager
@@ -22,13 +22,13 @@
         - event: comment-added
           require-approval:
             - username: jenkins
-              verified: 1
+              Verified: 1
     success:
       gerrit:
-        verified: 1
+        Verified: 1
     failure:
       gerrit:
-        verified: -1
+        Verified: -1
 
 projects:
   - name: org/project1
diff --git a/tests/fixtures/layout-smtp.yaml b/tests/fixtures/layout-smtp.yaml
index 813857b..4306443 100644
--- a/tests/fixtures/layout-smtp.yaml
+++ b/tests/fixtures/layout-smtp.yaml
@@ -9,13 +9,13 @@
         to: you@example.com
     success:
       gerrit:
-        verified: 1
+        Verified: 1
       smtp:
         to: alternative_me@example.com
         from: zuul_from@example.com
     failure:
       gerrit:
-        verified: -1
+        Verified: -1
 
 projects:
   - name: org/project
diff --git a/tests/fixtures/layout.yaml b/tests/fixtures/layout.yaml
index 6131de0..cd8ce19 100644
--- a/tests/fixtures/layout.yaml
+++ b/tests/fixtures/layout.yaml
@@ -11,10 +11,10 @@
         - event: patchset-created
     success:
       gerrit:
-        verified: 1
+        Verified: 1
     failure:
       gerrit:
-        verified: -1
+        Verified: -1
 
   - name: post
     manager: independent
@@ -34,17 +34,17 @@
       gerrit:
         - event: comment-added
           approval:
-            - approved: 1
+            - Approved: 1
     success:
       gerrit:
-        verified: 2
+        Verified: 2
         submit: true
     failure:
       gerrit:
-        verified: -2
+        Verified: -2
     start:
       gerrit:
-        verified: 0
+        Verified: 0
     precedence: high
 
   - name: unused
@@ -56,7 +56,7 @@
       gerrit:
         - event: comment-added
           approval:
-            - approved: 1
+            - Approved: 1
 
   - name: dup1
     manager: independent
@@ -67,10 +67,10 @@
         - event: change-restored
     success:
       gerrit:
-        verified: 1
+        Verified: 1
     failure:
       gerrit:
-        verified: -1
+        Verified: -1
 
   - name: dup2
     manager: independent
@@ -81,10 +81,10 @@
         - event: change-restored
     success:
       gerrit:
-        verified: 1
+        Verified: 1
     failure:
       gerrit:
-        verified: -1
+        Verified: -1
 
   - name: conflict
     manager: dependent
@@ -95,17 +95,17 @@
       gerrit:
         - event: comment-added
           approval:
-            - approved: 1
+            - Approved: 1
     success:
       gerrit:
-        verified: 2
+        Verified: 2
         submit: true
     failure:
       gerrit:
-        verified: -2
+        Verified: -2
     start:
       gerrit:
-        verified: 0
+        Verified: 0
 
   - name: experimental
     manager: independent
diff --git a/tests/fixtures/layouts/autohold.yaml b/tests/fixtures/layouts/autohold.yaml
new file mode 100644
index 0000000..515f79d
--- /dev/null
+++ b/tests/fixtures/layouts/autohold.yaml
@@ -0,0 +1,28 @@
+- pipeline:
+    name: check
+    manager: independent
+    trigger:
+      gerrit:
+        - event: patchset-created
+    success:
+      gerrit:
+        Verified: 1
+    failure:
+      gerrit:
+        Verified: -1
+
+- job:
+    name: base
+    parent: null
+
+- job:
+    name: project-test2
+    nodes:
+      - name: controller
+        label: label1
+
+- project:
+    name: org/project
+    check:
+      jobs:
+        - project-test2
diff --git a/tests/fixtures/layouts/basic-github.yaml b/tests/fixtures/layouts/basic-github.yaml
index 709fd02..d7b323a 100644
--- a/tests/fixtures/layouts/basic-github.yaml
+++ b/tests/fixtures/layouts/basic-github.yaml
@@ -18,7 +18,12 @@
       github: {}
 
 - job:
+    name: base
+    parent: null
+
+- job:
     name: project-test1
+
 - job:
     name: project-test2
 
diff --git a/tests/fixtures/layouts/crd-github.yaml b/tests/fixtures/layouts/crd-github.yaml
index 11bdf76..9696226 100644
--- a/tests/fixtures/layouts/crd-github.yaml
+++ b/tests/fixtures/layouts/crd-github.yaml
@@ -28,15 +28,24 @@
       github: {}
 
 - job:
+    name: base
+    parent: null
+
+- job:
     name: project1-test
+
 - job:
     name: project2-test
+
 - job:
     name: project3-test
+
 - job:
     name: project4-test
+
 - job:
     name: project5-test
+
 - job:
     name: project6-test
 
diff --git a/tests/fixtures/layouts/dependent-github.yaml b/tests/fixtures/layouts/dependent-github.yaml
index 46cc7b3..eb74163 100644
--- a/tests/fixtures/layouts/dependent-github.yaml
+++ b/tests/fixtures/layouts/dependent-github.yaml
@@ -16,9 +16,15 @@
         unlabel: 'merge'
 
 - job:
+    name: base
+    parent: null
+
+- job:
     name: project-test1
+
 - job:
     name: project-test2
+
 - job:
     name: project-merge
     failure-message: Unable to merge change
diff --git a/tests/fixtures/layouts/dequeue-github.yaml b/tests/fixtures/layouts/dequeue-github.yaml
index 25e92c9..ae61cd5 100644
--- a/tests/fixtures/layouts/dequeue-github.yaml
+++ b/tests/fixtures/layouts/dequeue-github.yaml
@@ -9,6 +9,10 @@
             - changed
 
 - job:
+    name: base
+    parent: null
+
+- job:
     name: one-job-project-merge
 
 - project:
diff --git a/tests/fixtures/layouts/disable_at.yaml b/tests/fixtures/layouts/disable_at.yaml
index 8e352d8..7b1b8c8 100644
--- a/tests/fixtures/layouts/disable_at.yaml
+++ b/tests/fixtures/layouts/disable_at.yaml
@@ -6,16 +6,20 @@
         - event: patchset-created
     success:
       gerrit:
-        verified: 1
+        Verified: 1
     failure:
       gerrit:
-        verified: -1
+        Verified: -1
     disabled:
       smtp:
         to: you@example.com
     disable-after-consecutive-failures: 3
 
 - job:
+    name: base
+    parent: null
+
+- job:
     name: project-test1
     nodes:
       - name: controller
diff --git a/tests/fixtures/layouts/dont-ignore-ref-deletes.yaml b/tests/fixtures/layouts/dont-ignore-ref-deletes.yaml
index 6a05fe6..6a92deb 100644
--- a/tests/fixtures/layouts/dont-ignore-ref-deletes.yaml
+++ b/tests/fixtures/layouts/dont-ignore-ref-deletes.yaml
@@ -8,6 +8,10 @@
           ignore-deletes: false
 
 - job:
+    name: base
+    parent: null
+
+- job:
     name: project-post
     nodes:
       - name: static
diff --git a/tests/fixtures/layouts/files-github.yaml b/tests/fixtures/layouts/files-github.yaml
index 734b945..ec35259 100644
--- a/tests/fixtures/layouts/files-github.yaml
+++ b/tests/fixtures/layouts/files-github.yaml
@@ -7,6 +7,10 @@
           action: opened
 
 - job:
+    name: base
+    parent: null
+
+- job:
     name: project-test1
     files:
       - '.*-requires'
diff --git a/tests/fixtures/layouts/footer-message.yaml b/tests/fixtures/layouts/footer-message.yaml
index 1261902..4ee25f6 100644
--- a/tests/fixtures/layouts/footer-message.yaml
+++ b/tests/fixtures/layouts/footer-message.yaml
@@ -8,24 +8,28 @@
       gerrit:
         - event: comment-added
           approval:
-            - approved: 1
+            - Approved: 1
     success:
       smtp:
         to: you@example.com
       gerrit:
-        verified: 2
+        Verified: 2
         submit: true
     failure:
       gerrit:
-        verified: -2
+        Verified: -2
       smtp:
         to: you@example.com
     start:
       gerrit:
-        verified: 0
+        Verified: 0
     precedence: high
 
 - job:
+    name: base
+    parent: null
+
+- job:
     name: project-test1
 #    success-url: http://logs.exxxample.com/{change.number}/{change.patchset}/{pipeline.name}/{job.name}
 
diff --git a/tests/fixtures/layouts/idle.yaml b/tests/fixtures/layouts/idle.yaml
index 49c45ac..ec31408 100644
--- a/tests/fixtures/layouts/idle.yaml
+++ b/tests/fixtures/layouts/idle.yaml
@@ -6,6 +6,10 @@
         - time: '* * * * * */1'
 
 - job:
+    name: base
+    parent: null
+
+- job:
     name: project-bitrot
     nodes:
       - name: static
diff --git a/tests/fixtures/layouts/ignore-dependencies.yaml b/tests/fixtures/layouts/ignore-dependencies.yaml
index 86fe674..89a82b3 100644
--- a/tests/fixtures/layouts/ignore-dependencies.yaml
+++ b/tests/fixtures/layouts/ignore-dependencies.yaml
@@ -7,10 +7,14 @@
         - event: patchset-created
     success:
       gerrit:
-        verified: 1
+        Verified: 1
     failure:
       gerrit:
-        verified: -1
+        Verified: -1
+
+- job:
+    name: base
+    parent: null
 
 - job:
     name: project1-merge
diff --git a/tests/fixtures/layouts/inheritance.yaml b/tests/fixtures/layouts/inheritance.yaml
index 65dddab..3fe7fd4 100644
--- a/tests/fixtures/layouts/inheritance.yaml
+++ b/tests/fixtures/layouts/inheritance.yaml
@@ -6,10 +6,14 @@
         - event: patchset-created
     success:
       gerrit:
-        verified: 1
+        Verified: 1
     failure:
       gerrit:
-        verified: -1
+        Verified: -1
+
+- job:
+    name: base
+    parent: null
 
 - job:
     name: project-test-irrelevant-starts-empty
diff --git a/tests/fixtures/layouts/irrelevant-files.yaml b/tests/fixtures/layouts/irrelevant-files.yaml
index 3d086dc..97f58e7 100644
--- a/tests/fixtures/layouts/irrelevant-files.yaml
+++ b/tests/fixtures/layouts/irrelevant-files.yaml
@@ -6,10 +6,14 @@
         - event: patchset-created
     success:
       gerrit:
-        verified: 1
+        Verified: 1
     failure:
       gerrit:
-        verified: -1
+        Verified: -1
+
+- job:
+    name: base
+    parent: null
 
 - job:
     name: project-test-irrelevant-files
diff --git a/tests/fixtures/layouts/labeling-github.yaml b/tests/fixtures/layouts/labeling-github.yaml
index 33ce993..2441a9c 100644
--- a/tests/fixtures/layouts/labeling-github.yaml
+++ b/tests/fixtures/layouts/labeling-github.yaml
@@ -20,6 +20,10 @@
           - 'test'
 
 - job:
+    name: base
+    parent: null
+
+- job:
     name: project-labels
 
 - project:
diff --git a/tests/fixtures/layouts/live-reconfiguration-add-job.yaml b/tests/fixtures/layouts/live-reconfiguration-add-job.yaml
index 5916282..57d2a5f 100644
--- a/tests/fixtures/layouts/live-reconfiguration-add-job.yaml
+++ b/tests/fixtures/layouts/live-reconfiguration-add-job.yaml
@@ -6,20 +6,24 @@
       gerrit:
         - event: comment-added
           approval:
-            - approved: 1
+            - Approved: 1
     success:
       gerrit:
-        verified: 2
+        Verified: 2
         submit: true
     failure:
       gerrit:
-        verified: -2
+        Verified: -2
     start:
       gerrit:
-        verified: 0
+        Verified: 0
     precedence: high
 
 - job:
+    name: base
+    parent: null
+
+- job:
     name: project-merge
     hold-following-changes: true
 
diff --git a/tests/fixtures/layouts/live-reconfiguration-del-project.yaml b/tests/fixtures/layouts/live-reconfiguration-del-project.yaml
index 299c612..b149af0 100644
--- a/tests/fixtures/layouts/live-reconfiguration-del-project.yaml
+++ b/tests/fixtures/layouts/live-reconfiguration-del-project.yaml
@@ -6,10 +6,14 @@
         - event: patchset-created
     success:
       gerrit:
-        verified: 1
+        Verified: 1
     failure:
       gerrit:
-        verified: -1
+        Verified: -1
+
+- job:
+    name: base
+    parent: null
 
 - job:
     name: project-merge
diff --git a/tests/fixtures/layouts/live-reconfiguration-failed-job.yaml b/tests/fixtures/layouts/live-reconfiguration-failed-job.yaml
index 0907880..c4719f4 100644
--- a/tests/fixtures/layouts/live-reconfiguration-failed-job.yaml
+++ b/tests/fixtures/layouts/live-reconfiguration-failed-job.yaml
@@ -6,10 +6,14 @@
         - event: patchset-created
     success:
       gerrit:
-        verified: 1
+        Verified: 1
     failure:
       gerrit:
-        verified: -1
+        Verified: -1
+
+- job:
+    name: base
+    parent: null
 
 - job:
     name: project-merge
diff --git a/tests/fixtures/layouts/live-reconfiguration-shared-queue.yaml b/tests/fixtures/layouts/live-reconfiguration-shared-queue.yaml
index bf4416a..e363b4c 100644
--- a/tests/fixtures/layouts/live-reconfiguration-shared-queue.yaml
+++ b/tests/fixtures/layouts/live-reconfiguration-shared-queue.yaml
@@ -6,10 +6,10 @@
         - event: patchset-created
     success:
       gerrit:
-        verified: 1
+        Verified: 1
     failure:
       gerrit:
-        verified: -1
+        Verified: -1
 
 - pipeline:
     name: gate
@@ -19,20 +19,24 @@
       gerrit:
         - event: comment-added
           approval:
-            - approved: 1
+            - Approved: 1
     success:
       gerrit:
-        verified: 2
+        Verified: 2
         submit: true
     failure:
       gerrit:
-        verified: -2
+        Verified: -2
     start:
       gerrit:
-        verified: 0
+        Verified: 0
     precedence: high
 
 - job:
+    name: base
+    parent: null
+
+- job:
     name: project-merge
     hold-following-changes: true
 
diff --git a/tests/fixtures/layouts/merge-failure.yaml b/tests/fixtures/layouts/merge-failure.yaml
index 228963f..7c5121c 100644
--- a/tests/fixtures/layouts/merge-failure.yaml
+++ b/tests/fixtures/layouts/merge-failure.yaml
@@ -6,10 +6,10 @@
         - event: patchset-created
     success:
       gerrit:
-        verified: 1
+        Verified: 1
     failure:
       gerrit:
-        verified: -1
+        Verified: -1
 
 - pipeline:
     name: post
@@ -28,25 +28,29 @@
       gerrit:
         - event: comment-added
           approval:
-            - approved: 1
+            - Approved: 1
     success:
       gerrit:
-        verified: 2
+        Verified: 2
         submit: true
     failure:
       gerrit:
-        verified: -2
+        Verified: -2
     merge-failure:
       gerrit:
-        verified: -1
+        Verified: -1
       smtp:
         to: you@example.com
     start:
       gerrit:
-        verified: 0
+        Verified: 0
     precedence: high
 
 - job:
+    name: base
+    parent: null
+
+- job:
     name: project-merge
     hold-following-changes: true
 
diff --git a/tests/fixtures/layouts/merging-github.yaml b/tests/fixtures/layouts/merging-github.yaml
index 9f43f75..c9673b9 100644
--- a/tests/fixtures/layouts/merging-github.yaml
+++ b/tests/fixtures/layouts/merging-github.yaml
@@ -13,6 +13,10 @@
         merge: true
         comment: false
 
+- job:
+    name: base
+    parent: null
+
 - project:
     name: org/project
     merge:
diff --git a/tests/fixtures/layouts/no-jobs-project.yaml b/tests/fixtures/layouts/no-jobs-project.yaml
index 803e5a0..8f965e2 100644
--- a/tests/fixtures/layouts/no-jobs-project.yaml
+++ b/tests/fixtures/layouts/no-jobs-project.yaml
@@ -6,10 +6,14 @@
         - event: patchset-created
     success:
       gerrit:
-        verified: 1
+        Verified: 1
     failure:
       gerrit:
-        verified: -1
+        Verified: -1
+
+- job:
+    name: base
+    parent: null
 
 - job:
     name: project-testfile
diff --git a/tests/fixtures/layouts/no-jobs.yaml b/tests/fixtures/layouts/no-jobs.yaml
index 66193b0..301b27a 100644
--- a/tests/fixtures/layouts/no-jobs.yaml
+++ b/tests/fixtures/layouts/no-jobs.yaml
@@ -6,10 +6,10 @@
         - event: patchset-created
     success:
       gerrit:
-        verified: 1
+        Verified: 1
     failure:
       gerrit:
-        verified: -1
+        Verified: -1
 
 - pipeline:
     name: gate
@@ -19,20 +19,24 @@
       gerrit:
         - event: comment-added
           approval:
-            - approved: 1
+            - Approved: 1
     success:
       gerrit:
-        verified: 2
+        Verified: 2
         submit: true
     failure:
       gerrit:
-        verified: -2
+        Verified: -2
     start:
       gerrit:
-        verified: 0
+        Verified: 0
     precedence: high
 
 - job:
+    name: base
+    parent: null
+
+- job:
     name: gate-noop
 
 - project:
diff --git a/tests/fixtures/layouts/no-timer.yaml b/tests/fixtures/layouts/no-timer.yaml
index 05f17d2..3790ea7 100644
--- a/tests/fixtures/layouts/no-timer.yaml
+++ b/tests/fixtures/layouts/no-timer.yaml
@@ -6,10 +6,10 @@
         - event: patchset-created
     success:
       gerrit:
-        verified: 1
+        Verified: 1
     failure:
       gerrit:
-        verified: -1
+        Verified: -1
 
 - pipeline:
     name: periodic
@@ -21,6 +21,10 @@
         - event: ref-updated
 
 - job:
+    name: base
+    parent: null
+
+- job:
     name: project-test1
 
 - job:
diff --git a/tests/fixtures/layouts/nonvoting-job.yaml b/tests/fixtures/layouts/nonvoting-job.yaml
index fee5043..6a912bf 100644
--- a/tests/fixtures/layouts/nonvoting-job.yaml
+++ b/tests/fixtures/layouts/nonvoting-job.yaml
@@ -6,20 +6,24 @@
       gerrit:
         - event: comment-added
           approval:
-            - approved: 1
+            - Approved: 1
     success:
       gerrit:
-        verified: 2
+        Verified: 2
         submit: true
     failure:
       gerrit:
-        verified: -2
+        Verified: -2
     start:
       gerrit:
-        verified: 0
+        Verified: 0
     precedence: high
 
 - job:
+    name: base
+    parent: null
+
+- job:
     name: nonvoting-project-merge
     hold-following-changes: true
 
diff --git a/tests/fixtures/layouts/nonvoting-pipeline.yaml b/tests/fixtures/layouts/nonvoting-pipeline.yaml
index be5d5af..d8468dd 100644
--- a/tests/fixtures/layouts/nonvoting-pipeline.yaml
+++ b/tests/fixtures/layouts/nonvoting-pipeline.yaml
@@ -10,6 +10,10 @@
       gerrit: {}
 
 - job:
+    name: base
+    parent: null
+
+- job:
     name: project-merge
     hold-following-changes: true
 
diff --git a/tests/fixtures/layouts/noop-job.yaml b/tests/fixtures/layouts/noop-job.yaml
index 8081216..3d1b20f 100644
--- a/tests/fixtures/layouts/noop-job.yaml
+++ b/tests/fixtures/layouts/noop-job.yaml
@@ -6,17 +6,17 @@
       gerrit:
         - event: comment-added
           approval:
-            - approved: 1
+            - Approved: 1
     success:
       gerrit:
-        verified: 2
+        Verified: 2
         submit: true
     failure:
       gerrit:
-        verified: -2
+        Verified: -2
     start:
       gerrit:
-        verified: 0
+        Verified: 0
     precedence: high
 
 - project:
diff --git a/tests/fixtures/layouts/one-job-project.yaml b/tests/fixtures/layouts/one-job-project.yaml
index b293269..4b682d3 100644
--- a/tests/fixtures/layouts/one-job-project.yaml
+++ b/tests/fixtures/layouts/one-job-project.yaml
@@ -6,10 +6,10 @@
         - event: patchset-created
     success:
       gerrit:
-        verified: 1
+        Verified: 1
     failure:
       gerrit:
-        verified: -1
+        Verified: -1
 
 - pipeline:
     name: gate
@@ -19,17 +19,17 @@
       gerrit:
         - event: comment-added
           approval:
-            - approved: 1
+            - Approved: 1
     success:
       gerrit:
-        verified: 2
+        Verified: 2
         submit: true
     failure:
       gerrit:
-        verified: -2
+        Verified: -2
     start:
       gerrit:
-        verified: 0
+        Verified: 0
     precedence: high
 
 - pipeline:
@@ -41,6 +41,10 @@
           ref: ^(?!refs/).*$
 
 - job:
+    name: base
+    parent: null
+
+- job:
     name: one-job-project-merge
     hold-following-changes: true
 
diff --git a/tests/fixtures/layouts/push-tag-github.yaml b/tests/fixtures/layouts/push-tag-github.yaml
index 54683e9..5805127 100644
--- a/tests/fixtures/layouts/push-tag-github.yaml
+++ b/tests/fixtures/layouts/push-tag-github.yaml
@@ -15,7 +15,12 @@
           ref: ^refs/tags/.*$
 
 - job:
+    name: base
+    parent: null
+
+- job:
     name: project-post
+
 - job:
     name: project-tag
 
diff --git a/tests/fixtures/layouts/rate-limit.yaml b/tests/fixtures/layouts/rate-limit.yaml
index 283354e..1f32dbf 100644
--- a/tests/fixtures/layouts/rate-limit.yaml
+++ b/tests/fixtures/layouts/rate-limit.yaml
@@ -6,17 +6,17 @@
       gerrit:
         - event: comment-added
           approval:
-            - approved: 1
+            - Approved: 1
     start:
       gerrit:
-        verified: 0
+        Verified: 0
     success:
       gerrit:
-        verified: 2
+        Verified: 2
         submit: true
     failure:
       gerrit:
-        verified: -2
+        Verified: -2
     window: 2
     window-floor: 1
     window-increase-type: linear
@@ -25,6 +25,10 @@
     window-decrease-factor: 2
 
 - job:
+    name: base
+    parent: null
+
+- job:
     name: project-merge
 
 - job:
diff --git a/tests/fixtures/layouts/repo-checkout-four-project.yaml b/tests/fixtures/layouts/repo-checkout-four-project.yaml
index 392931a..17303f5 100644
--- a/tests/fixtures/layouts/repo-checkout-four-project.yaml
+++ b/tests/fixtures/layouts/repo-checkout-four-project.yaml
@@ -6,10 +6,10 @@
         - event: patchset-created
     success:
       gerrit:
-        verified: 1
+        Verified: 1
     failure:
       gerrit:
-        verified: -1
+        Verified: -1
 
 - pipeline:
     name: gate
@@ -19,20 +19,24 @@
       gerrit:
         - event: comment-added
           approval:
-            - approved: 1
+            - Approved: 1
     success:
       gerrit:
-        verified: 2
+        Verified: 2
         submit: true
     failure:
       gerrit:
-        verified: -2
+        Verified: -2
     start:
       gerrit:
-        verified: 0
+        Verified: 0
     precedence: high
 
 - job:
+    name: base
+    parent: null
+
+- job:
     name: integration
     required-projects:
       - org/project1
diff --git a/tests/fixtures/layouts/repo-checkout-no-timer-override.yaml b/tests/fixtures/layouts/repo-checkout-no-timer-override.yaml
new file mode 100644
index 0000000..4680869
--- /dev/null
+++ b/tests/fixtures/layouts/repo-checkout-no-timer-override.yaml
@@ -0,0 +1,25 @@
+- pipeline:
+    name: periodic
+    manager: independent
+    # Trigger is required, set it to one that is a noop
+    # during tests that check the timer trigger.
+    trigger:
+      gerrit:
+        - event: ref-updated
+
+- job:
+    name: base
+    parent: null
+
+- job:
+    name: integration
+    branches: master
+    override-branch: stable/havana
+    required-projects:
+      - org/project1
+
+- project:
+    name: org/project1
+    periodic:
+      jobs:
+        - integration
diff --git a/tests/fixtures/layouts/repo-checkout-no-timer.yaml b/tests/fixtures/layouts/repo-checkout-no-timer.yaml
index 2b65850..ed20bb1 100644
--- a/tests/fixtures/layouts/repo-checkout-no-timer.yaml
+++ b/tests/fixtures/layouts/repo-checkout-no-timer.yaml
@@ -8,6 +8,10 @@
         - event: ref-updated
 
 - job:
+    name: base
+    parent: null
+
+- job:
     name: integration
     override-branch: stable/havana
     required-projects:
diff --git a/tests/fixtures/layouts/repo-checkout-post.yaml b/tests/fixtures/layouts/repo-checkout-post.yaml
index 9698289..191569c 100644
--- a/tests/fixtures/layouts/repo-checkout-post.yaml
+++ b/tests/fixtures/layouts/repo-checkout-post.yaml
@@ -7,6 +7,10 @@
           ref: ^(?!refs/).*$
 
 - job:
+    name: base
+    parent: null
+
+- job:
     name: integration
     required-projects:
       - org/project1
diff --git a/tests/fixtures/layouts/repo-checkout-six-project.yaml b/tests/fixtures/layouts/repo-checkout-six-project.yaml
index 93a64ea..9a81eae 100644
--- a/tests/fixtures/layouts/repo-checkout-six-project.yaml
+++ b/tests/fixtures/layouts/repo-checkout-six-project.yaml
@@ -6,10 +6,10 @@
         - event: patchset-created
     success:
       gerrit:
-        verified: 1
+        Verified: 1
     failure:
       gerrit:
-        verified: -1
+        Verified: -1
 
 - pipeline:
     name: gate
@@ -19,20 +19,24 @@
       gerrit:
         - event: comment-added
           approval:
-            - approved: 1
+            - Approved: 1
     success:
       gerrit:
-        verified: 2
+        Verified: 2
         submit: true
     failure:
       gerrit:
-        verified: -2
+        Verified: -2
     start:
       gerrit:
-        verified: 0
+        Verified: 0
     precedence: high
 
 - job:
+    name: base
+    parent: null
+
+- job:
     name: integration
     required-projects:
       - org/project1
diff --git a/tests/fixtures/layouts/repo-checkout-timer-override.yaml b/tests/fixtures/layouts/repo-checkout-timer-override.yaml
index 594d74c..99fc4f5 100644
--- a/tests/fixtures/layouts/repo-checkout-timer-override.yaml
+++ b/tests/fixtures/layouts/repo-checkout-timer-override.yaml
@@ -6,6 +6,10 @@
         - time: '* * * * * */1'
 
 - job:
+    name: base
+    parent: null
+
+- job:
     name: integration
     branches: master
     override-branch: stable/havana
diff --git a/tests/fixtures/layouts/repo-checkout-timer.yaml b/tests/fixtures/layouts/repo-checkout-timer.yaml
index 3c4d030..e707732 100644
--- a/tests/fixtures/layouts/repo-checkout-timer.yaml
+++ b/tests/fixtures/layouts/repo-checkout-timer.yaml
@@ -6,6 +6,10 @@
         - time: '* * * * * */1'
 
 - job:
+    name: base
+    parent: null
+
+- job:
     name: integration
     required-projects:
       - org/project1
diff --git a/tests/fixtures/layouts/repo-checkout-two-project.yaml b/tests/fixtures/layouts/repo-checkout-two-project.yaml
index 239d80c..7910ae7 100644
--- a/tests/fixtures/layouts/repo-checkout-two-project.yaml
+++ b/tests/fixtures/layouts/repo-checkout-two-project.yaml
@@ -6,10 +6,10 @@
         - event: patchset-created
     success:
       gerrit:
-        verified: 1
+        Verified: 1
     failure:
       gerrit:
-        verified: -1
+        Verified: -1
 
 - pipeline:
     name: gate
@@ -19,20 +19,24 @@
       gerrit:
         - event: comment-added
           approval:
-            - approved: 1
+            - Approved: 1
     success:
       gerrit:
-        verified: 2
+        Verified: 2
         submit: true
     failure:
       gerrit:
-        verified: -2
+        Verified: -2
     start:
       gerrit:
-        verified: 0
+        Verified: 0
     precedence: high
 
 - job:
+    name: base
+    parent: null
+
+- job:
     name: integration
     required-projects:
       - org/project1
diff --git a/tests/fixtures/layouts/repo-deleted.yaml b/tests/fixtures/layouts/repo-deleted.yaml
index 95d11bb..6e6c301 100644
--- a/tests/fixtures/layouts/repo-deleted.yaml
+++ b/tests/fixtures/layouts/repo-deleted.yaml
@@ -6,10 +6,10 @@
         - event: patchset-created
     success:
       gerrit:
-        verified: 1
+        Verified: 1
     failure:
       gerrit:
-        verified: -1
+        Verified: -1
 
 - pipeline:
     name: gate
@@ -19,20 +19,24 @@
       gerrit:
         - event: comment-added
           approval:
-            - approved: 1
+            - Approved: 1
     success:
       gerrit:
-        verified: 2
+        Verified: 2
         submit: true
     failure:
       gerrit:
-        verified: -2
+        Verified: -2
     start:
       gerrit:
-        verified: 0
+        Verified: 0
     precedence: high
 
 - job:
+    name: base
+    parent: null
+
+- job:
     name: project-merge
     hold-following-changes: true
 
diff --git a/tests/fixtures/layouts/reporting-github.yaml b/tests/fixtures/layouts/reporting-github.yaml
index 0fdec85..159f205 100644
--- a/tests/fixtures/layouts/reporting-github.yaml
+++ b/tests/fixtures/layouts/reporting-github.yaml
@@ -35,6 +35,27 @@
         comment: false
 
 - pipeline:
+    name: this_is_a_really_stupid_long_name_for_a_pipeline_that_should_never_be_used_in_production_becuase_it_will_be_too_long_for_the_API_to_make_use_of_without_crashing
+    description: Uncommon reporting
+    manager: independent
+    trigger:
+      github:
+        - event: pull_request
+          action: comment
+          comment: 'long pipeline'
+    start:
+      github:
+        status: 'pending'
+    success:
+      github:
+        comment: false
+        status: 'success'
+        status-url: http://logs.example.com/{tenant.name}/{pipeline.name}/{change.project}/{change.number}/{buildset.uuid}/
+    failure:
+      github:
+        comment: false
+
+- pipeline:
     name: push-reporting
     description: Uncommon reporting
     manager: independent
@@ -58,6 +79,10 @@
         status: 'failure'
 
 - job:
+    name: base
+    parent: null
+
+- job:
     name: project-test1
 
 - project:
@@ -68,6 +93,9 @@
     reporting:
       jobs:
         - project-test1
+    this_is_a_really_stupid_long_name_for_a_pipeline_that_should_never_be_used_in_production_becuase_it_will_be_too_long_for_the_API_to_make_use_of_without_crashing:
+      jobs:
+        - project-test1
 
 - project:
     name: org/project2
diff --git a/tests/fixtures/layouts/reporting-multiple-github.yaml b/tests/fixtures/layouts/reporting-multiple-github.yaml
new file mode 100644
index 0000000..0126ec5
--- /dev/null
+++ b/tests/fixtures/layouts/reporting-multiple-github.yaml
@@ -0,0 +1,45 @@
+- pipeline:
+    name: check
+    description: Standard check
+    manager: independent
+    trigger:
+      github:
+        - event: pull_request
+          action: opened
+      github_ent:
+        - event: pull_request
+          action: opened
+    start:
+      github:
+        status: 'pending'
+        comment: false
+      github_ent:
+        status: 'pending'
+        comment: false
+    success:
+      github:
+        status: 'success'
+      github_ent:
+        status: 'success'
+
+- job:
+    name: base
+    parent: null
+
+- job:
+    name: project1-test1
+
+- job:
+    name: project2-test2
+
+- project:
+    name: org/project1
+    check:
+      jobs:
+        - project1-test1
+
+- project:
+    name: org/project2
+    check:
+      jobs:
+        - project2-test2
diff --git a/tests/fixtures/layouts/requirements-github.yaml b/tests/fixtures/layouts/requirements-github.yaml
index 891a366..f2ecd16 100644
--- a/tests/fixtures/layouts/requirements-github.yaml
+++ b/tests/fixtures/layouts/requirements-github.yaml
@@ -184,23 +184,36 @@
         comment: true
 
 - job:
+    name: base
+    parent: null
+
+- job:
     name: project1-pipeline
+
 - job:
     name: project2-trigger
+
 - job:
     name: project3-reviewusername
+
 - job:
     name: project4-reviewreq
+
 - job:
     name: project5-reviewuserstate
+
 - job:
     name: project6-newerthan
+
 - job:
     name: project7-olderthan
+
 - job:
     name: project8-requireopen
+
 - job:
     name: project9-requirecurrent
+
 - job:
     name: project10-label
 
diff --git a/tests/fixtures/layouts/reviews-github.yaml b/tests/fixtures/layouts/reviews-github.yaml
index 1cc887a..f186fbe 100644
--- a/tests/fixtures/layouts/reviews-github.yaml
+++ b/tests/fixtures/layouts/reviews-github.yaml
@@ -12,6 +12,10 @@
           - 'tests passed'
 
 - job:
+    name: base
+    parent: null
+
+- job:
     name: project-reviews
 
 - project:
diff --git a/tests/fixtures/layouts/smtp.yaml b/tests/fixtures/layouts/smtp.yaml
index fd91d36..5ea75ce 100644
--- a/tests/fixtures/layouts/smtp.yaml
+++ b/tests/fixtures/layouts/smtp.yaml
@@ -9,13 +9,13 @@
         to: you@example.com
     success:
       gerrit:
-        verified: 1
+        Verified: 1
       smtp:
         to: alternative_me@example.com
         from: zuul_from@example.com
     failure:
       gerrit:
-        verified: -1
+        Verified: -1
 
 - pipeline:
     name: gate
@@ -25,20 +25,24 @@
       gerrit:
         - event: comment-added
           approval:
-            - approved: 1
+            - Approved: 1
     success:
       gerrit:
-        verified: 2
+        Verified: 2
         submit: true
     failure:
       gerrit:
-        verified: -2
+        Verified: -2
     start:
       gerrit:
-        verified: 0
+        Verified: 0
     precedence: high
 
 - job:
+    name: base
+    parent: null
+
+- job:
     name: project-merge
     hold-following-changes: true
 
diff --git a/tests/fixtures/layouts/tags.yaml b/tests/fixtures/layouts/tags.yaml
index 422eca2..f86f5ab 100644
--- a/tests/fixtures/layouts/tags.yaml
+++ b/tests/fixtures/layouts/tags.yaml
@@ -6,10 +6,14 @@
         - event: patchset-created
     success:
       gerrit:
-        verified: 1
+        Verified: 1
     failure:
       gerrit:
-        verified: -1
+        Verified: -1
+
+- job:
+    name: base
+    parent: null
 
 - job:
     name: merge
diff --git a/tests/fixtures/layouts/three-projects.yaml b/tests/fixtures/layouts/three-projects.yaml
index 5d10276..51cd406 100644
--- a/tests/fixtures/layouts/three-projects.yaml
+++ b/tests/fixtures/layouts/three-projects.yaml
@@ -6,10 +6,10 @@
         - event: patchset-created
     success:
       gerrit:
-        verified: 1
+        Verified: 1
     failure:
       gerrit:
-        verified: -1
+        Verified: -1
 
 - pipeline:
     name: gate
@@ -19,20 +19,24 @@
       gerrit:
         - event: comment-added
           approval:
-            - approved: 1
+            - Approved: 1
     success:
       gerrit:
-        verified: 2
+        Verified: 2
         submit: true
     failure:
       gerrit:
-        verified: -2
+        Verified: -2
     start:
       gerrit:
-        verified: 0
+        Verified: 0
     precedence: high
 
 - job:
+    name: base
+    parent: null
+
+- job:
     name: project-merge
     hold-following-changes: true
 
diff --git a/tests/fixtures/layouts/timer-smtp.yaml b/tests/fixtures/layouts/timer-smtp.yaml
index 66e9aaf..a27b183 100644
--- a/tests/fixtures/layouts/timer-smtp.yaml
+++ b/tests/fixtures/layouts/timer-smtp.yaml
@@ -11,6 +11,10 @@
         subject: Periodic check for {change.project} succeeded
 
 - job:
+    name: base
+    parent: null
+
+- job:
     name: project-bitrot-stable-old
     success-url: http://logs.example.com/{job.name}/{build.number}
 
diff --git a/tests/fixtures/layouts/timer.yaml b/tests/fixtures/layouts/timer.yaml
index dbce516..e1c4e77 100644
--- a/tests/fixtures/layouts/timer.yaml
+++ b/tests/fixtures/layouts/timer.yaml
@@ -6,10 +6,10 @@
         - event: patchset-created
     success:
       gerrit:
-        verified: 1
+        Verified: 1
     failure:
       gerrit:
-        verified: -1
+        Verified: -1
 
 - pipeline:
     name: periodic
@@ -19,6 +19,10 @@
         - time: '* * * * * */1'
 
 - job:
+    name: base
+    parent: null
+
+- job:
     name: project-test1
 
 - job:
diff --git a/tests/fixtures/layouts/unmanaged-project.yaml b/tests/fixtures/layouts/unmanaged-project.yaml
index d72c26e..4c4305e 100644
--- a/tests/fixtures/layouts/unmanaged-project.yaml
+++ b/tests/fixtures/layouts/unmanaged-project.yaml
@@ -10,13 +10,13 @@
         - event: patchset-created
     success:
       gerrit:
-        verified: 1
+        Verified: 1
     failure:
       gerrit:
-        verified: -1
+        Verified: -1
     start:
       gerrit:
-        verified: 0
+        Verified: 0
 
 - project:
     name: org/project1
diff --git a/tests/fixtures/layouts/untrusted-secrets.yaml b/tests/fixtures/layouts/untrusted-secrets.yaml
new file mode 100644
index 0000000..b90d3d7
--- /dev/null
+++ b/tests/fixtures/layouts/untrusted-secrets.yaml
@@ -0,0 +1,26 @@
+- pipeline:
+    name: check
+    manager: independent
+    trigger:
+      gerrit:
+        - event: patchset-created
+    success:
+      gerrit:
+        Verified: 1
+    failure:
+      gerrit:
+        Verified: -1
+
+- job:
+    name: base
+    parent: null
+
+- job:
+    name: project1-test
+    post-review: true
+
+- project:
+    name: org/project1
+    check:
+      jobs:
+        - project1-test
diff --git a/tests/fixtures/zuul-connections-gerrit-and-github.conf b/tests/fixtures/zuul-connections-gerrit-and-github.conf
index 64757d8..49e53c7 100644
--- a/tests/fixtures/zuul-connections-gerrit-and-github.conf
+++ b/tests/fixtures/zuul-connections-gerrit-and-github.conf
@@ -8,7 +8,6 @@
 git_dir=/tmp/zuul-test/git
 git_user_email=zuul@example.com
 git_user_name=zuul
-zuul_url=http://zuul.example.com/p
 
 [executor]
 git_dir=/tmp/zuul-test/executor-git
@@ -21,6 +20,7 @@
 
 [connection github]
 driver=github
+webhook_token=00000000000000000000000000000000000000000
 
 [connection outgoing_smtp]
 driver=smtp
diff --git a/tests/fixtures/zuul-connections-merger.conf b/tests/fixtures/zuul-connections-merger.conf
index df465d5..771fc50 100644
--- a/tests/fixtures/zuul-connections-merger.conf
+++ b/tests/fixtures/zuul-connections-merger.conf
@@ -8,7 +8,6 @@
 git_dir=/tmp/zuul-test/git
 git_user_email=zuul@example.com
 git_user_name=zuul
-zuul_url=http://zuul.example.com/p
 
 [executor]
 git_dir=/tmp/zuul-test/executor-git
diff --git a/tests/fixtures/zuul-connections-multiple-gerrits.conf b/tests/fixtures/zuul-connections-multiple-gerrits.conf
index 66a6926..c6eb39e 100644
--- a/tests/fixtures/zuul-connections-multiple-gerrits.conf
+++ b/tests/fixtures/zuul-connections-multiple-gerrits.conf
@@ -8,7 +8,6 @@
 git_dir=/tmp/zuul-test/merger-git
 git_user_email=zuul@example.com
 git_user_name=zuul
-zuul_url=http://zuul.example.com/p
 
 [executor]
 git_dir=/tmp/zuul-test/executor-git
diff --git a/tests/fixtures/zuul-connections-same-gerrit.conf b/tests/fixtures/zuul-connections-same-gerrit.conf
index 3262294..a4f558d 100644
--- a/tests/fixtures/zuul-connections-same-gerrit.conf
+++ b/tests/fixtures/zuul-connections-same-gerrit.conf
@@ -8,7 +8,6 @@
 git_dir=/tmp/zuul-test/merger-git
 git_user_email=zuul@example.com
 git_user_name=zuul
-zuul_url=http://zuul.example.com/p
 
 [executor]
 git_dir=/tmp/zuul-test/executor-git
diff --git a/tests/fixtures/zuul-disk-accounting.conf b/tests/fixtures/zuul-disk-accounting.conf
new file mode 100644
index 0000000..6f02fa4
--- /dev/null
+++ b/tests/fixtures/zuul-disk-accounting.conf
@@ -0,0 +1,27 @@
+[gearman]
+server=127.0.0.1
+
+[scheduler]
+tenant_config=main.yaml
+
+[merger]
+git_dir=/tmp/zuul-test/merger-git
+git_user_email=zuul@example.com
+git_user_name=zuul
+
+[executor]
+git_dir=/tmp/zuul-test/executor-git
+disk_limit_per_job=1
+
+[connection gerrit]
+driver=gerrit
+server=review.example.com
+user=jenkins
+sshkey=fake_id_rsa_path
+
+[connection smtp]
+driver=smtp
+server=localhost
+port=25
+default_from=zuul@example.com
+default_to=you@example.com
diff --git a/tests/fixtures/zuul-git-driver.conf b/tests/fixtures/zuul-git-driver.conf
index 4321871..b24b0a1 100644
--- a/tests/fixtures/zuul-git-driver.conf
+++ b/tests/fixtures/zuul-git-driver.conf
@@ -8,7 +8,6 @@
 git_dir=/tmp/zuul-test/git
 git_user_email=zuul@example.com
 git_user_name=zuul
-zuul_url=http://zuul.example.com/p
 
 [executor]
 git_dir=/tmp/zuul-test/executor-git
diff --git a/tests/fixtures/zuul-github-driver.conf b/tests/fixtures/zuul-github-driver.conf
index 3d61ab6..a96bde2 100644
--- a/tests/fixtures/zuul-github-driver.conf
+++ b/tests/fixtures/zuul-github-driver.conf
@@ -8,13 +8,13 @@
 git_dir=/tmp/zuul-test/git
 git_user_email=zuul@example.com
 git_user_name=zuul
-zuul_url=http://zuul.example.com/p
 
 [executor]
 git_dir=/tmp/zuul-test/executor-git
 
 [connection github]
 driver=github
+webhook_token=0000000000000000000000000000000000000000
 
 [connection github_ssh]
 driver=github
diff --git a/tests/fixtures/zuul-push-reqs.conf b/tests/fixtures/zuul-push-reqs.conf
index 4faac13..2217f94 100644
--- a/tests/fixtures/zuul-push-reqs.conf
+++ b/tests/fixtures/zuul-push-reqs.conf
@@ -8,13 +8,13 @@
 git_dir=/tmp/zuul-test/git
 git_user_email=zuul@example.com
 git_user_name=zuul
-zuul_url=http://zuul.example.com/p
 
 [executor]
 git_dir=/tmp/zuul-test/executor-git
 
 [connection github]
 driver=github
+webhook_token=00000000000000000000000000000000000000000
 
 [connection gerrit]
 driver=gerrit
diff --git a/tests/fixtures/zuul-sql-driver-bad.conf b/tests/fixtures/zuul-sql-driver-bad.conf
index 1f1b75f..e2a9438 100644
--- a/tests/fixtures/zuul-sql-driver-bad.conf
+++ b/tests/fixtures/zuul-sql-driver-bad.conf
@@ -8,7 +8,6 @@
 git_dir=/tmp/zuul-test/merger-git
 git_user_email=zuul@example.com
 git_user_name=zuul
-zuul_url=http://zuul.example.com/p
 
 [executor]
 git_dir=/tmp/zuul-test/executor-git
diff --git a/tests/fixtures/zuul-sql-driver.conf b/tests/fixtures/zuul-sql-driver.conf
index 688d65b..e0ff3d5 100644
--- a/tests/fixtures/zuul-sql-driver.conf
+++ b/tests/fixtures/zuul-sql-driver.conf
@@ -8,7 +8,6 @@
 git_dir=/tmp/zuul-test/merger-git
 git_user_email=zuul@example.com
 git_user_name=zuul
-zuul_url=http://zuul.example.com/p
 
 [executor]
 git_dir=/tmp/zuul-test/executor-git
diff --git a/tests/fixtures/zuul.conf b/tests/fixtures/zuul.conf
index d6de76c..7bc8c59 100644
--- a/tests/fixtures/zuul.conf
+++ b/tests/fixtures/zuul.conf
@@ -8,7 +8,6 @@
 git_dir=/tmp/zuul-test/merger-git
 git_user_email=zuul@example.com
 git_user_name=zuul
-zuul_url=http://zuul.example.com/p
 
 [executor]
 git_dir=/tmp/zuul-test/executor-git
diff --git a/tests/unit/test_configloader.py b/tests/unit/test_configloader.py
index d08c6a1..3b5c206 100644
--- a/tests/unit/test_configloader.py
+++ b/tests/unit/test_configloader.py
@@ -182,6 +182,7 @@
 
     def test_tenant_groups3(self):
         tenant = self.sched.abide.tenants.get('tenant-one')
+        self.assertEqual(False, tenant.exclude_unprotected_branches)
         self.assertEqual(['common-config'],
                          [x.name for x in tenant.config_projects])
         self.assertEqual(['org/project1', 'org/project2'],
@@ -212,6 +213,29 @@
                         project2_config.pipelines['check'].job_list.jobs)
 
 
+class TestTenantUnprotectedBranches(TenantParserTestCase):
+    tenant_config_file = 'config/tenant-parser/unprotected-branches.yaml'
+
+    def test_tenant_unprotected_branches(self):
+        tenant = self.sched.abide.tenants.get('tenant-one')
+        self.assertEqual(True, tenant.exclude_unprotected_branches)
+
+        self.assertEqual(['common-config'],
+                         [x.name for x in tenant.config_projects])
+        self.assertEqual(['org/project1', 'org/project2'],
+                         [x.name for x in tenant.untrusted_projects])
+
+        tpc = tenant.project_configs
+        project_name = tenant.config_projects[0].canonical_name
+        self.assertEqual(False, tpc[project_name].exclude_unprotected_branches)
+
+        project_name = tenant.untrusted_projects[0].canonical_name
+        self.assertIsNone(tpc[project_name].exclude_unprotected_branches)
+
+        project_name = tenant.untrusted_projects[1].canonical_name
+        self.assertIsNone(tpc[project_name].exclude_unprotected_branches)
+
+
 class TestSplitConfig(ZuulTestCase):
     tenant_config_file = 'config/split-config/main.yaml'
 
@@ -281,5 +305,6 @@
         tenant = self.sched.abide.tenants.get('tenant-one')
         jobs = sorted(tenant.layout.jobs.keys())
         self.assertEquals(
-            ['noop', 'trusted-zuul.yaml-job', 'untrusted-zuul.yaml-job'],
+            ['base', 'noop', 'trusted-zuul.yaml-job',
+             'untrusted-zuul.yaml-job'],
             jobs)
diff --git a/tests/unit/test_connection.py b/tests/unit/test_connection.py
index c9e2e91..4214e9f 100644
--- a/tests/unit/test_connection.py
+++ b/tests/unit/test_connection.py
@@ -39,7 +39,7 @@
         self.waitUntilSettled()
 
         self.assertEqual(len(A.patchsets[-1]['approvals']), 1)
-        self.assertEqual(A.patchsets[-1]['approvals'][0]['type'], 'verified')
+        self.assertEqual(A.patchsets[-1]['approvals'][0]['type'], 'Verified')
         self.assertEqual(A.patchsets[-1]['approvals'][0]['value'], '1')
         self.assertEqual(A.patchsets[-1]['approvals'][0]['by']['username'],
                          'jenkins')
@@ -51,7 +51,7 @@
         self.waitUntilSettled()
 
         self.assertEqual(len(B.patchsets[-1]['approvals']), 1)
-        self.assertEqual(B.patchsets[-1]['approvals'][0]['type'], 'verified')
+        self.assertEqual(B.patchsets[-1]['approvals'][0]['type'], 'Verified')
         self.assertEqual(B.patchsets[-1]['approvals'][0]['value'], '-1')
         self.assertEqual(B.patchsets[-1]['approvals'][0]['by']['username'],
                          'civoter')
@@ -86,7 +86,7 @@
         self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
         self.waitUntilSettled()
 
-        # Add a failed result for a negative score
+        # Add a failed result
         B = self.fake_gerrit.addFakeChange('org/project', 'master', 'B')
 
         self.executor_server.failJob('project-test1', B)
@@ -106,7 +106,7 @@
         self.assertEqual('org/project', buildset0['project'])
         self.assertEqual(1, buildset0['change'])
         self.assertEqual(1, buildset0['patchset'])
-        self.assertEqual(1, buildset0['score'])
+        self.assertEqual('SUCCESS', buildset0['result'])
         self.assertEqual('Build succeeded.', buildset0['message'])
         self.assertEqual('tenant-one', buildset0['tenant'])
 
@@ -130,7 +130,7 @@
         self.assertEqual('org/project', buildset1['project'])
         self.assertEqual(2, buildset1['change'])
         self.assertEqual(1, buildset1['patchset'])
-        self.assertEqual(-1, buildset1['score'])
+        self.assertEqual('FAILURE', buildset1['result'])
         self.assertEqual('Build failed.', buildset1['message'])
 
         buildset1_builds = conn.execute(
@@ -183,7 +183,7 @@
         self.assertEqual('org/project', buildsets_resultsdb[0]['project'])
         self.assertEqual(1, buildsets_resultsdb[0]['change'])
         self.assertEqual(1, buildsets_resultsdb[0]['patchset'])
-        self.assertEqual(1, buildsets_resultsdb[0]['score'])
+        self.assertEqual('SUCCESS', buildsets_resultsdb[0]['result'])
         self.assertEqual('Build succeeded.', buildsets_resultsdb[0]['message'])
 
         # Grab the sa tables for resultsdb_failures
@@ -204,7 +204,7 @@
             'org/project', buildsets_resultsdb_failures[0]['project'])
         self.assertEqual(2, buildsets_resultsdb_failures[0]['change'])
         self.assertEqual(1, buildsets_resultsdb_failures[0]['patchset'])
-        self.assertEqual(-1, buildsets_resultsdb_failures[0]['score'])
+        self.assertEqual('FAILURE', buildsets_resultsdb_failures[0]['result'])
         self.assertEqual(
             'Build failed.', buildsets_resultsdb_failures[0]['message'])
 
diff --git a/tests/unit/test_disk_accountant.py b/tests/unit/test_disk_accountant.py
new file mode 100644
index 0000000..7081b53
--- /dev/null
+++ b/tests/unit/test_disk_accountant.py
@@ -0,0 +1,93 @@
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+import os
+import tempfile
+import time
+
+from tests.base import BaseTestCase
+
+from zuul.executor.server import DiskAccountant
+
+
+class FakeExecutor(object):
+    def __init__(self):
+        self.stopped_jobs = set()
+        self.used = {}
+
+    def stopJobByJobDir(self, jobdir):
+        self.stopped_jobs.add(jobdir)
+
+    def usage(self, dirname, used):
+        self.used[dirname] = used
+
+
+class TestDiskAccountant(BaseTestCase):
+    def test_disk_accountant(self):
+        jobs_dir = tempfile.mkdtemp(
+            dir=os.environ.get("ZUUL_TEST_ROOT", None))
+        cache_dir = tempfile.mkdtemp()
+        executor_server = FakeExecutor()
+        da = DiskAccountant(jobs_dir, 1, executor_server.stopJobByJobDir,
+                            cache_dir)
+        da.start()
+
+        try:
+            jobdir = os.path.join(jobs_dir, '012345')
+            os.mkdir(jobdir)
+            testfile = os.path.join(jobdir, 'tfile')
+            with open(testfile, 'w') as tf:
+                tf.write(2 * 1024 * 1024 * '.')
+
+            # da should catch over-limit dir within 5 seconds
+            for i in range(0, 50):
+                if jobdir in executor_server.stopped_jobs:
+                    break
+                time.sleep(0.1)
+            self.assertEqual(set([jobdir]), executor_server.stopped_jobs)
+        finally:
+            da.stop()
+        self.assertFalse(da.thread.is_alive())
+
+    def test_cache_hard_links(self):
+        root_dir = tempfile.mkdtemp(
+            dir=os.environ.get("ZUUL_TEST_ROOT", None))
+        jobs_dir = os.path.join(root_dir, 'jobs')
+        os.mkdir(jobs_dir)
+        cache_dir = os.path.join(root_dir, 'cache')
+        os.mkdir(cache_dir)
+
+        executor_server = FakeExecutor()
+        da = DiskAccountant(jobs_dir, 1, executor_server.stopJobByJobDir,
+                            cache_dir, executor_server.usage)
+        da.start()
+        self.addCleanup(da.stop)
+
+        jobdir = os.path.join(jobs_dir, '012345')
+        os.mkdir(jobdir)
+
+        repo_dir = os.path.join(cache_dir, 'a.repo')
+        os.mkdir(repo_dir)
+        source_file = os.path.join(repo_dir, 'big_file')
+        with open(source_file, 'w') as tf:
+            tf.write(2 * 1024 * 1024 * '.')
+        dest_link = os.path.join(jobdir, 'big_file')
+        os.link(source_file, dest_link)
+
+        # da should _not_ count this file. Wait for 5s to get noticed
+        for i in range(0, 50):
+            if jobdir in executor_server.used:
+                break
+            time.sleep(0.1)
+        self.assertEqual(set(), executor_server.stopped_jobs)
+        self.assertIn(jobdir, executor_server.used)
+        self.assertTrue(executor_server.used[jobdir] <= 1)
diff --git a/tests/unit/test_executor.py b/tests/unit/test_executor.py
index 4700bd1..3793edc 100755
--- a/tests/unit/test_executor.py
+++ b/tests/unit/test_executor.py
@@ -86,10 +86,10 @@
         projects = [p1, p2]
         A = self.fake_gerrit.addFakeChange('org/project1', 'master', 'A')
         B = self.fake_gerrit.addFakeChange('org/project2', 'master', 'B')
-        A.addApproval('code-review', 2)
-        B.addApproval('code-review', 2)
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
-        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
+        A.addApproval('Code-Review', 2)
+        B.addApproval('Code-Review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
+        self.fake_gerrit.addEvent(B.addApproval('Approved', 1))
 
         self.waitUntilSettled()
 
@@ -124,12 +124,12 @@
         B = self.fake_gerrit.addFakeChange('org/project2', 'stable/havana',
                                            'B')
         C = self.fake_gerrit.addFakeChange('org/project3', 'master', 'C')
-        A.addApproval('code-review', 2)
-        B.addApproval('code-review', 2)
-        C.addApproval('code-review', 2)
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
-        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
-        self.fake_gerrit.addEvent(C.addApproval('approved', 1))
+        A.addApproval('Code-Review', 2)
+        B.addApproval('Code-Review', 2)
+        C.addApproval('Code-Review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
+        self.fake_gerrit.addEvent(B.addApproval('Approved', 1))
+        self.fake_gerrit.addEvent(C.addApproval('Approved', 1))
 
         self.waitUntilSettled()
 
@@ -183,14 +183,14 @@
         C = self.fake_gerrit.addFakeChange('org/project2', 'master', 'C')
         D = self.fake_gerrit.addFakeChange('org/project3', 'stable/havana',
                                            'D')
-        A.addApproval('code-review', 2)
-        B.addApproval('code-review', 2)
-        C.addApproval('code-review', 2)
-        D.addApproval('code-review', 2)
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
-        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
-        self.fake_gerrit.addEvent(C.addApproval('approved', 1))
-        self.fake_gerrit.addEvent(D.addApproval('approved', 1))
+        A.addApproval('Code-Review', 2)
+        B.addApproval('Code-Review', 2)
+        C.addApproval('Code-Review', 2)
+        D.addApproval('Code-Review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
+        self.fake_gerrit.addEvent(B.addApproval('Approved', 1))
+        self.fake_gerrit.addEvent(C.addApproval('Approved', 1))
+        self.fake_gerrit.addEvent(D.addApproval('Approved', 1))
 
         self.waitUntilSettled()
 
@@ -274,8 +274,14 @@
         # Stop queuing timer triggered jobs so that the assertions
         # below don't race against more jobs being queued.
         self.commitConfigUpdate('common-config',
-                                'layouts/repo-checkout-no-timer.yaml')
+                                'layouts/repo-checkout-no-timer-override.yaml')
         self.sched.reconfigure(self.config)
+        self.waitUntilSettled()
+        # If APScheduler is in mid-event when we remove the job, we
+        # can end up with one more event firing, so give it an extra
+        # second to settle.
+        time.sleep(1)
+        self.waitUntilSettled()
 
         self.assertEquals(1, len(self.builds), "One build is running")
 
@@ -313,6 +319,12 @@
         self.commitConfigUpdate('common-config',
                                 'layouts/repo-checkout-no-timer.yaml')
         self.sched.reconfigure(self.config)
+        self.waitUntilSettled()
+        # If APScheduler is in mid-event when we remove the job, we
+        # can end up with one more event firing, so give it an extra
+        # second to settle.
+        time.sleep(1)
+        self.waitUntilSettled()
 
         self.assertEquals(2, len(self.builds), "Two builds are running")
 
@@ -336,19 +348,31 @@
         p1 = "review.example.com/org/project1"
         p2 = "review.example.com/org/project2"
         projects = [p1, p2]
+        upstream = self.getUpstreamRepos(projects)
 
         A = self.fake_gerrit.addFakeChange('org/project1', 'master', 'A')
         event = A.getRefUpdatedEvent()
         A.setMerged()
+        A_commit = str(upstream[p1].commit('master'))
+        self.log.debug("A commit: %s" % A_commit)
+
+        # Add another commit to the repo that merged right after this
+        # one to make sure that our post job runs with the one that we
+        # intended rather than simply the current repo state.
+        B = self.fake_gerrit.addFakeChange('org/project1', 'master', 'B',
+                                           parent='refs/changes/1/1/1')
+        B.setMerged()
+        B_commit = str(upstream[p1].commit('master'))
+        self.log.debug("B commit: %s" % B_commit)
+
         self.fake_gerrit.addEvent(event)
         self.waitUntilSettled()
 
-        upstream = self.getUpstreamRepos(projects)
         states = [
-            {p1: dict(commit=str(upstream[p1].commit('master')),
-                      present=[A], branch='master'),
+            {p1: dict(commit=A_commit,
+                      present=[A], absent=[B], branch='master'),
              p2: dict(commit=str(upstream[p2].commit('master')),
-                      absent=[A], branch='master'),
+                      absent=[A, B], branch='master'),
              },
         ]
 
diff --git a/tests/unit/test_github_driver.py b/tests/unit/test_github_driver.py
index 8493570..a088236 100644
--- a/tests/unit/test_github_driver.py
+++ b/tests/unit/test_github_driver.py
@@ -34,11 +34,6 @@
         self.fake_github.emitEvent(A.getPullRequestOpenedEvent())
         self.waitUntilSettled()
 
-        build_params = self.builds[0].parameters
-        self.assertEqual('master', build_params['ZUUL_BRANCH'])
-        self.assertEqual(str(A.number), build_params['ZUUL_CHANGE'])
-        self.assertEqual(A.head_sha, build_params['ZUUL_PATCHSET'])
-
         self.executor_server.hold_jobs_in_build = False
         self.executor_server.release()
         self.waitUntilSettled()
@@ -50,8 +45,9 @@
 
         job = self.getJobFromHistory('project-test2')
         zuulvars = job.parameters['zuul']
-        self.assertEqual(A.number, zuulvars['change'])
-        self.assertEqual(A.head_sha, zuulvars['patchset'])
+        self.assertEqual(str(A.number), zuulvars['change'])
+        self.assertEqual(str(A.head_sha), zuulvars['patchset'])
+        self.assertEqual('master', zuulvars['branch'])
         self.assertEqual(1, len(A.comments))
         self.assertEqual(2, len(self.history))
 
@@ -113,11 +109,9 @@
         self.waitUntilSettled()
 
         build_params = self.builds[0].parameters
-        self.assertEqual('refs/tags/newtag', build_params['ZUUL_REF'])
-        self.assertEqual('00000000000000000000000000000000',
-                         build_params['ZUUL_OLDREV'])
-        self.assertEqual(sha, build_params['ZUUL_NEWREV'])
-
+        self.assertEqual('refs/tags/newtag', build_params['zuul']['ref'])
+        self.assertFalse('oldrev' in build_params['zuul'])
+        self.assertEqual(sha, build_params['zuul']['newrev'])
         self.executor_server.hold_jobs_in_build = False
         self.executor_server.release()
         self.waitUntilSettled()
@@ -129,17 +123,21 @@
     def test_push_event(self):
         self.executor_server.hold_jobs_in_build = True
 
-        old_sha = random_sha1()
-        new_sha = random_sha1()
-        self.fake_github.emitEvent(
-            self.fake_github.getPushEvent('org/project', 'refs/heads/master',
-                                          old_sha, new_sha))
+        A = self.fake_github.openFakePullRequest('org/project', 'master', 'A')
+        old_sha = '0' * 40
+        new_sha = A.head_sha
+        A.setMerged("merging A")
+        pevent = self.fake_github.getPushEvent(project='org/project',
+                                               ref='refs/heads/master',
+                                               old_rev=old_sha,
+                                               new_rev=new_sha)
+        self.fake_github.emitEvent(pevent)
         self.waitUntilSettled()
 
         build_params = self.builds[0].parameters
-        self.assertEqual('refs/heads/master', build_params['ZUUL_REF'])
-        self.assertEqual(old_sha, build_params['ZUUL_OLDREV'])
-        self.assertEqual(new_sha, build_params['ZUUL_NEWREV'])
+        self.assertEqual('refs/heads/master', build_params['zuul']['ref'])
+        self.assertFalse('oldrev' in build_params['zuul'])
+        self.assertEqual(new_sha, build_params['zuul']['newrev'])
 
         self.executor_server.hold_jobs_in_build = False
         self.executor_server.release()
@@ -262,21 +260,27 @@
     @simple_layout('layouts/reporting-github.yaml', driver='github')
     def test_reporting(self):
         project = 'org/project'
+        github = self.fake_github.github_client
+
         # pipeline reports pull status both on start and success
         self.executor_server.hold_jobs_in_build = True
         A = self.fake_github.openFakePullRequest(project, 'master', 'A')
         self.fake_github.emitEvent(A.getPullRequestOpenedEvent())
         self.waitUntilSettled()
+
         # We should have a status container for the head sha
-        statuses = self.fake_github.statuses[project][A.head_sha]
-        self.assertIn(A.head_sha, self.fake_github.statuses[project].keys())
+        self.assertIn(
+            A.head_sha, github.repo_from_project(project)._commits.keys())
+        statuses = self.fake_github.getCommitStatuses(project, A.head_sha)
+
         # We should only have one status for the head sha
         self.assertEqual(1, len(statuses))
         check_status = statuses[0]
         check_url = ('http://zuul.example.com/status/#%s,%s' %
                      (A.number, A.head_sha))
         self.assertEqual('tenant-one/check', check_status['context'])
-        self.assertEqual('Standard check', check_status['description'])
+        self.assertEqual('check status: pending',
+                         check_status['description'])
         self.assertEqual('pending', check_status['state'])
         self.assertEqual(check_url, check_status['url'])
         self.assertEqual(0, len(A.comments))
@@ -285,12 +289,14 @@
         self.executor_server.release()
         self.waitUntilSettled()
         # We should only have two statuses for the head sha
-        statuses = self.fake_github.statuses[project][A.head_sha]
+        statuses = self.fake_github.getCommitStatuses(project, A.head_sha)
         self.assertEqual(2, len(statuses))
         check_status = statuses[0]
         check_url = ('http://zuul.example.com/status/#%s,%s' %
                      (A.number, A.head_sha))
         self.assertEqual('tenant-one/check', check_status['context'])
+        self.assertEqual('check status: success',
+                         check_status['description'])
         self.assertEqual('success', check_status['state'])
         self.assertEqual(check_url, check_status['url'])
         self.assertEqual(1, len(A.comments))
@@ -302,7 +308,7 @@
         self.fake_github.emitEvent(
             A.getCommentAddedEvent('reporting check'))
         self.waitUntilSettled()
-        statuses = self.fake_github.statuses[project][A.head_sha]
+        statuses = self.fake_github.getCommitStatuses(project, A.head_sha)
         self.assertEqual(2, len(statuses))
         # comments increased by one for the start message
         self.assertEqual(2, len(A.comments))
@@ -312,10 +318,12 @@
         self.executor_server.release()
         self.waitUntilSettled()
         # pipeline reports success status
-        statuses = self.fake_github.statuses[project][A.head_sha]
+        statuses = self.fake_github.getCommitStatuses(project, A.head_sha)
         self.assertEqual(3, len(statuses))
         report_status = statuses[0]
         self.assertEqual('tenant-one/reporting', report_status['context'])
+        self.assertEqual('reporting status: success',
+                         report_status['description'])
         self.assertEqual('success', report_status['state'])
         self.assertEqual(2, len(A.comments))
 
@@ -334,13 +342,46 @@
                         MatchesRegex('^[a-fA-F0-9]{32}\/$'))
 
     @simple_layout('layouts/reporting-github.yaml', driver='github')
+    def test_truncated_status_description(self):
+        project = 'org/project'
+        # pipeline reports pull status both on start and success
+        self.executor_server.hold_jobs_in_build = True
+        A = self.fake_github.openFakePullRequest(project, 'master', 'A')
+        self.fake_github.emitEvent(
+            A.getCommentAddedEvent('long pipeline'))
+        self.waitUntilSettled()
+        statuses = self.fake_github.getCommitStatuses(project, A.head_sha)
+        self.assertEqual(1, len(statuses))
+        check_status = statuses[0]
+        # Status is truncated due to long pipeline name
+        self.assertEqual('status: pending',
+                         check_status['description'])
+
+        self.executor_server.hold_jobs_in_build = False
+        self.executor_server.release()
+        self.waitUntilSettled()
+        # We should only have two statuses for the head sha
+        statuses = self.fake_github.getCommitStatuses(project, A.head_sha)
+        self.assertEqual(2, len(statuses))
+        check_status = statuses[0]
+        # Status is truncated due to long pipeline name
+        self.assertEqual('status: success',
+                         check_status['description'])
+
+    @simple_layout('layouts/reporting-github.yaml', driver='github')
     def test_push_reporting(self):
         project = 'org/project2'
         # pipeline reports pull status both on start and success
         self.executor_server.hold_jobs_in_build = True
-        pevent = self.fake_github.getPushEvent(project=project,
-                                               ref='refs/heads/master')
 
+        A = self.fake_github.openFakePullRequest(project, 'master', 'A')
+        old_sha = '0' * 40
+        new_sha = A.head_sha
+        A.setMerged("merging A")
+        pevent = self.fake_github.getPushEvent(project=project,
+                                               ref='refs/heads/master',
+                                               old_rev=old_sha,
+                                               new_rev=new_sha)
         self.fake_github.emitEvent(pevent)
         self.waitUntilSettled()
 
@@ -410,6 +451,52 @@
         self.assertEqual(len(D.comments), 1)
         self.assertEqual(D.comments[0], 'Merge failed')
 
+    @simple_layout('layouts/reporting-multiple-github.yaml', driver='github')
+    def test_reporting_multiple_github(self):
+        project = 'org/project1'
+        github = self.fake_github.github_client
+
+        # pipeline reports pull status both on start and success
+        self.executor_server.hold_jobs_in_build = True
+        A = self.fake_github.openFakePullRequest(project, 'master', 'A')
+        self.fake_github.emitEvent(A.getPullRequestOpenedEvent())
+        # open one on B as well, which should not effect A reporting
+        B = self.fake_github.openFakePullRequest('org/project2', 'master',
+                                                 'B')
+        self.fake_github.emitEvent(B.getPullRequestOpenedEvent())
+        self.waitUntilSettled()
+        # We should have a status container for the head sha
+        statuses = self.fake_github.getCommitStatuses(project, A.head_sha)
+        self.assertIn(
+            A.head_sha, github.repo_from_project(project)._commits.keys())
+        # We should only have one status for the head sha
+        self.assertEqual(1, len(statuses))
+        check_status = statuses[0]
+        check_url = ('http://zuul.example.com/status/#%s,%s' %
+                     (A.number, A.head_sha))
+        self.assertEqual('tenant-one/check', check_status['context'])
+        self.assertEqual('check status: pending', check_status['description'])
+        self.assertEqual('pending', check_status['state'])
+        self.assertEqual(check_url, check_status['url'])
+        self.assertEqual(0, len(A.comments))
+
+        self.executor_server.hold_jobs_in_build = False
+        self.executor_server.release()
+        self.waitUntilSettled()
+        # We should only have two statuses for the head sha
+        statuses = self.fake_github.getCommitStatuses(project, A.head_sha)
+        self.assertEqual(2, len(statuses))
+        check_status = statuses[0]
+        check_url = ('http://zuul.example.com/status/#%s,%s' %
+                     (A.number, A.head_sha))
+        self.assertEqual('tenant-one/check', check_status['context'])
+        self.assertEqual('success', check_status['state'])
+        self.assertEqual('check status: success', check_status['description'])
+        self.assertEqual(check_url, check_status['url'])
+        self.assertEqual(1, len(A.comments))
+        self.assertThat(A.comments[0],
+                        MatchesRegex('.*Build succeeded.*', re.DOTALL))
+
     @simple_layout('layouts/dependent-github.yaml', driver='github')
     def test_parallel_changes(self):
         "Test that changes are tested in parallel and merged in series"
@@ -585,7 +672,7 @@
 
     @simple_layout('layouts/basic-github.yaml', driver='github')
     def test_push_event_reconfigure(self):
-        pevent = self.fake_github.getPushEvent(project='common-config',
+        pevent = self.fake_github.getPushEvent(project='org/common-config',
                                                ref='refs/heads/master',
                                                modified_files=['zuul.yaml'])
 
@@ -612,3 +699,20 @@
             self.fake_github.emitEvent,
             ('ping', pevent),
         )
+
+
+class TestGithubUnprotectedBranches(ZuulTestCase):
+    config_file = 'zuul-github-driver.conf'
+    tenant_config_file = 'config/unprotected-branches/main.yaml'
+
+    def test_unprotected_branches(self):
+        tenant = self.sched.abide.tenants.get('tenant-one')
+
+        project1 = tenant.untrusted_projects[0]
+        project2 = tenant.untrusted_projects[1]
+
+        # project1 should have parsed master
+        self.assertIn('master', project1.unparsed_branch_config.keys())
+
+        # project2 should have no parsed branch
+        self.assertEqual(0, len(project2.unparsed_branch_config.keys()))
diff --git a/tests/unit/test_model.py b/tests/unit/test_model.py
index a52a2ee..ce30e7c 100644
--- a/tests/unit/test_model.py
+++ b/tests/unit/test_model.py
@@ -54,6 +54,8 @@
                 encryption.deserialize_rsa_keypair(f.read())
         self.context = model.SourceContext(self.project, 'master',
                                            'test', True)
+        self.untrusted_context = model.SourceContext(self.project, 'master',
+                                                     'test', False)
         m = yaml.Mark('name', 0, 0, 0, '', 0)
         self.start_mark = configloader.ZuulMark(m, m, '')
 
@@ -65,6 +67,7 @@
             '_source_context': self.context,
             '_start_mark': self.start_mark,
             'name': 'job',
+            'parent': None,
             'irrelevant-files': [
                 '^docs/.*$'
             ]})
@@ -96,16 +99,15 @@
     def test_job_inheritance(self):
         # This is standard job inheritance.
 
-        base_pre = model.PlaybookContext(self.context, 'base-pre', [])
-        base_run = model.PlaybookContext(self.context, 'base-run', [])
-        base_post = model.PlaybookContext(self.context, 'base-post', [])
+        base_pre = model.PlaybookContext(self.context, 'base-pre', [], [])
+        base_run = model.PlaybookContext(self.context, 'base-run', [], [])
+        base_post = model.PlaybookContext(self.context, 'base-post', [], [])
 
         base = model.Job('base')
         base.timeout = 30
         base.pre_run = [base_pre]
         base.run = [base_run]
         base.post_run = [base_post]
-        base.auth = model.AuthContext()
 
         py27 = model.Job('py27')
         self.assertIsNone(py27.timeout)
@@ -117,23 +119,21 @@
                          [x.path for x in py27.run])
         self.assertEqual(['base-post'],
                          [x.path for x in py27.post_run])
-        self.assertIsNone(py27.auth)
 
     def test_job_variants(self):
         # This simulates freezing a job.
 
-        py27_pre = model.PlaybookContext(self.context, 'py27-pre', [])
-        py27_run = model.PlaybookContext(self.context, 'py27-run', [])
-        py27_post = model.PlaybookContext(self.context, 'py27-post', [])
+        secrets = ['foo']
+        py27_pre = model.PlaybookContext(self.context, 'py27-pre', [], secrets)
+        py27_run = model.PlaybookContext(self.context, 'py27-run', [], secrets)
+        py27_post = model.PlaybookContext(self.context, 'py27-post', [],
+                                          secrets)
 
         py27 = model.Job('py27')
         py27.timeout = 30
         py27.pre_run = [py27_pre]
         py27.run = [py27_run]
         py27.post_run = [py27_post]
-        auth = model.AuthContext()
-        auth.secrets.append('foo')
-        py27.auth = auth
 
         job = py27.copy()
         self.assertEqual(30, job.timeout)
@@ -150,7 +150,9 @@
                          [x.path for x in job.run])
         self.assertEqual(['py27-post'],
                          [x.path for x in job.post_run])
-        self.assertEqual(auth, job.auth)
+        self.assertEqual(secrets, job.pre_run[0].secrets)
+        self.assertEqual(secrets, job.run[0].secrets)
+        self.assertEqual(secrets, job.post_run[0].secrets)
 
         # Set the job to final for the following checks
         job.final = True
@@ -184,6 +186,7 @@
             '_source_context': self.context,
             '_start_mark': self.start_mark,
             'name': 'base',
+            'parent': None,
             'timeout': 30,
             'pre-run': 'base-pre',
             'post-run': 'base-post',
@@ -339,108 +342,156 @@
 
         conf = yaml.safe_load('''
 - secret:
-    name: pypi-credentials
+    name: trusted-secret
     data:
       username: test-username
+      longpassword: !encrypted/pkcs1-oaep
+        - BFhtdnm8uXx7kn79RFL/zJywmzLkT1GY78P3bOtp4WghUFWobkifSu7ZpaV4NeO0s71Y
+          Usi1wGZZL0LveZjUN0t6OU1VZKSG8R5Ly7urjaSo1pPVIq5Rtt/H7W14Lecd+cUeKb4j
+          oeusC9drN3AA8a4oykcVpt1wVqUnTbMGC9ARMCQP6eopcs1l7tzMseprW4RDNhIuz3CR
+          gd0QBMPl6VDoFgBPB8vxtJw+3m0rqBYZCLZgCXekqlny8s2s92nJMuUABbJOEcDRarzi
+          bDsSXsfJt1y+5n7yOURsC7lovMg4GF/vCl/0YMKjBO5bpv9EM5fToeKYyPGSKQoHOnCY
+          ceb3cAVcv5UawcCic8XjhEhp4K7WPdYf2HVAC/qtxhbpjTxG4U5Q/SoppOJ60WqEkQvb
+          Xs6n5Dvy7xmph6GWmU/bAv3eUK3pdD3xa2Ue1lHWz3U+rsYraI+AKYsMYx3RBlfAmCeC
+          1ve2BXPrqnOo7G8tnUvfdYPbK4Aakk0ds/AVqFHEZN+S6hRBmBjLaRFWZ3QSO1NjbBxW
+          naHKZYT7nkrJm8AMCgZU0ZArFLpaufKCeiK5ECSsDxic4FIsY1OkWT42qEUfL0Wd+150
+          AKGNZpPJnnP3QYY4W/MWcKH/zdO400+zWN52WevbSqZy90tqKDJrBkMl1ydqbuw1E4ZH
+          vIs=
+        - BFhtdnm8uXx7kn79RFL/zJywmzLkT1GY78P3bOtp4WghUFWobkifSu7ZpaV4NeO0s71Y
+          Usi1wGZZL0LveZjUN0t6OU1VZKSG8R5Ly7urjaSo1pPVIq5Rtt/H7W14Lecd+cUeKb4j
+          oeusC9drN3AA8a4oykcVpt1wVqUnTbMGC9ARMCQP6eopcs1l7tzMseprW4RDNhIuz3CR
+          gd0QBMPl6VDoFgBPB8vxtJw+3m0rqBYZCLZgCXekqlny8s2s92nJMuUABbJOEcDRarzi
+          bDsSXsfJt1y+5n7yOURsC7lovMg4GF/vCl/0YMKjBO5bpv9EM5fToeKYyPGSKQoHOnCY
+          ceb3cAVcv5UawcCic8XjhEhp4K7WPdYf2HVAC/qtxhbpjTxG4U5Q/SoppOJ60WqEkQvb
+          Xs6n5Dvy7xmph6GWmU/bAv3eUK3pdD3xa2Ue1lHWz3U+rsYraI+AKYsMYx3RBlfAmCeC
+          1ve2BXPrqnOo7G8tnUvfdYPbK4Aakk0ds/AVqFHEZN+S6hRBmBjLaRFWZ3QSO1NjbBxW
+          naHKZYT7nkrJm8AMCgZU0ZArFLpaufKCeiK5ECSsDxic4FIsY1OkWT42qEUfL0Wd+150
+          AKGNZpPJnnP3QYY4W/MWcKH/zdO400+zWN52WevbSqZy90tqKDJrBkMl1ydqbuw1E4ZH
+          vIs=
       password: !encrypted/pkcs1-oaep |
-        BFhtdnm8uXx7kn79RFL/zJywmzLkT1GY78P3bOtp4WghUFWobkifSu7ZpaV4NeO0s71YUsi1wGZZ
-        L0LveZjUN0t6OU1VZKSG8R5Ly7urjaSo1pPVIq5Rtt/H7W14Lecd+cUeKb4joeusC9drN3AA8a4o
-        ykcVpt1wVqUnTbMGC9ARMCQP6eopcs1l7tzMseprW4RDNhIuz3CRgd0QBMPl6VDoFgBPB8vxtJw+
-        3m0rqBYZCLZgCXekqlny8s2s92nJMuUABbJOEcDRarzibDsSXsfJt1y+5n7yOURsC7lovMg4GF/v
-        Cl/0YMKjBO5bpv9EM5fToeKYyPGSKQoHOnCYceb3cAVcv5UawcCic8XjhEhp4K7WPdYf2HVAC/qt
-        xhbpjTxG4U5Q/SoppOJ60WqEkQvbXs6n5Dvy7xmph6GWmU/bAv3eUK3pdD3xa2Ue1lHWz3U+rsYr
-        aI+AKYsMYx3RBlfAmCeC1ve2BXPrqnOo7G8tnUvfdYPbK4Aakk0ds/AVqFHEZN+S6hRBmBjLaRFW
-        Z3QSO1NjbBxWnaHKZYT7nkrJm8AMCgZU0ZArFLpaufKCeiK5ECSsDxic4FIsY1OkWT42qEUfL0Wd
-        +150AKGNZpPJnnP3QYY4W/MWcKH/zdO400+zWN52WevbSqZy90tqKDJrBkMl1ydqbuw1E4ZHvIs=
+        BFhtdnm8uXx7kn79RFL/zJywmzLkT1GY78P3bOtp4WghUFWobkifSu7ZpaV4NeO0s71Y
+        Usi1wGZZL0LveZjUN0t6OU1VZKSG8R5Ly7urjaSo1pPVIq5Rtt/H7W14Lecd+cUeKb4j
+        oeusC9drN3AA8a4oykcVpt1wVqUnTbMGC9ARMCQP6eopcs1l7tzMseprW4RDNhIuz3CR
+        gd0QBMPl6VDoFgBPB8vxtJw+3m0rqBYZCLZgCXekqlny8s2s92nJMuUABbJOEcDRarzi
+        bDsSXsfJt1y+5n7yOURsC7lovMg4GF/vCl/0YMKjBO5bpv9EM5fToeKYyPGSKQoHOnCY
+        ceb3cAVcv5UawcCic8XjhEhp4K7WPdYf2HVAC/qtxhbpjTxG4U5Q/SoppOJ60WqEkQvb
+        Xs6n5Dvy7xmph6GWmU/bAv3eUK3pdD3xa2Ue1lHWz3U+rsYraI+AKYsMYx3RBlfAmCeC
+        1ve2BXPrqnOo7G8tnUvfdYPbK4Aakk0ds/AVqFHEZN+S6hRBmBjLaRFWZ3QSO1NjbBxW
+        naHKZYT7nkrJm8AMCgZU0ZArFLpaufKCeiK5ECSsDxic4FIsY1OkWT42qEUfL0Wd+150
+        AKGNZpPJnnP3QYY4W/MWcKH/zdO400+zWN52WevbSqZy90tqKDJrBkMl1ydqbuw1E4ZH
+        vIs=
 ''')[0]['secret']
 
         conf['_source_context'] = self.context
         conf['_start_mark'] = self.start_mark
 
-        secret = configloader.SecretParser.fromYaml(layout, conf)
-        layout.addSecret(secret)
+        trusted_secret = configloader.SecretParser.fromYaml(layout, conf)
+        layout.addSecret(trusted_secret)
+
+        conf['name'] = 'untrusted-secret'
+        conf['_source_context'] = self.untrusted_context
+
+        untrusted_secret = configloader.SecretParser.fromYaml(layout, conf)
+        layout.addSecret(untrusted_secret)
 
         base = configloader.JobParser.fromYaml(self.tenant, self.layout, {
             '_source_context': self.context,
             '_start_mark': self.start_mark,
             'name': 'base',
+            'parent': None,
             'timeout': 30,
         })
         layout.addJob(base)
-        pypi_upload_without_inherit = configloader.JobParser.fromYaml(
-            tenant, layout, {
-                '_source_context': self.context,
-                '_start_mark': self.start_mark,
-                'name': 'pypi-upload-without-inherit',
-                'parent': 'base',
-                'timeout': 40,
-                'auth': {
-                    'secrets': [
-                        'pypi-credentials',
-                    ]
-                }
-            })
-        layout.addJob(pypi_upload_without_inherit)
-        pypi_upload_with_inherit = configloader.JobParser.fromYaml(
-            tenant, layout, {
-                '_source_context': self.context,
-                '_start_mark': self.start_mark,
-                'name': 'pypi-upload-with-inherit',
-                'parent': 'base',
-                'timeout': 40,
-                'auth': {
-                    'inherit': True,
-                    'secrets': [
-                        'pypi-credentials',
-                    ]
-                }
-            })
-        layout.addJob(pypi_upload_with_inherit)
-        pypi_upload_with_inherit_false = configloader.JobParser.fromYaml(
-            tenant, layout, {
-                '_source_context': self.context,
-                '_start_mark': self.start_mark,
-                'name': 'pypi-upload-with-inherit-false',
-                'parent': 'base',
-                'timeout': 40,
-                'auth': {
-                    'inherit': False,
-                    'secrets': [
-                        'pypi-credentials',
-                    ]
-                }
-            })
-        layout.addJob(pypi_upload_with_inherit_false)
-        in_repo_job_without_inherit = configloader.JobParser.fromYaml(
-            tenant, layout, {
-                '_source_context': self.context,
-                '_start_mark': self.start_mark,
-                'name': 'in-repo-job-without-inherit',
-                'parent': 'pypi-upload-without-inherit',
-            })
-        layout.addJob(in_repo_job_without_inherit)
-        in_repo_job_with_inherit = configloader.JobParser.fromYaml(
-            tenant, layout, {
-                '_source_context': self.context,
-                '_start_mark': self.start_mark,
-                'name': 'in-repo-job-with-inherit',
-                'parent': 'pypi-upload-with-inherit',
-            })
-        layout.addJob(in_repo_job_with_inherit)
-        in_repo_job_with_inherit_false = configloader.JobParser.fromYaml(
-            tenant, layout, {
-                '_source_context': self.context,
-                '_start_mark': self.start_mark,
-                'name': 'in-repo-job-with-inherit-false',
-                'parent': 'pypi-upload-with-inherit-false',
-            })
-        layout.addJob(in_repo_job_with_inherit_false)
 
-        self.assertIsNone(in_repo_job_without_inherit.auth)
-        self.assertEqual(1, len(in_repo_job_with_inherit.auth.secrets))
-        self.assertEqual(in_repo_job_with_inherit.auth.secrets[0].name,
-                         'pypi-credentials')
-        self.assertIsNone(in_repo_job_with_inherit_false.auth)
+        trusted_secrets_job = configloader.JobParser.fromYaml(
+            tenant, layout, {
+                '_source_context': self.context,
+                '_start_mark': self.start_mark,
+                'name': 'trusted-secrets',
+                'parent': 'base',
+                'timeout': 40,
+                'secrets': [
+                    'trusted-secret',
+                ]
+            })
+        layout.addJob(trusted_secrets_job)
+        untrusted_secrets_job = configloader.JobParser.fromYaml(
+            tenant, layout, {
+                '_source_context': self.untrusted_context,
+                '_start_mark': self.start_mark,
+                'name': 'untrusted-secrets',
+                'parent': 'base',
+                'timeout': 40,
+                'secrets': [
+                    'untrusted-secret',
+                ]
+            })
+        layout.addJob(untrusted_secrets_job)
+        trusted_secrets_trusted_child_job = configloader.JobParser.fromYaml(
+            tenant, layout, {
+                '_source_context': self.context,
+                '_start_mark': self.start_mark,
+                'name': 'trusted-secrets-trusted-child',
+                'parent': 'trusted-secrets',
+            })
+        layout.addJob(trusted_secrets_trusted_child_job)
+        trusted_secrets_untrusted_child_job = configloader.JobParser.fromYaml(
+            tenant, layout, {
+                '_source_context': self.untrusted_context,
+                '_start_mark': self.start_mark,
+                'name': 'trusted-secrets-untrusted-child',
+                'parent': 'trusted-secrets',
+            })
+        layout.addJob(trusted_secrets_untrusted_child_job)
+        untrusted_secrets_trusted_child_job = configloader.JobParser.fromYaml(
+            tenant, layout, {
+                '_source_context': self.context,
+                '_start_mark': self.start_mark,
+                'name': 'untrusted-secrets-trusted-child',
+                'parent': 'untrusted-secrets',
+            })
+        layout.addJob(untrusted_secrets_trusted_child_job)
+        untrusted_secrets_untrusted_child_job = \
+            configloader.JobParser.fromYaml(
+                tenant, layout, {
+                    '_source_context': self.untrusted_context,
+                    '_start_mark': self.start_mark,
+                    'name': 'untrusted-secrets-untrusted-child',
+                    'parent': 'untrusted-secrets',
+                })
+        layout.addJob(untrusted_secrets_untrusted_child_job)
+
+        self.assertIsNone(trusted_secrets_job.post_review)
+        self.assertTrue(untrusted_secrets_job.post_review)
+        self.assertIsNone(
+            trusted_secrets_trusted_child_job.post_review)
+        self.assertIsNone(
+            trusted_secrets_untrusted_child_job.post_review)
+        self.assertTrue(
+            untrusted_secrets_trusted_child_job.post_review)
+        self.assertTrue(
+            untrusted_secrets_untrusted_child_job.post_review)
+
+        self.assertEqual(trusted_secrets_job.implied_run[0].secrets[0].name,
+                         'trusted-secret')
+        self.assertEqual(trusted_secrets_job.implied_run[0].secrets[0].
+                         secret_data['longpassword'],
+                         'test-passwordtest-password')
+        self.assertEqual(trusted_secrets_job.implied_run[0].secrets[0].
+                         secret_data['password'],
+                         'test-password')
+        self.assertEqual(
+            len(trusted_secrets_trusted_child_job.implied_run[0].secrets), 0)
+        self.assertEqual(
+            len(trusted_secrets_untrusted_child_job.implied_run[0].secrets), 0)
+
+        self.assertEqual(untrusted_secrets_job.implied_run[0].secrets[0].name,
+                         'untrusted-secret')
+        self.assertEqual(
+            len(untrusted_secrets_trusted_child_job.implied_run[0].secrets), 0)
+        self.assertEqual(
+            len(untrusted_secrets_untrusted_child_job.implied_run[0].secrets),
+            0)
 
     def test_job_inheritance_job_tree(self):
         tenant = model.Tenant('tenant')
@@ -456,6 +507,7 @@
             '_source_context': self.context,
             '_start_mark': self.start_mark,
             'name': 'base',
+            'parent': None,
             'timeout': 30,
         })
         layout.addJob(base)
@@ -534,6 +586,7 @@
             '_source_context': self.context,
             '_start_mark': self.start_mark,
             'name': 'base',
+            'parent': None,
             'timeout': 30,
         })
         layout.addJob(base)
@@ -583,6 +636,7 @@
         base = configloader.JobParser.fromYaml(tenant, layout, {
             '_source_context': base_context,
             '_start_mark': self.start_mark,
+            'parent': None,
             'name': 'base',
         })
         layout.addJob(base)
@@ -608,6 +662,7 @@
             '_source_context': self.context,
             '_start_mark': self.start_mark,
             'name': 'job',
+            'parent': None,
             'allowed-projects': ['project'],
         })
         self.layout.addJob(job)
@@ -642,16 +697,15 @@
                 "Project project2 is not allowed to run job job"):
             item.freezeJobGraph()
 
-    def test_job_pipeline_allow_secrets(self):
-        self.pipeline.allow_secrets = False
+    def test_job_pipeline_allow_untrusted_secrets(self):
+        self.pipeline.post_review = False
         job = configloader.JobParser.fromYaml(self.tenant, self.layout, {
             '_source_context': self.context,
             '_start_mark': self.start_mark,
             'name': 'job',
+            'parent': None,
         })
-        auth = model.AuthContext()
-        auth.secrets.append('foo')
-        job.auth = auth
+        job.post_review = True
 
         self.layout.addJob(job)
 
@@ -676,7 +730,7 @@
         item.current_build_set.layout = self.layout
         with testtools.ExpectedException(
                 Exception,
-                "Pipeline gate does not allow jobs with secrets"):
+                "Pre-review pipeline gate does not allow post-review job"):
             item.freezeJobGraph()
 
 
diff --git a/tests/unit/test_multi_driver.py b/tests/unit/test_multi_driver.py
index 864bd31..1844c33 100644
--- a/tests/unit/test_multi_driver.py
+++ b/tests/unit/test_multi_driver.py
@@ -43,3 +43,13 @@
         self.executor_server.hold_jobs_in_build = False
         self.executor_server.release()
         self.waitUntilSettled()
+
+        # Check on reporting results
+        # github should have a success status (only).
+        statuses = self.fake_github.getCommitStatuses(
+            'org/project1', B.head_sha)
+        self.assertEqual(1, len(statuses))
+        self.assertEqual('success', statuses[0]['state'])
+
+        # gerrit should have only reported twice, on start and success
+        self.assertEqual(A.reported, 2)
diff --git a/tests/unit/test_openstack.py b/tests/unit/test_openstack.py
index 4fceba0..980797f 100644
--- a/tests/unit/test_openstack.py
+++ b/tests/unit/test_openstack.py
@@ -27,8 +27,8 @@
 
     def test_nova_master(self):
         A = self.fake_gerrit.addFakeChange('openstack/nova', 'master', 'A')
-        A.addApproval('code-review', 2)
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        A.addApproval('Code-Review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
         self.waitUntilSettled()
         self.assertEqual(self.getJobFromHistory('python27').result,
                          'SUCCESS')
@@ -44,8 +44,8 @@
         self.create_branch('openstack/nova', 'stable/mitaka')
         A = self.fake_gerrit.addFakeChange('openstack/nova',
                                            'stable/mitaka', 'A')
-        A.addApproval('code-review', 2)
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        A.addApproval('Code-Review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
         self.waitUntilSettled()
         self.assertEqual(self.getJobFromHistory('python27').result,
                          'SUCCESS')
diff --git a/tests/unit/test_push_reqs.py b/tests/unit/test_push_reqs.py
index d3a1feb..80c3be9 100644
--- a/tests/unit/test_push_reqs.py
+++ b/tests/unit/test_push_reqs.py
@@ -25,12 +25,13 @@
     def test_push_requirements(self):
         self.executor_server.hold_jobs_in_build = True
 
-        # Create a github change, add a change and emit a push event
         A = self.fake_github.openFakePullRequest('org/project1', 'master', 'A')
-        old_sha = A.head_sha
+        new_sha = A.head_sha
+        A.setMerged("merging A")
         pevent = self.fake_github.getPushEvent(project='org/project1',
                                                ref='refs/heads/master',
-                                               old_rev=old_sha)
+                                               new_rev=new_sha)
+
         self.fake_github.emitEvent(pevent)
 
         self.waitUntilSettled()
@@ -43,7 +44,7 @@
         # Make a gerrit change, and emit a ref-updated event
         B = self.fake_gerrit.addFakeChange('org/project2', 'master', 'B')
         self.fake_gerrit.addEvent(B.getRefUpdatedEvent())
-
+        B.setMerged()
         self.waitUntilSettled()
 
         # All but one pipeline should be skipped, increasing builds by 1
diff --git a/tests/unit/test_requirements.py b/tests/unit/test_requirements.py
index 7e578cf..ff39fd7 100644
--- a/tests/unit/test_requirements.py
+++ b/tests/unit/test_requirements.py
@@ -37,21 +37,21 @@
     def _test_require_approval_newer_than(self, project, job):
         A = self.fake_gerrit.addFakeChange(project, 'master', 'A')
         # A comment event that we will keep submitting to trigger
-        comment = A.addApproval('code-review', 2, username='nobody')
+        comment = A.addApproval('Code-Review', 2, username='nobody')
         self.fake_gerrit.addEvent(comment)
         self.waitUntilSettled()
         # No +1 from Jenkins so should not be enqueued
         self.assertEqual(len(self.history), 0)
 
         # Add a too-old +1, should not be enqueued
-        A.addApproval('verified', 1, username='jenkins',
+        A.addApproval('Verified', 1, username='jenkins',
                       granted_on=time.time() - 72 * 60 * 60)
         self.fake_gerrit.addEvent(comment)
         self.waitUntilSettled()
         self.assertEqual(len(self.history), 0)
 
         # Add a recent +1
-        self.fake_gerrit.addEvent(A.addApproval('verified', 1,
+        self.fake_gerrit.addEvent(A.addApproval('Verified', 1,
                                                 username='jenkins'))
         self.fake_gerrit.addEvent(comment)
         self.waitUntilSettled()
@@ -77,20 +77,20 @@
     def _test_require_approval_older_than(self, project, job):
         A = self.fake_gerrit.addFakeChange(project, 'master', 'A')
         # A comment event that we will keep submitting to trigger
-        comment = A.addApproval('code-review', 2, username='nobody')
+        comment = A.addApproval('Code-Review', 2, username='nobody')
         self.fake_gerrit.addEvent(comment)
         self.waitUntilSettled()
         # No +1 from Jenkins so should not be enqueued
         self.assertEqual(len(self.history), 0)
 
         # Add a recent +1 which should not be enqueued
-        A.addApproval('verified', 1)
+        A.addApproval('Verified', 1)
         self.fake_gerrit.addEvent(comment)
         self.waitUntilSettled()
         self.assertEqual(len(self.history), 0)
 
         # Add an old +1 which should be enqueued
-        A.addApproval('verified', 1, username='jenkins',
+        A.addApproval('Verified', 1, username='jenkins',
                       granted_on=time.time() - 72 * 60 * 60)
         self.fake_gerrit.addEvent(comment)
         self.waitUntilSettled()
@@ -116,14 +116,14 @@
     def _test_require_approval_username(self, project, job):
         A = self.fake_gerrit.addFakeChange(project, 'master', 'A')
         # A comment event that we will keep submitting to trigger
-        comment = A.addApproval('code-review', 2, username='nobody')
+        comment = A.addApproval('Code-Review', 2, username='nobody')
         self.fake_gerrit.addEvent(comment)
         self.waitUntilSettled()
         # No approval from Jenkins so should not be enqueued
         self.assertEqual(len(self.history), 0)
 
         # Add an approval from Jenkins
-        A.addApproval('verified', 1, username='jenkins')
+        A.addApproval('Verified', 1, username='jenkins')
         self.fake_gerrit.addEvent(comment)
         self.waitUntilSettled()
         self.assertEqual(len(self.history), 1)
@@ -148,14 +148,14 @@
     def _test_require_approval_email(self, project, job):
         A = self.fake_gerrit.addFakeChange(project, 'master', 'A')
         # A comment event that we will keep submitting to trigger
-        comment = A.addApproval('code-review', 2, username='nobody')
+        comment = A.addApproval('Code-Review', 2, username='nobody')
         self.fake_gerrit.addEvent(comment)
         self.waitUntilSettled()
         # No approval from Jenkins so should not be enqueued
         self.assertEqual(len(self.history), 0)
 
         # Add an approval from Jenkins
-        A.addApproval('verified', 1, username='jenkins')
+        A.addApproval('Verified', 1, username='jenkins')
         self.fake_gerrit.addEvent(comment)
         self.waitUntilSettled()
         self.assertEqual(len(self.history), 1)
@@ -180,20 +180,20 @@
     def _test_require_approval_vote1(self, project, job):
         A = self.fake_gerrit.addFakeChange(project, 'master', 'A')
         # A comment event that we will keep submitting to trigger
-        comment = A.addApproval('code-review', 2, username='nobody')
+        comment = A.addApproval('Code-Review', 2, username='nobody')
         self.fake_gerrit.addEvent(comment)
         self.waitUntilSettled()
         # No approval from Jenkins so should not be enqueued
         self.assertEqual(len(self.history), 0)
 
         # A -1 from jenkins should not cause it to be enqueued
-        A.addApproval('verified', -1, username='jenkins')
+        A.addApproval('Verified', -1, username='jenkins')
         self.fake_gerrit.addEvent(comment)
         self.waitUntilSettled()
         self.assertEqual(len(self.history), 0)
 
         # A +1 should allow it to be enqueued
-        A.addApproval('verified', 1, username='jenkins')
+        A.addApproval('Verified', 1, username='jenkins')
         self.fake_gerrit.addEvent(comment)
         self.waitUntilSettled()
         self.assertEqual(len(self.history), 1)
@@ -218,26 +218,26 @@
     def _test_require_approval_vote2(self, project, job):
         A = self.fake_gerrit.addFakeChange(project, 'master', 'A')
         # A comment event that we will keep submitting to trigger
-        comment = A.addApproval('code-review', 2, username='nobody')
+        comment = A.addApproval('Code-Review', 2, username='nobody')
         self.fake_gerrit.addEvent(comment)
         self.waitUntilSettled()
         # No approval from Jenkins so should not be enqueued
         self.assertEqual(len(self.history), 0)
 
         # A -1 from jenkins should not cause it to be enqueued
-        A.addApproval('verified', -1, username='jenkins')
+        A.addApproval('Verified', -1, username='jenkins')
         self.fake_gerrit.addEvent(comment)
         self.waitUntilSettled()
         self.assertEqual(len(self.history), 0)
 
         # A -2 from jenkins should not cause it to be enqueued
-        A.addApproval('verified', -2, username='jenkins')
+        A.addApproval('Verified', -2, username='jenkins')
         self.fake_gerrit.addEvent(comment)
         self.waitUntilSettled()
         self.assertEqual(len(self.history), 0)
 
         # A +1 from jenkins should allow it to be enqueued
-        A.addApproval('verified', 1, username='jenkins')
+        A.addApproval('Verified', 1, username='jenkins')
         self.fake_gerrit.addEvent(comment)
         self.waitUntilSettled()
         self.assertEqual(len(self.history), 1)
@@ -246,13 +246,13 @@
         # A +2 from nobody should not cause it to be enqueued
         B = self.fake_gerrit.addFakeChange(project, 'master', 'B')
         # A comment event that we will keep submitting to trigger
-        comment = B.addApproval('code-review', 2, username='nobody')
+        comment = B.addApproval('Code-Review', 2, username='nobody')
         self.fake_gerrit.addEvent(comment)
         self.waitUntilSettled()
         self.assertEqual(len(self.history), 1)
 
         # A +2 from jenkins should allow it to be enqueued
-        B.addApproval('verified', 2, username='jenkins')
+        B.addApproval('Verified', 2, username='jenkins')
         self.fake_gerrit.addEvent(comment)
         self.waitUntilSettled()
         self.assertEqual(len(self.history), 2)
@@ -269,10 +269,10 @@
         # comment on first patchset and check that no additional
         # jobs are run.
         A = self.fake_gerrit.addFakeChange('current-project', 'master', 'A')
-        self.fake_gerrit.addEvent(A.addApproval('code-review', 1))
+        self.fake_gerrit.addEvent(A.addApproval('Code-Review', 1))
         self.waitUntilSettled()
         A.addPatchset()
-        self.fake_gerrit.addEvent(A.addApproval('code-review', 1))
+        self.fake_gerrit.addEvent(A.addApproval('Code-Review', 1))
         self.waitUntilSettled()
 
         self.assertEqual(len(self.history), 2)  # one job for each ps
@@ -290,24 +290,24 @@
     def test_pipeline_require_open(self):
         A = self.fake_gerrit.addFakeChange('open-project', 'master', 'A',
                                            status='MERGED')
-        self.fake_gerrit.addEvent(A.addApproval('code-review', 2))
+        self.fake_gerrit.addEvent(A.addApproval('Code-Review', 2))
         self.waitUntilSettled()
         self.assertEqual(len(self.history), 0)
 
         B = self.fake_gerrit.addFakeChange('open-project', 'master', 'B')
-        self.fake_gerrit.addEvent(B.addApproval('code-review', 2))
+        self.fake_gerrit.addEvent(B.addApproval('Code-Review', 2))
         self.waitUntilSettled()
         self.assertEqual(len(self.history), 1)
 
     def test_pipeline_require_status(self):
         A = self.fake_gerrit.addFakeChange('status-project', 'master', 'A',
                                            status='MERGED')
-        self.fake_gerrit.addEvent(A.addApproval('code-review', 2))
+        self.fake_gerrit.addEvent(A.addApproval('Code-Review', 2))
         self.waitUntilSettled()
         self.assertEqual(len(self.history), 0)
 
         B = self.fake_gerrit.addFakeChange('status-project', 'master', 'B')
-        self.fake_gerrit.addEvent(B.addApproval('code-review', 2))
+        self.fake_gerrit.addEvent(B.addApproval('Code-Review', 2))
         self.waitUntilSettled()
         self.assertEqual(len(self.history), 1)
 
@@ -326,21 +326,21 @@
         self.assertEqual(len(self.history), 0)
 
         # add in a comment that will trigger
-        self.fake_gerrit.addEvent(A.addApproval('code-review', 1,
+        self.fake_gerrit.addEvent(A.addApproval('Code-Review', 1,
                                                 username='reviewer'))
         self.waitUntilSettled()
         self.assertEqual(len(self.history), 1)
         self.assertEqual(self.history[0].name, job)
 
         # add in a comment from jenkins user which shouldn't trigger
-        self.fake_gerrit.addEvent(A.addApproval('verified', 1,
+        self.fake_gerrit.addEvent(A.addApproval('Verified', 1,
                                                 username='jenkins'))
         self.waitUntilSettled()
         self.assertEqual(len(self.history), 1)
 
         # Check future reviews also won't trigger as a 'jenkins' user has
         # commented previously
-        self.fake_gerrit.addEvent(A.addApproval('code-review', 1,
+        self.fake_gerrit.addEvent(A.addApproval('Code-Review', 1,
                                                 username='reviewer'))
         self.waitUntilSettled()
         self.assertEqual(len(self.history), 1)
@@ -368,39 +368,39 @@
         self.assertEqual(len(self.history), 0)
 
         # First positive vote should not queue until jenkins has +1'd
-        comment = A.addApproval('verified', 1, username='reviewer_a')
+        comment = A.addApproval('Verified', 1, username='reviewer_a')
         self.fake_gerrit.addEvent(comment)
         self.waitUntilSettled()
         self.assertEqual(len(self.history), 0)
 
         # Jenkins should put in a +1 which will also queue
-        comment = A.addApproval('verified', 1, username='jenkins')
+        comment = A.addApproval('Verified', 1, username='jenkins')
         self.fake_gerrit.addEvent(comment)
         self.waitUntilSettled()
         self.assertEqual(len(self.history), 1)
         self.assertEqual(self.history[0].name, job)
 
         # Negative vote should not queue
-        comment = A.addApproval('verified', -1, username='reviewer_b')
+        comment = A.addApproval('Verified', -1, username='reviewer_b')
         self.fake_gerrit.addEvent(comment)
         self.waitUntilSettled()
         self.assertEqual(len(self.history), 1)
 
         # Future approvals should do nothing
-        comment = A.addApproval('verified', 1, username='reviewer_c')
+        comment = A.addApproval('Verified', 1, username='reviewer_c')
         self.fake_gerrit.addEvent(comment)
         self.waitUntilSettled()
         self.assertEqual(len(self.history), 1)
 
         # Change/update negative vote should queue
-        comment = A.addApproval('verified', 1, username='reviewer_b')
+        comment = A.addApproval('Verified', 1, username='reviewer_b')
         self.fake_gerrit.addEvent(comment)
         self.waitUntilSettled()
         self.assertEqual(len(self.history), 2)
         self.assertEqual(self.history[1].name, job)
 
         # Future approvals should also queue
-        comment = A.addApproval('verified', 1, username='reviewer_d')
+        comment = A.addApproval('Verified', 1, username='reviewer_d')
         self.fake_gerrit.addEvent(comment)
         self.waitUntilSettled()
         self.assertEqual(len(self.history), 3)
diff --git a/tests/unit/test_scheduler.py b/tests/unit/test_scheduler.py
index d4290a9..97d53e0 100755
--- a/tests/unit/test_scheduler.py
+++ b/tests/unit/test_scheduler.py
@@ -49,8 +49,8 @@
         "Test that jobs are executed and a change is merged"
 
         A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
-        A.addApproval('code-review', 2)
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        A.addApproval('Code-Review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
         self.waitUntilSettled()
         self.assertEqual(self.getJobFromHistory('project-merge').result,
                          'SUCCESS')
@@ -62,7 +62,8 @@
         self.assertEqual(A.reported, 2)
         self.assertEqual(self.getJobFromHistory('project-test1').node,
                          'label1')
-        self.assertIsNone(self.getJobFromHistory('project-test2').node)
+        self.assertEqual(self.getJobFromHistory('project-test2').node,
+                         'label1')
 
 
 class TestScheduler(ZuulTestCase):
@@ -72,8 +73,8 @@
         "Test that jobs are executed and a change is merged"
 
         A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
-        A.addApproval('code-review', 2)
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        A.addApproval('Code-Review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
         self.waitUntilSettled()
         self.assertEqual(self.getJobFromHistory('project-merge').result,
                          'SUCCESS')
@@ -85,7 +86,8 @@
         self.assertEqual(A.reported, 2)
         self.assertEqual(self.getJobFromHistory('project-test1').node,
                          'label1')
-        self.assertIsNone(self.getJobFromHistory('project-test2').node)
+        self.assertEqual(self.getJobFromHistory('project-test2').node,
+                         'label1')
 
         # TODOv3(jeblair): we may want to report stats by tenant (also?).
         # Per-driver
@@ -107,8 +109,8 @@
         self.assertReportedStat(
             'zuul.pipeline.gate.org.project.total_changes', value='1|c')
 
-        for build in self.builds:
-            self.assertEqual(build.parameters['ZUUL_VOTING'], '1')
+        for build in self.history:
+            self.assertTrue(build.parameters['zuul']['voting'])
 
     def test_initial_pipeline_gauges(self):
         "Test that each pipeline reported its length on start"
@@ -121,8 +123,8 @@
         "Test the correct variant of a job runs on a branch"
         self.create_branch('org/project', 'stable')
         A = self.fake_gerrit.addFakeChange('org/project', 'stable', 'A')
-        A.addApproval('code-review', 2)
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        A.addApproval('Code-Review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
         self.waitUntilSettled()
         self.assertEqual(self.getJobFromHistory('project-test1').result,
                          'SUCCESS')
@@ -143,13 +145,13 @@
         A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
         B = self.fake_gerrit.addFakeChange('org/project', 'master', 'B')
         C = self.fake_gerrit.addFakeChange('org/project', 'master', 'C')
-        A.addApproval('code-review', 2)
-        B.addApproval('code-review', 2)
-        C.addApproval('code-review', 2)
+        A.addApproval('Code-Review', 2)
+        B.addApproval('Code-Review', 2)
+        C.addApproval('Code-Review', 2)
 
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
-        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
-        self.fake_gerrit.addEvent(C.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
+        self.fake_gerrit.addEvent(B.addApproval('Approved', 1))
+        self.fake_gerrit.addEvent(C.addApproval('Approved', 1))
 
         self.waitUntilSettled()
         self.assertEqual(len(self.builds), 1)
@@ -219,13 +221,13 @@
 
         A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
         B = self.fake_gerrit.addFakeChange('org/project', 'master', 'B')
-        A.addApproval('code-review', 2)
-        B.addApproval('code-review', 2)
+        A.addApproval('Code-Review', 2)
+        B.addApproval('Code-Review', 2)
 
         self.executor_server.failJob('project-test1', A)
 
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
-        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
+        self.fake_gerrit.addEvent(B.addApproval('Approved', 1))
         self.waitUntilSettled()
         self.assertBuilds([dict(name='project-merge', changes='1,1')])
 
@@ -279,17 +281,17 @@
         A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
         B = self.fake_gerrit.addFakeChange('org/project1', 'master', 'B')
         C = self.fake_gerrit.addFakeChange('org/project2', 'master', 'C')
-        A.addApproval('code-review', 2)
-        B.addApproval('code-review', 2)
-        C.addApproval('code-review', 2)
+        A.addApproval('Code-Review', 2)
+        B.addApproval('Code-Review', 2)
+        C.addApproval('Code-Review', 2)
 
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
         self.waitUntilSettled()
 
-        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(B.addApproval('Approved', 1))
         self.waitUntilSettled()
 
-        self.fake_gerrit.addEvent(C.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(C.addApproval('Approved', 1))
         self.waitUntilSettled()
 
         # There should be one merge job at the head of each queue running
@@ -353,15 +355,15 @@
         A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
         B = self.fake_gerrit.addFakeChange('org/project', 'master', 'B')
         C = self.fake_gerrit.addFakeChange('org/project', 'master', 'C')
-        A.addApproval('code-review', 2)
-        B.addApproval('code-review', 2)
-        C.addApproval('code-review', 2)
+        A.addApproval('Code-Review', 2)
+        B.addApproval('Code-Review', 2)
+        C.addApproval('Code-Review', 2)
 
         self.executor_server.failJob('project-test1', A)
 
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
-        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
-        self.fake_gerrit.addEvent(C.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
+        self.fake_gerrit.addEvent(B.addApproval('Approved', 1))
+        self.fake_gerrit.addEvent(C.addApproval('Approved', 1))
 
         self.waitUntilSettled()
 
@@ -468,15 +470,15 @@
         A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
         B = self.fake_gerrit.addFakeChange('org/project', 'master', 'B')
         C = self.fake_gerrit.addFakeChange('org/project', 'master', 'C')
-        A.addApproval('code-review', 2)
-        B.addApproval('code-review', 2)
-        C.addApproval('code-review', 2)
+        A.addApproval('Code-Review', 2)
+        B.addApproval('Code-Review', 2)
+        C.addApproval('Code-Review', 2)
 
         self.executor_server.failJob('project-test1', B)
 
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
-        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
-        self.fake_gerrit.addEvent(C.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
+        self.fake_gerrit.addEvent(B.addApproval('Approved', 1))
+        self.fake_gerrit.addEvent(C.addApproval('Approved', 1))
 
         self.waitUntilSettled()
 
@@ -545,15 +547,15 @@
         A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
         B = self.fake_gerrit.addFakeChange('org/project', 'master', 'B')
         C = self.fake_gerrit.addFakeChange('org/project', 'master', 'C')
-        A.addApproval('code-review', 2)
-        B.addApproval('code-review', 2)
-        C.addApproval('code-review', 2)
+        A.addApproval('Code-Review', 2)
+        B.addApproval('Code-Review', 2)
+        C.addApproval('Code-Review', 2)
 
         self.executor_server.failJob('project-test1', A)
 
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
-        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
-        self.fake_gerrit.addEvent(C.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
+        self.fake_gerrit.addEvent(B.addApproval('Approved', 1))
+        self.fake_gerrit.addEvent(C.addApproval('Approved', 1))
 
         self.waitUntilSettled()
         queue = self.gearman_server.getQueue()
@@ -619,8 +621,8 @@
     def _test_time_database(self, iteration):
         self.executor_server.hold_jobs_in_build = True
         A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
-        A.addApproval('code-review', 2)
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        A.addApproval('Code-Review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
         self.waitUntilSettled()
         time.sleep(2)
 
@@ -664,16 +666,16 @@
         A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
         B = self.fake_gerrit.addFakeChange('org/project', 'master', 'B')
         C = self.fake_gerrit.addFakeChange('org/project', 'master', 'C')
-        A.addApproval('code-review', 2)
-        B.addApproval('code-review', 2)
-        C.addApproval('code-review', 2)
+        A.addApproval('Code-Review', 2)
+        B.addApproval('Code-Review', 2)
+        C.addApproval('Code-Review', 2)
 
         self.executor_server.failJob('project-test1', A)
         self.executor_server.failJob('project-test1', B)
 
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
-        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
-        self.fake_gerrit.addEvent(C.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
+        self.fake_gerrit.addEvent(B.addApproval('Approved', 1))
+        self.fake_gerrit.addEvent(C.addApproval('Approved', 1))
         self.waitUntilSettled()
 
         self.executor_server.release('.*-merge')
@@ -767,9 +769,9 @@
         A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
         B = self.fake_gerrit.addFakeChange('org/project', 'master', 'B')
         C = self.fake_gerrit.addFakeChange('org/project', 'master', 'C')
-        A.addApproval('code-review', 2)
-        B.addApproval('code-review', 2)
-        C.addApproval('code-review', 2)
+        A.addApproval('Code-Review', 2)
+        B.addApproval('Code-Review', 2)
+        C.addApproval('Code-Review', 2)
 
         M2 = self.fake_gerrit.addFakeChange('org/project', 'master', 'M2')
         M1 = self.fake_gerrit.addFakeChange('org/project', 'master', 'M1')
@@ -786,7 +788,7 @@
         A.setDependsOn(M1, 1)
         M1.setDependsOn(M2, 1)
 
-        self.fake_gerrit.addEvent(C.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(C.addApproval('Approved', 1))
 
         self.waitUntilSettled()
 
@@ -794,8 +796,8 @@
         self.assertEqual(B.data['status'], 'NEW')
         self.assertEqual(C.data['status'], 'NEW')
 
-        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(B.addApproval('Approved', 1))
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
 
         self.waitUntilSettled()
         self.assertEqual(M2.queried, 0)
@@ -830,14 +832,14 @@
         F.setDependsOn(B, 1)
         G.setDependsOn(A, 1)
 
-        A.addApproval('code-review', 2)
-        B.addApproval('code-review', 2)
-        C.addApproval('code-review', 2)
-        D.addApproval('code-review', 2)
-        E.addApproval('code-review', 2)
-        F.addApproval('code-review', 2)
-        G.addApproval('code-review', 2)
-        self.fake_gerrit.addEvent(C.addApproval('approved', 1))
+        A.addApproval('Code-Review', 2)
+        B.addApproval('Code-Review', 2)
+        C.addApproval('Code-Review', 2)
+        D.addApproval('Code-Review', 2)
+        E.addApproval('Code-Review', 2)
+        F.addApproval('Code-Review', 2)
+        G.addApproval('Code-Review', 2)
+        self.fake_gerrit.addEvent(C.addApproval('Approved', 1))
 
         self.waitUntilSettled()
 
@@ -859,13 +861,13 @@
             connection.maintainCache([])
 
         self.executor_server.hold_jobs_in_build = True
-        A.addApproval('approved', 1)
-        B.addApproval('approved', 1)
-        D.addApproval('approved', 1)
-        E.addApproval('approved', 1)
-        F.addApproval('approved', 1)
-        G.addApproval('approved', 1)
-        self.fake_gerrit.addEvent(C.addApproval('approved', 1))
+        A.addApproval('Approved', 1)
+        B.addApproval('Approved', 1)
+        D.addApproval('Approved', 1)
+        E.addApproval('Approved', 1)
+        F.addApproval('Approved', 1)
+        G.addApproval('Approved', 1)
+        self.fake_gerrit.addEvent(C.addApproval('Approved', 1))
 
         for x in range(8):
             self.executor_server.release('.*-merge')
@@ -898,8 +900,8 @@
         A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
         B = self.fake_gerrit.addFakeChange('org/project', 'master', 'B')
         X = self.fake_gerrit.addFakeChange('org/project', 'master', 'X')
-        A.addApproval('code-review', 2)
-        B.addApproval('code-review', 2)
+        A.addApproval('Code-Review', 2)
+        B.addApproval('Code-Review', 2)
 
         M1 = self.fake_gerrit.addFakeChange('org/project', 'master', 'M1')
         M1.setMerged()
@@ -907,21 +909,21 @@
         B.setDependsOn(A, 1)
         A.setDependsOn(M1, 1)
 
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
         self.fake_gerrit.addEvent(X.getPatchsetCreatedEvent(1))
 
         self.waitUntilSettled()
 
         for build in self.builds:
-            if build.parameters['ZUUL_PIPELINE'] == 'check':
+            if build.pipeline == 'check':
                 build.release()
         self.waitUntilSettled()
         for build in self.builds:
-            if build.parameters['ZUUL_PIPELINE'] == 'check':
+            if build.pipeline == 'check':
                 build.release()
         self.waitUntilSettled()
 
-        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(B.addApproval('Approved', 1))
         self.waitUntilSettled()
 
         self.log.debug("len %s" % self.fake_gerrit._change_cache.keys())
@@ -956,11 +958,11 @@
         mgr = tenant.layout.pipelines['gate'].manager
         self.assertFalse(source.canMerge(a, mgr.getSubmitAllowNeeds()))
 
-        A.addApproval('code-review', 2)
+        A.addApproval('Code-Review', 2)
         a = source.getChange(event, refresh=True)
         self.assertFalse(source.canMerge(a, mgr.getSubmitAllowNeeds()))
 
-        A.addApproval('approved', 1)
+        A.addApproval('Approved', 1)
         a = source.getChange(event, refresh=True)
         self.assertTrue(source.canMerge(a, mgr.getSubmitAllowNeeds()))
 
@@ -976,12 +978,12 @@
                                            files={'conflict': 'bar'})
         C = self.fake_gerrit.addFakeChange('org/project',
                                            'master', 'C')
-        A.addApproval('code-review', 2)
-        B.addApproval('code-review', 2)
-        C.addApproval('code-review', 2)
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
-        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
-        self.fake_gerrit.addEvent(C.addApproval('approved', 1))
+        A.addApproval('Code-Review', 2)
+        B.addApproval('Code-Review', 2)
+        C.addApproval('Code-Review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
+        self.fake_gerrit.addEvent(B.addApproval('Approved', 1))
+        self.fake_gerrit.addEvent(C.addApproval('Approved', 1))
         self.waitUntilSettled()
 
         self.assertEqual(A.reported, 1)
@@ -1033,8 +1035,8 @@
         C.setDependsOn(B, 1)
 
         # A enters the gate queue; B and C enter the check queue
-        A.addApproval('code-review', 2)
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        A.addApproval('Code-Review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
         self.waitUntilSettled()
         self.fake_gerrit.addEvent(B.getPatchsetCreatedEvent(1))
         self.waitUntilSettled()
@@ -1103,6 +1105,12 @@
 
     def test_post(self):
         "Test that post jobs run"
+        p = "review.example.com/org/project"
+        upstream = self.getUpstreamRepos([p])
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        A.setMerged()
+        A_commit = str(upstream[p].commit('master'))
+        self.log.debug("A commit: %s" % A_commit)
 
         e = {
             "type": "ref-updated",
@@ -1111,7 +1119,7 @@
             },
             "refUpdate": {
                 "oldRev": "90f173846e3af9154517b88543ffbd1691f31366",
-                "newRev": "d479a0bfcb34da57a31adb2a595c0cf687812543",
+                "newRev": A_commit,
                 "refName": "master",
                 "project": "org/project",
             }
@@ -1156,7 +1164,7 @@
             "refUpdate": {
                 "oldRev": "90f173846e3af9154517b88543ffbd1691f31366",
                 "newRev": "0000000000000000000000000000000000000000",
-                "refName": "master",
+                "refName": "testbranch",
                 "project": "org/project",
             }
         }
@@ -1212,9 +1220,9 @@
         A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
         B = self.fake_gerrit.addFakeChange('org/project', 'master', 'B')
         C = self.fake_gerrit.addFakeChange('org/project', 'master', 'C')
-        A.addApproval('code-review', 2)
-        B.addApproval('code-review', 2)
-        C.addApproval('code-review', 2)
+        A.addApproval('Code-Review', 2)
+        B.addApproval('Code-Review', 2)
+        C.addApproval('Code-Review', 2)
 
         M1 = self.fake_gerrit.addFakeChange('org/project', 'master', 'M1')
         M1.setMerged()
@@ -1227,9 +1235,9 @@
 
         self.executor_server.failJob('project-merge', A)
 
-        self.fake_gerrit.addEvent(C.addApproval('approved', 1))
-        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(C.addApproval('Approved', 1))
+        self.fake_gerrit.addEvent(B.addApproval('Approved', 1))
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
 
         self.waitUntilSettled()
 
@@ -1250,11 +1258,11 @@
         C = self.fake_gerrit.addFakeChange('org/project', 'master', 'C')
         D = self.fake_gerrit.addFakeChange('org/project', 'master', 'D')
         E = self.fake_gerrit.addFakeChange('org/project', 'master', 'E')
-        A.addApproval('code-review', 2)
-        B.addApproval('code-review', 2)
-        C.addApproval('code-review', 2)
-        D.addApproval('code-review', 2)
-        E.addApproval('code-review', 2)
+        A.addApproval('Code-Review', 2)
+        B.addApproval('Code-Review', 2)
+        C.addApproval('Code-Review', 2)
+        D.addApproval('Code-Review', 2)
+        E.addApproval('Code-Review', 2)
 
         # E, D -> C -> B, A
 
@@ -1263,11 +1271,11 @@
 
         self.executor_server.failJob('project-test1', B)
 
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
-        self.fake_gerrit.addEvent(D.addApproval('approved', 1))
-        self.fake_gerrit.addEvent(C.addApproval('approved', 1))
-        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
-        self.fake_gerrit.addEvent(E.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
+        self.fake_gerrit.addEvent(D.addApproval('Approved', 1))
+        self.fake_gerrit.addEvent(C.addApproval('Approved', 1))
+        self.fake_gerrit.addEvent(B.addApproval('Approved', 1))
+        self.fake_gerrit.addEvent(E.addApproval('Approved', 1))
 
         self.waitUntilSettled()
         self.executor_server.release('.*-merge')
@@ -1283,7 +1291,7 @@
 
         self.executor_server.hold_jobs_in_build = False
         for build in self.builds:
-            if build.parameters['ZUUL_CHANGE'] != '1':
+            if build.parameters['zuul']['change'] != '1':
                 build.release()
                 self.waitUntilSettled()
 
@@ -1316,16 +1324,16 @@
         A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
         B = self.fake_gerrit.addFakeChange('org/project', 'master', 'B')
         C = self.fake_gerrit.addFakeChange('org/project', 'master', 'C')
-        A.addApproval('code-review', 2)
-        B.addApproval('code-review', 2)
-        C.addApproval('code-review', 2)
+        A.addApproval('Code-Review', 2)
+        B.addApproval('Code-Review', 2)
+        C.addApproval('Code-Review', 2)
 
         self.executor_server.failJob('project-test1', A)
         self.executor_server.failJob('project-test2', A)
 
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
-        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
-        self.fake_gerrit.addEvent(C.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
+        self.fake_gerrit.addEvent(B.addApproval('Approved', 1))
+        self.fake_gerrit.addEvent(C.addApproval('Approved', 1))
 
         self.waitUntilSettled()
 
@@ -1374,9 +1382,9 @@
 
         A = self.fake_gerrit.addFakeChange('org/nonvoting-project',
                                            'master', 'A')
-        A.addApproval('code-review', 2)
+        A.addApproval('Code-Review', 2)
         self.executor_server.failJob('nonvoting-project-test2', A)
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
 
         self.waitUntilSettled()
 
@@ -1392,8 +1400,12 @@
             self.getJobFromHistory('nonvoting-project-test2').result,
             'FAILURE')
 
-        for build in self.builds:
-            self.assertEqual(build.parameters['ZUUL_VOTING'], '0')
+        self.assertTrue(self.getJobFromHistory('nonvoting-project-merge').
+                        parameters['zuul']['voting'])
+        self.assertTrue(self.getJobFromHistory('nonvoting-project-test1').
+                        parameters['zuul']['voting'])
+        self.assertFalse(self.getJobFromHistory('nonvoting-project-test2').
+                         parameters['zuul']['voting'])
 
     def test_check_queue_success(self):
         "Test successful check queue jobs."
@@ -1430,6 +1442,60 @@
         self.assertEqual(self.getJobFromHistory('project-test2').result,
                          'FAILURE')
 
+    @simple_layout('layouts/autohold.yaml')
+    def test_autohold(self):
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+
+        client = zuul.rpcclient.RPCClient('127.0.0.1',
+                                          self.gearman_server.port)
+        self.addCleanup(client.shutdown)
+        r = client.autohold('tenant-one', 'org/project', 'project-test2',
+                            "reason text", 1)
+        self.assertTrue(r)
+
+        self.executor_server.failJob('project-test2', A)
+        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
+
+        self.waitUntilSettled()
+
+        self.assertEqual(A.data['status'], 'NEW')
+        self.assertEqual(A.reported, 1)
+        self.assertEqual(self.getJobFromHistory('project-test2').result,
+                         'FAILURE')
+
+        # Check nodepool for a held node
+        held_node = None
+        for node in self.fake_nodepool.getNodes():
+            if node['state'] == zuul.model.STATE_HOLD:
+                held_node = node
+                break
+        self.assertIsNotNone(held_node)
+
+        # Validate node has recorded the failed job
+        self.assertEqual(
+            held_node['hold_job'],
+            " ".join(['tenant-one',
+                      'review.example.com/org/project',
+                      'project-test2'])
+        )
+        self.assertEqual(held_node['comment'], "reason text")
+
+        # Another failed change should not hold any more nodes
+        B = self.fake_gerrit.addFakeChange('org/project', 'master', 'B')
+        self.executor_server.failJob('project-test2', B)
+        self.fake_gerrit.addEvent(B.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+        self.assertEqual(B.data['status'], 'NEW')
+        self.assertEqual(B.reported, 1)
+        self.assertEqual(self.getJobFromHistory('project-test2').result,
+                         'FAILURE')
+
+        held_nodes = 0
+        for node in self.fake_nodepool.getNodes():
+            if node['state'] == zuul.model.STATE_HOLD:
+                held_nodes += 1
+        self.assertEqual(held_nodes, 1)
+
     @simple_layout('layouts/three-projects.yaml')
     def test_dependent_behind_dequeue(self):
         # This particular test does a large amount of merges and needs a little
@@ -1448,21 +1514,21 @@
         F = self.fake_gerrit.addFakeChange('org/project3', 'master', 'F')
         D.setDependsOn(C, 1)
         E.setDependsOn(D, 1)
-        A.addApproval('code-review', 2)
-        B.addApproval('code-review', 2)
-        C.addApproval('code-review', 2)
-        D.addApproval('code-review', 2)
-        E.addApproval('code-review', 2)
-        F.addApproval('code-review', 2)
+        A.addApproval('Code-Review', 2)
+        B.addApproval('Code-Review', 2)
+        C.addApproval('Code-Review', 2)
+        D.addApproval('Code-Review', 2)
+        E.addApproval('Code-Review', 2)
+        F.addApproval('Code-Review', 2)
 
         A.fail_merge = True
 
         # Change object re-use in the gerrit trigger is hidden if
         # changes are added in quick succession; waiting makes it more
         # like real life.
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
         self.waitUntilSettled()
-        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(B.addApproval('Approved', 1))
         self.waitUntilSettled()
 
         self.executor_server.release('.*-merge')
@@ -1470,13 +1536,13 @@
         self.executor_server.release('.*-merge')
         self.waitUntilSettled()
 
-        self.fake_gerrit.addEvent(C.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(C.addApproval('Approved', 1))
         self.waitUntilSettled()
-        self.fake_gerrit.addEvent(D.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(D.addApproval('Approved', 1))
         self.waitUntilSettled()
-        self.fake_gerrit.addEvent(E.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(E.addApproval('Approved', 1))
         self.waitUntilSettled()
-        self.fake_gerrit.addEvent(F.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(F.addApproval('Approved', 1))
         self.waitUntilSettled()
 
         self.executor_server.release('.*-merge')
@@ -1524,8 +1590,8 @@
         "Test that the merger works after a repack"
 
         A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
-        A.addApproval('code-review', 2)
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        A.addApproval('Code-Review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
         self.waitUntilSettled()
         self.assertEqual(self.getJobFromHistory('project-merge').result,
                          'SUCCESS')
@@ -1548,8 +1614,8 @@
             repack_repo(path)
 
         A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
-        A.addApproval('code-review', 2)
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        A.addApproval('Code-Review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
         self.waitUntilSettled()
         self.assertEqual(self.getJobFromHistory('project-merge').result,
                          'SUCCESS')
@@ -1583,8 +1649,8 @@
         if os.path.exists(path):
             repack_repo(path)
 
-        A.addApproval('code-review', 2)
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        A.addApproval('Code-Review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
         self.waitUntilSettled()
         self.assertEqual(self.getJobFromHistory('project-merge').result,
                          'SUCCESS')
@@ -1606,19 +1672,19 @@
         B = self.fake_gerrit.addFakeChange('org/project', 'master', 'B')
         C = self.fake_gerrit.addFakeChange('org/project', 'master', 'C')
         D = self.fake_gerrit.addFakeChange('org/project', 'master', 'D')
-        A.addApproval('code-review', 2)
-        B.addApproval('code-review', 2)
-        C.addApproval('code-review', 2)
-        D.addApproval('code-review', 2)
+        A.addApproval('Code-Review', 2)
+        B.addApproval('Code-Review', 2)
+        C.addApproval('Code-Review', 2)
+        D.addApproval('Code-Review', 2)
 
         C.setDependsOn(B, 1)
         B.setDependsOn(A, 1)
         A.setDependsOn(M, 1)
 
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
-        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
-        self.fake_gerrit.addEvent(C.addApproval('approved', 1))
-        self.fake_gerrit.addEvent(D.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
+        self.fake_gerrit.addEvent(B.addApproval('Approved', 1))
+        self.fake_gerrit.addEvent(C.addApproval('Approved', 1))
+        self.fake_gerrit.addEvent(D.addApproval('Approved', 1))
         self.waitUntilSettled()
 
         B.addPatchset()
@@ -1741,8 +1807,8 @@
         self.executor_server.hold_jobs_in_build = True
 
         A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
-        A.addApproval('code-review', 2)
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        A.addApproval('Code-Review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
         self.waitUntilSettled()
         self.assertEqual(len(self.builds), 1, "One job being built (on hold)")
         self.assertEqual(self.builds[0].name, 'project-merge')
@@ -1833,6 +1899,12 @@
         # Must be in same repo, so overwrite config with another one
         self.commitConfigUpdate('common-config', 'layouts/no-timer.yaml')
         self.sched.reconfigure(self.config)
+        self.waitUntilSettled()
+        # If APScheduler is in mid-event when we remove the job, we
+        # can end up with one more event firing, so give it an extra
+        # second to settle.
+        time.sleep(1)
+        self.waitUntilSettled()
 
         self.assertEqual(len(self.builds), 1, "One timer job")
 
@@ -1849,24 +1921,6 @@
         self.executor_server.release()
         self.waitUntilSettled()
 
-    def test_zuul_url_return(self):
-        "Test if ZUUL_URL is returning when zuul_url is set in zuul.conf"
-        self.assertTrue(self.sched.config.has_option('merger', 'zuul_url'))
-        self.executor_server.hold_jobs_in_build = True
-
-        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
-        A.addApproval('code-review', 2)
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
-        self.waitUntilSettled()
-
-        self.assertEqual(len(self.builds), 1)
-        for build in self.builds:
-            self.assertTrue('ZUUL_URL' in build.parameters)
-
-        self.executor_server.hold_jobs_in_build = False
-        self.executor_server.release()
-        self.waitUntilSettled()
-
     def test_new_patchset_dequeues_old_on_head(self):
         "Test that a new patchset causes the old to be dequeued (at head)"
         # D -> C (depends on B) -> B (depends on A) -> A -> M
@@ -1877,19 +1931,19 @@
         B = self.fake_gerrit.addFakeChange('org/project', 'master', 'B')
         C = self.fake_gerrit.addFakeChange('org/project', 'master', 'C')
         D = self.fake_gerrit.addFakeChange('org/project', 'master', 'D')
-        A.addApproval('code-review', 2)
-        B.addApproval('code-review', 2)
-        C.addApproval('code-review', 2)
-        D.addApproval('code-review', 2)
+        A.addApproval('Code-Review', 2)
+        B.addApproval('Code-Review', 2)
+        C.addApproval('Code-Review', 2)
+        D.addApproval('Code-Review', 2)
 
         C.setDependsOn(B, 1)
         B.setDependsOn(A, 1)
         A.setDependsOn(M, 1)
 
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
-        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
-        self.fake_gerrit.addEvent(C.addApproval('approved', 1))
-        self.fake_gerrit.addEvent(D.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
+        self.fake_gerrit.addEvent(B.addApproval('Approved', 1))
+        self.fake_gerrit.addEvent(C.addApproval('Approved', 1))
+        self.fake_gerrit.addEvent(D.addApproval('Approved', 1))
         self.waitUntilSettled()
 
         A.addPatchset()
@@ -1916,13 +1970,13 @@
         A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
         B = self.fake_gerrit.addFakeChange('org/project', 'master', 'B')
         C = self.fake_gerrit.addFakeChange('org/project', 'master', 'C')
-        A.addApproval('code-review', 2)
-        B.addApproval('code-review', 2)
-        C.addApproval('code-review', 2)
+        A.addApproval('Code-Review', 2)
+        B.addApproval('Code-Review', 2)
+        C.addApproval('Code-Review', 2)
 
-        self.fake_gerrit.addEvent(C.addApproval('approved', 1))
-        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(C.addApproval('Approved', 1))
+        self.fake_gerrit.addEvent(B.addApproval('Approved', 1))
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
         self.waitUntilSettled()
 
         B.addPatchset()
@@ -1973,8 +2027,8 @@
     def test_noop_job(self):
         "Test that the internal noop job works"
         A = self.fake_gerrit.addFakeChange('org/noop-project', 'master', 'A')
-        A.addApproval('code-review', 2)
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        A.addApproval('Code-Review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
         self.waitUntilSettled()
 
         self.assertEqual(len(self.gearman_server.getQueue()), 0)
@@ -2014,14 +2068,14 @@
         B = self.fake_gerrit.addFakeChange('org/project1', 'master', 'B')
         C = self.fake_gerrit.addFakeChange('org/project2', 'master', 'C')
         D = self.fake_gerrit.addFakeChange('org/project2', 'master', 'D')
-        A.addApproval('code-review', 2)
-        B.addApproval('code-review', 2)
-        C.addApproval('code-review', 2)
-        D.addApproval('code-review', 2)
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
-        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
-        self.fake_gerrit.addEvent(C.addApproval('approved', 1))
-        self.fake_gerrit.addEvent(D.addApproval('approved', 1))
+        A.addApproval('Code-Review', 2)
+        B.addApproval('Code-Review', 2)
+        C.addApproval('Code-Review', 2)
+        D.addApproval('Code-Review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
+        self.fake_gerrit.addEvent(B.addApproval('Approved', 1))
+        self.fake_gerrit.addEvent(C.addApproval('Approved', 1))
+        self.fake_gerrit.addEvent(D.addApproval('Approved', 1))
 
         self.waitUntilSettled()
         self.executor_server.release('.*-merge')
@@ -2033,34 +2087,19 @@
         self.executor_server.release('.*-merge')
         self.waitUntilSettled()
 
-        a_zref = b_zref = c_zref = d_zref = None
         a_build = b_build = c_build = d_build = None
         for x in self.builds:
-            if x.parameters['ZUUL_CHANGE'] == '3':
-                a_zref = x.parameters['ZUUL_REF']
+            if x.parameters['zuul']['change'] == '3':
                 a_build = x
-            elif x.parameters['ZUUL_CHANGE'] == '4':
-                b_zref = x.parameters['ZUUL_REF']
+            elif x.parameters['zuul']['change'] == '4':
                 b_build = x
-            elif x.parameters['ZUUL_CHANGE'] == '5':
-                c_zref = x.parameters['ZUUL_REF']
+            elif x.parameters['zuul']['change'] == '5':
                 c_build = x
-            elif x.parameters['ZUUL_CHANGE'] == '6':
-                d_zref = x.parameters['ZUUL_REF']
+            elif x.parameters['zuul']['change'] == '6':
                 d_build = x
             if a_build and b_build and c_build and d_build:
                 break
 
-        # There are... four... refs.
-        self.assertIsNotNone(a_zref)
-        self.assertIsNotNone(b_zref)
-        self.assertIsNotNone(c_zref)
-        self.assertIsNotNone(d_zref)
-
-        # And they should all be different
-        refs = set([a_zref, b_zref, c_zref, d_zref])
-        self.assertEqual(len(refs), 4)
-
         # should have a, not b, and should not be in project2
         self.assertTrue(a_build.hasChanges(A))
         self.assertFalse(a_build.hasChanges(B, M2))
@@ -2093,8 +2132,8 @@
         "Test that if a worker fails to run a job, it is run again"
         self.executor_server.hold_jobs_in_build = True
         A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
-        A.addApproval('code-review', 2)
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        A.addApproval('Code-Review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
         self.waitUntilSettled()
 
         self.builds[0].requeue = True
@@ -2121,8 +2160,8 @@
         # We want to hold the project-merge job that the fake change enqueues
         self.gearman_server.hold_jobs_in_queue = True
         A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
-        A.addApproval('code-review', 2)
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        A.addApproval('Code-Review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
         self.waitUntilSettled()
         # The assertion is that we have one job in the queue, project-merge
         self.assertEqual(len(self.gearman_server.getQueue()), 1)
@@ -2170,10 +2209,10 @@
         A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
         A.addPatchset({'pip-requires': 'foo'})
         B = self.fake_gerrit.addFakeChange('org/project', 'master', 'B')
-        A.addApproval('code-review', 2)
-        B.addApproval('code-review', 2)
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
-        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
+        A.addApproval('Code-Review', 2)
+        B.addApproval('Code-Review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
+        self.fake_gerrit.addEvent(B.addApproval('Approved', 1))
         self.waitUntilSettled()
 
         testfile_jobs = [x for x in self.history
@@ -2249,8 +2288,8 @@
         self.executor_server.hold_jobs_in_build = True
         A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
         self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
-        A.addApproval('code-review', 2)
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        A.addApproval('Code-Review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
 
         self.waitUntilSettled()
         self.gearman_server.hold_jobs_in_queue = False
@@ -2274,8 +2313,8 @@
         "Test that we can retrieve JSON status info"
         self.executor_server.hold_jobs_in_build = True
         A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
-        A.addApproval('code-review', 2)
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        A.addApproval('Code-Review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
         self.waitUntilSettled()
 
         self.executor_server.release('project-merge')
@@ -2378,8 +2417,8 @@
         "Test that live reconfiguration works"
         self.executor_server.hold_jobs_in_build = True
         A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
-        A.addApproval('code-review', 2)
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        A.addApproval('Code-Review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
         self.waitUntilSettled()
 
         self.sched.reconfigure(self.config)
@@ -2410,17 +2449,17 @@
         # next change.  This change will succeed and merge.
         A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
         A.addPatchset({'conflict': 'A'})
-        A.addApproval('code-review', 2)
+        A.addApproval('Code-Review', 2)
 
         # This change will be in merge conflict.  During the
         # reconfiguration, we will add a job.  We want to make sure
         # that doesn't cause it to get stuck.
         B = self.fake_gerrit.addFakeChange('org/project', 'master', 'B')
         B.addPatchset({'conflict': 'B'})
-        B.addApproval('code-review', 2)
+        B.addApproval('Code-Review', 2)
 
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
-        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
+        self.fake_gerrit.addEvent(B.addApproval('Approved', 1))
 
         self.waitUntilSettled()
 
@@ -2465,16 +2504,16 @@
         # reconfiguration.  This change will succeed and merge.
         A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
         A.addPatchset({'conflict': 'A'})
-        A.addApproval('code-review', 2)
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        A.addApproval('Code-Review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
         self.waitUntilSettled()
         self.executor_server.release('.*-merge')
         self.waitUntilSettled()
 
         B = self.fake_gerrit.addFakeChange('org/project', 'master', 'B')
         self.executor_server.failJob('project-merge', B)
-        B.addApproval('code-review', 2)
-        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
+        B.addApproval('Code-Review', 2)
+        self.fake_gerrit.addEvent(B.addApproval('Approved', 1))
         self.waitUntilSettled()
 
         self.executor_server.release('.*-merge')
@@ -2633,11 +2672,11 @@
         A = self.fake_gerrit.addFakeChange('org/project1', 'master', 'A')
         B = self.fake_gerrit.addFakeChange('org/project1', 'master', 'B')
         B.setDependsOn(A, 1)
-        A.addApproval('code-review', 2)
-        B.addApproval('code-review', 2)
+        A.addApproval('Code-Review', 2)
+        B.addApproval('Code-Review', 2)
 
         # Add the parent change.
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
         self.waitUntilSettled()
         self.executor_server.release('.*-merge')
         self.waitUntilSettled()
@@ -2647,7 +2686,7 @@
         self.waitUntilSettled()
 
         # Add the child change.
-        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(B.addApproval('Approved', 1))
         self.waitUntilSettled()
         self.executor_server.release('.*-merge')
         self.waitUntilSettled()
@@ -2679,7 +2718,7 @@
         # A Depends-On: B
         A.data['commitMessage'] = '%s\n\nDepends-On: %s\n' % (
             A.subject, B.data['id'])
-        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(B.addApproval('Approved', 1))
 
         self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
         self.fake_gerrit.addEvent(C.getPatchsetCreatedEvent(1))
@@ -2729,8 +2768,8 @@
         self.init_repo("org/new-project")
         A = self.fake_gerrit.addFakeChange('org/new-project', 'master', 'A')
 
-        A.addApproval('code-review', 2)
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        A.addApproval('Code-Review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
         self.waitUntilSettled()
         self.assertEqual(self.getJobFromHistory('project-merge').result,
                          'SUCCESS')
@@ -2746,8 +2785,8 @@
         self.init_repo("org/delete-project")
         A = self.fake_gerrit.addFakeChange('org/delete-project', 'master', 'A')
 
-        A.addApproval('code-review', 2)
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        A.addApproval('Code-Review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
         self.waitUntilSettled()
         self.assertEqual(self.getJobFromHistory('project-merge').result,
                          'SUCCESS')
@@ -2767,8 +2806,8 @@
 
         B = self.fake_gerrit.addFakeChange('org/delete-project', 'master', 'B')
 
-        B.addApproval('code-review', 2)
-        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
+        B.addApproval('Code-Review', 2)
+        self.fake_gerrit.addEvent(B.addApproval('Approved', 1))
         self.waitUntilSettled()
         self.assertEqual(self.getJobFromHistory('project-merge').result,
                          'SUCCESS')
@@ -2779,6 +2818,18 @@
         self.assertEqual(B.data['status'], 'MERGED')
         self.assertEqual(B.reported, 2)
 
+    @simple_layout('layouts/untrusted-secrets.yaml')
+    def test_untrusted_secrets(self):
+        "Test untrusted secrets"
+        A = self.fake_gerrit.addFakeChange('org/project1', 'master', 'A')
+        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+
+        self.assertHistory([])
+        self.assertEqual(A.patchsets[0]['approvals'][0]['value'], "-1")
+        self.assertIn('does not allow post-review job',
+                      A.messages[0])
+
     @simple_layout('layouts/tags.yaml')
     def test_tags(self):
         "Test job tags"
@@ -2829,6 +2880,12 @@
         # below don't race against more jobs being queued.
         self.commitConfigUpdate('common-config', 'layouts/no-timer.yaml')
         self.sched.reconfigure(self.config)
+        self.waitUntilSettled()
+        # If APScheduler is in mid-event when we remove the job, we
+        # can end up with one more event firing, so give it an extra
+        # second to settle.
+        time.sleep(1)
+        self.waitUntilSettled()
         self.executor_server.release()
         self.waitUntilSettled()
 
@@ -2876,6 +2933,11 @@
                                     'layouts/no-timer.yaml')
             self.sched.reconfigure(self.config)
             self.waitUntilSettled()
+            # If APScheduler is in mid-event when we remove the job,
+            # we can end up with one more event firing, so give it an
+            # extra second to settle.
+            time.sleep(1)
+            self.waitUntilSettled()
             self.assertEqual(len(self.builds), 1,
                              'Timer builds iteration #%d' % x)
             self.executor_server.release('.*')
@@ -2954,6 +3016,11 @@
         self.commitConfigUpdate('common-config', 'layouts/no-timer.yaml')
         self.sched.reconfigure(self.config)
         self.waitUntilSettled()
+        # If APScheduler is in mid-event when we remove the job, we
+        # can end up with one more event firing, so give it an extra
+        # second to settle.
+        time.sleep(1)
+        self.waitUntilSettled()
         self.executor_server.release('.*')
         self.waitUntilSettled()
 
@@ -2998,14 +3065,19 @@
         self.sched.reconfigure(self.config)
         self.registerJobs()
         self.waitUntilSettled()
+        # If APScheduler is in mid-event when we remove the job, we
+        # can end up with one more event firing, so give it an extra
+        # second to settle.
+        time.sleep(1)
+        self.waitUntilSettled()
         self.worker.release('.*')
         self.waitUntilSettled()
 
     def test_client_enqueue_change(self):
         "Test that the RPC client can enqueue a change"
         A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
-        A.addApproval('code-review', 2)
-        A.addApproval('approved', 1)
+        A.addApproval('Code-Review', 2)
+        A.addApproval('Approved', 1)
 
         client = zuul.rpcclient.RPCClient('127.0.0.1',
                                           self.gearman_server.port)
@@ -3028,6 +3100,12 @@
 
     def test_client_enqueue_ref(self):
         "Test that the RPC client can enqueue a ref"
+        p = "review.example.com/org/project"
+        upstream = self.getUpstreamRepos([p])
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        A.setMerged()
+        A_commit = str(upstream[p].commit('master'))
+        self.log.debug("A commit: %s" % A_commit)
 
         client = zuul.rpcclient.RPCClient('127.0.0.1',
                                           self.gearman_server.port)
@@ -3039,7 +3117,7 @@
             trigger='gerrit',
             ref='master',
             oldrev='90f173846e3af9154517b88543ffbd1691f31366',
-            newrev='d479a0bfcb34da57a31adb2a595c0cf687812543')
+            newrev=A_commit)
         self.waitUntilSettled()
         job_names = [x.name for x in self.history]
         self.assertEqual(len(self.history), 1)
@@ -3106,13 +3184,13 @@
         A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
         B = self.fake_gerrit.addFakeChange('org/project', 'master', 'B')
         C = self.fake_gerrit.addFakeChange('org/project', 'master', 'C')
-        A.addApproval('code-review', 2)
-        B.addApproval('code-review', 2)
-        C.addApproval('code-review', 2)
+        A.addApproval('Code-Review', 2)
+        B.addApproval('Code-Review', 2)
+        C.addApproval('Code-Review', 2)
 
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
-        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
-        self.fake_gerrit.addEvent(C.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
+        self.fake_gerrit.addEvent(B.addApproval('Approved', 1))
+        self.fake_gerrit.addEvent(C.addApproval('Approved', 1))
 
         self.waitUntilSettled()
 
@@ -3186,13 +3264,13 @@
 
         C.setDependsOn(B, 1)
 
-        A.addApproval('code-review', 2)
-        B.addApproval('code-review', 2)
-        C.addApproval('code-review', 2)
+        A.addApproval('Code-Review', 2)
+        B.addApproval('Code-Review', 2)
+        C.addApproval('Code-Review', 2)
 
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
-        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
-        self.fake_gerrit.addEvent(C.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
+        self.fake_gerrit.addEvent(B.addApproval('Approved', 1))
+        self.fake_gerrit.addEvent(C.addApproval('Approved', 1))
 
         self.waitUntilSettled()
 
@@ -3247,8 +3325,8 @@
         "Test that the RPC client returns errors for promotion"
         self.executor_server.hold_jobs_in_build = True
         A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
-        A.addApproval('code-review', 2)
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        A.addApproval('Code-Review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
         self.waitUntilSettled()
 
         client = zuul.rpcclient.RPCClient('127.0.0.1',
@@ -3282,13 +3360,13 @@
         C.setDependsOn(B, 1)
         self.executor_server.failJob('project-test1', A)
 
-        A.addApproval('code-review', 2)
-        B.addApproval('code-review', 2)
-        C.addApproval('code-review', 2)
+        A.addApproval('Code-Review', 2)
+        B.addApproval('Code-Review', 2)
+        C.addApproval('Code-Review', 2)
 
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
-        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
-        self.fake_gerrit.addEvent(C.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
+        self.fake_gerrit.addEvent(B.addApproval('Approved', 1))
+        self.fake_gerrit.addEvent(C.addApproval('Approved', 1))
         self.waitUntilSettled()
 
         # Only A and B will have their merge jobs queued because
@@ -3374,13 +3452,13 @@
 
         self.executor_server.failJob('project-test1', A)
 
-        A.addApproval('code-review', 2)
-        B.addApproval('code-review', 2)
-        C.addApproval('code-review', 2)
+        A.addApproval('Code-Review', 2)
+        B.addApproval('Code-Review', 2)
+        C.addApproval('Code-Review', 2)
 
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
-        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
-        self.fake_gerrit.addEvent(C.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
+        self.fake_gerrit.addEvent(B.addApproval('Approved', 1))
+        self.fake_gerrit.addEvent(C.addApproval('Approved', 1))
         self.waitUntilSettled()
 
         # Only A and B will have their merge jobs queued because
@@ -3436,8 +3514,8 @@
         self.executor_server.hold_jobs_in_build = True
 
         A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
-        A.addApproval('code-review', 2)
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        A.addApproval('Code-Review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
         self.waitUntilSettled()
 
         self.assertEqual(len(self.executor_client.builds), 1)
@@ -3467,14 +3545,14 @@
     def test_footer_message(self):
         "Test a pipeline's footer message is correctly added to the report."
         A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
-        A.addApproval('code-review', 2)
+        A.addApproval('Code-Review', 2)
         self.executor_server.failJob('project-test1', A)
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
         self.waitUntilSettled()
 
         B = self.fake_gerrit.addFakeChange('org/project', 'master', 'B')
-        B.addApproval('code-review', 2)
-        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
+        B.addApproval('Code-Review', 2)
+        self.fake_gerrit.addEvent(B.addApproval('Approved', 1))
         self.waitUntilSettled()
 
         self.assertEqual(2, len(self.smtp_messages))
@@ -3554,9 +3632,9 @@
 
         # Check a test failure isn't reported to SMTP
         A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
-        A.addApproval('code-review', 2)
+        A.addApproval('Code-Review', 2)
         self.executor_server.failJob('project-test1', A)
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
         self.waitUntilSettled()
 
         self.assertEqual(3, len(self.history))  # 3 jobs
@@ -3568,10 +3646,10 @@
         B.addPatchset(['conflict'])
         C = self.fake_gerrit.addFakeChange('org/project', 'master', 'C')
         C.addPatchset(['conflict'])
-        B.addApproval('code-review', 2)
-        C.addApproval('code-review', 2)
-        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
-        self.fake_gerrit.addEvent(C.addApproval('approved', 1))
+        B.addApproval('Code-Review', 2)
+        C.addApproval('Code-Review', 2)
+        self.fake_gerrit.addEvent(B.addApproval('Approved', 1))
+        self.fake_gerrit.addEvent(C.addApproval('Approved', 1))
         self.waitUntilSettled()
 
         self.assertEqual(6, len(self.history))  # A and B jobs
@@ -3588,10 +3666,10 @@
         A.addPatchset(['conflict'])
         B = self.fake_gerrit.addFakeChange('org/project', 'master', 'B')
         B.addPatchset(['conflict'])
-        A.addApproval('code-review', 2)
-        B.addApproval('code-review', 2)
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
-        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
+        A.addApproval('Code-Review', 2)
+        B.addApproval('Code-Review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
+        self.fake_gerrit.addEvent(B.addApproval('Approved', 1))
         self.waitUntilSettled()
 
         self.assertEqual(3, len(self.history))  # A jobs
@@ -3609,8 +3687,8 @@
         "Test that the RPC client can get a list of running jobs"
         self.executor_server.hold_jobs_in_build = True
         A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
-        A.addApproval('code-review', 2)
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        A.addApproval('Code-Review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
         self.waitUntilSettled()
 
         client = zuul.rpcclient.RPCClient('127.0.0.1',
@@ -3690,8 +3768,8 @@
         "Test cross-repo dependencies"
         A = self.fake_gerrit.addFakeChange('org/project1', 'master', 'A')
         B = self.fake_gerrit.addFakeChange('org/project2', 'master', 'B')
-        A.addApproval('code-review', 2)
-        B.addApproval('code-review', 2)
+        A.addApproval('Code-Review', 2)
+        B.addApproval('Code-Review', 2)
 
         AM2 = self.fake_gerrit.addFakeChange('org/project1', 'master', 'AM2')
         AM1 = self.fake_gerrit.addFakeChange('org/project1', 'master', 'AM1')
@@ -3719,7 +3797,7 @@
         A.data['commitMessage'] = '%s\n\nDepends-On: %s\n' % (
             A.subject, B.data['id'])
 
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
         self.waitUntilSettled()
 
         self.assertEqual(A.data['status'], 'NEW')
@@ -3729,8 +3807,8 @@
             connection.maintainCache([])
 
         self.executor_server.hold_jobs_in_build = True
-        B.addApproval('approved', 1)
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        B.addApproval('Approved', 1)
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
         self.waitUntilSettled()
 
         self.executor_server.release('.*-merge')
@@ -3760,18 +3838,18 @@
         B = self.fake_gerrit.addFakeChange('org/project2', 'master', 'B')
         C = self.fake_gerrit.addFakeChange('org/project2', 'mp', 'C')
         C.data['id'] = B.data['id']
-        A.addApproval('code-review', 2)
-        B.addApproval('code-review', 2)
-        C.addApproval('code-review', 2)
+        A.addApproval('Code-Review', 2)
+        B.addApproval('Code-Review', 2)
+        C.addApproval('Code-Review', 2)
 
         # A Depends-On: B+C
         A.data['commitMessage'] = '%s\n\nDepends-On: %s\n' % (
             A.subject, B.data['id'])
 
         self.executor_server.hold_jobs_in_build = True
-        B.addApproval('approved', 1)
-        C.addApproval('approved', 1)
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        B.addApproval('Approved', 1)
+        C.addApproval('Approved', 1)
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
         self.waitUntilSettled()
 
         self.executor_server.release('.*-merge')
@@ -3800,18 +3878,18 @@
         A = self.fake_gerrit.addFakeChange('org/project1', 'master', 'A')
         B = self.fake_gerrit.addFakeChange('org/project2', 'master', 'B')
         C = self.fake_gerrit.addFakeChange('org/project2', 'master', 'C')
-        A.addApproval('code-review', 2)
-        B.addApproval('code-review', 2)
-        C.addApproval('code-review', 2)
+        A.addApproval('Code-Review', 2)
+        B.addApproval('Code-Review', 2)
+        C.addApproval('Code-Review', 2)
 
         # A Depends-On: B+C
         A.data['commitMessage'] = '%s\n\nDepends-On: %s\nDepends-On: %s\n' % (
             A.subject, B.data['id'], C.data['id'])
 
         self.executor_server.hold_jobs_in_build = True
-        B.addApproval('approved', 1)
-        C.addApproval('approved', 1)
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        B.addApproval('Approved', 1)
+        C.addApproval('Approved', 1)
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
         self.waitUntilSettled()
 
         self.executor_server.release('.*-merge')
@@ -3839,8 +3917,8 @@
         "Test cross-repo dependencies in unshared gate queues"
         A = self.fake_gerrit.addFakeChange('org/project1', 'master', 'A')
         B = self.fake_gerrit.addFakeChange('org/project', 'master', 'B')
-        A.addApproval('code-review', 2)
-        B.addApproval('code-review', 2)
+        A.addApproval('Code-Review', 2)
+        B.addApproval('Code-Review', 2)
 
         # A Depends-On: B
         A.data['commitMessage'] = '%s\n\nDepends-On: %s\n' % (
@@ -3848,8 +3926,8 @@
 
         # A and B do not share a queue, make sure that A is unable to
         # enqueue B (and therefore, A is unable to be enqueued).
-        B.addApproval('approved', 1)
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        B.addApproval('Approved', 1)
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
         self.waitUntilSettled()
 
         self.assertEqual(A.data['status'], 'NEW')
@@ -3859,7 +3937,7 @@
         self.assertEqual(len(self.history), 0)
 
         # Enqueue and merge B alone.
-        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(B.addApproval('Approved', 1))
         self.waitUntilSettled()
 
         self.assertEqual(B.data['status'], 'MERGED')
@@ -3867,7 +3945,7 @@
 
         # Now that B is merged, A should be able to be enqueued and
         # merged.
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
         self.waitUntilSettled()
 
         self.assertEqual(A.data['status'], 'MERGED')
@@ -3877,23 +3955,23 @@
         "Test reverse cross-repo dependencies"
         A = self.fake_gerrit.addFakeChange('org/project1', 'master', 'A')
         B = self.fake_gerrit.addFakeChange('org/project2', 'master', 'B')
-        A.addApproval('code-review', 2)
-        B.addApproval('code-review', 2)
+        A.addApproval('Code-Review', 2)
+        B.addApproval('Code-Review', 2)
 
         # A Depends-On: B
 
         A.data['commitMessage'] = '%s\n\nDepends-On: %s\n' % (
             A.subject, B.data['id'])
 
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
         self.waitUntilSettled()
 
         self.assertEqual(A.data['status'], 'NEW')
         self.assertEqual(B.data['status'], 'NEW')
 
         self.executor_server.hold_jobs_in_build = True
-        A.addApproval('approved', 1)
-        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
+        A.addApproval('Approved', 1)
+        self.fake_gerrit.addEvent(B.addApproval('Approved', 1))
         self.waitUntilSettled()
 
         self.executor_server.release('.*-merge')
@@ -3917,8 +3995,8 @@
         "Test cross-repo dependency cycles"
         A = self.fake_gerrit.addFakeChange('org/project1', 'master', 'A')
         B = self.fake_gerrit.addFakeChange('org/project2', 'master', 'B')
-        A.addApproval('code-review', 2)
-        B.addApproval('code-review', 2)
+        A.addApproval('Code-Review', 2)
+        B.addApproval('Code-Review', 2)
 
         # A -> B -> A (via commit-depends)
 
@@ -3927,7 +4005,7 @@
         B.data['commitMessage'] = '%s\n\nDepends-On: %s\n' % (
             B.subject, A.data['id'])
 
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
         self.waitUntilSettled()
 
         self.assertEqual(A.reported, 0)
@@ -3940,15 +4018,15 @@
         self.init_repo("org/unknown", tag='init')
         A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
         B = self.fake_gerrit.addFakeChange('org/unknown', 'master', 'B')
-        A.addApproval('code-review', 2)
-        B.addApproval('code-review', 2)
+        A.addApproval('Code-Review', 2)
+        B.addApproval('Code-Review', 2)
 
         # A Depends-On: B
         A.data['commitMessage'] = '%s\n\nDepends-On: %s\n' % (
             A.subject, B.data['id'])
 
-        B.addApproval('approved', 1)
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        B.addApproval('Approved', 1)
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
         self.waitUntilSettled()
 
         # Unknown projects cannot share a queue with any other
@@ -3966,13 +4044,13 @@
         # event triggers a gerrit query to update the change, we get
         # the information that it was merged.
         B.setMerged()
-        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(B.addApproval('Approved', 1))
         self.waitUntilSettled()
         self.assertEqual(len(self.history), 0)
 
         # Now that B is merged, A should be able to be enqueued and
         # merged.
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
         self.waitUntilSettled()
 
         self.assertEqual(A.data['status'], 'MERGED')
@@ -4419,10 +4497,10 @@
                                            'master', 'A')
         B = self.fake_gerrit.addFakeChange('org/one-job-project',
                                            'master', 'B')
-        A.addApproval('code-review', 2)
-        B.addApproval('code-review', 2)
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
-        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
+        A.addApproval('Code-Review', 2)
+        B.addApproval('Code-Review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
+        self.fake_gerrit.addEvent(B.addApproval('Approved', 1))
         self.waitUntilSettled()
 
         self.assertEqual(A.data['status'], 'MERGED')
@@ -4466,8 +4544,8 @@
 
         self.fake_nodepool.paused = True
         A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
-        A.addApproval('code-review', 2)
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        A.addApproval('Code-Review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
         self.waitUntilSettled()
 
         self.zk.client.stop()
@@ -4483,8 +4561,8 @@
 
         self.fake_nodepool.paused = True
         A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
-        A.addApproval('code-review', 2)
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        A.addApproval('Code-Review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
         self.waitUntilSettled()
 
         req = self.fake_nodepool.getNodeRequests()[0]
@@ -4528,8 +4606,8 @@
 
         self.executor_server.hold_jobs_in_build = True
         A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
-        A.addApproval('code-review', 2)
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        A.addApproval('Code-Review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
         self.waitUntilSettled()
 
 
@@ -4550,8 +4628,8 @@
         self.executor_server.hold_jobs_in_build = True
         change = self.fake_gerrit.addFakeChange(
             'org/project', 'master', 'change')
-        change.addApproval('code-review', 2)
-        self.fake_gerrit.addEvent(change.addApproval('approved', 1))
+        change.addApproval('Code-Review', 2)
+        self.fake_gerrit.addEvent(change.addApproval('Approved', 1))
 
         self.waitUntilSettled()
         self.assertEqual([b.name for b in self.builds], ['A'])
@@ -4608,11 +4686,11 @@
         self.executor_server.hold_jobs_in_build = True
         change = self.fake_gerrit.addFakeChange(
             'org/project', 'master', 'change')
-        change.addApproval('code-review', 2)
+        change.addApproval('Code-Review', 2)
 
         self.executor_server.failJob('C', change)
 
-        self.fake_gerrit.addEvent(change.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(change.addApproval('Approved', 1))
 
         self.waitUntilSettled()
         self.assertEqual([b.name for b in self.builds], ['A'])
@@ -4767,21 +4845,19 @@
         A = self.fake_gerrit.addFakeChange(project, 'master', 'A')
         B = self.fake_gerrit.addFakeChange(project, 'master', 'B')
         C = self.fake_gerrit.addFakeChange(project, 'master', 'C')
-        A.addApproval('code-review', 2)
-        B.addApproval('code-review', 2)
-        C.addApproval('code-review', 2)
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
-        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
-        self.fake_gerrit.addEvent(C.addApproval('approved', 1))
+        A.addApproval('Code-Review', 2)
+        B.addApproval('Code-Review', 2)
+        C.addApproval('Code-Review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
+        self.fake_gerrit.addEvent(B.addApproval('Approved', 1))
+        self.fake_gerrit.addEvent(C.addApproval('Approved', 1))
         self.waitUntilSettled()
 
         build = self.builds[-1]
-        ref = self.getParameter(build, 'ZUUL_REF')
-
         path = os.path.join(build.jobdir.src_root, 'review.example.com',
                             project)
         repo = git.Repo(path)
-        repo_messages = [c.message.strip() for c in repo.iter_commits(ref)]
+        repo_messages = [c.message.strip() for c in repo.iter_commits()]
         repo_messages.reverse()
 
         self.executor_server.hold_jobs_in_build = False
@@ -4831,12 +4907,12 @@
             'org/project-merge-branches', 'mp', 'B')
         C = self.fake_gerrit.addFakeChange(
             'org/project-merge-branches', 'mp', 'C')
-        A.addApproval('code-review', 2)
-        B.addApproval('code-review', 2)
-        C.addApproval('code-review', 2)
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
-        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
-        self.fake_gerrit.addEvent(C.addApproval('approved', 1))
+        A.addApproval('Code-Review', 2)
+        B.addApproval('Code-Review', 2)
+        C.addApproval('Code-Review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
+        self.fake_gerrit.addEvent(B.addApproval('Approved', 1))
+        self.fake_gerrit.addEvent(C.addApproval('Approved', 1))
         self.waitUntilSettled()
 
         self.executor_server.release('.*-merge')
@@ -4847,13 +4923,12 @@
         self.waitUntilSettled()
 
         build = self.builds[-1]
-        self.assertEqual(self.getParameter(build, 'ZUUL_BRANCH'), 'mp')
-        ref = self.getParameter(build, 'ZUUL_REF')
+        self.assertEqual(build.parameters['zuul']['branch'], 'mp')
         path = os.path.join(build.jobdir.src_root, 'review.example.com',
                             'org/project-merge-branches')
         repo = git.Repo(path)
 
-        repo_messages = [c.message.strip() for c in repo.iter_commits(ref)]
+        repo_messages = [c.message.strip() for c in repo.iter_commits()]
         repo_messages.reverse()
         correct_messages = [
             'initial commit',
@@ -4877,28 +4952,24 @@
             'org/project-merge-branches', 'mp', 'B')
         C = self.fake_gerrit.addFakeChange(
             'org/project-merge-branches', 'master', 'C')
-        A.addApproval('code-review', 2)
-        B.addApproval('code-review', 2)
-        C.addApproval('code-review', 2)
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
-        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
-        self.fake_gerrit.addEvent(C.addApproval('approved', 1))
+        A.addApproval('Code-Review', 2)
+        B.addApproval('Code-Review', 2)
+        C.addApproval('Code-Review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
+        self.fake_gerrit.addEvent(B.addApproval('Approved', 1))
+        self.fake_gerrit.addEvent(C.addApproval('Approved', 1))
         self.waitUntilSettled()
 
         job_A = None
         for job in self.builds:
             if 'project-merge' in job.name:
                 job_A = job
-        ref_A = self.getParameter(job_A, 'ZUUL_REF')
-        commit_A = self.getParameter(job_A, 'ZUUL_COMMIT')
-        self.log.debug("Got Zuul ref for change A: %s" % ref_A)
-        self.log.debug("Got Zuul commit for change A: %s" % commit_A)
 
         path = os.path.join(job_A.jobdir.src_root, 'review.example.com',
                             'org/project-merge-branches')
         repo = git.Repo(path)
         repo_messages = [c.message.strip()
-                         for c in repo.iter_commits(ref_A)]
+                         for c in repo.iter_commits()]
         repo_messages.reverse()
         correct_messages = [
             'initial commit', 'add content from fixture', 'A-1']
@@ -4911,16 +4982,11 @@
         for job in self.builds:
             if 'project-merge' in job.name:
                 job_B = job
-        ref_B = self.getParameter(job_B, 'ZUUL_REF')
-        commit_B = self.getParameter(job_B, 'ZUUL_COMMIT')
-        self.log.debug("Got Zuul ref for change B: %s" % ref_B)
-        self.log.debug("Got Zuul commit for change B: %s" % commit_B)
 
         path = os.path.join(job_B.jobdir.src_root, 'review.example.com',
                             'org/project-merge-branches')
         repo = git.Repo(path)
-        repo_messages = [c.message.strip()
-                         for c in repo.iter_commits(ref_B)]
+        repo_messages = [c.message.strip() for c in repo.iter_commits()]
         repo_messages.reverse()
         correct_messages = [
             'initial commit', 'add content from fixture', 'mp commit', 'B-1']
@@ -4933,15 +4999,11 @@
         for job in self.builds:
             if 'project-merge' in job.name:
                 job_C = job
-        ref_C = self.getParameter(job_C, 'ZUUL_REF')
-        commit_C = self.getParameter(job_C, 'ZUUL_COMMIT')
-        self.log.debug("Got Zuul ref for change C: %s" % ref_C)
-        self.log.debug("Got Zuul commit for change C: %s" % commit_C)
+
         path = os.path.join(job_C.jobdir.src_root, 'review.example.com',
                             'org/project-merge-branches')
         repo = git.Repo(path)
-        repo_messages = [c.message.strip()
-                         for c in repo.iter_commits(ref_C)]
+        repo_messages = [c.message.strip() for c in repo.iter_commits()]
 
         repo_messages.reverse()
         correct_messages = [
@@ -5337,6 +5399,7 @@
 
 
 class TestSemaphoreInRepo(ZuulTestCase):
+    config_file = 'zuul-connections-gerrit-and-github.conf'
     tenant_config_file = 'config/in-repo/main.yaml'
 
     def test_semaphore_in_repo(self):
@@ -5352,6 +5415,9 @@
         in_repo_conf = textwrap.dedent(
             """
             - job:
+                name: project-test1
+
+            - job:
                 name: project-test2
                 semaphore: test-semaphore
 
@@ -5384,12 +5450,12 @@
 
         self.executor_server.hold_jobs_in_build = True
 
-        A.addApproval('code-review', 2)
-        B.addApproval('code-review', 2)
-        C.addApproval('code-review', 2)
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
-        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
-        self.fake_gerrit.addEvent(C.addApproval('approved', 1))
+        A.addApproval('Code-Review', 2)
+        B.addApproval('Code-Review', 2)
+        C.addApproval('Code-Review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
+        self.fake_gerrit.addEvent(B.addApproval('Approved', 1))
+        self.fake_gerrit.addEvent(C.addApproval('Approved', 1))
         self.waitUntilSettled()
 
         # check that the layout in a queue item still has max value of 1
diff --git a/tests/unit/test_v3.py b/tests/unit/test_v3.py
old mode 100644
new mode 100755
index fb80660..2293ca0
--- a/tests/unit/test_v3.py
+++ b/tests/unit/test_v3.py
@@ -31,8 +31,8 @@
 
     def test_multiple_tenants(self):
         A = self.fake_gerrit.addFakeChange('org/project1', 'master', 'A')
-        A.addApproval('code-review', 2)
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        A.addApproval('Code-Review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
         self.waitUntilSettled()
         self.assertEqual(self.getJobFromHistory('project1-test1').result,
                          'SUCCESS')
@@ -47,8 +47,8 @@
                          "A should *not* transit tenant-two gate")
 
         B = self.fake_gerrit.addFakeChange('org/project2', 'master', 'B')
-        B.addApproval('code-review', 2)
-        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
+        B.addApproval('Code-Review', 2)
+        self.fake_gerrit.addEvent(B.addApproval('Approved', 1))
         self.waitUntilSettled()
         self.assertEqual(self.getJobFromHistory('python27',
                                                 'org/project2').result,
@@ -67,15 +67,99 @@
                          "not affect tenant one")
 
 
+class TestFinal(ZuulTestCase):
+
+    tenant_config_file = 'config/final/main.yaml'
+
+    def test_final_variant_ok(self):
+        # test clean usage of final parent job
+        in_repo_conf = textwrap.dedent(
+            """
+            - project:
+                name: org/project
+                check:
+                  jobs:
+                    - job-final
+            """)
+
+        file_dict = {'.zuul.yaml': in_repo_conf}
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A',
+                                           files=file_dict)
+        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+
+        self.assertEqual(A.reported, 1)
+        self.assertEqual(A.patchsets[-1]['approvals'][0]['value'], '1')
+
+    def test_final_variant_error(self):
+        # test misuse of final parent job
+        in_repo_conf = textwrap.dedent(
+            """
+            - project:
+                name: org/project
+                check:
+                  jobs:
+                    - job-final:
+                        vars:
+                          dont_override_this: bar
+            """)
+        file_dict = {'.zuul.yaml': in_repo_conf}
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A',
+                                           files=file_dict)
+        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+
+        # The second patch tried to override some variables.
+        # Thus it should fail.
+        self.assertEqual(A.reported, 1)
+        self.assertEqual(A.patchsets[-1]['approvals'][0]['value'], '-1')
+        self.assertIn('Unable to modify final job', A.messages[0])
+
+    def test_final_inheritance(self):
+        # test misuse of final parent job
+        in_repo_conf = textwrap.dedent(
+            """
+            - job:
+                name: project-test
+                parent: job-final
+
+            - project:
+                name: org/project
+                check:
+                  jobs:
+                    - project-test
+            """)
+
+        in_repo_playbook = textwrap.dedent(
+            """
+            - hosts: all
+              tasks: []
+            """)
+
+        file_dict = {'.zuul.yaml': in_repo_conf,
+                     'playbooks/project-test.yaml': in_repo_playbook}
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A',
+                                           files=file_dict)
+        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+
+        # The second patch tried to override some variables.
+        # Thus it should fail.
+        self.assertEqual(A.reported, 1)
+        self.assertEqual(A.patchsets[-1]['approvals'][0]['value'], '-1')
+        self.assertIn('Unable to inherit from final job', A.messages[0])
+
+
 class TestInRepoConfig(ZuulTestCase):
     # A temporary class to hold new tests while others are disabled
 
+    config_file = 'zuul-connections-gerrit-and-github.conf'
     tenant_config_file = 'config/in-repo/main.yaml'
 
     def test_in_repo_config(self):
         A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
-        A.addApproval('code-review', 2)
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        A.addApproval('Code-Review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
         self.waitUntilSettled()
         self.assertEqual(self.getJobFromHistory('project-test1').result,
                          'SUCCESS')
@@ -89,6 +173,9 @@
         in_repo_conf = textwrap.dedent(
             """
             - job:
+                name: project-test1
+
+            - job:
                 name: project-test2
 
             - project:
@@ -108,8 +195,8 @@
                      'playbooks/project-test2.yaml': in_repo_playbook}
         A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A',
                                            files=file_dict)
-        A.addApproval('code-review', 2)
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        A.addApproval('Code-Review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
         self.waitUntilSettled()
         self.assertEqual(A.data['status'], 'MERGED')
         self.assertEqual(A.reported, 2,
@@ -125,8 +212,8 @@
         # Now that the config change is landed, it should be live for
         # subsequent changes.
         B = self.fake_gerrit.addFakeChange('org/project', 'master', 'B')
-        B.addApproval('code-review', 2)
-        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
+        B.addApproval('Code-Review', 2)
+        self.fake_gerrit.addEvent(B.addApproval('Approved', 1))
         self.waitUntilSettled()
         self.assertEqual(self.getJobFromHistory('project-test2').result,
                          'SUCCESS')
@@ -134,6 +221,77 @@
             dict(name='project-test2', result='SUCCESS', changes='1,1'),
             dict(name='project-test2', result='SUCCESS', changes='2,1')])
 
+    def test_dynamic_config_non_existing_job(self):
+        """Test that requesting a non existent job fails"""
+        in_repo_conf = textwrap.dedent(
+            """
+            - job:
+                name: project-test1
+
+            - project:
+                name: org/project
+                check:
+                  jobs:
+                    - non-existent-job
+            """)
+
+        in_repo_playbook = textwrap.dedent(
+            """
+            - hosts: all
+              tasks: []
+            """)
+
+        file_dict = {'.zuul.yaml': in_repo_conf,
+                     'playbooks/project-test2.yaml': in_repo_playbook}
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A',
+                                           files=file_dict)
+        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+        self.assertEqual(A.reported, 1,
+                         "A should report failure")
+        self.assertEqual(A.patchsets[0]['approvals'][0]['value'], "-1")
+        self.assertIn('Job non-existent-job not defined', A.messages[0],
+                      "A should have failed the check pipeline")
+        self.assertHistory([])
+
+    def test_dynamic_config_non_existing_job_in_template(self):
+        """Test that requesting a non existent job fails"""
+        in_repo_conf = textwrap.dedent(
+            """
+            - job:
+                name: project-test1
+
+            - project-template:
+                name: test-template
+                check:
+                  jobs:
+                    - non-existent-job
+
+            - project:
+                name: org/project
+                templates:
+                  - test-template
+            """)
+
+        in_repo_playbook = textwrap.dedent(
+            """
+            - hosts: all
+              tasks: []
+            """)
+
+        file_dict = {'.zuul.yaml': in_repo_conf,
+                     'playbooks/project-test2.yaml': in_repo_playbook}
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A',
+                                           files=file_dict)
+        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+        self.assertEqual(A.reported, 1,
+                         "A should report failure")
+        self.assertEqual(A.patchsets[0]['approvals'][0]['value'], "-1")
+        self.assertIn('Job non-existent-job not defined', A.messages[0],
+                      "A should have failed the check pipeline")
+        self.assertHistory([])
+
     def test_dynamic_config_new_patchset(self):
         self.executor_server.hold_jobs_in_build = True
 
@@ -143,6 +301,9 @@
         in_repo_conf = textwrap.dedent(
             """
             - job:
+                name: project-test1
+
+            - job:
                 name: project-test2
 
             - project:
@@ -173,6 +334,9 @@
         in_repo_conf = textwrap.dedent(
             """
             - job:
+                name: project-test1
+
+            - job:
                 name: project-test2
 
             - project:
@@ -196,9 +360,16 @@
         self.assertTrue(items[0].live)
 
         self.executor_server.hold_jobs_in_build = False
+        self.executor_server.release('project-test1')
+        self.waitUntilSettled()
         self.executor_server.release()
         self.waitUntilSettled()
 
+        self.assertHistory([
+            dict(name='project-test2', result='ABORTED', changes='1,1'),
+            dict(name='project-test1', result='SUCCESS', changes='1,2'),
+            dict(name='project-test2', result='SUCCESS', changes='1,2')])
+
     def test_dynamic_dependent_pipeline(self):
         # Test dynamically adding a project to a
         # dependent pipeline for the first time
@@ -210,6 +381,9 @@
         in_repo_conf = textwrap.dedent(
             """
             - job:
+                name: project-test1
+
+            - job:
                 name: project-test2
 
             - project:
@@ -229,8 +403,8 @@
                      'playbooks/project-test2.yaml': in_repo_playbook}
         A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A',
                                            files=file_dict)
-        A.addApproval('approved', 1)
-        self.fake_gerrit.addEvent(A.addApproval('code-review', 2))
+        A.addApproval('Approved', 1)
+        self.fake_gerrit.addEvent(A.addApproval('Code-Review', 2))
         self.waitUntilSettled()
 
         items = gate_pipeline.getAllItems()
@@ -249,6 +423,9 @@
         in_repo_conf = textwrap.dedent(
             """
             - job:
+                name: project-test1
+
+            - job:
                 name: project-test2
 
             - project:
@@ -269,8 +446,8 @@
         self.create_branch('org/project', 'stable')
         A = self.fake_gerrit.addFakeChange('org/project', 'stable', 'A',
                                            files=file_dict)
-        A.addApproval('code-review', 2)
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        A.addApproval('Code-Review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
         self.waitUntilSettled()
         self.assertEqual(A.data['status'], 'MERGED')
         self.assertEqual(A.reported, 2,
@@ -284,8 +461,8 @@
 
         # The config change should not affect master.
         B = self.fake_gerrit.addFakeChange('org/project', 'master', 'B')
-        B.addApproval('code-review', 2)
-        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
+        B.addApproval('Code-Review', 2)
+        self.fake_gerrit.addEvent(B.addApproval('Approved', 1))
         self.waitUntilSettled()
         self.assertHistory([
             dict(name='project-test2', result='SUCCESS', changes='1,1'),
@@ -294,8 +471,8 @@
         # The config change should be live for further changes on
         # stable.
         C = self.fake_gerrit.addFakeChange('org/project', 'stable', 'C')
-        C.addApproval('code-review', 2)
-        self.fake_gerrit.addEvent(C.addApproval('approved', 1))
+        C.addApproval('Code-Review', 2)
+        self.fake_gerrit.addEvent(C.addApproval('Approved', 1))
         self.waitUntilSettled()
         self.assertHistory([
             dict(name='project-test2', result='SUCCESS', changes='1,1'),
@@ -311,6 +488,9 @@
         in_repo_conf = textwrap.dedent(
             """
             - job:
+                name: project-test1
+
+            - job:
                 name: project-test2
 
             - project:
@@ -368,8 +548,8 @@
         file_dict = {'.zuul.yaml': in_repo_conf}
         A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A',
                                            files=file_dict)
-        A.addApproval('code-review', 2)
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        A.addApproval('Code-Review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
         self.waitUntilSettled()
 
         self.assertEqual(A.data['status'], 'NEW')
@@ -389,8 +569,8 @@
         file_dict = {'zuul.yaml': in_repo_conf}
         A = self.fake_gerrit.addFakeChange('common-config', 'master', 'A',
                                            files=file_dict)
-        A.addApproval('code-review', 2)
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        A.addApproval('Code-Review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
         self.waitUntilSettled()
 
         self.assertEqual(A.data['status'], 'NEW')
@@ -409,8 +589,8 @@
         file_dict = {'.zuul.yaml': in_repo_conf}
         A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A',
                                            files=file_dict)
-        A.addApproval('code-review', 2)
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        A.addApproval('Code-Review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
         self.waitUntilSettled()
 
         self.assertEqual(A.data['status'], 'NEW')
@@ -429,8 +609,8 @@
         file_dict = {'.zuul.yaml': in_repo_conf}
         A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A',
                                            files=file_dict)
-        A.addApproval('code-review', 2)
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        A.addApproval('Code-Review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
         self.waitUntilSettled()
 
         self.assertEqual(A.data['status'], 'NEW')
@@ -449,8 +629,8 @@
         file_dict = {'.zuul.yaml': in_repo_conf}
         A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A',
                                            files=file_dict)
-        A.addApproval('code-review', 2)
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        A.addApproval('Code-Review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
         self.waitUntilSettled()
 
         self.assertEqual(A.data['status'], 'NEW')
@@ -469,8 +649,8 @@
         file_dict = {'.zuul.yaml': in_repo_conf}
         A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A',
                                            files=file_dict)
-        A.addApproval('code-review', 2)
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        A.addApproval('Code-Review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
         self.waitUntilSettled()
 
         self.assertEqual(A.data['status'], 'NEW')
@@ -494,8 +674,8 @@
         file_dict = {'.zuul.yaml': in_repo_conf}
         A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A',
                                            files=file_dict)
-        A.addApproval('code-review', 2)
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        A.addApproval('Code-Review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
         self.waitUntilSettled()
 
         self.assertEqual(A.data['status'], 'NEW')
@@ -522,8 +702,8 @@
         file_dict = {'.zuul.yaml': in_repo_conf}
         A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A',
                                            files=file_dict)
-        A.addApproval('code-review', 2)
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        A.addApproval('Code-Review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
         self.waitUntilSettled()
 
         self.assertEqual(A.data['status'], 'NEW')
@@ -549,8 +729,8 @@
         file_dict = {'.zuul.yaml': downstream_repo_conf}
         A = self.fake_gerrit.addFakeChange('org/project1', 'master', 'A',
                                            files=file_dict)
-        A.addApproval('code-review', 2)
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        A.addApproval('Code-Review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
         self.waitUntilSettled()
 
         self.assertEqual(A.data['status'], 'MERGED')
@@ -575,8 +755,8 @@
         file_dict = {'.zuul.yaml': upstream_repo_conf}
         B = self.fake_gerrit.addFakeChange('org/project', 'master', 'B',
                                            files=file_dict)
-        B.addApproval('code-review', 2)
-        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
+        B.addApproval('Code-Review', 2)
+        self.fake_gerrit.addEvent(B.addApproval('Approved', 1))
         self.waitUntilSettled()
 
         self.assertEqual(B.data['status'], 'MERGED')
@@ -595,54 +775,120 @@
     tenant_config_file = 'config/ansible/main.yaml'
 
     def test_playbook(self):
+        # Keep the jobdir around so we can inspect contents if an
+        # assert fails.
+        self.executor_server.keep_jobdir = True
+        # Output extra ansible info so we might see errors.
+        self.executor_server.verbose = True
+        # Add a site variables file, used by check-vars
+        path = os.path.join(FIXTURE_DIR, 'config', 'ansible',
+                            'variables.yaml')
+        self.config.set('executor', 'variables', path)
         A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
         self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
         self.waitUntilSettled()
         build_timeout = self.getJobFromHistory('timeout')
-        self.assertEqual(build_timeout.result, 'TIMED_OUT')
+        with self.jobLog(build_timeout):
+            self.assertEqual(build_timeout.result, 'TIMED_OUT')
         build_faillocal = self.getJobFromHistory('faillocal')
-        self.assertEqual(build_faillocal.result, 'FAILURE')
+        with self.jobLog(build_faillocal):
+            self.assertEqual(build_faillocal.result, 'FAILURE')
         build_failpost = self.getJobFromHistory('failpost')
-        self.assertEqual(build_failpost.result, 'POST_FAILURE')
+        with self.jobLog(build_failpost):
+            self.assertEqual(build_failpost.result, 'POST_FAILURE')
         build_check_vars = self.getJobFromHistory('check-vars')
-        self.assertEqual(build_check_vars.result, 'SUCCESS')
+        with self.jobLog(build_check_vars):
+            self.assertEqual(build_check_vars.result, 'SUCCESS')
         build_hello = self.getJobFromHistory('hello-world')
-        self.assertEqual(build_hello.result, 'SUCCESS')
+        with self.jobLog(build_hello):
+            self.assertEqual(build_hello.result, 'SUCCESS')
         build_python27 = self.getJobFromHistory('python27')
-        self.assertEqual(build_python27.result, 'SUCCESS')
-        flag_path = os.path.join(self.test_root, build_python27.uuid + '.flag')
-        self.assertTrue(os.path.exists(flag_path))
-        copied_path = os.path.join(self.test_root, build_python27.uuid +
-                                   '.copied')
-        self.assertTrue(os.path.exists(copied_path))
-        failed_path = os.path.join(self.test_root, build_python27.uuid +
-                                   '.failed')
-        self.assertFalse(os.path.exists(failed_path))
-        pre_flag_path = os.path.join(self.test_root, build_python27.uuid +
-                                     '.pre.flag')
-        self.assertTrue(os.path.exists(pre_flag_path))
-        post_flag_path = os.path.join(self.test_root, build_python27.uuid +
-                                      '.post.flag')
-        self.assertTrue(os.path.exists(post_flag_path))
-        bare_role_flag_path = os.path.join(self.test_root,
-                                           build_python27.uuid +
-                                           '.bare-role.flag')
-        self.assertTrue(os.path.exists(bare_role_flag_path))
+        with self.jobLog(build_python27):
+            self.assertEqual(build_python27.result, 'SUCCESS')
+            flag_path = os.path.join(self.test_root,
+                                     build_python27.uuid + '.flag')
+            self.assertTrue(os.path.exists(flag_path))
+            copied_path = os.path.join(self.test_root, build_python27.uuid +
+                                       '.copied')
+            self.assertTrue(os.path.exists(copied_path))
+            failed_path = os.path.join(self.test_root, build_python27.uuid +
+                                       '.failed')
+            self.assertFalse(os.path.exists(failed_path))
+            pre_flag_path = os.path.join(self.test_root, build_python27.uuid +
+                                         '.pre.flag')
+            self.assertTrue(os.path.exists(pre_flag_path))
+            post_flag_path = os.path.join(self.test_root, build_python27.uuid +
+                                          '.post.flag')
+            self.assertTrue(os.path.exists(post_flag_path))
+            bare_role_flag_path = os.path.join(self.test_root,
+                                               build_python27.uuid +
+                                               '.bare-role.flag')
+            self.assertTrue(os.path.exists(bare_role_flag_path))
+            secrets_path = os.path.join(self.test_root,
+                                        build_python27.uuid + '.secrets')
+            with open(secrets_path) as f:
+                self.assertEqual(f.read(), "test-username test-password")
 
-        secrets_path = os.path.join(self.test_root,
-                                    build_python27.uuid + '.secrets')
-        with open(secrets_path) as f:
-            self.assertEqual(f.read(), "test-username test-password")
+            msg = A.messages[0]
+            success = "{} https://success.example.com/zuul-logs/{}"
+            fail = "{} https://failure.example.com/zuul-logs/{}"
+            self.assertIn(success.format("python27", build_python27.uuid), msg)
+            self.assertIn(fail.format("faillocal", build_faillocal.uuid), msg)
+            self.assertIn(success.format("check-vars",
+                                         build_check_vars.uuid), msg)
+            self.assertIn(success.format("hello-world", build_hello.uuid), msg)
+            self.assertIn(fail.format("timeout", build_timeout.uuid), msg)
+            self.assertIn(fail.format("failpost", build_failpost.uuid), msg)
 
-        msg = A.messages[0]
-        success = "{} https://success.example.com/zuul-logs/{}"
-        fail = "{} https://failure.example.com/zuul-logs/{}"
-        self.assertIn(success.format("python27", build_python27.uuid), msg)
-        self.assertIn(fail.format("faillocal", build_faillocal.uuid), msg)
-        self.assertIn(success.format("check-vars", build_check_vars.uuid), msg)
-        self.assertIn(success.format("hello-world", build_hello.uuid), msg)
-        self.assertIn(fail.format("timeout", build_timeout.uuid), msg)
-        self.assertIn(fail.format("failpost", build_failpost.uuid), msg)
+    def _add_job(self, job_name):
+        conf = textwrap.dedent(
+            """
+            - job:
+                name: %s
+
+            - project:
+                name: org/plugin-project
+                check:
+                  jobs:
+                    - %s
+            """ % (job_name, job_name))
+
+        file_dict = {'.zuul.yaml': conf}
+        A = self.fake_gerrit.addFakeChange('org/plugin-project', 'master', 'A',
+                                           files=file_dict)
+        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+
+    def test_plugins(self):
+        # Keep the jobdir around so we can inspect contents if an
+        # assert fails.
+        self.executor_server.keep_jobdir = True
+        # Output extra ansible info so we might see errors.
+        self.executor_server.verbose = True
+
+        count = 0
+        plugin_tests = [
+            ('passwd', 'FAILURE'),
+            ('cartesian', 'SUCCESS'),
+            ('consul_kv', 'FAILURE'),
+            ('credstash', 'FAILURE'),
+            ('csvfile_good', 'SUCCESS'),
+            ('csvfile_bad', 'FAILURE'),
+            ('uri_bad_path', 'FAILURE'),
+            ('uri_bad_scheme', 'FAILURE'),
+        ]
+        for job_name, result in plugin_tests:
+            count += 1
+            self._add_job(job_name)
+
+            job = self.getJobFromHistory(job_name)
+            with self.jobLog(job):
+                self.assertEqual(count, len(self.history))
+                build = self.history[-1]
+                self.assertEqual(build.result, result)
+
+        # TODOv3(jeblair): parse the ansible output and verify we're
+        # getting the exception we expect.
 
 
 class TestPrePlaybooks(AnsibleZuulTestCase):
@@ -696,6 +942,7 @@
     # sure we exercise that code, in this test we allow Zuul to create
     # keys for the project on startup.
     create_project_keys = True
+    config_file = 'zuul-connections-gerrit-and-github.conf'
     tenant_config_file = 'config/in-repo/main.yaml'
 
     def test_key_generation(self):
@@ -908,3 +1155,179 @@
         self.assertIn('- data-return-relative '
                       'http://example.com/test/log/url/docs/index.html',
                       A.messages[-1])
+
+
+class TestDiskAccounting(AnsibleZuulTestCase):
+    config_file = 'zuul-disk-accounting.conf'
+    tenant_config_file = 'config/disk-accountant/main.yaml'
+
+    def test_disk_accountant_kills_job(self):
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+        self.assertHistory([
+            dict(name='dd-big-empty-file', result='ABORTED', changes='1,1')])
+
+
+class TestMaxNodesPerJob(AnsibleZuulTestCase):
+    tenant_config_file = 'config/multi-tenant/main.yaml'
+
+    def test_max_nodes_reached(self):
+        in_repo_conf = textwrap.dedent(
+            """
+            - job:
+                name: test-job
+                nodes:
+                  - name: node01
+                    label: fake
+                  - name: node02
+                    label: fake
+                  - name: node03
+                    label: fake
+                  - name: node04
+                    label: fake
+                  - name: node05
+                    label: fake
+                  - name: node06
+                    label: fake
+            """)
+        file_dict = {'.zuul.yaml': in_repo_conf}
+        A = self.fake_gerrit.addFakeChange('org/project1', 'master', 'A',
+                                           files=file_dict)
+        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+        self.assertIn('The job "test-job" exceeds tenant max-nodes-per-job 5.',
+                      A.messages[0], "A should fail because of nodes limit")
+
+        B = self.fake_gerrit.addFakeChange('org/project2', 'master', 'A',
+                                           files=file_dict)
+        self.fake_gerrit.addEvent(B.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+        self.assertNotIn("exceeds tenant max-nodes", B.messages[0],
+                         "B should not fail because of nodes limit")
+
+
+class TestBaseJobs(ZuulTestCase):
+    tenant_config_file = 'config/base-jobs/main.yaml'
+
+    def test_multiple_base_jobs(self):
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+        self.assertHistory([
+            dict(name='my-job', result='SUCCESS', changes='1,1'),
+            dict(name='other-job', result='SUCCESS', changes='1,1'),
+        ], ordered=False)
+        self.assertEqual(self.getJobFromHistory('my-job').
+                         parameters['zuul']['jobtags'],
+                         ['mybase'])
+        self.assertEqual(self.getJobFromHistory('other-job').
+                         parameters['zuul']['jobtags'],
+                         ['otherbase'])
+
+    def test_untrusted_base_job(self):
+        """Test that a base job may not be defined in an untrusted repo"""
+        in_repo_conf = textwrap.dedent(
+            """
+            - job:
+                name: fail-base
+                parent: null
+            """)
+
+        file_dict = {'.zuul.yaml': in_repo_conf}
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A',
+                                           files=file_dict)
+        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+        self.assertEqual(A.reported, 1,
+                         "A should report failure")
+        self.assertEqual(A.patchsets[0]['approvals'][0]['value'], "-1")
+        self.assertIn('Base jobs must be defined in config projects',
+                      A.messages[0])
+        self.assertHistory([])
+
+
+class TestSecretLeaks(AnsibleZuulTestCase):
+    tenant_config_file = 'config/secret-leaks/main.yaml'
+
+    def searchForContent(self, path, content):
+        matches = []
+        for (dirpath, dirnames, filenames) in os.walk(path):
+            for filename in filenames:
+                filepath = os.path.join(dirpath, filename)
+                with open(filepath, 'rb') as f:
+                    if content in f.read():
+                        matches.append(filepath[len(path):])
+        return matches
+
+    def _test_secret_file(self):
+        # Or rather -- test that they *don't* leak.
+        # Keep the jobdir around so we can inspect contents.
+        self.executor_server.keep_jobdir = True
+        conf = textwrap.dedent(
+            """
+            - project:
+                name: org/project
+                check:
+                  jobs:
+                    - secret-file
+            """)
+
+        file_dict = {'.zuul.yaml': conf}
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A',
+                                           files=file_dict)
+        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+        self.assertHistory([
+            dict(name='secret-file', result='SUCCESS', changes='1,1'),
+        ], ordered=False)
+        matches = self.searchForContent(self.history[0].jobdir.root,
+                                        b'test-password')
+        self.assertEqual(set(['/ansible/playbook_0/secrets.yaml',
+                              '/work/secret-file.txt']),
+                         set(matches))
+
+    def test_secret_file(self):
+        self._test_secret_file()
+
+    def test_secret_file_verbose(self):
+        # Output extra ansible info to exercise alternate logging code
+        # paths.
+        self.executor_server.verbose = True
+        self._test_secret_file()
+
+    def _test_secret_file_fail(self):
+        # Or rather -- test that they *don't* leak.
+        # Keep the jobdir around so we can inspect contents.
+        self.executor_server.keep_jobdir = True
+        conf = textwrap.dedent(
+            """
+            - project:
+                name: org/project
+                check:
+                  jobs:
+                    - secret-file-fail
+            """)
+
+        file_dict = {'.zuul.yaml': conf}
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A',
+                                           files=file_dict)
+        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+        self.assertHistory([
+            dict(name='secret-file-fail', result='FAILURE', changes='1,1'),
+        ], ordered=False)
+        matches = self.searchForContent(self.history[0].jobdir.root,
+                                        b'test-password')
+        self.assertEqual(set(['/ansible/playbook_0/secrets.yaml',
+                              '/work/failure-file.txt']),
+                         set(matches))
+
+    def test_secret_file_fail(self):
+        self._test_secret_file_fail()
+
+    def test_secret_file_fail_verbose(self):
+        # Output extra ansible info to exercise alternate logging code
+        # paths.
+        self.executor_server.verbose = True
+        self._test_secret_file_fail()
diff --git a/tests/unit/test_webapp.py b/tests/unit/test_webapp.py
index da027c1..5b6950b 100644
--- a/tests/unit/test_webapp.py
+++ b/tests/unit/test_webapp.py
@@ -31,11 +31,11 @@
         super(TestWebapp, self).setUp()
         self.executor_server.hold_jobs_in_build = True
         A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
-        A.addApproval('code-review', 2)
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        A.addApproval('Code-Review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
         B = self.fake_gerrit.addFakeChange('org/project1', 'master', 'B')
-        B.addApproval('code-review', 2)
-        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
+        B.addApproval('Code-Review', 2)
+        self.fake_gerrit.addEvent(B.addApproval('Approved', 1))
         self.waitUntilSettled()
         self.port = self.webapp.server.socket.getsockname()[1]
 
diff --git a/tests/unit/test_zuultrigger.py b/tests/unit/test_zuultrigger.py
index 476cb74..3c4dead 100644
--- a/tests/unit/test_zuultrigger.py
+++ b/tests/unit/test_zuultrigger.py
@@ -31,17 +31,17 @@
         A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
         B1 = self.fake_gerrit.addFakeChange('org/project', 'master', 'B1')
         B2 = self.fake_gerrit.addFakeChange('org/project', 'master', 'B2')
-        A.addApproval('code-review', 2)
-        B1.addApproval('code-review', 2)
-        B2.addApproval('code-review', 2)
-        A.addApproval('verified', 1)    # required by gate
-        B1.addApproval('verified', -1)  # should go to check
-        B2.addApproval('verified', 1)   # should go to gate
-        B1.addApproval('approved', 1)
-        B2.addApproval('approved', 1)
+        A.addApproval('Code-Review', 2)
+        B1.addApproval('Code-Review', 2)
+        B2.addApproval('Code-Review', 2)
+        A.addApproval('Verified', 1)    # required by gate
+        B1.addApproval('Verified', -1)  # should go to check
+        B2.addApproval('Verified', 1)   # should go to gate
+        B1.addApproval('Approved', 1)
+        B2.addApproval('Approved', 1)
         B1.setDependsOn(A, 1)
         B2.setDependsOn(A, 1)
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
         # Jobs are being held in build to make sure that 3,1 has time
         # to enqueue behind 1,1 so that the test is more
         # deterministic.
@@ -85,8 +85,8 @@
         B.addPatchset({'conflict': 'bar'})
         D.addPatchset({'conflict2': 'foo'})
         E.addPatchset({'conflict2': 'bar'})
-        A.addApproval('code-review', 2)
-        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        A.addApproval('Code-Review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
         self.waitUntilSettled()
 
         self.assertEqual(len(self.history), 1)
@@ -112,8 +112,8 @@
         # configuration.
         self.sched.reconfigure(self.config)
 
-        D.addApproval('code-review', 2)
-        self.fake_gerrit.addEvent(D.addApproval('approved', 1))
+        D.addApproval('Code-Review', 2)
+        self.fake_gerrit.addEvent(D.addApproval('Approved', 1))
         self.waitUntilSettled()
 
         self.assertEqual(len(self.history), 2)
diff --git a/tools/encrypt_secret.py b/tools/encrypt_secret.py
index 72429e9..df4f449 100755
--- a/tools/encrypt_secret.py
+++ b/tools/encrypt_secret.py
@@ -14,10 +14,13 @@
 
 import argparse
 import base64
+import math
 import os
+import re
 import subprocess
 import sys
 import tempfile
+import textwrap
 
 # we to import Request and urlopen differently for python 2 and 3
 try:
@@ -68,28 +71,70 @@
     else:
         plaintext = sys.stdin.read()
 
+    plaintext = plaintext.encode("utf-8")
+
     pubkey_file = tempfile.NamedTemporaryFile(delete=False)
     try:
         pubkey_file.write(pubkey.read())
         pubkey_file.close()
 
-        p = subprocess.Popen(['openssl', 'rsautl', '-encrypt',
-                              '-oaep', '-pubin', '-inkey',
+        p = subprocess.Popen(['openssl', 'rsa', '-text',
+                              '-pubin', '-in',
                               pubkey_file.name],
-                             stdin=subprocess.PIPE,
                              stdout=subprocess.PIPE)
-        (stdout, stderr) = p.communicate(plaintext.encode("utf-8"))
+        (stdout, stderr) = p.communicate()
         if p.returncode != 0:
             raise Exception("Return code %s from openssl" % p.returncode)
-        ciphertext = base64.b64encode(stdout)
+        output = stdout.decode('utf-8')
+        m = re.match(r'^Public-Key: \((\d+) bit\)$', output, re.MULTILINE)
+        nbits = int(m.group(1))
+        nbytes = int(nbits / 8)
+        max_bytes = nbytes - 42  # PKCS1-OAEP overhead
+        chunks = int(math.ceil(float(len(plaintext)) / max_bytes))
+
+        ciphertext_chunks = []
+
+        print("Public key length: {} bits ({} bytes)".format(nbits, nbytes))
+        print("Max plaintext length per chunk: {} bytes".format(max_bytes))
+        print("Input plaintext length: {} bytes".format(len(plaintext)))
+        print("Number of chunks: {}".format(chunks))
+
+        for count in range(chunks):
+            chunk = plaintext[int(count * max_bytes):
+                              int((count + 1) * max_bytes)]
+            p = subprocess.Popen(['openssl', 'rsautl', '-encrypt',
+                                  '-oaep', '-pubin', '-inkey',
+                                  pubkey_file.name],
+                                 stdin=subprocess.PIPE,
+                                 stdout=subprocess.PIPE)
+            (stdout, stderr) = p.communicate(chunk)
+            if p.returncode != 0:
+                raise Exception("Return code %s from openssl" % p.returncode)
+            ciphertext_chunks.append(base64.b64encode(stdout).decode('utf-8'))
+
     finally:
         os.unlink(pubkey_file.name)
 
+    output = textwrap.dedent(
+        '''
+        - secret:
+            name: <name>
+            data:
+              <fieldname>: !encrypted/pkcs1-oaep
+        ''')
+
+    twrap = textwrap.TextWrapper(width=79,
+                                 initial_indent=' ' * 8,
+                                 subsequent_indent=' ' * 10)
+    for chunk in ciphertext_chunks:
+        chunk = twrap.fill('- ' + chunk)
+        output += chunk + '\n'
+
     if args.outfile:
-        with open(args.outfile, "wb") as f:
-            f.write(ciphertext)
+        with open(args.outfile, "w") as f:
+            f.write(output)
     else:
-        print(ciphertext.decode("utf-8"))
+        print(output)
 
 
 if __name__ == '__main__':
diff --git a/tox.ini b/tox.ini
index a3f018f..cc5ea58 100644
--- a/tox.ini
+++ b/tox.ini
@@ -27,8 +27,12 @@
 commands = bindep test
 
 [testenv:pep8]
-# streamer is python3 only, so we need to run flake8 in python3
-commands = flake8 {posargs}
+# --ignore-missing-imports tells mypy to not try to follow imported modules
+# out of the current tree. As you might expect, we don't want to run static
+# type checking on the world - just on ourselves.
+commands =
+  flake8 {posargs}
+  mypy --ignore-missing-imports zuul
 
 [testenv:cover]
 commands =
diff --git a/zuul/ansible/action/add_host.pyi b/zuul/ansible/action/add_host.pyi
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/zuul/ansible/action/add_host.pyi
diff --git a/zuul/ansible/action/asa_config.pyi b/zuul/ansible/action/asa_config.pyi
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/zuul/ansible/action/asa_config.pyi
diff --git a/zuul/ansible/action/asa_template.pyi b/zuul/ansible/action/asa_template.pyi
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/zuul/ansible/action/asa_template.pyi
diff --git a/zuul/ansible/action/assemble.pyi b/zuul/ansible/action/assemble.pyi
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/zuul/ansible/action/assemble.pyi
diff --git a/zuul/ansible/action/copy.pyi b/zuul/ansible/action/copy.pyi
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/zuul/ansible/action/copy.pyi
diff --git a/zuul/ansible/action/dellos10_config.pyi b/zuul/ansible/action/dellos10_config.pyi
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/zuul/ansible/action/dellos10_config.pyi
diff --git a/zuul/ansible/action/dellos6_config.pyi b/zuul/ansible/action/dellos6_config.pyi
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/zuul/ansible/action/dellos6_config.pyi
diff --git a/zuul/ansible/action/dellos9_config.pyi b/zuul/ansible/action/dellos9_config.pyi
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/zuul/ansible/action/dellos9_config.pyi
diff --git a/zuul/ansible/action/eos_config.pyi b/zuul/ansible/action/eos_config.pyi
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/zuul/ansible/action/eos_config.pyi
diff --git a/zuul/ansible/action/eos_template.pyi b/zuul/ansible/action/eos_template.pyi
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/zuul/ansible/action/eos_template.pyi
diff --git a/zuul/ansible/action/fetch.pyi b/zuul/ansible/action/fetch.pyi
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/zuul/ansible/action/fetch.pyi
diff --git a/zuul/ansible/action/include_vars.pyi b/zuul/ansible/action/include_vars.pyi
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/zuul/ansible/action/include_vars.pyi
diff --git a/zuul/ansible/action/ios_config.pyi b/zuul/ansible/action/ios_config.pyi
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/zuul/ansible/action/ios_config.pyi
diff --git a/zuul/ansible/action/ios_template.pyi b/zuul/ansible/action/ios_template.pyi
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/zuul/ansible/action/ios_template.pyi
diff --git a/zuul/ansible/action/iosxr_config.pyi b/zuul/ansible/action/iosxr_config.pyi
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/zuul/ansible/action/iosxr_config.pyi
diff --git a/zuul/ansible/action/iosxr_template.pyi b/zuul/ansible/action/iosxr_template.pyi
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/zuul/ansible/action/iosxr_template.pyi
diff --git a/zuul/ansible/action/junos_config.pyi b/zuul/ansible/action/junos_config.pyi
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/zuul/ansible/action/junos_config.pyi
diff --git a/zuul/ansible/action/junos_template.pyi b/zuul/ansible/action/junos_template.pyi
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/zuul/ansible/action/junos_template.pyi
diff --git a/zuul/ansible/action/net_config.pyi b/zuul/ansible/action/net_config.pyi
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/zuul/ansible/action/net_config.pyi
diff --git a/zuul/ansible/action/net_template.pyi b/zuul/ansible/action/net_template.pyi
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/zuul/ansible/action/net_template.pyi
diff --git a/zuul/ansible/action/network.pyi b/zuul/ansible/action/network.pyi
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/zuul/ansible/action/network.pyi
diff --git a/zuul/ansible/action/normal.py b/zuul/ansible/action/normal.py
index 74e732e..803b0a7 100644
--- a/zuul/ansible/action/normal.py
+++ b/zuul/ansible/action/normal.py
@@ -1,4 +1,4 @@
-# Copyright 2016 Red Hat, Inc.
+# Copyright 2017 Red Hat, Inc.
 #
 # This module is free software: you can redistribute it and/or modify
 # it under the terms of the GNU General Public License as published by
@@ -13,13 +13,27 @@
 # You should have received a copy of the GNU General Public License
 # along with this software.  If not, see <http://www.gnu.org/licenses/>.
 
+from ansible.module_utils.six.moves.urllib.parse import urlparse
+from ansible.errors import AnsibleError
+
 from zuul.ansible import paths
 normal = paths._import_ansible_action_plugin('normal')
 
+ALLOWED_URL_SCHEMES = ('https', 'http', 'ftp')
+
 
 class ActionModule(normal.ActionModule):
+    '''Override the normal action plugin
+
+    :py:class:`ansible.plugins.normal.ActionModule` is run for every
+    module that does not have a more specific matching action plugin.
+
+    Our overridden version of it wraps the execution with checks to block
+    undesired actions on localhost.
+    '''
 
     def run(self, tmp=None, task_vars=None):
+        '''Overridden primary method from the base class.'''
 
         if (self._play_context.connection == 'local'
                 or self._play_context.remote_addr == 'localhost'
@@ -27,16 +41,61 @@
                 or self._task.delegate_to == 'localhost'
                 or (self._task.delegate_to
                     and self._task.delegate_to.startswtih('127.'))):
-            if self._task.action == 'stat':
-                paths._fail_if_unsafe(self._task.args['path'])
-            elif self._task.action == 'file':
-                dest = self._task.args.get(
-                    'path', self._task.args.get(
-                        'dest', self._task.args.get(
-                            'name')))
-                paths._fail_if_unsafe(dest)
-            else:
-                return dict(
-                    failed=True,
-                    msg="Executing local code is prohibited")
+            if not self.dispatch_handler():
+                raise AnsibleError("Executing local code is prohibited")
         return super(ActionModule, self).run(tmp, task_vars)
+
+    def dispatch_handler(self):
+        '''Run per-action handler if one exists.'''
+        handler_name = 'handle_{action}'.format(action=self._task.action)
+        handler = getattr(self, handler_name, None)
+        if handler:
+            handler()
+            return True
+        return False
+
+    def handle_stat(self):
+        '''Allow stat module on localhost if it doesn't touch unsafe files.
+
+        The :ansible:module:`stat` can be useful in jobs for manipulating logs
+        and artifacts.
+
+        Block any access of files outside the zuul work dir.
+        '''
+        paths._fail_if_unsafe(self._task.args['path'])
+
+    def handle_file(self):
+        '''Allow file module on localhost if it doesn't touch unsafe files.
+
+        The :ansible:module:`file` can be useful in jobs for manipulating logs
+        and artifacts.
+
+        Block any access of files outside the zuul work dir.
+        '''
+        for arg in ('path', 'dest', 'name'):
+            dest = self._task.args.get(arg)
+            if dest:
+                paths._fail_if_unsafe(dest)
+
+    def handle_uri(self):
+        '''Allow uri module on localhost if it doesn't touch unsafe files.
+
+        The :ansible:module:`uri` can be used from the executor to do
+        things like pinging readthedocs.org that otherwise don't need a node.
+        However, it can also download content to a local file, or be used to
+        read from file:/// urls.
+
+        Block any use of url schemes other than https, http and ftp. Further,
+        block any local file interaction that falls outside of the zuul
+        work dir.
+        '''
+        # uri takes all the file arguments, so just let handle_file validate
+        # them for us.
+        self.handle_file()
+        scheme = urlparse(self._task.args['url']).scheme
+        if scheme not in ALLOWED_URL_SCHEMES:
+            raise AnsibleError(
+                "{scheme} urls are not allowed from localhost."
+                " Only {allowed_schemes} are allowed".format(
+                    scheme=scheme,
+                    allowed_schemes=ALLOWED_URL_SCHEMES))
diff --git a/zuul/ansible/action/normal.pyi b/zuul/ansible/action/normal.pyi
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/zuul/ansible/action/normal.pyi
diff --git a/zuul/ansible/action/nxos_config.pyi b/zuul/ansible/action/nxos_config.pyi
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/zuul/ansible/action/nxos_config.pyi
diff --git a/zuul/ansible/action/nxos_template.pyi b/zuul/ansible/action/nxos_template.pyi
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/zuul/ansible/action/nxos_template.pyi
diff --git a/zuul/ansible/action/ops_config.pyi b/zuul/ansible/action/ops_config.pyi
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/zuul/ansible/action/ops_config.pyi
diff --git a/zuul/ansible/action/ops_template.pyi b/zuul/ansible/action/ops_template.pyi
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/zuul/ansible/action/ops_template.pyi
diff --git a/zuul/ansible/action/patch.pyi b/zuul/ansible/action/patch.pyi
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/zuul/ansible/action/patch.pyi
diff --git a/zuul/ansible/action/script.pyi b/zuul/ansible/action/script.pyi
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/zuul/ansible/action/script.pyi
diff --git a/zuul/ansible/action/sros_config.pyi b/zuul/ansible/action/sros_config.pyi
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/zuul/ansible/action/sros_config.pyi
diff --git a/zuul/ansible/action/synchronize.pyi b/zuul/ansible/action/synchronize.pyi
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/zuul/ansible/action/synchronize.pyi
diff --git a/zuul/ansible/action/template.pyi b/zuul/ansible/action/template.pyi
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/zuul/ansible/action/template.pyi
diff --git a/zuul/ansible/action/unarchive.pyi b/zuul/ansible/action/unarchive.pyi
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/zuul/ansible/action/unarchive.pyi
diff --git a/zuul/ansible/action/vyos_config.pyi b/zuul/ansible/action/vyos_config.pyi
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/zuul/ansible/action/vyos_config.pyi
diff --git a/zuul/ansible/action/win_copy.pyi b/zuul/ansible/action/win_copy.pyi
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/zuul/ansible/action/win_copy.pyi
diff --git a/zuul/ansible/action/win_template.pyi b/zuul/ansible/action/win_template.pyi
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/zuul/ansible/action/win_template.pyi
diff --git a/zuul/ansible/callback/zuul_stream.py b/zuul/ansible/callback/zuul_stream.py
index 078e1c9..34d4406 100644
--- a/zuul/ansible/callback/zuul_stream.py
+++ b/zuul/ansible/callback/zuul_stream.py
@@ -98,6 +98,7 @@
         self._daemon_running = False
         self._play = None
         self._streamers = []
+        self._streamers_stop = False
         self.configure_logger()
         self._items_done = False
         self._deferred_result = None
@@ -142,6 +143,11 @@
             for line in linesplit(s):
                 if "[Zuul] Task exit code" in line:
                     return
+                elif self._streamers_stop and "[Zuul] Log not found" in line:
+                    return
+                elif "[Zuul] Log not found" in line:
+                    # don't output this line
+                    pass
                 else:
                     ts, ln = line.split(' | ', 1)
                     ln = ln.strip()
@@ -223,6 +229,7 @@
                 self._streamers.append(streamer)
 
     def _stop_streamers(self):
+        self._streamers_stop = True
         while True:
             if not self._streamers:
                 break
@@ -231,6 +238,7 @@
             if streamer.is_alive():
                 msg = "[Zuul] Log Stream did not terminate"
                 self._log(msg, job=True, executor=True)
+        self._streamers_stop = False
 
     def _process_result_for_localhost(self, result, is_task=True):
         result_dict = dict(result._result)
@@ -285,10 +293,16 @@
         self._log("")
 
     def v2_runner_on_skipped(self, result):
-        reason = result._result.get('skip_reason')
-        if reason:
-            # No reason means it's an item, which we'll log differently
-            self._log_message(result, status='skipping', msg=reason)
+        if result._task.loop:
+            self._items_done = False
+            self._deferred_result = dict(result._result)
+        else:
+            reason = result._result.get('skip_reason')
+            if reason:
+                # No reason means it's an item, which we'll log differently
+                self._log_message(result, status='skipping', msg=reason)
+                # Log an extra blank line to get space after each skip
+                self._log("")
 
     def v2_runner_item_on_skipped(self, result):
         reason = result._result.get('skip_reason')
@@ -297,14 +311,14 @@
         else:
             self._log_message(result, status='skipping')
 
+        if self._deferred_result:
+            self._process_deferred(result)
+
     def v2_runner_on_ok(self, result):
         if (self._play.strategy == 'free'
                 and self._last_task_banner != result._task._uuid):
             self._print_task_banner(result._task)
 
-        if result._task.action in ('include', 'include_role', 'setup'):
-            return
-
         result_dict = dict(result._result)
 
         self._clean_results(result_dict, result._task.action)
@@ -388,8 +402,6 @@
 
         if self._deferred_result:
             self._process_deferred(result)
-        # Log an extra blank line to get space after each task
-        self._log("")
 
     def v2_runner_item_on_failed(self, result):
         result_dict = dict(result._result)
@@ -434,10 +446,13 @@
         self._items_done = True
         result_dict = self._deferred_result
         self._deferred_result = None
+        status = result_dict.get('status')
 
-        self._log_message(
-            result, "All items complete",
-            status=result_dict['status'])
+        if status:
+            self._log_message(result, "All items complete", status=status)
+
+        # Log an extra blank line to get space after each task
+        self._log("")
 
     def _print_task_banner(self, task):
 
diff --git a/zuul/ansible/library/zuul_console.py b/zuul/ansible/library/zuul_console.py
index ac85dec..ddada3f 100644
--- a/zuul/ansible/library/zuul_console.py
+++ b/zuul/ansible/library/zuul_console.py
@@ -185,6 +185,7 @@
                     console = self.chunkConsole(conn, log_uuid)
                     if console:
                         break
+                    conn.send('[Zuul] Log not found\n')
                     time.sleep(0.5)
                 while True:
                     if self.followConsole(console, conn):
diff --git a/zuul/ansible/lookup/_banned.pyi b/zuul/ansible/lookup/_banned.pyi
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/zuul/ansible/lookup/_banned.pyi
diff --git a/zuul/ansible/lookup/consul_kv.pyi b/zuul/ansible/lookup/consul_kv.pyi
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/zuul/ansible/lookup/consul_kv.pyi
diff --git a/zuul/ansible/lookup/credstash.pyi b/zuul/ansible/lookup/credstash.pyi
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/zuul/ansible/lookup/credstash.pyi
diff --git a/zuul/ansible/lookup/csvfile.py b/zuul/ansible/lookup/csvfile.py
index 6506aa2..f3d543b 100644
--- a/zuul/ansible/lookup/csvfile.py
+++ b/zuul/ansible/lookup/csvfile.py
@@ -13,6 +13,10 @@
 # You should have received a copy of the GNU General Public License
 # along with this software.  If not, see <http://www.gnu.org/licenses/>.
 
+import csv
+
+from ansible.errors import AnsibleError
+from ansible.module_utils._text import to_native
 
 from zuul.ansible import paths
 csvfile = paths._import_ansible_lookup_plugin("csvfile")
@@ -20,6 +24,21 @@
 
 class LookupModule(csvfile.LookupModule):
 
-    def read_csv(self, filename, *args, **kwargs):
+    def read_csv(
+            self, filename, key, delimiter, encoding='utf-8',
+            dflt=None, col=1):
         paths._fail_if_unsafe(filename)
-        return super(LookupModule, self).read_csv(filename, *args, **kwargs)
+
+        # upstream csvfile read_csv does not work with python3 so
+        # carry our own version.
+        try:
+            f = open(filename, 'r')
+            creader = csv.reader(f, dialect=csv.excel, delimiter=delimiter)
+
+            for row in creader:
+                if row[0] == key:
+                    return row[int(col)]
+        except Exception as e:
+            raise AnsibleError("csvfile: %s" % to_native(e))
+
+        return dflt
diff --git a/zuul/ansible/lookup/csvfile.pyi b/zuul/ansible/lookup/csvfile.pyi
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/zuul/ansible/lookup/csvfile.pyi
diff --git a/zuul/ansible/lookup/dig.pyi b/zuul/ansible/lookup/dig.pyi
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/zuul/ansible/lookup/dig.pyi
diff --git a/zuul/ansible/lookup/dnstxt.pyi b/zuul/ansible/lookup/dnstxt.pyi
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/zuul/ansible/lookup/dnstxt.pyi
diff --git a/zuul/ansible/lookup/env.pyi b/zuul/ansible/lookup/env.pyi
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/zuul/ansible/lookup/env.pyi
diff --git a/zuul/ansible/lookup/etcd.pyi b/zuul/ansible/lookup/etcd.pyi
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/zuul/ansible/lookup/etcd.pyi
diff --git a/zuul/ansible/lookup/file.pyi b/zuul/ansible/lookup/file.pyi
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/zuul/ansible/lookup/file.pyi
diff --git a/zuul/ansible/lookup/fileglob.pyi b/zuul/ansible/lookup/fileglob.pyi
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/zuul/ansible/lookup/fileglob.pyi
diff --git a/zuul/ansible/lookup/filetree.pyi b/zuul/ansible/lookup/filetree.pyi
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/zuul/ansible/lookup/filetree.pyi
diff --git a/zuul/ansible/lookup/first_found.pyi b/zuul/ansible/lookup/first_found.pyi
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/zuul/ansible/lookup/first_found.pyi
diff --git a/zuul/ansible/lookup/hashi_valut.pyi b/zuul/ansible/lookup/hashi_valut.pyi
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/zuul/ansible/lookup/hashi_valut.pyi
diff --git a/zuul/ansible/lookup/ini.pyi b/zuul/ansible/lookup/ini.pyi
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/zuul/ansible/lookup/ini.pyi
diff --git a/zuul/ansible/lookup/keyring.pyi b/zuul/ansible/lookup/keyring.pyi
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/zuul/ansible/lookup/keyring.pyi
diff --git a/zuul/ansible/lookup/lastpass.pyi b/zuul/ansible/lookup/lastpass.pyi
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/zuul/ansible/lookup/lastpass.pyi
diff --git a/zuul/ansible/lookup/lines.pyi b/zuul/ansible/lookup/lines.pyi
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/zuul/ansible/lookup/lines.pyi
diff --git a/zuul/ansible/lookup/mongodb.pyi b/zuul/ansible/lookup/mongodb.pyi
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/zuul/ansible/lookup/mongodb.pyi
diff --git a/zuul/ansible/lookup/password.pyi b/zuul/ansible/lookup/password.pyi
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/zuul/ansible/lookup/password.pyi
diff --git a/zuul/ansible/lookup/passwordstore.pyi b/zuul/ansible/lookup/passwordstore.pyi
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/zuul/ansible/lookup/passwordstore.pyi
diff --git a/zuul/ansible/lookup/pipe.pyi b/zuul/ansible/lookup/pipe.pyi
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/zuul/ansible/lookup/pipe.pyi
diff --git a/zuul/ansible/lookup/redis_kv.pyi b/zuul/ansible/lookup/redis_kv.pyi
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/zuul/ansible/lookup/redis_kv.pyi
diff --git a/zuul/ansible/lookup/shelvefile.pyi b/zuul/ansible/lookup/shelvefile.pyi
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/zuul/ansible/lookup/shelvefile.pyi
diff --git a/zuul/ansible/lookup/template.pyi b/zuul/ansible/lookup/template.pyi
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/zuul/ansible/lookup/template.pyi
diff --git a/zuul/ansible/lookup/url.pyi b/zuul/ansible/lookup/url.pyi
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/zuul/ansible/lookup/url.pyi
diff --git a/zuul/cmd/client.py b/zuul/cmd/client.py
index b55aed8..177283e 100755
--- a/zuul/cmd/client.py
+++ b/zuul/cmd/client.py
@@ -46,6 +46,21 @@
                                            description='valid commands',
                                            help='additional help')
 
+        cmd_autohold = subparsers.add_parser(
+            'autohold', help='hold nodes for failed job')
+        cmd_autohold.add_argument('--tenant', help='tenant name',
+                                  required=True)
+        cmd_autohold.add_argument('--project', help='project name',
+                                  required=True)
+        cmd_autohold.add_argument('--job', help='job name',
+                                  required=True)
+        cmd_autohold.add_argument('--reason', help='reason for the hold',
+                                  required=True)
+        cmd_autohold.add_argument('--count',
+                                  help='number of job runs (default: 1)',
+                                  required=False, type=int, default=1)
+        cmd_autohold.set_defaults(func=self.autohold)
+
         cmd_enqueue = subparsers.add_parser('enqueue', help='enqueue a change')
         cmd_enqueue.add_argument('--tenant', help='tenant name',
                                  required=True)
@@ -137,6 +152,16 @@
         else:
             sys.exit(1)
 
+    def autohold(self):
+        client = zuul.rpcclient.RPCClient(
+            self.server, self.port, self.ssl_key, self.ssl_cert, self.ssl_ca)
+        r = client.autohold(tenant=self.args.tenant,
+                            project=self.args.project,
+                            job=self.args.job,
+                            reason=self.args.reason,
+                            count=self.args.count)
+        return r
+
     def enqueue(self):
         client = zuul.rpcclient.RPCClient(
             self.server, self.port, self.ssl_key, self.ssl_cert, self.ssl_ca)
diff --git a/zuul/cmd/executor.py b/zuul/cmd/executor.py
index 6a1a214..06ef0ba 100755
--- a/zuul/cmd/executor.py
+++ b/zuul/cmd/executor.py
@@ -82,7 +82,7 @@
 
             self.log.info("Starting log streamer")
             streamer = zuul.lib.log_streamer.LogStreamer(
-                self.user, '0.0.0.0', self.finger_port, self.jobroot_dir)
+                self.user, '0.0.0.0', self.finger_port, self.job_dir)
 
             # Keep running until the parent dies:
             pipe_read = os.fdopen(pipe_read)
@@ -111,15 +111,15 @@
 
         self.user = get_default(self.config, 'executor', 'user', 'zuul')
 
-        if self.config.has_option('executor', 'jobroot_dir'):
-            self.jobroot_dir = os.path.expanduser(
-                self.config.get('executor', 'jobroot_dir'))
-            if not os.path.isdir(self.jobroot_dir):
-                print("Invalid jobroot_dir: {jobroot_dir}".format(
-                    jobroot_dir=self.jobroot_dir))
+        if self.config.has_option('executor', 'job_dir'):
+            self.job_dir = os.path.expanduser(
+                self.config.get('executor', 'job_dir'))
+            if not os.path.isdir(self.job_dir):
+                print("Invalid job_dir: {job_dir}".format(
+                    job_dir=self.job_dir))
                 sys.exit(1)
         else:
-            self.jobroot_dir = tempfile.gettempdir()
+            self.job_dir = tempfile.gettempdir()
 
         self.setup_logging('executor', 'log_config')
         self.log = logging.getLogger("zuul.Executor")
@@ -134,7 +134,7 @@
 
         ExecutorServer = zuul.executor.server.ExecutorServer
         self.executor = ExecutorServer(self.config, self.connections,
-                                       jobdir_root=self.jobroot_dir,
+                                       jobdir_root=self.job_dir,
                                        keep_jobdir=self.args.keep_jobdir,
                                        log_streaming_port=self.finger_port)
         self.executor.start()
diff --git a/zuul/cmd/merger.py b/zuul/cmd/merger.py
index c5cfd6c..9771fff 100755
--- a/zuul/cmd/merger.py
+++ b/zuul/cmd/merger.py
@@ -22,7 +22,6 @@
 # instead it depends on lockfile-0.9.1 which uses pidfile.
 pid_file_module = extras.try_imports(['daemon.pidlockfile', 'daemon.pidfile'])
 
-import os
 import sys
 import signal
 
@@ -80,17 +79,6 @@
     server.read_config()
     server.configure_connections(source_only=True)
 
-    state_dir = get_default(server.config, 'merger', 'state_dir',
-                            '/var/lib/zuul', expand_user=True)
-    test_fn = os.path.join(state_dir, 'test')
-    try:
-        f = open(test_fn, 'w')
-        f.close()
-        os.unlink(test_fn)
-    except Exception:
-        print("\nUnable to write to state directory: %s\n" % state_dir)
-        raise
-
     pid_fn = get_default(server.config, 'merger', 'pidfile',
                          '/var/run/zuul-merger/zuul-merger.pid',
                          expand_user=True)
diff --git a/zuul/configloader.py b/zuul/configloader.py
index 7640dfc..8b459b3 100644
--- a/zuul/configloader.py
+++ b/zuul/configloader.py
@@ -67,6 +67,15 @@
         super(DuplicateNodeError, self).__init__(message)
 
 
+class MaxNodeError(Exception):
+    def __init__(self, job, tenant):
+        message = textwrap.dedent("""\
+        The job "{job}" exceeds tenant max-nodes-per-job {maxnodes}.""")
+        message = textwrap.fill(message.format(
+            job=job.name, maxnodes=tenant.max_nodes_per_job))
+        super(MaxNodeError, self).__init__(message)
+
+
 class DuplicateGroupError(Exception):
     def __init__(self, nodeset, group):
         message = textwrap.dedent("""\
@@ -214,7 +223,11 @@
     yaml_loader = yaml.SafeLoader
 
     def __init__(self, ciphertext):
-        self.ciphertext = base64.b64decode(ciphertext)
+        if isinstance(ciphertext, list):
+            self.ciphertext = [base64.b64decode(x.value)
+                               for x in ciphertext]
+        else:
+            self.ciphertext = base64.b64decode(ciphertext)
 
     def __ne__(self, other):
         return not self.__eq__(other)
@@ -229,8 +242,14 @@
         return cls(node.value)
 
     def decrypt(self, private_key):
-        return encryption.decrypt_pkcs1_oaep(self.ciphertext,
-                                             private_key).decode('utf8')
+        if isinstance(self.ciphertext, list):
+            return ''.join([
+                encryption.decrypt_pkcs1_oaep(chunk, private_key).
+                decode('utf8')
+                for chunk in self.ciphertext])
+        else:
+            return encryption.decrypt_pkcs1_oaep(self.ciphertext,
+                                                 private_key).decode('utf8')
 
 
 class NodeSetParser(object):
@@ -305,10 +324,6 @@
 
     @staticmethod
     def getSchema():
-        auth = {'secrets': to_list(str),
-                'inherit': bool,
-                }
-
         node = {vs.Required('name'): str,
                 vs.Required('label'): str,
                 }
@@ -325,7 +340,8 @@
                        'override-branch': str}
 
         job = {vs.Required('name'): str,
-               'parent': str,
+               'parent': vs.Any(str, None),
+               'final': bool,
                'failure-message': str,
                'success-message': str,
                'failure-url': str,
@@ -336,7 +352,7 @@
                'tags': to_list(str),
                'branches': to_list(str),
                'files': to_list(str),
-               'auth': auth,
+               'secrets': to_list(str),
                'irrelevant-files': to_list(str),
                'nodes': vs.Any([node], str),
                'timeout': int,
@@ -353,11 +369,13 @@
                'allowed-projects': to_list(str),
                'override-branch': str,
                'description': str,
+               'post-review': bool
                }
 
         return vs.Schema(job)
 
     simple_attributes = [
+        'final',
         'timeout',
         'workspace',
         'voting',
@@ -407,24 +425,54 @@
 
         job = model.Job(conf['name'])
         job.source_context = conf.get('_source_context')
-        if 'auth' in conf:
-            job.auth = model.AuthContext()
-            if 'inherit' in conf['auth']:
-                job.auth.inherit = conf['auth']['inherit']
 
-            for secret_name in conf['auth'].get('secrets', []):
-                secret = layout.secrets[secret_name]
-                if secret.source_context != job.source_context:
-                    raise Exception(
-                        "Unable to use secret %s.  Secrets must be "
-                        "defined in the same project in which they "
-                        "are used" % secret_name)
-                job.auth.secrets.append(secret.decrypt(
-                    job.source_context.project.private_key))
-
+        is_variant = layout.hasJob(conf['name'])
         if 'parent' in conf:
-            parent = layout.getJob(conf['parent'])
-            job.inheritFrom(parent)
+            if conf['parent'] is not None:
+                # Parent job is explicitly specified, so inherit from it.
+                parent = layout.getJob(conf['parent'])
+                job.inheritFrom(parent)
+            else:
+                # Parent is explicitly set as None, so user intends
+                # this to be a base job.  That's only okay if we're in
+                # a config project.
+                if not conf['_source_context'].trusted:
+                    raise Exception(
+                        "Base jobs must be defined in config projects")
+        else:
+            # Parent is not explicitly set, so inherit from the
+            # default -- but only if this is the primary definition
+            # for the job (ie, not a variant -- variants don't need to
+            # have a parent as long as the main job does).
+            if not is_variant:
+                parent = layout.getJob(tenant.default_base_job)
+                job.inheritFrom(parent)
+        # Secrets are part of the playbook context so we must establish
+        # them earlier than playbooks.
+        secrets = []
+        for secret_name in conf.get('secrets', []):
+            secret = layout.secrets[secret_name]
+            if secret.source_context != job.source_context:
+                raise Exception(
+                    "Unable to use secret %s.  Secrets must be "
+                    "defined in the same project in which they "
+                    "are used" % secret_name)
+            secrets.append(secret.decrypt(
+                job.source_context.project.private_key))
+
+        # A job in an untrusted repo that uses secrets requires
+        # special care.  We must note this, and carry this flag
+        # through inheritance to ensure that we don't run this job in
+        # an unsafe check pipeline.
+        if secrets and not conf['_source_context'].trusted:
+            job.post_review = True
+
+        if 'post-review' in conf:
+            if conf['post-review']:
+                job.post_review = True
+            else:
+                raise Exception("Once set, the post-review attribute "
+                                "may not be unset")
 
         # Roles are part of the playbook context so we must establish
         # them earlier than playbooks.
@@ -444,21 +492,23 @@
 
         for pre_run_name in as_list(conf.get('pre-run')):
             pre_run = model.PlaybookContext(job.source_context,
-                                            pre_run_name, job.roles)
+                                            pre_run_name, job.roles,
+                                            secrets)
             job.pre_run = job.pre_run + (pre_run,)
         for post_run_name in as_list(conf.get('post-run')):
             post_run = model.PlaybookContext(job.source_context,
-                                             post_run_name, job.roles)
+                                             post_run_name, job.roles,
+                                             secrets)
             job.post_run = (post_run,) + job.post_run
         if 'run' in conf:
             run = model.PlaybookContext(job.source_context, conf['run'],
-                                        job.roles)
+                                        job.roles, secrets)
             job.run = (run,)
         else:
             if not project_pipeline:
                 run_name = os.path.join('playbooks', job.name)
                 run = model.PlaybookContext(job.source_context, run_name,
-                                            job.roles)
+                                            job.roles, secrets)
                 job.implied_run = (run,) + job.implied_run
 
         for k in JobParser.simple_attributes:
@@ -475,6 +525,9 @@
                 for conf_node in conf_nodes:
                     node = model.Node(conf_node['name'], conf_node['label'])
                     ns.addNode(node)
+            if tenant.max_nodes_per_job != -1 and \
+               len(ns) > tenant.max_nodes_per_job:
+                raise MaxNodeError(job, tenant)
             job.nodeset = ns
 
         if 'required-projects' in conf:
@@ -632,6 +685,12 @@
                 raise Exception("Job must be a string or dictionary")
             attrs['_source_context'] = source_context
             attrs['_start_mark'] = start_mark
+
+            # validate that the job is existing
+            with configuration_exceptions('project or project-template',
+                                          attrs):
+                layout.getJob(attrs['name'])
+
             job_list.addJob(JobParser.fromYaml(tenant, layout, attrs,
                                                project_pipeline=True))
 
@@ -762,20 +821,6 @@
 
         precedence = vs.Any('normal', 'low', 'high')
 
-        approval = vs.Schema({'username': str,
-                              'email-filter': str,
-                              'email': str,
-                              'older-than': str,
-                              'newer-than': str,
-                              }, extra=vs.ALLOW_EXTRA)
-
-        require = {'approval': to_list(approval),
-                   'open': bool,
-                   'current-patchset': bool,
-                   'status': to_list(str)}
-
-        reject = {'approval': to_list(approval)}
-
         window = vs.All(int, vs.Range(min=0))
         window_floor = vs.All(int, vs.Range(min=1))
         window_type = vs.Any('linear', 'exponential')
@@ -785,15 +830,13 @@
                     vs.Required('manager'): manager,
                     'precedence': precedence,
                     'description': str,
-                    'require': require,
-                    'reject': reject,
                     'success-message': str,
                     'failure-message': str,
                     'merge-failure-message': str,
                     'footer-message': str,
                     'dequeue-on-new-patchset': bool,
                     'ignore-dependencies': bool,
-                    'allow-secrets': bool,
+                    'post-review': bool,
                     'disable-after-consecutive-failures':
                         vs.All(int, vs.Range(min=1)),
                     'window': window,
@@ -843,7 +886,8 @@
             'dequeue-on-new-patchset', True)
         pipeline.ignore_dependencies = conf.get(
             'ignore-dependencies', False)
-        pipeline.allow_secrets = conf.get('allow-secrets', False)
+        pipeline.post_review = conf.get(
+            'post-review', False)
 
         for conf_key, action in PipelineParser.reporter_actions.items():
             reporter_set = []
@@ -933,6 +977,7 @@
         'include': to_list(classes),
         'exclude': to_list(classes),
         'shadow': to_list(str),
+        'exclude-unprotected-branches': bool,
     }}
 
     project = vs.Any(str, project_dict)
@@ -968,7 +1013,11 @@
     @staticmethod
     def getSchema(connections=None):
         tenant = {vs.Required('name'): str,
-                  'source': TenantParser.validateTenantSources(connections)}
+                  'max-nodes-per-job': int,
+                  'source': TenantParser.validateTenantSources(connections),
+                  'exclude-unprotected-branches': bool,
+                  'default-parent': str,
+                  }
         return vs.Schema(tenant)
 
     @staticmethod
@@ -976,6 +1025,13 @@
                  cached):
         TenantParser.getSchema(connections)(conf)
         tenant = model.Tenant(conf['name'])
+        if conf.get('max-nodes-per-job') is not None:
+            tenant.max_nodes_per_job = conf['max-nodes-per-job']
+        if conf.get('exclude-unprotected-branches') is not None:
+            tenant.exclude_unprotected_branches = \
+                conf['exclude-unprotected-branches']
+        tenant.default_base_job = conf.get('default-parent', 'base')
+
         tenant.unparsed_config = conf
         unparsed_config = model.UnparsedTenantConfig()
         # tpcs is TenantProjectConfigs
@@ -994,7 +1050,7 @@
             TenantParser._loadTenantInRepoLayouts(merger, connections,
                                                   tenant.config_projects,
                                                   tenant.untrusted_projects,
-                                                  cached)
+                                                  cached, tenant)
         unparsed_config.extend(tenant.config_projects_config)
         unparsed_config.extend(tenant.untrusted_projects_config)
         tenant.layout = TenantParser._parseLayout(base, tenant,
@@ -1066,6 +1122,7 @@
             project = source.getProject(conf)
             project_include = current_include
             shadow_projects = []
+            project_exclude_unprotected_branches = None
         else:
             project_name = list(conf.keys())[0]
             project = source.getProject(project_name)
@@ -1079,10 +1136,14 @@
                 as_list(conf[project_name].get('exclude', [])))
             if project_exclude:
                 project_include = frozenset(project_include - project_exclude)
+            project_exclude_unprotected_branches = conf[project_name].get(
+                'exclude-unprotected-branches', None)
 
         tenant_project_config = model.TenantProjectConfig(project)
         tenant_project_config.load_classes = frozenset(project_include)
         tenant_project_config.shadow_projects = shadow_projects
+        tenant_project_config.exclude_unprotected_branches = \
+            project_exclude_unprotected_branches
 
         return tenant_project_config
 
@@ -1149,7 +1210,7 @@
 
     @staticmethod
     def _loadTenantInRepoLayouts(merger, connections, config_projects,
-                                 untrusted_projects, cached):
+                                 untrusted_projects, cached, tenant):
         config_projects_config = model.UnparsedTenantConfig()
         untrusted_projects_config = model.UnparsedTenantConfig()
         jobs = []
@@ -1197,7 +1258,7 @@
             # branch.  Remember the branch and then implicitly add a
             # branch selector to each job there.  This makes the
             # in-repo configuration apply only to that branch.
-            for branch in project.source.getProjectBranches(project):
+            for branch in project.source.getProjectBranches(project, tenant):
                 project.unparsed_branch_config[branch] = \
                     model.UnparsedTenantConfig()
                 job = merger.getFiles(
@@ -1417,11 +1478,11 @@
         new_abide.tenants[tenant.name] = new_tenant
         return new_abide
 
-    def _loadDynamicProjectData(self, config, project, files, trusted):
+    def _loadDynamicProjectData(self, config, project, files, trusted, tenant):
         if trusted:
             branches = ['master']
         else:
-            branches = project.source.getProjectBranches(project)
+            branches = project.source.getProjectBranches(project, tenant)
 
         for branch in branches:
             fns1 = []
@@ -1473,11 +1534,12 @@
         if include_config_projects:
             config = model.UnparsedTenantConfig()
             for project in tenant.config_projects:
-                self._loadDynamicProjectData(config, project, files, True)
+                self._loadDynamicProjectData(
+                    config, project, files, True, tenant)
         else:
             config = tenant.config_projects_config.copy()
         for project in tenant.untrusted_projects:
-            self._loadDynamicProjectData(config, project, files, False)
+            self._loadDynamicProjectData(config, project, files, False, tenant)
 
         layout = model.Layout(tenant)
         # NOTE: the actual pipeline objects (complete with queues and
diff --git a/zuul/driver/__init__.py b/zuul/driver/__init__.py
index c78283d..6ac9197 100644
--- a/zuul/driver/__init__.py
+++ b/zuul/driver/__init__.py
@@ -33,7 +33,7 @@
     The class or instance attribute **name** must be provided as a string.
 
     """
-    name = None
+    name = None  # type: str
 
     def reconfigure(self, tenant):
         """Called when a tenant is reconfigured.
@@ -272,11 +272,11 @@
         pass
 
     @abc.abstractmethod
-    def setMountsMap(self, state_dir, ro_dirs=[], rw_dirs=[]):
+    def setMountsMap(self, state_dir, ro_paths=None, rw_paths=None):
         """Add additional mount point to the execution environment.
 
         :arg str state_dir: the state directory to be read write
-        :arg list ro_dirs: read only directories paths
-        :arg list rw_dirs: read write directories paths
+        :arg list ro_paths: read only files or directories to bind mount
+        :arg list rw_paths: read write files or directories to bind mount
         """
         pass
diff --git a/zuul/driver/bubblewrap/__init__.py b/zuul/driver/bubblewrap/__init__.py
index 3609a71..cbaa609 100644
--- a/zuul/driver/bubblewrap/__init__.py
+++ b/zuul/driver/bubblewrap/__init__.py
@@ -22,6 +22,9 @@
 import shlex
 import subprocess
 import sys
+import re
+
+from typing import Dict, List  # flake8: noqa
 
 from zuul.driver import (Driver, WrapperInterface)
 
@@ -70,7 +73,8 @@
     name = 'bubblewrap'
     log = logging.getLogger("zuul.BubblewrapDriver")
 
-    mounts_map = {'rw': [], 'ro': []}
+    mounts_map = {'rw': [], 'ro': []}  # type: Dict[str, List]
+    release_file_re = re.compile('^\W+-release$')
 
     def __init__(self):
         self.bwrap_command = self._bwrap_command()
@@ -81,8 +85,12 @@
     def stop(self):
         pass
 
-    def setMountsMap(self, state_dir, ro_dirs=[], rw_dirs=[]):
-        self.mounts_map = {'ro': ro_dirs, 'rw': [state_dir] + rw_dirs}
+    def setMountsMap(self, ro_paths=None, rw_paths=None):
+        if not ro_paths:
+            ro_paths = []
+        if not rw_paths:
+            rw_paths = []
+        self.mounts_map = {'ro': ro_paths, 'rw': rw_paths}
 
     def getPopen(self, **kwargs):
         # Set zuul_dir if it was not passed in
@@ -152,7 +160,6 @@
             '--ro-bind', '/etc/resolv.conf', '/etc/resolv.conf',
             '--ro-bind', '/etc/hosts', '/etc/hosts',
             '--ro-bind', '{ssh_auth_sock}', '{ssh_auth_sock}',
-            '--dir', '{work_dir}',
             '--bind', '{work_dir}', '{work_dir}',
             '--dev', '/dev',
             '--chdir', '{work_dir}',
@@ -165,11 +172,16 @@
             '--file', '{gid_fd}', '/etc/group',
         ]
 
-        if os.path.isdir('/lib64'):
-            bwrap_command.extend(['--ro-bind', '/lib64', '/lib64'])
-        if os.path.isfile('/etc/nsswitch.conf'):
-            bwrap_command.extend(['--ro-bind', '/etc/nsswitch.conf',
-                                  '/etc/nsswitch.conf'])
+        for path in ['/lib64',
+                     '/etc/nsswitch.conf',
+                     '/etc/lsb-release.d',
+                     ]:
+            if os.path.exists(path):
+                bwrap_command.extend(['--ro-bind', path, path])
+        for fn in os.listdir('/etc'):
+            if self.release_file_re.match(fn):
+                path = os.path.join('/etc', fn)
+                bwrap_command.extend(['--ro-bind', path, path])
 
         return bwrap_command
 
@@ -180,12 +192,16 @@
     driver = BubblewrapDriver()
 
     parser = argparse.ArgumentParser()
+    parser.add_argument('--ro-bind', nargs='+')
+    parser.add_argument('--rw-bind', nargs='+')
     parser.add_argument('work_dir')
     parser.add_argument('run_args', nargs='+')
     cli_args = parser.parse_args()
 
     ssh_auth_sock = os.environ.get('SSH_AUTH_SOCK')
 
+    driver.setMountsMap(cli_args.ro_bind, cli_args.rw_bind)
+
     popen = driver.getPopen(work_dir=cli_args.work_dir,
                             ssh_auth_sock=ssh_auth_sock)
     x = popen(cli_args.run_args)
diff --git a/zuul/driver/gerrit/gerritconnection.py b/zuul/driver/gerrit/gerritconnection.py
index 8f8465a..de72c69 100644
--- a/zuul/driver/gerrit/gerritconnection.py
+++ b/zuul/driver/gerrit/gerritconnection.py
@@ -25,8 +25,10 @@
 import queue
 import voluptuous as v
 
+from typing import Dict, List
+
 from zuul.connection import BaseConnection
-from zuul.model import Ref, Tag, Branch
+from zuul.model import Ref, Tag, Branch, Project
 from zuul import exceptions
 from zuul.driver.gerrit.gerritmodel import GerritChange, GerritTriggerEvent
 
@@ -80,7 +82,7 @@
             patchset = data.get('patchSet')
             if patchset:
                 event.patch_number = patchset.get('number')
-                event.refspec = patchset.get('ref')
+                event.ref = patchset.get('ref')
             event.approvals = data.get('approvals', [])
             event.comment = data.get('comment')
         refupdate = data.get('refUpdate')
@@ -272,10 +274,10 @@
         self.gerrit_event_connector = None
         self.source = driver.getSource(self)
 
-    def getProject(self, name):
+    def getProject(self, name: str) -> Project:
         return self.projects.get(name)
 
-    def addProject(self, project):
+    def addProject(self, project: Project) -> None:
         self.projects[project.name] = project
 
     def maintainCache(self, relevant):
@@ -418,7 +420,7 @@
         files = []
         for ps in data['patchSets']:
             if ps['number'] == change.patchset:
-                change.refspec = ps['ref']
+                change.ref = ps['ref']
                 for f in ps.get('files', []):
                     files.append(f['file'])
             if int(ps['number']) > int(max_ps):
@@ -540,7 +542,8 @@
             return True
         return False
 
-    def _waitForRefSha(self, project, ref, old_sha=''):
+    def _waitForRefSha(self, project: Project,
+                       ref: str, old_sha: str='') -> bool:
         # Wait for the ref to show up in the repo
         start = time.time()
         while time.time() - start < self.replication_timeout:
@@ -550,8 +553,8 @@
             time.sleep(self.replication_retry_interval)
         return False
 
-    def getRefSha(self, project, ref):
-        refs = {}
+    def getRefSha(self, project: Project, ref: str) -> str:
+        refs = {}  # type: Dict[str, str]
         try:
             refs = self.getInfoRefs(project)
         except:
@@ -581,7 +584,7 @@
                             continue
                         elif label['status'] in ['NEED', 'REJECT']:
                             # It may be our own rejection, so we ignore
-                            if label['label'].lower() not in allow_needs:
+                            if label['label'] not in allow_needs:
                                 return False
                             continue
                         else:
@@ -596,14 +599,14 @@
             return False
         return True
 
-    def getProjectOpenChanges(self, project):
+    def getProjectOpenChanges(self, project: Project) -> List[GerritChange]:
         # This is a best-effort function in case Gerrit is unable to return
         # a particular change.  It happens.
         query = "project:%s status:open" % (project.name,)
         self.log.debug("Running query %s to get project open changes" %
                        (query,))
         data = self.simpleQuery(query)
-        changes = []
+        changes = []  # type: List[GerritChange]
         for record in data:
             try:
                 changes.append(
@@ -614,7 +617,7 @@
                                    (record.get('number'),))
         return changes
 
-    def getProjectBranches(self, project):
+    def getProjectBranches(self, project: Project, tenant) -> List[str]:
         refs = self.getInfoRefs(project)
         heads = [str(k[len('refs/heads/'):]) for k in refs.keys()
                  if k.startswith('refs/heads/')]
@@ -711,8 +714,8 @@
             chunk, more_changes = _query_chunk("%s %s" % (query, resume))
         return alldata
 
-    def _uploadPack(self, project_name):
-        cmd = "git-upload-pack %s" % project_name
+    def _uploadPack(self, project: Project) -> str:
+        cmd = "git-upload-pack %s" % project.name
         out, err = self._ssh(cmd, "0000")
         return out
 
@@ -757,7 +760,7 @@
             raise Exception("Gerrit error executing %s" % command)
         return (out, err)
 
-    def getInfoRefs(self, project):
+    def getInfoRefs(self, project: Project) -> Dict[str, str]:
         try:
             data = self._uploadPack(project)
         except:
@@ -791,13 +794,13 @@
             ret[ref] = revision
         return ret
 
-    def getGitUrl(self, project):
+    def getGitUrl(self, project: Project) -> str:
         url = 'ssh://%s@%s:%s/%s' % (self.user, self.server, self.port,
                                      project.name)
         return url
 
-    def _getGitwebUrl(self, project, sha=None):
-        url = '%s/gitweb?p=%s.git' % (self.baseurl, project)
+    def _getGitwebUrl(self, project: Project, sha: str=None) -> str:
+        url = '%s/gitweb?p=%s.git' % (self.baseurl, project.name)
         if sha:
             url += ';a=commitdiff;h=' + sha
         return url
diff --git a/zuul/driver/gerrit/gerritmodel.py b/zuul/driver/gerrit/gerritmodel.py
index 818d260..b96ed4c 100644
--- a/zuul/driver/gerrit/gerritmodel.py
+++ b/zuul/driver/gerrit/gerritmodel.py
@@ -24,11 +24,6 @@
 EMPTY_GIT_REF = '0' * 40  # git sha of all zeros, used during creates/deletes
 
 
-def normalize_category(name):
-    name = name.lower()
-    return re.sub(' ', '-', name)
-
-
 class GerritChange(Change):
     def __init__(self, project):
         super(GerritChange, self).__init__(project)
@@ -66,23 +61,22 @@
 class GerritApprovalFilter(object):
     def __init__(self, required_approvals=[], reject_approvals=[]):
         self._required_approvals = copy.deepcopy(required_approvals)
-        self.required_approvals = self._tidy_approvals(required_approvals)
+        self.required_approvals = self._tidy_approvals(
+            self._required_approvals)
         self._reject_approvals = copy.deepcopy(reject_approvals)
-        self.reject_approvals = self._tidy_approvals(reject_approvals)
+        self.reject_approvals = self._tidy_approvals(self._reject_approvals)
 
     def _tidy_approvals(self, approvals):
         for a in approvals:
             for k, v in a.items():
                 if k == 'username':
                     a['username'] = re.compile(v)
-                elif k in ['email', 'email-filter']:
+                elif k == 'email':
                     a['email'] = re.compile(v)
                 elif k == 'newer-than':
                     a[k] = time_to_seconds(v)
                 elif k == 'older-than':
                     a[k] = time_to_seconds(v)
-            if 'email-filter' in a:
-                del a['email-filter']
         return approvals
 
     def _match_approval_required_approval(self, rapproval, approval):
@@ -109,7 +103,7 @@
             else:
                 if not isinstance(v, list):
                     v = [v]
-                if (normalize_category(approval['description']) != k or
+                if (approval['description'] != k or
                         int(approval['value']) not in v):
                     return False
         return True
@@ -281,8 +275,8 @@
         for category, value in self.event_approvals.items():
             matches_approval = False
             for eapp in event.approvals:
-                if (normalize_category(eapp['description']) == category and
-                    int(eapp['value']) == int(value)):
+                if (eapp['description'] == category and
+                        int(eapp['value']) == int(value)):
                     matches_approval = True
             if not matches_approval:
                 return False
diff --git a/zuul/driver/gerrit/gerritsource.py b/zuul/driver/gerrit/gerritsource.py
index 4571cc1..7141080 100644
--- a/zuul/driver/gerrit/gerritsource.py
+++ b/zuul/driver/gerrit/gerritsource.py
@@ -54,8 +54,8 @@
     def getProjectOpenChanges(self, project):
         return self.connection.getProjectOpenChanges(project)
 
-    def getProjectBranches(self, project):
-        return self.connection.getProjectBranches(project)
+    def getProjectBranches(self, project, tenant):
+        return self.connection.getProjectBranches(project, tenant)
 
     def getGitUrl(self, project):
         return self.connection.getGitUrl(project)
@@ -82,7 +82,6 @@
 
 
 approval = vs.Schema({'username': str,
-                      'email-filter': str,
                       'email': str,
                       'older-than': str,
                       'newer-than': str,
diff --git a/zuul/driver/gerrit/gerrittrigger.py b/zuul/driver/gerrit/gerrittrigger.py
index 706b7df..cfedd4e 100644
--- a/zuul/driver/gerrit/gerrittrigger.py
+++ b/zuul/driver/gerrit/gerrittrigger.py
@@ -77,7 +77,6 @@
     variable_dict = v.Schema(dict)
 
     approval = v.Schema({'username': str,
-                         'email-filter': str,
                          'email': str,
                          'older-than': str,
                          'newer-than': str,
diff --git a/zuul/driver/git/gitconnection.py b/zuul/driver/git/gitconnection.py
index f4fe7e5..0624088 100644
--- a/zuul/driver/git/gitconnection.py
+++ b/zuul/driver/git/gitconnection.py
@@ -48,7 +48,7 @@
     def addProject(self, project):
         self.projects[project.name] = project
 
-    def getProjectBranches(self, project):
+    def getProjectBranches(self, project, tenant):
         # TODO(jeblair): implement; this will need to handle local or
         # remote git urls.
         raise NotImplemented()
diff --git a/zuul/driver/git/gitsource.py b/zuul/driver/git/gitsource.py
index 61a328e..8d85c08 100644
--- a/zuul/driver/git/gitsource.py
+++ b/zuul/driver/git/gitsource.py
@@ -45,8 +45,8 @@
             self.connection.addProject(p)
         return p
 
-    def getProjectBranches(self, project):
-        return self.connection.getProjectBranches(project)
+    def getProjectBranches(self, project, tenant):
+        return self.connection.getProjectBranches(project, tenant)
 
     def getGitUrl(self, project):
         return self.connection.getGitUrl(project)
diff --git a/zuul/driver/github/githubconnection.py b/zuul/driver/github/githubconnection.py
index ff113ce..616e774 100644
--- a/zuul/driver/github/githubconnection.py
+++ b/zuul/driver/github/githubconnection.py
@@ -40,6 +40,12 @@
 PREVIEW_JSON_ACCEPT = 'application/vnd.github.machine-man-preview+json'
 
 
+def _sign_request(body, secret):
+    signature = 'sha1=' + hmac.new(
+        secret.encode('utf-8'), body, hashlib.sha1).hexdigest()
+    return signature
+
+
 class UTC(datetime.tzinfo):
     """UTC"""
 
@@ -263,7 +269,7 @@
     def _validate_signature(self, request):
         secret = self.connection.connection_config.get('webhook_token', None)
         if secret is None:
-            return True
+            raise RuntimeError("webhook_token is required")
 
         body = request.body
         try:
@@ -272,13 +278,12 @@
             raise webob.exc.HTTPUnauthorized(
                 'Please specify a X-Hub-Signature header with secret.')
 
-        payload_signature = 'sha1=' + hmac.new(secret.encode('utf-8'),
-                                               body,
-                                               hashlib.sha1).hexdigest()
+        payload_signature = _sign_request(body, secret)
 
         self.log.debug("Payload Signature: {0}".format(str(payload_signature)))
         self.log.debug("Request Signature: {0}".format(str(request_signature)))
-        if str(payload_signature) != str(request_signature):
+        if not hmac.compare_digest(
+            str(payload_signature), str(request_signature)):
             raise webob.exc.HTTPUnauthorized(
                 'Request signature does not match calculated payload '
                 'signature. Check that secret is correct.')
@@ -299,7 +304,7 @@
                                                       event.change_number)
         event.updated_at = pr_body.get('updated_at')
         event.branch = base.get('ref')
-        event.refspec = "refs/pull/" + str(pr_body.get('number')) + "/head"
+        event.ref = "refs/pull/" + str(pr_body.get('number')) + "/head"
         event.patch_number = head.get('sha')
 
         event.title = pr_body.get('title')
@@ -321,30 +326,31 @@
         self._data = None
 
     def __getitem__(self, key):
-        if self._data is None:
-            self._data = self._init_data()
+        self._init_data()
         return self._data[key]
 
     def __iter__(self):
+        self._init_data()
         return iter(self._data)
 
     def __len__(self):
+        self._init_data()
         return len(self._data)
 
     def _init_data(self):
-        user = self._github.user(self._username)
-        log_rate_limit(self.log, self._github)
-        data = {
-            'username': user.login,
-            'name': user.name,
-            'email': user.email
-        }
-        return data
+        if self._data is None:
+            user = self._github.user(self._username)
+            log_rate_limit(self.log, self._github)
+            self._data = {
+                'username': user.login,
+                'name': user.name,
+                'email': user.email
+            }
 
 
 class GithubConnection(BaseConnection):
     driver_name = 'github'
-    log = logging.getLogger("connection.github")
+    log = logging.getLogger("zuul.GithubConnection")
     payload_path = 'payload'
 
     def __init__(self, driver, connection_name, connection_config):
@@ -358,6 +364,12 @@
             'canonical_hostname', self.server)
         self.source = driver.getSource(self)
 
+        # ssl verification must default to true
+        verify_ssl = self.connection_config.get('verify_ssl', 'true')
+        self.verify_ssl = True
+        if verify_ssl.lower() == 'false':
+            self.verify_ssl = False
+
         self._github = None
         self.app_id = None
         self.app_key = None
@@ -390,7 +402,11 @@
     def _createGithubClient(self):
         if self.server != 'github.com':
             url = 'https://%s/' % self.server
-            github = github3.GitHubEnterprise(url)
+            if not self.verify_ssl:
+                # disabling ssl verification is evil so emit a warning
+                self.log.warning("SSL verification disabled for "
+                                 "GitHub Enterprise")
+            github = github3.GitHubEnterprise(url, verify=self.verify_ssl)
         else:
             github = github3.GitHub()
 
@@ -601,7 +617,7 @@
 
         self.log.info("Updating %s" % (change,))
         change.pr = self.getPull(change.project.name, change.number)
-        change.refspec = "refs/pull/%s/head" % change.number
+        change.ref = "refs/pull/%s/head" % change.number
         change.branch = change.pr.get('base').get('ref')
         change.files = change.pr.get('files')
         change.title = change.pr.get('title')
@@ -682,11 +698,21 @@
     def addProject(self, project):
         self.projects[project.name] = project
 
-    def getProjectBranches(self, project):
+    def getProjectBranches(self, project, tenant):
+
+        # Evaluate if unprotected branches should be excluded or not. The first
+        # match wins. The order is project -> tenant (default is false).
+        project_config = tenant.project_configs.get(project.canonical_name)
+        if project_config.exclude_unprotected_branches is not None:
+            exclude_unprotected = project_config.exclude_unprotected_branches
+        else:
+            exclude_unprotected = tenant.exclude_unprotected_branches
+
         github = self.getGithubClient()
         owner, proj = project.name.split('/')
         repository = github.repository(owner, proj)
-        branches = [branch.name for branch in repository.branches()]
+        branches = [branch.name for branch in repository.branches(
+            protected=exclude_unprotected)]
         log_rate_limit(self.log, github)
         return branches
 
diff --git a/zuul/driver/github/githubmodel.py b/zuul/driver/github/githubmodel.py
index db119f0..ffd1c3f 100644
--- a/zuul/driver/github/githubmodel.py
+++ b/zuul/driver/github/githubmodel.py
@@ -68,8 +68,8 @@
                  reject_reviews=[]):
         self._required_reviews = copy.deepcopy(required_reviews)
         self._reject_reviews = copy.deepcopy(reject_reviews)
-        self.required_reviews = self._tidy_reviews(required_reviews)
-        self.reject_reviews = self._tidy_reviews(reject_reviews)
+        self.required_reviews = self._tidy_reviews(self._required_reviews)
+        self.reject_reviews = self._tidy_reviews(self._reject_reviews)
         self.required_statuses = required_statuses
 
     def _tidy_reviews(self, reviews):
diff --git a/zuul/driver/github/githubreporter.py b/zuul/driver/github/githubreporter.py
index ea41ccd..3b8f518 100644
--- a/zuul/driver/github/githubreporter.py
+++ b/zuul/driver/github/githubreporter.py
@@ -19,6 +19,7 @@
 from zuul.reporter import BaseReporter
 from zuul.exceptions import MergeFailure
 from zuul.driver.util import scalar_or_list
+from zuul.driver.github.githubsource import GithubSource
 
 
 class GithubReporter(BaseReporter):
@@ -41,6 +42,17 @@
 
     def report(self, item):
         """Report on an event."""
+
+        # If the source is not GithubSource we cannot report anything here.
+        if not isinstance(item.change.project.source, GithubSource):
+            return
+
+        # For supporting several Github connections we also must filter by
+        # the canonical hostname.
+        if item.change.project.source.connection.canonical_hostname != \
+                self.connection.canonical_hostname:
+            return
+
         # order is important for github branch protection.
         # A status should be set before a merge attempt
         if self._commit_status is not None:
@@ -89,9 +101,15 @@
                 url_pattern = sched_config.get('webapp', 'status_url')
         url = item.formatUrlPattern(url_pattern) if url_pattern else ''
 
-        description = ''
-        if item.pipeline.description:
-            description = item.pipeline.description
+        description = '%s status: %s' % (item.pipeline.name,
+                                         self._commit_status)
+
+        if len(description) >= 140:
+            # This pipeline is named with a long name and thus this
+            # desciption would overflow the GitHub limit of 1024 bytes.
+            # Truncate the description. In practice, anything over 140
+            # characters seems to trip the limit.
+            description = 'status: %s' % self._commit_status
 
         self.log.debug(
             'Reporting change %s, params %s, status:\n'
diff --git a/zuul/driver/github/githubsource.py b/zuul/driver/github/githubsource.py
index 1bd280f..1e7e07a 100644
--- a/zuul/driver/github/githubsource.py
+++ b/zuul/driver/github/githubsource.py
@@ -68,8 +68,8 @@
             self.connection.addProject(p)
         return p
 
-    def getProjectBranches(self, project):
-        return self.connection.getProjectBranches(project)
+    def getProjectBranches(self, project, tenant):
+        return self.connection.getProjectBranches(project, tenant)
 
     def getProjectOpenChanges(self, project):
         """Get the open changes for a project."""
diff --git a/zuul/driver/smtp/smtpreporter.py b/zuul/driver/smtp/smtpreporter.py
index 1f232e9..421d14b 100644
--- a/zuul/driver/smtp/smtpreporter.py
+++ b/zuul/driver/smtp/smtpreporter.py
@@ -47,7 +47,6 @@
 
 def getSchema():
     smtp_reporter = v.Schema({
-        'connection': str,
         'to': str,
         'from': str,
         'subject': str,
diff --git a/zuul/driver/sql/alembic_reporter/versions/60c119eb1e3f_use_build_set_results.py b/zuul/driver/sql/alembic_reporter/versions/60c119eb1e3f_use_build_set_results.py
new file mode 100644
index 0000000..8e8142f
--- /dev/null
+++ b/zuul/driver/sql/alembic_reporter/versions/60c119eb1e3f_use_build_set_results.py
@@ -0,0 +1,49 @@
+"""Use build_set results
+
+Revision ID: 60c119eb1e3f
+Revises: f86c9871ee67
+Create Date: 2017-07-27 17:09:20.374782
+
+"""
+
+# revision identifiers, used by Alembic.
+revision = '60c119eb1e3f'
+down_revision = 'f86c9871ee67'
+branch_labels = None
+depends_on = None
+
+from alembic import op
+import sqlalchemy as sa
+
+BUILDSET_TABLE = 'zuul_buildset'
+
+
+def upgrade():
+    op.add_column(BUILDSET_TABLE, sa.Column('result', sa.String(255)))
+
+    connection = op.get_bind()
+    connection.execute(
+        """
+        UPDATE {buildset_table}
+         SET result=(
+             SELECT CASE score
+                WHEN 1 THEN 'SUCCESS'
+                ELSE 'FAILURE' END)
+        """.format(buildset_table=BUILDSET_TABLE))
+
+    op.drop_column(BUILDSET_TABLE, 'score')
+
+
+def downgrade():
+    op.add_column(BUILDSET_TABLE, sa.Column('score', sa.Integer))
+
+    connection = op.get_bind()
+    connection.execute(
+        """
+        UPDATE {buildset_table}
+         SET score=(
+             SELECT CASE result
+                WHEN 'SUCCESS' THEN 1
+                ELSE -1 END)
+        """.format(buildset_table=BUILDSET_TABLE))
+    op.drop_column(BUILDSET_TABLE, 'result')
diff --git a/zuul/driver/sql/sqlconnection.py b/zuul/driver/sql/sqlconnection.py
index 0e3f0dd..d76fafd 100644
--- a/zuul/driver/sql/sqlconnection.py
+++ b/zuul/driver/sql/sqlconnection.py
@@ -17,6 +17,7 @@
 import alembic
 import alembic.config
 import sqlalchemy as sa
+import sqlalchemy.pool
 import voluptuous as v
 
 from zuul.connection import BaseConnection
@@ -40,7 +41,14 @@
         self.tables_established = False
         try:
             self.dburi = self.connection_config.get('dburi')
-            self.engine = sa.create_engine(self.dburi)
+            # Recycle connections if they've been idle for more than 1 second.
+            # MySQL connections are lightweight and thus keeping long-lived
+            # connections around is not valuable.
+            # TODO(mordred) Add a config paramter
+            self.engine = sa.create_engine(
+                self.dburi,
+                poolclass=sqlalchemy.pool.QueuePool,
+                pool_recycle=1)
             self._migrate()
             self._setup_tables()
             self.zuul_buildset_table, self.zuul_build_table \
@@ -83,7 +91,7 @@
             sa.Column('change', sa.Integer, nullable=True),
             sa.Column('patchset', sa.Integer, nullable=True),
             sa.Column('ref', sa.String(255)),
-            sa.Column('score', sa.Integer, nullable=True),
+            sa.Column('result', sa.String(255)),
             sa.Column('message', sa.TEXT()),
             sa.Column('tenant', sa.String(255)),
         )
diff --git a/zuul/driver/sql/sqlreporter.py b/zuul/driver/sql/sqlreporter.py
index 214b667..7c79176 100644
--- a/zuul/driver/sql/sqlreporter.py
+++ b/zuul/driver/sql/sqlreporter.py
@@ -25,12 +25,6 @@
     name = 'sql'
     log = logging.getLogger("zuul.reporter.mysql.SQLReporter")
 
-    def __init__(self, driver, connection, config={}):
-        super(SQLReporter, self).__init__(
-            driver, connection, config)
-        # TODO(jeblair): document this is stored as NULL if unspecified
-        self.result_score = config.get('score', None)
-
     def report(self, item):
         """Create an entry into a database."""
 
@@ -41,15 +35,15 @@
         with self.connection.engine.begin() as conn:
             change = getattr(item.change, 'number', '')
             patchset = getattr(item.change, 'patchset', '')
-            refspec = getattr(item.change, 'refspec', item.change.newrev)
+            ref = getattr(item.change, 'ref', '')
             buildset_ins = self.connection.zuul_buildset_table.insert().values(
                 zuul_ref=item.current_build_set.ref,
                 pipeline=item.pipeline.name,
                 project=item.change.project.name,
                 change=change,
                 patchset=patchset,
-                ref=refspec,
-                score=self.result_score,
+                ref=ref,
+                result=item.current_build_set.result,
                 message=self._formatItemReport(
                     item, with_jobs=False),
                 tenant=item.pipeline.layout.tenant.name,
@@ -85,7 +79,5 @@
 
 
 def getSchema():
-    sql_reporter = v.Schema({
-        'score': int,
-    })
+    sql_reporter = v.Schema(None)
     return sql_reporter
diff --git a/zuul/driver/timer/__init__.py b/zuul/driver/timer/__init__.py
index 4489808..69cd508 100644
--- a/zuul/driver/timer/__init__.py
+++ b/zuul/driver/timer/__init__.py
@@ -81,7 +81,7 @@
     def _onTrigger(self, tenant, pipeline_name, timespec):
         for project_name in tenant.layout.project_configs.keys():
             (trusted, project) = tenant.getProject(project_name)
-            for branch in project.source.getProjectBranches(project):
+            for branch in project.source.getProjectBranches(project, tenant):
                 event = TimerTriggerEvent()
                 event.type = 'timer'
                 event.timespec = timespec
diff --git a/zuul/driver/zuul/__init__.py b/zuul/driver/zuul/__init__.py
index 08612dc..0f6ec7d 100644
--- a/zuul/driver/zuul/__init__.py
+++ b/zuul/driver/zuul/__init__.py
@@ -82,7 +82,7 @@
         event.branch = change.branch
         event.change_url = change.url
         event.patch_number = change.patchset
-        event.refspec = change.refspec
+        event.ref = change.ref
         self.sched.addEvent(event)
 
     def _createParentChangeEnqueuedEvents(self, change, pipeline):
@@ -104,7 +104,7 @@
         event.branch = change.branch
         event.change_url = change.url
         event.patch_number = change.patchset
-        event.refspec = change.refspec
+        event.ref = change.ref
         self.sched.addEvent(event)
 
     def getTrigger(self, connection_name, config=None):
diff --git a/zuul/driver/zuul/zuultrigger.py b/zuul/driver/zuul/zuultrigger.py
index 628687e..7757a31 100644
--- a/zuul/driver/zuul/zuultrigger.py
+++ b/zuul/driver/zuul/zuultrigger.py
@@ -43,20 +43,11 @@
 
 
 def getSchema():
-    approval = v.Schema({'username': str,
-                         'email-filter': str,
-                         'email': str,
-                         'older-than': str,
-                         'newer-than': str,
-                         }, extra=v.ALLOW_EXTRA)
-
     zuul_trigger = {
         v.Required('event'):
         scalar_or_list(v.Any('parent-change-enqueued',
                              'project-change-merged')),
         'pipeline': scalar_or_list(str),
-        'require-approval': scalar_or_list(approval),
-        'reject-approval': scalar_or_list(approval),
     }
 
     return zuul_trigger
diff --git a/zuul/executor/client.py b/zuul/executor/client.py
index beb8964..e503f41 100644
--- a/zuul/executor/client.py
+++ b/zuul/executor/client.py
@@ -16,6 +16,7 @@
 import gear
 import json
 import logging
+import os
 import time
 import threading
 from uuid import uuid4
@@ -152,14 +153,18 @@
         # replace the environment variables below.
         project = dict(
             name=item.change.project.name,
+            short_name=item.change.project.name.split('/')[-1],
             canonical_hostname=item.change.project.canonical_hostname,
-            canonical_name=item.change.project.canonical_name)
+            canonical_name=item.change.project.canonical_name,
+            src_dir=os.path.join('src', item.change.project.canonical_name),
+        )
 
         zuul_params = dict(build=uuid,
                            buildset=item.current_build_set.uuid,
                            ref=item.change.ref,
                            pipeline=pipeline.name,
                            job=job.name,
+                           voting=job.voting,
                            project=project,
                            tenant=tenant.name,
                            jobtags=sorted(job.tags))
@@ -168,81 +173,35 @@
         if hasattr(item.change, 'tag'):
             zuul_params['tag'] = item.change.tag
         if hasattr(item.change, 'number'):
-            zuul_params['change'] = item.change.number
+            zuul_params['change'] = str(item.change.number)
         if hasattr(item.change, 'patchset'):
-            zuul_params['patchset'] = item.change.patchset
-        if hasattr(item.change, 'oldrev') and item.change.oldrev:
+            zuul_params['patchset'] = str(item.change.patchset)
+        if (hasattr(item.change, 'oldrev') and item.change.oldrev
+            and item.change.oldrev != '0' * 40):
             zuul_params['oldrev'] = item.change.oldrev
-        if hasattr(item.change, 'newrev') and item.change.newrev:
+        if (hasattr(item.change, 'newrev') and item.change.newrev
+            and item.change.newrev != '0' * 40):
             zuul_params['newrev'] = item.change.newrev
         zuul_params['items'] = []
         for i in all_items:
             d = dict()
             d['project'] = dict(
                 name=i.change.project.name,
+                short_name=i.change.project.name.split('/')[-1],
                 canonical_hostname=i.change.project.canonical_hostname,
-                canonical_name=i.change.project.canonical_name)
+                canonical_name=i.change.project.canonical_name,
+                src_dir=os.path.join('src', i.change.project.canonical_name),
+            )
             if hasattr(i.change, 'number'):
-                d['change'] = i.change.number
+                d['change'] = str(i.change.number)
             if hasattr(i.change, 'patchset'):
-                d['patchset'] = i.change.number
+                d['patchset'] = str(i.change.patchset)
             if hasattr(i.change, 'branch'):
                 d['branch'] = i.change.branch
             zuul_params['items'].append(d)
 
         # Legacy environment variables
-        params = dict(ZUUL_UUID=uuid,
-                      ZUUL_PROJECT=item.change.project.name)
-        params['ZUUL_PIPELINE'] = pipeline.name
-        params['ZUUL_URL'] = item.current_build_set.zuul_url
-        params['ZUUL_VOTING'] = job.voting and '1' or '0'
-        if hasattr(item.change, 'refspec'):
-            changes_str = '^'.join(
-                ['%s:%s:%s' % (i.change.project.name, i.change.branch,
-                               i.change.refspec)
-                 for i in all_items])
-            params['ZUUL_BRANCH'] = item.change.branch
-            params['ZUUL_CHANGES'] = changes_str
-            params['ZUUL_REF'] = ('refs/zuul/%s/%s' %
-                                  (item.change.branch,
-                                   item.current_build_set.ref))
-            params['ZUUL_COMMIT'] = item.current_build_set.commit
-
-            zuul_changes = ' '.join(['%s,%s' % (i.change.number,
-                                                i.change.patchset)
-                                     for i in all_items])
-            params['ZUUL_CHANGE_IDS'] = zuul_changes
-            params['ZUUL_CHANGE'] = str(item.change.number)
-            params['ZUUL_PATCHSET'] = str(item.change.patchset)
-        if hasattr(item.change, 'ref') and item.change.ref is not None:
-            params['ZUUL_REFNAME'] = item.change.ref
-            params['ZUUL_OLDREV'] = item.change.oldrev
-            params['ZUUL_NEWREV'] = item.change.newrev
-
-            params['ZUUL_REF'] = item.change.ref
-            params['ZUUL_COMMIT'] = item.change.newrev
-
-        # This is what we should be heading toward for parameters:
-
-        # required:
-        # ZUUL_UUID
-        # ZUUL_REF (/refs/zuul/..., /refs/tags/foo, master)
-        # ZUUL_COMMIT
-
-        # optional:
-        # ZUUL_PROJECT
-        # ZUUL_PIPELINE
-
-        # optional (changes only):
-        # ZUUL_BRANCH
-        # ZUUL_CHANGE
-        # ZUUL_CHANGE_IDS
-        # ZUUL_PATCHSET
-
-        # optional (ref updated only):
-        # ZUUL_OLDREV
-        # ZUUL_NEWREV
-
+        params = dict()
         params['job'] = job.name
         params['timeout'] = job.timeout
         params['items'] = merger_items
@@ -274,11 +233,6 @@
         params['nodes'] = nodes
         params['groups'] = [group.toDict() for group in nodeset.getGroups()]
         params['vars'] = copy.deepcopy(job.variables)
-        params['secrets'] = {}
-        if job.auth:
-            for secret in job.auth.secrets:
-                secret_data = copy.deepcopy(secret.secret_data)
-                params['secrets'][secret.name] = secret_data
         params['zuul'] = zuul_params
         projects = set()
 
@@ -455,6 +409,10 @@
                 del self.builds[job.unique]
             except:
                 pass
+            # Since this isn't otherwise going to get a build complete
+            # event, send one to the scheduler so that it can unlock
+            # the nodes.
+            self.sched.onBuildCompleted(build, 'CANCELED', {})
             return True
         return False
 
diff --git a/zuul/executor/server.py b/zuul/executor/server.py
index acdf782..499b4d8 100644
--- a/zuul/executor/server.py
+++ b/zuul/executor/server.py
@@ -59,6 +59,88 @@
     pass
 
 
+class DiskAccountant(object):
+    ''' A single thread to periodically run du and monitor a base directory
+
+    Whenever the accountant notices a dir over limit, it will call the
+    given func with an argument of the job directory. That function
+    should be used to remediate the problem, generally by killing the
+    job producing the disk bloat). The function will be called every
+    time the problem is noticed, so it should be handled synchronously
+    to avoid stacking up calls.
+    '''
+    log = logging.getLogger("zuul.ExecutorDiskAccountant")
+
+    def __init__(self, jobs_base, limit, func, cache_dir, usage_func=None):
+        '''
+        :param str jobs_base: absolute path name of dir to be monitored
+        :param int limit: maximum number of MB allowed to be in use in any one
+                          subdir
+        :param callable func: Function to call with overlimit dirs
+        :param str cache_dir: absolute path name of dir to be passed as the
+                              first argument to du. This will ensure du does
+                              not count any hardlinks to files in this
+                              directory against a single job.
+        :param callable usage_func: Optional function to call with usage
+                                    for every dir _NOT_ over limit
+        '''
+        # Don't cross the streams
+        if cache_dir == jobs_base:
+            raise Exception("Cache dir and jobs dir cannot be the same")
+        self.thread = threading.Thread(target=self._run,
+                                       name='executor-diskaccountant')
+        self.thread.daemon = True
+        self._running = False
+        self.jobs_base = jobs_base
+        self.limit = limit
+        self.func = func
+        self.cache_dir = cache_dir
+        self.usage_func = usage_func
+        self.stop_event = threading.Event()
+
+    def _run(self):
+        while self._running:
+            # Walk job base
+            before = time.time()
+            du = subprocess.Popen(
+                ['du', '-m', '--max-depth=1', self.cache_dir, self.jobs_base],
+                stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
+            for line in du.stdout:
+                (size, dirname) = line.rstrip().split()
+                dirname = dirname.decode('utf8')
+                if dirname == self.jobs_base or dirname == self.cache_dir:
+                    continue
+                if os.path.dirname(dirname) == self.cache_dir:
+                    continue
+                size = int(size)
+                if size > self.limit:
+                    self.log.info(
+                        "{job} is using {size}MB (limit={limit})"
+                        .format(size=size, job=dirname, limit=self.limit))
+                    self.func(dirname)
+                elif self.usage_func:
+                    self.log.debug(
+                        "{job} is using {size}MB (limit={limit})"
+                        .format(size=size, job=dirname, limit=self.limit))
+                    self.usage_func(dirname, size)
+            du.wait()
+            after = time.time()
+            # Sleep half as long as that took, or 1s, whichever is longer
+            delay_time = max((after - before) / 2, 1.0)
+            self.stop_event.wait(delay_time)
+
+    def start(self):
+        self._running = True
+        self.thread.start()
+
+    def stop(self):
+        self._running = False
+        self.stop_event.set()
+        # We join here to avoid whitelisting the thread -- if it takes more
+        # than 5s to stop in tests, there's a problem.
+        self.thread.join(timeout=5)
+
+
 class Watchdog(object):
     def __init__(self, timeout, function, args):
         self.timeout = timeout
@@ -173,6 +255,8 @@
         self.roles_path = []
         self.ansible_config = os.path.join(self.root, 'ansible.cfg')
         self.project_link = os.path.join(self.root, 'project')
+        self.secrets = os.path.join(self.root, 'secrets.yaml')
+        self.has_secrets = False
 
     def addRole(self):
         count = len(self.roles)
@@ -193,18 +277,19 @@
             log streaming daemon find job logs.
         '''
         # root
-        #   .ansible
-        #     fact-cache/localhost
-        #   ansible
+        #   ansible (mounted in bwrap read-only)
         #     inventory.yaml
-        #   playbook_0
+        #   .ansible (mounted in bwrap read-write)
+        #     fact-cache/localhost
+        #   playbook_0 (mounted in bwrap for each playbook read-only)
+        #     secrets.yaml
         #     project -> ../trusted/project_0/...
         #     role_0 -> ../trusted/project_0/...
-        #   trusted
+        #   trusted (mounted in bwrap read-only)
         #     project_0
         #       <git.example.com>
         #         <project>
-        #   work
+        #   work (mounted in bwrap read-write)
         #     .ssh
         #       known_hosts
         #     src
@@ -233,8 +318,8 @@
         ssh_dir = os.path.join(self.work_root, '.ssh')
         os.mkdir(ssh_dir, 0o700)
         # Create ansible cache directory
-        ansible_cache = os.path.join(self.root, '.ansible')
-        self.fact_cache = os.path.join(ansible_cache, 'fact-cache')
+        self.ansible_cache_root = os.path.join(self.root, '.ansible')
+        self.fact_cache = os.path.join(self.ansible_cache_root, 'fact-cache')
         os.makedirs(self.fact_cache)
         localhost_facts = os.path.join(self.fact_cache, 'localhost')
         # NOTE(pabelanger): We do not want to leak zuul-executor facts to other
@@ -249,8 +334,6 @@
             pass
         self.known_hosts = os.path.join(ssh_dir, 'known_hosts')
         self.inventory = os.path.join(self.ansible_root, 'inventory.yaml')
-        self.secrets = os.path.join(self.ansible_root, 'secrets.yaml')
-        self.has_secrets = False
         self.playbooks = []  # The list of candidate playbooks
         self.playbook = None  # A pointer to the candidate we have chosen
         self.pre_playbooks = []
@@ -429,7 +512,6 @@
         # perhaps hostname+pid.
         self.hostname = socket.gethostname()
         self.log_streaming_port = log_streaming_port
-        self.zuul_url = config.get('merger', 'zuul_url')
         self.merger_lock = threading.Lock()
         self.verbose = False
         self.command_map = dict(
@@ -447,6 +529,8 @@
                                       '/var/lib/zuul/executor-git')
         self.default_username = get_default(self.config, 'executor',
                                             'default_username', 'zuul')
+        self.disk_limit_per_job = int(get_default(self.config, 'executor',
+                                                  'disk_limit_per_job', 250))
         self.merge_email = get_default(self.config, 'merger', 'git_user_email')
         self.merge_name = get_default(self.config, 'merger', 'git_user_name')
         execution_wrapper_name = get_default(self.config, 'executor',
@@ -490,6 +574,10 @@
             pass
 
         self.job_workers = {}
+        self.disk_accountant = DiskAccountant(self.jobdir_root,
+                                              self.disk_limit_per_job,
+                                              self.stopJobByJobdir,
+                                              self.merge_root)
 
     def _getMerger(self, root, logger=None):
         if root != self.merge_root:
@@ -534,6 +622,7 @@
         self.executor_thread = threading.Thread(target=self.run_executor)
         self.executor_thread.daemon = True
         self.executor_thread.start()
+        self.disk_accountant.start()
 
     def register(self):
         self.executor_worker.registerFunction("executor:execute")
@@ -541,9 +630,11 @@
                                               self.hostname)
         self.merger_worker.registerFunction("merger:merge")
         self.merger_worker.registerFunction("merger:cat")
+        self.merger_worker.registerFunction("merger:refstate")
 
     def stop(self):
         self.log.debug("Stopping")
+        self.disk_accountant.stop()
         self._running = False
         self._command_running = False
         self.command_socket.stop()
@@ -636,6 +727,9 @@
                     elif job.name == 'merger:merge':
                         self.log.debug("Got merge job: %s" % job.unique)
                         self.merge(job)
+                    elif job.name == 'merger:refstate':
+                        self.log.debug("Got refstate job: %s" % job.unique)
+                        self.refstate(job)
                     else:
                         self.log.error("Unable to handle job %s" % job.name)
                         job.sendWorkFail()
@@ -679,23 +773,30 @@
     def finishJob(self, unique):
         del(self.job_workers[unique])
 
+    def stopJobByJobdir(self, jobdir):
+        unique = os.path.basename(jobdir)
+        self.stopJobByUnique(unique)
+
     def stopJob(self, job):
         try:
             args = json.loads(job.arguments)
             self.log.debug("Stop job with arguments: %s" % (args,))
             unique = args['uuid']
-            job_worker = self.job_workers.get(unique)
-            if not job_worker:
-                self.log.debug("Unable to find worker for job %s" % (unique,))
-                return
-            try:
-                job_worker.stop()
-            except Exception:
-                self.log.exception("Exception sending stop command "
-                                   "to worker:")
+            self.stopJobByUnique(unique)
         finally:
             job.sendWorkComplete()
 
+    def stopJobByUnique(self, unique):
+        job_worker = self.job_workers.get(unique)
+        if not job_worker:
+            self.log.debug("Unable to find worker for job %s" % (unique,))
+            return
+        try:
+            job_worker.stop()
+        except Exception:
+            self.log.exception("Exception sending stop command "
+                               "to worker:")
+
     def cat(self, job):
         args = json.loads(job.arguments)
         task = self.update(args['connection'], args['project'])
@@ -705,8 +806,15 @@
                                          args['branch'], args['files'],
                                          args.get('dirs', []))
         result = dict(updated=True,
-                      files=files,
-                      zuul_url=self.zuul_url)
+                      files=files)
+        job.sendWorkComplete(json.dumps(result))
+
+    def refstate(self, job):
+        args = json.loads(job.arguments)
+        with self.merger_lock:
+            success, repo_state = self.merger.getRepoState(args['items'])
+        result = dict(updated=success,
+                      repo_state=repo_state)
         job.sendWorkComplete(json.dumps(result))
 
     def merge(self, job):
@@ -715,8 +823,7 @@
             ret = self.merger.mergeChanges(args['items'], args.get('files'),
                                            args.get('dirs', []),
                                            args.get('repo_state'))
-        result = dict(merged=(ret is not None),
-                      zuul_url=self.zuul_url)
+        result = dict(merged=(ret is not None))
         if ret is None:
             result['commit'] = result['files'] = result['repo_state'] = None
         else:
@@ -761,6 +868,12 @@
                                             '~/.ssh/id_rsa')
         self.ssh_agent = SshAgent()
 
+        self.executor_variables_file = None
+
+        if self.executor_server.config.has_option('executor', 'variables'):
+            self.executor_variables_file = self.executor_server.config.get(
+                'executor', 'variables')
+
     def run(self):
         self.running = True
         self.thread = threading.Thread(target=self.execute)
@@ -816,8 +929,7 @@
 
         # Make sure all projects used by the job are updated...
         for project in args['projects']:
-            self.log.debug("Job %s: updating project %s" %
-                           (self.job.unique, project))
+            self.log.debug("Updating project %s" % (project,))
             tasks.append(self.executor_server.update(
                 project['connection'], project['name']))
             projects.add((project['connection'], project['name']))
@@ -831,8 +943,7 @@
             repos += playbook['roles']
 
         for repo in repos:
-            self.log.debug("Job %s: updating playbook or role %s" %
-                           (self.job.unique, repo))
+            self.log.debug("Updating playbook or role %s" % (repo,))
             key = (repo['connection'], repo['project'])
             if key not in projects:
                 tasks.append(self.executor_server.update(*key))
@@ -841,7 +952,7 @@
         for task in tasks:
             task.wait()
 
-        self.log.debug("Job %s: git updates complete" % (self.job.unique,))
+        self.log.debug("Git updates complete")
         merger = self.executor_server._getMerger(self.jobdir.src_root,
                                                  self.log)
         repos = {}
@@ -852,7 +963,7 @@
                                   project['name'])
             repos[project['canonical_name']] = repo
 
-        merge_items = [i for i in args['items'] if i.get('refspec')]
+        merge_items = [i for i in args['items'] if i.get('number')]
         if merge_items:
             if not self.doMergeChanges(merger, merge_items,
                                        args['repo_state']):
@@ -860,6 +971,10 @@
                 # a work complete result, don't run any jobs
                 return
 
+        state_items = [i for i in args['items'] if not i.get('number')]
+        if state_items:
+            merger.setRepoState(state_items, args['repo_state'])
+
         for project in args['projects']:
             repo = repos[project['canonical_name']]
             # If this project is the Zuul project and this is a ref
@@ -1150,7 +1265,7 @@
         for role in playbook['roles']:
             self.prepareRole(jobdir_playbook, role, args)
 
-        self.writeAnsibleConfig(jobdir_playbook)
+        self.writeAnsibleConfig(jobdir_playbook, playbook)
 
     def checkoutTrustedProject(self, project, branch):
         root = self.jobdir.getTrustedProject(project.canonical_name,
@@ -1256,6 +1371,7 @@
             hostname=self.executor_server.hostname,
             src_root=self.jobdir.src_root,
             log_root=self.jobdir.log_root,
+            work_root=self.jobdir.work_root,
             result_data_file=self.jobdir.result_data_file)
 
         nodes = self.getHostList(args)
@@ -1270,17 +1386,17 @@
                 for key in node['host_keys']:
                     known_hosts.write('%s\n' % key)
 
-        secrets = args['secrets'].copy()
+    def writeAnsibleConfig(self, jobdir_playbook, playbook):
+        trusted = jobdir_playbook.trusted
+
+        secrets = playbook['secrets'].copy()
         if secrets:
             if 'zuul' in secrets:
                 raise Exception("Defining secrets named 'zuul' is not allowed")
-            with open(self.jobdir.secrets, 'w') as secrets_yaml:
+            with open(jobdir_playbook.secrets, 'w') as secrets_yaml:
                 secrets_yaml.write(
                     yaml.safe_dump(secrets, default_flow_style=False))
-            self.jobdir.has_secrets = True
-
-    def writeAnsibleConfig(self, jobdir_playbook):
-        trusted = jobdir_playbook.trusted
+            jobdir_playbook.has_secrets = True
 
         # TODO(mordred) This should likely be extracted into a more generalized
         #               mechanism for deployers being able to add callback
@@ -1296,10 +1412,10 @@
         with open(jobdir_playbook.ansible_config, 'w') as config:
             config.write('[defaults]\n')
             config.write('hostfile = %s\n' % self.jobdir.inventory)
-            config.write('local_tmp = %s/.ansible/local_tmp\n' %
-                         self.jobdir.root)
-            config.write('remote_tmp = %s/.ansible/remote_tmp\n' %
-                         self.jobdir.root)
+            config.write('local_tmp = %s/local_tmp\n' %
+                         self.jobdir.ansible_cache_root)
+            config.write('remote_tmp = %s/remote_tmp\n' %
+                         self.jobdir.ansible_cache_root)
             config.write('retry_files_enabled = False\n')
             config.write('gathering = smart\n')
             config.write('fact_caching = jsonfile\n')
@@ -1324,13 +1440,12 @@
                 config.write('roles_path = %s\n' % ':'.join(
                     jobdir_playbook.roles_path))
 
-            # On trusted jobs, we want to prevent the printing of args,
-            # since trusted jobs might have access to secrets that they may
-            # need to pass to a task or a role. On the other hand, there
-            # should be no sensitive data in untrusted jobs, and printing
-            # the args could be useful for debugging.
+            # On playbooks with secrets we want to prevent the
+            # printing of args since they may be passed to a task or a
+            # role. Otherwise, printing the args could be useful for
+            # debugging.
             config.write('display_args_to_stdout = %s\n' %
-                         str(not trusted))
+                         str(not secrets))
 
             config.write('[ssh_connection]\n')
             # NB: when setting pipelining = True, keep_remote_files
@@ -1363,7 +1478,8 @@
             except Exception:
                 self.log.exception("Exception while killing ansible process:")
 
-    def runAnsible(self, cmd, timeout, config_file, trusted):
+    def runAnsible(self, cmd, timeout, playbook):
+        config_file = playbook.ansible_config
         env_copy = os.environ.copy()
         env_copy.update(self.ssh_agent.env)
         env_copy['ZUUL_JOB_OUTPUT_FILE'] = self.jobdir.job_output_file
@@ -1376,23 +1492,32 @@
         pythonpath = [self.executor_server.ansible_dir] + pythonpath
         env_copy['PYTHONPATH'] = os.path.pathsep.join(pythonpath)
 
-        if trusted:
+        if playbook.trusted:
             opt_prefix = 'trusted'
         else:
             opt_prefix = 'untrusted'
-        ro_dirs = get_default(self.executor_server.config, 'executor',
-                              '%s_ro_dirs' % opt_prefix)
-        rw_dirs = get_default(self.executor_server.config, 'executor',
-                              '%s_rw_dirs' % opt_prefix)
-        state_dir = get_default(self.executor_server.config, 'executor',
-                                'state_dir', '/var/lib/zuul', expand_user=True)
-        ro_dirs = ro_dirs.split(":") if ro_dirs else []
-        rw_dirs = rw_dirs.split(":") if rw_dirs else []
-        self.executor_server.execution_wrapper.setMountsMap(state_dir, ro_dirs,
-                                                            rw_dirs)
+        ro_paths = get_default(self.executor_server.config, 'executor',
+                               '%s_ro_paths' % opt_prefix)
+        rw_paths = get_default(self.executor_server.config, 'executor',
+                               '%s_rw_paths' % opt_prefix)
+        ro_paths = ro_paths.split(":") if ro_paths else []
+        rw_paths = rw_paths.split(":") if rw_paths else []
+
+        ro_paths.append(self.executor_server.ansible_dir)
+        ro_paths.append(self.jobdir.ansible_root)
+        ro_paths.append(self.jobdir.trusted_root)
+        ro_paths.append(playbook.root)
+
+        rw_paths.append(self.jobdir.ansible_cache_root)
+
+        if self.executor_variables_file:
+            ro_paths.append(self.executor_variables_file)
+
+        self.executor_server.execution_wrapper.setMountsMap(ro_paths,
+                                                            rw_paths)
 
         popen = self.executor_server.execution_wrapper.getPopen(
-            work_dir=self.jobdir.root,
+            work_dir=self.jobdir.work_root,
             ssh_auth_sock=env_copy.get('SSH_AUTH_SOCK'))
 
         env_copy['ANSIBLE_CONFIG'] = config_file
@@ -1433,6 +1558,7 @@
             if timeout:
                 watchdog.stop()
                 self.log.debug("Stopped watchdog")
+            self.log.debug("Stopped disk job killer")
 
         with self.proc_lock:
             self.proc = None
@@ -1471,8 +1597,8 @@
             verbose = '-v'
 
         cmd = ['ansible-playbook', verbose, playbook.path]
-        if self.jobdir.has_secrets:
-            cmd.extend(['-e', '@' + self.jobdir.secrets])
+        if playbook.has_secrets:
+            cmd.extend(['-e', '@' + playbook.secrets])
 
         if success is not None:
             cmd.extend(['-e', 'success=%s' % str(bool(success))])
@@ -1490,10 +1616,11 @@
             % playbook.canonical_name_and_path])
         cmd.extend(['-e', 'zuul_execution_branch=%s' % str(playbook.branch)])
 
+        if self.executor_variables_file is not None:
+            cmd.extend(['-e@%s' % self.executor_variables_file])
+
         result, code = self.runAnsible(
-            cmd=cmd, timeout=timeout,
-            config_file=playbook.ansible_config,
-            trusted=playbook.trusted)
+            cmd=cmd, timeout=timeout, playbook=playbook)
         self.log.debug("Ansible complete, result %s code %s" % (
             self.RESULT_MAP[result], code))
         return result, code
diff --git a/zuul/lib/yamlutil.py b/zuul/lib/yamlutil.py
index 2419906..2c84b06 100644
--- a/zuul/lib/yamlutil.py
+++ b/zuul/lib/yamlutil.py
@@ -13,7 +13,8 @@
 from yaml import YAMLObject, YAMLError  # noqa: F401
 
 try:
-    from yaml import cyaml
+    # Explicit type ignore to deal with provisional import failure
+    from yaml import cyaml  # type: ignore
     import _yaml
     SafeLoader = cyaml.CSafeLoader
     SafeDumper = cyaml.CSafeDumper
diff --git a/zuul/manager/__init__.py b/zuul/manager/__init__.py
index 09b09d7..8282f86 100644
--- a/zuul/manager/__init__.py
+++ b/zuul/manager/__init__.py
@@ -13,6 +13,7 @@
 import logging
 
 from zuul import exceptions
+from zuul import model
 
 
 class DynamicChangeQueueContextManager(object):
@@ -483,20 +484,18 @@
     def scheduleMerge(self, item, files=None, dirs=None):
         build_set = item.current_build_set
 
-        if not hasattr(item.change, 'branch'):
-            self.log.debug("Change %s does not have an associated branch, "
-                           "not scheduling a merge job for item %s" %
-                           (item.change, item))
-            build_set.merge_state = build_set.COMPLETE
-            return True
-
         self.log.debug("Scheduling merge for item %s (files: %s, dirs: %s)" %
                        (item, files, dirs))
         build_set = item.current_build_set
         build_set.merge_state = build_set.PENDING
-        self.sched.merger.mergeChanges(build_set.merger_items,
-                                       item.current_build_set, files, dirs,
-                                       precedence=self.pipeline.precedence)
+        if isinstance(item.change, model.Change):
+            self.sched.merger.mergeChanges(build_set.merger_items,
+                                           item.current_build_set, files, dirs,
+                                           precedence=self.pipeline.precedence)
+        else:
+            self.sched.merger.getRepoState(build_set.merger_items,
+                                           item.current_build_set,
+                                           precedence=self.pipeline.precedence)
         return False
 
     def prepareItem(self, item):
@@ -675,13 +674,13 @@
         build_set = event.build_set
         item = build_set.item
         build_set.merge_state = build_set.COMPLETE
-        build_set.zuul_url = event.zuul_url
+        build_set.repo_state = event.repo_state
         if event.merged:
             build_set.commit = event.commit
             build_set.files.setFiles(event.files)
-            build_set.repo_state = event.repo_state
         elif event.updated:
-            build_set.commit = item.change.newrev
+            build_set.commit = (item.change.newrev or
+                                '0000000000000000000000000000000000000000')
         if not build_set.commit:
             self.log.info("Unable to merge change %s" % item.change)
             item.setUnableToMerge()
diff --git a/zuul/manager/independent.py b/zuul/manager/independent.py
index 06c9a01..7b0a9f5 100644
--- a/zuul/manager/independent.py
+++ b/zuul/manager/independent.py
@@ -44,6 +44,9 @@
         if hasattr(change, 'number'):
             history = history or []
             history.append(change.number)
+        else:
+            # Don't enqueue dependencies ahead of a non-change ref.
+            return True
 
         ret = self.checkForChangesNeededBy(change, change_queue)
         if ret in [True, False]:
diff --git a/zuul/merger/client.py b/zuul/merger/client.py
index e354d5d..5191a44 100644
--- a/zuul/merger/client.py
+++ b/zuul/merger/client.py
@@ -116,6 +116,11 @@
                     repo_state=repo_state)
         self.submitJob('merger:merge', data, build_set, precedence)
 
+    def getRepoState(self, items, build_set,
+                     precedence=zuul.model.PRECEDENCE_NORMAL):
+        data = dict(items=items)
+        self.submitJob('merger:refstate', data, build_set, precedence)
+
     def getFiles(self, connection_name, project_name, branch, files, dirs=[],
                  precedence=zuul.model.PRECEDENCE_HIGH):
         data = dict(connection=connection_name,
@@ -128,7 +133,6 @@
 
     def onBuildCompleted(self, job):
         data = getJobData(job)
-        zuul_url = data.get('zuul_url')
         merged = data.get('merged', False)
         updated = data.get('updated', False)
         commit = data.get('commit')
@@ -140,7 +144,7 @@
                       (job, merged, updated, commit))
         job.setComplete()
         if job.build_set:
-            self.sched.onMergeCompleted(job.build_set, zuul_url,
+            self.sched.onMergeCompleted(job.build_set,
                                         merged, updated, commit, files,
                                         repo_state)
         # The test suite expects the job to be removed from the
diff --git a/zuul/merger/merger.py b/zuul/merger/merger.py
index 93340fa..ed98696 100644
--- a/zuul/merger/merger.py
+++ b/zuul/merger/merger.py
@@ -20,6 +20,8 @@
 
 import zuul.model
 
+NULL_REF = '0000000000000000000000000000000000000000'
+
 
 def reset_repo_to_head(repo):
     # This lets us reset the repo even if there is a file in the root
@@ -178,8 +180,13 @@
             self.setRef(path, hexsha, repo)
             unseen.discard(path)
         for path in unseen:
-            self.log.debug("Delete reference %s", path)
-            git.refs.SymbolicReference.delete(repo, ref.path)
+            self.deleteRef(path, repo)
+
+    def deleteRef(self, path, repo=None):
+        if repo is None:
+            repo = self.createRepoObject()
+        self.log.debug("Delete reference %s", path)
+        git.refs.SymbolicReference.delete(repo, path)
 
     def checkout(self, ref):
         repo = self.createRepoObject()
@@ -225,9 +232,9 @@
         except AssertionError:
             origin.fetch(ref)
 
-    def fetchFrom(self, repository, refspec):
+    def fetchFrom(self, repository, ref):
         repo = self.createRepoObject()
-        repo.git.fetch(repository, refspec)
+        repo.git.fetch(repository, ref)
 
     def createZuulRef(self, ref, commit='HEAD'):
         repo = self.createRepoObject()
@@ -369,6 +376,16 @@
                     recent[key] = ref.object
             project[ref.path] = ref.object.hexsha
 
+    def _alterRepoState(self, connection_name, project_name,
+                        repo_state, path, hexsha):
+        projects = repo_state.setdefault(connection_name, {})
+        project = projects.setdefault(project_name, {})
+        if hexsha == NULL_REF:
+            if path in project:
+                del project[path]
+        else:
+            project[path] = hexsha
+
     def _restoreRepoState(self, connection_name, project_name, repo,
                           repo_state):
         projects = repo_state.get(connection_name, {})
@@ -391,11 +408,11 @@
         try:
             mode = item['merge_mode']
             if mode == zuul.model.MERGER_MERGE:
-                commit = repo.merge(item['refspec'])
+                commit = repo.merge(item['ref'])
             elif mode == zuul.model.MERGER_MERGE_RESOLVE:
-                commit = repo.merge(item['refspec'], 'resolve')
+                commit = repo.merge(item['ref'], 'resolve')
             elif mode == zuul.model.MERGER_CHERRY_PICK:
-                commit = repo.cherryPick(item['refspec'])
+                commit = repo.cherryPick(item['ref'])
             else:
                 raise Exception("Unsupported merge mode: %s" % mode)
         except git.GitCommandError:
@@ -410,9 +427,10 @@
         return commit
 
     def _mergeItem(self, item, recent, repo_state):
-        self.log.debug("Processing refspec %s for project %s/%s / %s ref %s" %
-                       (item['refspec'], item['connection'],
-                        item['project'], item['branch'], item['ref']))
+        self.log.debug("Processing ref %s for project %s/%s / %s uuid %s" %
+                       (item['ref'], item['connection'],
+                        item['project'], item['branch'],
+                        item['buildset_uuid']))
         repo = self.getRepo(item['connection'], item['project'])
         key = (item['connection'], item['project'], item['branch'])
 
@@ -451,7 +469,7 @@
             zuul_ref = None
             try:
                 repo = self.getRepo(connection, project)
-                zuul_ref = branch + '/' + item['ref']
+                zuul_ref = branch + '/' + item['buildset_uuid']
                 if not repo.getCommitFromRef(zuul_ref):
                     repo.createZuulRef(zuul_ref, mrc)
             except Exception:
@@ -469,12 +487,8 @@
         if repo_state is None:
             repo_state = {}
         for item in items:
-            if item.get("number") and item.get("patchset"):
-                self.log.debug("Merging for change %s,%s." %
-                               (item["number"], item["patchset"]))
-            elif item.get("newrev") and item.get("oldrev"):
-                self.log.debug("Merging for rev %s with oldrev %s." %
-                               (item["newrev"], item["oldrev"]))
+            self.log.debug("Merging for change %s,%s" %
+                           (item["number"], item["patchset"]))
             commit = self._mergeItem(item, recent, repo_state)
             if not commit:
                 return None
@@ -491,6 +505,49 @@
             ret_recent[k] = v.hexsha
         return commit.hexsha, read_files, repo_state, ret_recent
 
+    def setRepoState(self, items, repo_state):
+        # Sets the repo state for the items
+        seen = set()
+        for item in items:
+            repo = self.getRepo(item['connection'], item['project'])
+            key = (item['connection'], item['project'], item['branch'])
+
+            if key in seen:
+                continue
+
+            repo.reset()
+            self._restoreRepoState(item['connection'], item['project'], repo,
+                                   repo_state)
+
+    def getRepoState(self, items):
+        # Gets the repo state for items.  Generally this will be
+        # called in any non-change pipeline.  We will return the repo
+        # state for each item, but manipulated with any information in
+        # the item (eg, if it creates a ref, that will be in the repo
+        # state regardless of the actual state).
+        seen = set()
+        recent = {}
+        repo_state = {}
+        for item in items:
+            repo = self.getRepo(item['connection'], item['project'])
+            key = (item['connection'], item['project'], item['branch'])
+            if key not in seen:
+                try:
+                    repo.reset()
+                except Exception:
+                    self.log.exception("Unable to reset repo %s" % repo)
+                    return (False, {})
+
+                self._saveRepoState(item['connection'], item['project'], repo,
+                                    repo_state, recent)
+
+            if item.get('newrev'):
+                # This is a ref update rather than a branch tip, so make sure
+                # our returned state includes this change.
+                self._alterRepoState(item['connection'], item['project'],
+                                     repo_state, item['ref'], item['newrev'])
+        return (True, repo_state)
+
     def getFiles(self, connection_name, project_name, branch, files, dirs=[]):
         repo = self.getRepo(connection_name, project_name)
         return repo.getFiles(files, dirs, branch=branch)
diff --git a/zuul/merger/server.py b/zuul/merger/server.py
index 555a4bc..fc599c1 100644
--- a/zuul/merger/server.py
+++ b/zuul/merger/server.py
@@ -28,7 +28,6 @@
 
     def __init__(self, config, connections={}):
         self.config = config
-        self.zuul_url = config.get('merger', 'zuul_url')
 
         merge_root = get_default(self.config, 'merger', 'git_dir',
                                  '/var/lib/zuul/merger-git')
@@ -59,6 +58,7 @@
     def register(self):
         self.worker.registerFunction("merger:merge")
         self.worker.registerFunction("merger:cat")
+        self.worker.registerFunction("merger:refstate")
 
     def stop(self):
         self.log.debug("Stopping")
@@ -81,6 +81,9 @@
                     elif job.name == 'merger:cat':
                         self.log.debug("Got cat job: %s" % job.unique)
                         self.cat(job)
+                    elif job.name == 'merger:refstate':
+                        self.log.debug("Got refstate job: %s" % job.unique)
+                        self.refstate(job)
                     else:
                         self.log.error("Unable to handle job %s" % job.name)
                         job.sendWorkFail()
@@ -97,8 +100,7 @@
         ret = self.merger.mergeChanges(
             args['items'], args.get('files'),
             args.get('dirs'), args.get('repo_state'))
-        result = dict(merged=(ret is not None),
-                      zuul_url=self.zuul_url)
+        result = dict(merged=(ret is not None))
         if ret is None:
             result['commit'] = result['files'] = result['repo_state'] = None
         else:
@@ -106,6 +108,14 @@
              recent) = ret
         job.sendWorkComplete(json.dumps(result))
 
+    def refstate(self, job):
+        args = json.loads(job.arguments)
+
+        success, repo_state = self.merger.getItemRepoState(args['items'])
+        result = dict(updated=success,
+                      repo_state=repo_state)
+        job.sendWorkComplete(json.dumps(result))
+
     def cat(self, job):
         args = json.loads(job.arguments)
         self.merger.updateRepo(args['connection'], args['project'])
@@ -113,6 +123,5 @@
                                      args['branch'], args['files'],
                                      args.get('dirs'))
         result = dict(updated=True,
-                      files=files,
-                      zuul_url=self.zuul_url)
+                      files=files)
         job.sendWorkComplete(json.dumps(result))
diff --git a/zuul/model.py b/zuul/model.py
index ed77864..5a157bc 100644
--- a/zuul/model.py
+++ b/zuul/model.py
@@ -98,7 +98,7 @@
         self.success_message = None
         self.footer_message = None
         self.start_message = None
-        self.allow_secrets = False
+        self.post_review = False
         self.dequeue_on_new_patchset = True
         self.ignore_dependencies = False
         self.manager = None
@@ -356,6 +356,8 @@
         self.label = label
         self.id = None
         self.lock = None
+        self.hold_job = None
+        self.comment = None
         # Attributes from Nodepool
         self._state = 'unknown'
         self.state_time = time.time()
@@ -396,6 +398,8 @@
     def toDict(self):
         d = {}
         d['state'] = self.state
+        d['hold_job'] = self.hold_job
+        d['comment'] = self.comment
         for k in self._keys:
             d[k] = getattr(self, k)
         return d
@@ -497,6 +501,9 @@
             name = ''
         return '<NodeSet %s%s%s>' % (name, self.nodes, self.groups)
 
+    def __len__(self):
+        return len(self.nodes)
+
 
 class NodeRequest(object):
     """A request for a set of nodes."""
@@ -644,10 +651,11 @@
 
     """
 
-    def __init__(self, source_context, path, roles):
+    def __init__(self, source_context, path, roles, secrets):
         self.source_context = source_context
         self.path = path
         self.roles = roles
+        self.secrets = secrets
 
     def __repr__(self):
         return '<PlaybookContext %s %s>' % (self.source_context,
@@ -661,16 +669,22 @@
             return False
         return (self.source_context == other.source_context and
                 self.path == other.path and
-                self.roles == other.roles)
+                self.roles == other.roles and
+                self.secrets == other.secrets)
 
     def toDict(self):
         # Render to a dict to use in passing json to the executor
+        secrets = {}
+        for secret in self.secrets:
+            secret_data = copy.deepcopy(secret.secret_data)
+            secrets[secret.name] = secret_data
         return dict(
             connection=self.source_context.project.connection_name,
             project=self.source_context.project.name,
             branch=self.source_context.branch,
             trusted=self.source_context.trusted,
             roles=[r.toDict() for r in self.roles],
+            secrets=secrets,
             path=self.path)
 
 
@@ -733,28 +747,6 @@
         return d
 
 
-class AuthContext(object):
-    """The authentication information for a job.
-
-    Authentication information (both the actual data and metadata such
-    as whether it should be inherited) for a job is grouped together
-    in this object.
-    """
-
-    def __init__(self, inherit=False):
-        self.inherit = inherit
-        self.secrets = []
-
-    def __ne__(self, other):
-        return not self.__eq__(other)
-
-    def __eq__(self, other):
-        if not isinstance(other, AuthContext):
-            return False
-        return (self.inherit == other.inherit and
-                self.secrets == other.secrets)
-
-
 class Job(object):
 
     """A Job represents the defintion of actions to perform.
@@ -797,7 +789,6 @@
             timeout=None,
             variables={},
             nodeset=NodeSet(),
-            auth=None,
             workspace=None,
             pre_run=(),
             post_run=(),
@@ -810,6 +801,7 @@
             required_projects={},
             allowed_projects=None,
             override_branch=None,
+            post_review=None,
         )
 
         # These are generally internal attributes which are not
@@ -925,13 +917,13 @@
         if not isinstance(other, Job):
             raise Exception("Job unable to inherit from %s" % (other,))
 
-        do_not_inherit = set()
-        if other.auth and not other.auth.inherit:
-            do_not_inherit.add('auth')
+        if other.final:
+            raise Exception("Unable to inherit from final job %s" %
+                            (repr(other),))
 
         # copy all attributes
         for k in self.inheritable_attributes:
-            if (other._get(k) is not None and k not in do_not_inherit):
+            if (other._get(k) is not None):
                 setattr(self, k, copy.deepcopy(getattr(other, k)))
 
         msg = 'inherit from %s' % (repr(other),)
@@ -1233,7 +1225,6 @@
         self.previous_build_set = None
         self.uuid = None
         self.commit = None
-        self.zuul_url = None
         self.dependent_items = None
         self.merger_items = None
         self.unable_to_merge = False
@@ -1841,12 +1832,10 @@
         patchset = None
         oldrev = None
         newrev = None
-        refspec = None
         branch = None
         if hasattr(self.change, 'number'):
             number = self.change.number
             patchset = self.change.patchset
-            refspec = self.change.refspec
         if hasattr(self.change, 'newrev'):
             oldrev = self.change.oldrev
             newrev = self.change.newrev
@@ -1860,9 +1849,9 @@
         return dict(project=project.name,
                     connection=connection_name,
                     merge_mode=self.current_build_set.getMergeMode(),
-                    refspec=refspec,
+                    ref=self.change.ref,
                     branch=branch,
-                    ref=self.current_build_set.ref,
+                    buildset_uuid=self.current_build_set.uuid,
                     number=number,
                     patchset=patchset,
                     oldrev=oldrev,
@@ -1951,7 +1940,6 @@
         self.number = None
         self.url = None
         self.patchset = None
-        self.refspec = None
 
         self.needs_changes = []
         self.needed_by_changes = []
@@ -2008,6 +1996,7 @@
         # common
         self.type = None
         self.branch_updated = False
+        self.ref = None
         # For management events (eg: enqueue / promote)
         self.tenant_name = None
         self.project_hostname = None
@@ -2019,12 +2008,10 @@
         self.change_number = None
         self.change_url = None
         self.patch_number = None
-        self.refspec = None
         self.branch = None
         self.comment = None
         self.state = None
         # ref-updated
-        self.ref = None
         self.oldrev = None
         self.newrev = None
         # For events that arrive with a destination pipeline (eg, from
@@ -2089,6 +2076,10 @@
         self.load_classes = set()
         self.shadow_projects = set()
 
+        # The tenant's default setting of exclude_unprotected_branches will
+        # be overridden by this one if not None.
+        self.exclude_unprotected_branches = None
+
 
 class ProjectConfig(object):
     # Represents a project cofiguration
@@ -2232,6 +2223,9 @@
             return self.jobs[name][0]
         raise Exception("Job %s not defined" % (name,))
 
+    def hasJob(self, name):
+        return name in self.jobs
+
     def getJobs(self, name):
         return self.jobs.get(name, [])
 
@@ -2313,11 +2307,6 @@
                 # (that is to say that it must match more than just
                 # the job that is defined in the tree).
                 continue
-            # If the job does not allow auth inheritance, do not allow
-            # the project-pipeline variants to update its execution
-            # attributes.
-            if frozen_job.auth and not frozen_job.auth.inherit:
-                frozen_job.final = True
             # Whether the change matches any of the project pipeline
             # variants
             matched = False
@@ -2333,10 +2322,9 @@
                 change.project.name not in frozen_job.allowed_projects):
                 raise Exception("Project %s is not allowed to run job %s" %
                                 (change.project.name, frozen_job.name))
-            if ((not pipeline.allow_secrets) and frozen_job.auth and
-                frozen_job.auth.secrets):
-                raise Exception("Pipeline %s does not allow jobs with "
-                                "secrets (job %s)" % (
+            if ((not pipeline.post_review) and frozen_job.post_review):
+                raise Exception("Pre-review pipeline %s does not allow "
+                                "post-review job %s" % (
                                     pipeline.name, frozen_job.name))
             job_graph.addJob(frozen_job)
 
@@ -2448,6 +2436,9 @@
 class Tenant(object):
     def __init__(self, name):
         self.name = name
+        self.max_nodes_per_job = 5
+        self.exclude_unprotected_branches = False
+        self.default_base_job = None
         self.layout = None
         # The unparsed configuration from the main zuul config for
         # this tenant.
diff --git a/zuul/nodepool.py b/zuul/nodepool.py
index 8f6489c..dc855cd 100644
--- a/zuul/nodepool.py
+++ b/zuul/nodepool.py
@@ -29,10 +29,15 @@
         req = model.NodeRequest(self.sched.hostname, build_set, job, nodeset)
         self.requests[req.uid] = req
 
-        self.sched.zk.submitNodeRequest(req, self._updateNodeRequest)
-        # Logged after submission so that we have the request id
-        self.log.info("Submited node request %s" % (req,))
-
+        if nodeset.nodes:
+            self.sched.zk.submitNodeRequest(req, self._updateNodeRequest)
+            # Logged after submission so that we have the request id
+            self.log.info("Submited node request %s" % (req,))
+        else:
+            self.log.info("Fulfilling empty node request %s" % (req,))
+            req.state = model.STATE_FULFILLED
+            self.sched.onNodesProvisioned(req)
+            del self.requests[req.uid]
         return req
 
     def cancelRequest(self, request):
@@ -44,6 +49,35 @@
             except Exception:
                 self.log.exception("Error deleting node request:")
 
+    def holdNodeSet(self, nodeset, autohold_key):
+        '''
+        If requested, perform a hold on the given set of nodes.
+
+        :param NodeSet nodeset: The object containing the set of nodes to hold.
+        :param set autohold_key: A set with the tenant/project/job names
+            associated with the given NodeSet.
+        '''
+        if autohold_key not in self.sched.autohold_requests:
+            return
+
+        (hold_iterations, reason) = self.sched.autohold_requests[autohold_key]
+        nodes = nodeset.getNodes()
+
+        for node in nodes:
+            node.state = model.STATE_HOLD
+            node.hold_job = " ".join(autohold_key)
+            node.comment = reason
+            self.sched.zk.storeNode(node)
+
+        # We remove the autohold when the number of nodes in hold
+        # is equal to or greater than (run iteration count can be
+        # altered) the number of nodes used in a single job run
+        # times the number of run iterations requested.
+        nodes_in_hold = self.sched.zk.heldNodeCount(autohold_key)
+        if nodes_in_hold >= len(nodes) * hold_iterations:
+            self.log.debug("Removing autohold for %s", autohold_key)
+            del self.sched.autohold_requests[autohold_key]
+
     def useNodeSet(self, nodeset):
         self.log.info("Setting nodeset %s in use" % (nodeset,))
         for node in nodeset.getNodes():
diff --git a/zuul/rpcclient.py b/zuul/rpcclient.py
index fd3517f..1a0a084 100644
--- a/zuul/rpcclient.py
+++ b/zuul/rpcclient.py
@@ -48,6 +48,14 @@
         self.log.debug("Job complete, success: %s" % (not job.failure))
         return job
 
+    def autohold(self, tenant, project, job, reason, count):
+        data = {'tenant': tenant,
+                'project': project,
+                'job': job,
+                'reason': reason,
+                'count': count}
+        return not self.submitJob('zuul:autohold', data).failure
+
     def enqueue(self, tenant, pipeline, project, trigger, change):
         data = {'tenant': tenant,
                 'pipeline': pipeline,
diff --git a/zuul/rpclistener.py b/zuul/rpclistener.py
index 6543c91..52a7e51 100644
--- a/zuul/rpclistener.py
+++ b/zuul/rpclistener.py
@@ -49,6 +49,7 @@
         self.thread.start()
 
     def register(self):
+        self.worker.registerFunction("zuul:autohold")
         self.worker.registerFunction("zuul:enqueue")
         self.worker.registerFunction("zuul:enqueue_ref")
         self.worker.registerFunction("zuul:promote")
@@ -89,6 +90,39 @@
             except Exception:
                 self.log.exception("Exception while getting job")
 
+    def handle_autohold(self, job):
+        args = json.loads(job.arguments)
+        params = {}
+
+        tenant = self.sched.abide.tenants.get(args['tenant'])
+        if tenant:
+            params['tenant_name'] = args['tenant']
+        else:
+            error = "Invalid tenant: %s" % args['tenant']
+            job.sendWorkException(error.encode('utf8'))
+            return
+
+        (trusted, project) = tenant.getProject(args['project'])
+        if project:
+            params['project_name'] = project.canonical_name
+        else:
+            error = "Invalid project: %s" % args['project']
+            job.sendWorkException(error.encode('utf8'))
+            return
+
+        params['job_name'] = args['job']
+        params['reason'] = args['reason']
+
+        if args['count'] < 0:
+            error = "Invalid count: %d" % args['count']
+            job.sendWorkException(error.encode('utf8'))
+            return
+
+        params['count'] = args['count']
+
+        self.sched.autohold(**params)
+        job.sendWorkComplete()
+
     def _common_enqueue(self, job):
         args = json.loads(job.arguments)
         event = model.TriggerEvent()
diff --git a/zuul/scheduler.py b/zuul/scheduler.py
index 2217b0b..a64d9e0 100644
--- a/zuul/scheduler.py
+++ b/zuul/scheduler.py
@@ -136,17 +136,15 @@
     """A remote merge operation has completed
 
     :arg BuildSet build_set: The build_set which is ready.
-    :arg str zuul_url: The URL of the Zuul Merger.
     :arg bool merged: Whether the merge succeeded (changes with refs).
     :arg bool updated: Whether the repo was updated (changes without refs).
     :arg str commit: The SHA of the merged commit (changes with refs).
     :arg dict repo_state: The starting repo state before the merge.
     """
 
-    def __init__(self, build_set, zuul_url, merged, updated, commit,
+    def __init__(self, build_set, merged, updated, commit,
                  files, repo_state):
         self.build_set = build_set
-        self.zuul_url = zuul_url
         self.merged = merged
         self.updated = updated
         self.commit = commit
@@ -231,6 +229,7 @@
         self.zuul_version = zuul_version.version_info.release_string()
         self.last_reconfigured = None
         self.tenant_last_reconfigured = {}
+        self.autohold_requests = {}
 
     def stop(self):
         self._stopped = True
@@ -316,11 +315,11 @@
         self.wake_event.set()
         self.log.debug("Done adding complete event for build: %s" % build)
 
-    def onMergeCompleted(self, build_set, zuul_url, merged, updated,
+    def onMergeCompleted(self, build_set, merged, updated,
                          commit, files, repo_state):
         self.log.debug("Adding merge complete event for build set: %s" %
                        build_set)
-        event = MergeCompletedEvent(build_set, zuul_url, merged,
+        event = MergeCompletedEvent(build_set, merged,
                                     updated, commit, files, repo_state)
         self.result_event_queue.put(event)
         self.wake_event.set()
@@ -349,6 +348,15 @@
         self.last_reconfigured = int(time.time())
         # TODOv3(jeblair): reconfigure time should be per-tenant
 
+    def autohold(self, tenant_name, project_name, job_name, reason, count):
+        key = (tenant_name, project_name, job_name)
+        if count == 0 and key in self.autohold_requests:
+            self.log.debug("Removing autohold for %s", key)
+            del self.autohold_requests[key]
+        else:
+            self.log.debug("Autohold requested for %s", key)
+            self.autohold_requests[key] = (count, reason)
+
     def promote(self, tenant_name, pipeline_name, change_ids):
         event = PromoteEvent(tenant_name, pipeline_name, change_ids)
         self.management_event_queue.put(event)
@@ -828,6 +836,16 @@
         # the nodes to nodepool.
         try:
             nodeset = build.build_set.getJobNodeSet(build.job.name)
+            autohold_key = (build.pipeline.layout.tenant.name,
+                            build.build_set.item.change.project.canonical_name,
+                            build.job.name)
+
+            try:
+                self.nodepool.holdNodeSet(nodeset, autohold_key)
+            except Exception:
+                self.log.exception("Unable to process autohold for %s",
+                                   autohold_key)
+
             self.nodepool.returnNodeSet(nodeset)
         except Exception:
             self.log.exception("Unable to return nodeset %s" % (nodeset,))
diff --git a/zuul/source/__init__.py b/zuul/source/__init__.py
index b37aeb4..0396aff 100644
--- a/zuul/source/__init__.py
+++ b/zuul/source/__init__.py
@@ -64,7 +64,7 @@
         """Get a project."""
 
     @abc.abstractmethod
-    def getProjectBranches(self, project):
+    def getProjectBranches(self, project, tenant):
         """Get branches for a project"""
 
     @abc.abstractmethod
diff --git a/zuul/sphinx/__init__.py b/zuul/sphinx/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/zuul/sphinx/__init__.py
diff --git a/zuul/sphinx/ansible.py b/zuul/sphinx/ansible.py
new file mode 100644
index 0000000..4a47bc3
--- /dev/null
+++ b/zuul/sphinx/ansible.py
@@ -0,0 +1,53 @@
+# Copyright 2017 Red Hat, Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+from docutils import nodes
+from sphinx.domains import Domain
+
+MODULE_URL = 'http://docs.ansible.com/ansible/latest/{module_name}_module.html'
+
+
+def ansible_module_role(
+        name, rawtext, text, lineno, inliner, options={}, content=[]):
+    """Link to an upstream Ansible module.
+
+    Returns 2 part tuple containing list of nodes to insert into the
+    document and a list of system messages.  Both are allowed to be
+    empty.
+
+    :param name: The role name used in the document.
+    :param rawtext: The entire markup snippet, with role.
+    :param text: The text marked with the role.
+    :param lineno: The line number where rawtext appears in the input.
+    :param inliner: The inliner instance that called us.
+    :param options: Directive options for customization.
+    :param content: The directive content for customization.
+    """
+    node = nodes.reference(
+        rawtext, "Ansible {module_name} module".format(module_name=text),
+        refuri=MODULE_URL.format(module_name=text), **options)
+    return ([node], [])
+
+
+class AnsibleDomain(Domain):
+    name = 'ansible'
+    label = 'Ansible'
+
+    roles = {
+        'module': ansible_module_role,
+    }
+
+
+def setup(app):
+    app.add_domain(AnsibleDomain)
diff --git a/zuul/sphinx/zuul.py b/zuul/sphinx/zuul.py
new file mode 100644
index 0000000..575bcb2
--- /dev/null
+++ b/zuul/sphinx/zuul.py
@@ -0,0 +1,261 @@
+# Copyright 2017 Red Hat, Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+from sphinx import addnodes
+from sphinx.domains import Domain
+from sphinx.roles import XRefRole
+from sphinx.directives import ObjectDescription
+from sphinx.util.nodes import make_refnode
+from docutils import nodes
+
+from typing import Dict # noqa
+
+
+class ZuulConfigObject(ObjectDescription):
+    object_names = {
+        'attr': 'attribute',
+        'var': 'variable',
+    }
+
+    def get_path(self):
+        return self.env.ref_context.get('zuuldoc:attr_path', [])
+
+    def get_display_path(self):
+        return self.env.ref_context.get('zuuldoc:display_attr_path', [])
+
+    @property
+    def parent_pathname(self):
+        return '.'.join(self.get_display_path())
+
+    @property
+    def full_pathname(self):
+        name = self.names[-1].lower()
+        return '.'.join(self.get_path() + [name])
+
+    def add_target_and_index(self, name, sig, signode):
+        targetname = self.objtype + '-' + self.full_pathname
+        if targetname not in self.state.document.ids:
+            signode['names'].append(targetname)
+            signode['ids'].append(targetname)
+            signode['first'] = (not self.names)
+            self.state.document.note_explicit_target(signode)
+            objects = self.env.domaindata['zuuldoc']['objects']
+            if targetname in objects:
+                self.state_machine.reporter.warning(
+                    'duplicate object description of %s, ' % targetname +
+                    'other instance in ' +
+                    self.env.doc2path(objects[targetname][0]) +
+                    ', use :noindex: for one of them',
+                    line=self.lineno)
+            objects[targetname] = (self.env.docname, self.objtype)
+
+        objname = self.object_names.get(self.objtype, self.objtype)
+        if self.parent_pathname:
+            indextext = '%s (%s of %s)' % (name, objname,
+                                           self.parent_pathname)
+        else:
+            indextext = '%s (%s)' % (name, objname)
+        self.indexnode['entries'].append(('single', indextext,
+                                          targetname, '', None))
+
+
+class ZuulAttrDirective(ZuulConfigObject):
+    has_content = True
+
+    option_spec = {
+        'required': lambda x: x,
+        'default': lambda x: x,
+        'noindex': lambda x: x,
+    }
+
+    def before_content(self):
+        path = self.env.ref_context.setdefault('zuuldoc:attr_path', [])
+        path.append(self.names[-1])
+        path = self.env.ref_context.setdefault('zuuldoc:display_attr_path', [])
+        path.append(self.names[-1])
+
+    def after_content(self):
+        path = self.env.ref_context.get('zuuldoc:attr_path')
+        if path:
+            path.pop()
+        path = self.env.ref_context.get('zuuldoc:display_attr_path')
+        if path:
+            path.pop()
+
+    def handle_signature(self, sig, signode):
+        path = self.get_display_path()
+        signode['is_multiline'] = True
+        line = addnodes.desc_signature_line()
+        line['add_permalink'] = True
+        for x in path:
+            line += addnodes.desc_addname(x + '.', x + '.')
+        line += addnodes.desc_name(sig, sig)
+        if 'required' in self.options:
+            line += addnodes.desc_annotation(' (required)', ' (required)')
+        signode += line
+        if 'default' in self.options:
+            line = addnodes.desc_signature_line()
+            line += addnodes.desc_type('Default: ', 'Default: ')
+            line += nodes.literal(self.options['default'],
+                                  self.options['default'])
+            signode += line
+        return sig
+
+
+class ZuulValueDirective(ZuulConfigObject):
+    has_content = True
+
+    def handle_signature(self, sig, signode):
+        signode += addnodes.desc_name(sig, sig)
+        return sig
+
+
+class ZuulVarDirective(ZuulConfigObject):
+    has_content = True
+
+    option_spec = {
+        'type': lambda x: x,
+        'hidden': lambda x: x,
+        'noindex': lambda x: x,
+    }
+
+    type_map = {
+        'list': '[]',
+        'dict': '{}',
+    }
+
+    def get_type_str(self):
+        if 'type' in self.options:
+            return self.type_map[self.options['type']]
+        return ''
+
+    def before_content(self):
+        path = self.env.ref_context.setdefault('zuuldoc:attr_path', [])
+        element = self.names[-1]
+        path.append(element)
+        path = self.env.ref_context.setdefault('zuuldoc:display_attr_path', [])
+        element = self.names[-1] + self.get_type_str()
+        path.append(element)
+
+    def after_content(self):
+        path = self.env.ref_context.get('zuuldoc:attr_path')
+        if path:
+            path.pop()
+        path = self.env.ref_context.get('zuuldoc:display_attr_path')
+        if path:
+            path.pop()
+
+    def handle_signature(self, sig, signode):
+        if 'hidden' in self.options:
+            return sig
+        path = self.get_display_path()
+        for x in path:
+            signode += addnodes.desc_addname(x + '.', x + '.')
+        signode += addnodes.desc_name(sig, sig)
+        return sig
+
+
+class ZuulStatDirective(ZuulConfigObject):
+    has_content = True
+
+    option_spec = {
+        'type': lambda x: x,
+        'hidden': lambda x: x,
+        'noindex': lambda x: x,
+    }
+
+    def before_content(self):
+        path = self.env.ref_context.setdefault('zuuldoc:attr_path', [])
+        element = self.names[-1]
+        path.append(element)
+        path = self.env.ref_context.setdefault('zuuldoc:display_attr_path', [])
+        element = self.names[-1]
+        path.append(element)
+
+    def after_content(self):
+        path = self.env.ref_context.get('zuuldoc:attr_path')
+        if path:
+            path.pop()
+        path = self.env.ref_context.get('zuuldoc:display_attr_path')
+        if path:
+            path.pop()
+
+    def handle_signature(self, sig, signode):
+        if 'hidden' in self.options:
+            return sig
+        path = self.get_display_path()
+        for x in path:
+            signode += addnodes.desc_addname(x + '.', x + '.')
+        signode += addnodes.desc_name(sig, sig)
+        if 'type' in self.options:
+            t = ' (%s)' % self.options['type']
+            signode += addnodes.desc_annotation(t, t)
+        return sig
+
+
+class ZuulAbbreviatedXRefRole(XRefRole):
+
+    def process_link(self, env, refnode, has_explicit_title, title,
+                     target):
+        title, target = super(ZuulAbbreviatedXRefRole, self).process_link(
+            env, refnode, has_explicit_title, title, target)
+        if not has_explicit_title:
+            title = title.split('.')[-1]
+        return title, target
+
+
+class ZuulDomain(Domain):
+    name = 'zuuldoc'
+    label = 'Zuul'
+
+    directives = {
+        'attr': ZuulAttrDirective,
+        'value': ZuulValueDirective,
+        'var': ZuulVarDirective,
+        'stat': ZuulStatDirective,
+    }
+
+    roles = {
+        'attr': XRefRole(innernodeclass=nodes.inline,  # type: ignore
+                         warn_dangling=True),
+        'value': ZuulAbbreviatedXRefRole(
+            innernodeclass=nodes.inline,  # type: ignore
+            warn_dangling=True),
+        'var': XRefRole(innernodeclass=nodes.inline,  # type: ignore
+                        warn_dangling=True),
+        'stat': XRefRole(innernodeclass=nodes.inline,  # type: ignore
+                         warn_dangling=True),
+    }
+
+    initial_data = {
+        'objects': {},
+    }  # type: Dict[str, Dict]
+
+    def resolve_xref(self, env, fromdocname, builder, type, target,
+                     node, contnode):
+        objects = self.data['objects']
+        name = type + '-' + target
+        obj = objects.get(name)
+        if obj:
+            return make_refnode(builder, fromdocname, obj[0], name,
+                                contnode, name)
+
+    def clear_doc(self, docname):
+        for fullname, (fn, _l) in list(self.data['objects'].items()):
+            if fn == docname:
+                del self.data['objects'][fullname]
+
+
+def setup(app):
+    app.add_domain(ZuulDomain)
diff --git a/zuul/webapp.py b/zuul/webapp.py
index e4feaa0..b9129b8 100644
--- a/zuul/webapp.py
+++ b/zuul/webapp.py
@@ -133,11 +133,15 @@
                 return handler(path, '', request)
 
         # Now try with a tenant_name stripped
-        tenant_name = request.path.split('/')[1]
-        path = request.path.replace('/' + tenant_name, '')
+        x, tenant_name, path = request.path.split('/', 2)
+        path = '/' + path
         # Handle keys
         if path.startswith('/keys'):
-            return self._handle_keys(request, path)
+            try:
+                return self._handle_keys(request, path)
+            except Exception as e:
+                self.log.exception("Issue with _handle_keys")
+                raise
         for path_re, handler in self.routes.values():
             if path_re.match(path):
                 return handler(path, tenant_name, request)
diff --git a/zuul/zk.py b/zuul/zk.py
index 31b85ea..5ea4e56 100644
--- a/zuul/zk.py
+++ b/zuul/zk.py
@@ -15,19 +15,12 @@
 import json
 import logging
 import time
+
 from kazoo.client import KazooClient, KazooState
 from kazoo import exceptions as kze
 from kazoo.recipe.lock import Lock
 
-# States:
-# We are building this node but it is not ready for use.
-BUILDING = 'building'
-# The node is ready for use.
-READY = 'ready'
-# The node should be deleted.
-DELETING = 'deleting'
-
-STATES = set([BUILDING, READY, DELETING])
+import zuul.model
 
 
 class LockException(Exception):
@@ -246,3 +239,25 @@
             raise LockException("Node %s does not hold a lock" % (node,))
         node.lock.release()
         node.lock = None
+
+    def heldNodeCount(self, autohold_key):
+        '''
+        Count the number of nodes being held for the given tenant/project/job.
+
+        :param set autohold_key: A set with the tenant/project/job names.
+        '''
+        identifier = " ".join(autohold_key)
+        try:
+            nodes = self.client.get_children(self.NODE_ROOT)
+        except kze.NoNodeError:
+            return 0
+
+        count = 0
+        for nodeid in nodes:
+            node_path = '%s/%s' % (self.NODE_ROOT, nodeid)
+            node_data, node_stat = self.client.get(node_path)
+            node_data = self._strToDict(node_data)
+            if (node_data['state'] == zuul.model.STATE_HOLD and
+                    node_data.get('hold_job') == identifier):
+                count += 1
+        return count