Merge "Do late decoding of log stream buffer" into feature/zuulv3
diff --git a/doc/source/admin/drivers/gerrit.rst b/doc/source/admin/drivers/gerrit.rst
index ac42bd3..935cb32 100644
--- a/doc/source/admin/drivers/gerrit.rst
+++ b/doc/source/admin/drivers/gerrit.rst
@@ -61,6 +61,17 @@
 
       Path to Gerrit web interface.
 
+   .. attr:: gitweb_url_template
+      :default: {baseurl}/gitweb?p={project.name}.git;a=commitdiff;h={sha}
+
+      Url template for links to specific git shas. By default this will
+      point at Gerrit's built in gitweb but you can customize this value
+      to point elsewhere (like cgit or github).
+
+      The three values available for string interpolation are baseurl
+      which points back to Gerrit, project and all of its safe attributes,
+      and sha which is the git sha1.
+
    .. attr:: user
       :default: zuul
 
diff --git a/doc/source/user/config.rst b/doc/source/user/config.rst
index c955b3c..0019932 100644
--- a/doc/source/user/config.rst
+++ b/doc/source/user/config.rst
@@ -448,9 +448,7 @@
 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.
-Subsequent job definitions with the same name are called variants.
+Multiple job definitions with the same name are called variants.
 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
@@ -645,13 +643,11 @@
         branch specifier is used.  If no branch specifier appears, the
         job applies to all branches.
 
-      * 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.
+      * In the case of an :term:`untrusted-project`, if the project
+        has only one branch, no implied branch specifier is applied to
+        :ref:`job` definitions.  If the project has more than one
+        branch, the branch containing the job definition is used as an
+        implied branch specifier.
 
       * In the case of a job variant defined within a :ref:`project`,
         if the project definition is in a :term:`config-project`, no
@@ -665,16 +661,15 @@
         implied branch specifier for the :ref:`project` definition which
         uses the project-template will be used.
 
-      * Any further job variants other than the reference definition
-        in an untrusted-project will, if they do not have a branch
-        specifier, have an implied branch specifier for the current
-        branch applied.
-
       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.
+      affect that branch, and likewise, changes to the master branch
+      only affect it.
+
+      See :attr:`pragma.implied-branch-matchers` for how to override
+      this behavior on a per-file basis.
 
    .. attr:: files
 
@@ -786,42 +781,55 @@
 
    .. 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.
+      The name of a playbook or list of playbooks 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.
 
+      .. warning::
+
+         If the path as specified does not exist, Zuul will try
+         appending the extensions ``.yaml`` and ``.yml``.  This
+         behavior is deprecated and will be removed in the future all
+         playbook paths should include the file extension.
+
    .. 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.
+      The name of a playbook or list of playbooks 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.
 
+      .. warning::
+
+         If the path as specified does not exist, Zuul will try
+         appending the extensions ``.yaml`` and ``.yml``.  This
+         behavior is deprecated and will be removed in the future all
+         playbook paths should include the file extension.
+
    .. 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:
+      The name of the main playbook for this job.  If it is not
+      supplied, the parent's playbook will be used (and likewise up
+      the inheritance chain).  The full path within the repo is
+      required.  Example:
 
       .. code-block:: yaml
 
-         run: playbooks/<name of the job>
+         run: playbooks/job-playbook.yaml
+
+      .. warning::
+
+         If the path as specified does not exist, Zuul will try
+         appending the extensions ``.yaml`` and ``.yml``.  This
+         behavior is deprecated and will be removed in the future all
+         playbook paths should include the file extension.
 
    .. attr:: roles
 
@@ -1283,3 +1291,41 @@
       :default: 1
 
       The maximum number of running jobs which can use this semaphore.
+
+.. _pragma:
+
+Pragma
+~~~~~~
+
+The `pragma` item does not behave like the others.  It can not be
+included or excluded from configuration loading by the administrator,
+and does not form part of the final configuration itself.  It is used
+to alter how the configuration is processed while loading.
+
+A pragma item only affects the current file.  The same file in another
+branch of the same project will not be affected, nor any other files
+or any other projects.  The effect is global within that file --
+pragma directives may not be set and then unset within the same file.
+
+.. code-block:: yaml
+
+   - pragma:
+       implied-branch-matchers: False
+
+.. attr:: pragma
+
+   The pragma item currently only supports one attribute:
+
+   .. attr:: implied-branch-matchers
+
+      This is a boolean, which, if set, may be used to enable
+      (``True``) or disable (``False``) the addition of implied branch
+      matchers to job definitions.  Normally Zuul decides whether to
+      add these based on heuristics described in :attr:`job.branches`.
+      This attribute overrides that behavior.
+
+      This can be useful if a project has multiple branches, yet the
+      jobs defined in the master branch should apply to all branches.
+
+      Note that if a job contains an explicit branch matcher, it will
+      be used regardless of the value supplied here.
diff --git a/tests/base.py b/tests/base.py
index d3c5d62..2a2f164 100755
--- a/tests/base.py
+++ b/tests/base.py
@@ -67,6 +67,7 @@
 import zuul.model
 import zuul.nodepool
 import zuul.zk
+import zuul.configloader
 from zuul.exceptions import MergeFailure
 
 FIXTURE_DIR = os.path.join(os.path.dirname(__file__),
@@ -2229,8 +2230,14 @@
                                      files={'README': ''},
                                      branch='master', tag='init')
             if 'job' in item:
-                jobname = item['job']['name']
-                files['playbooks/%s.yaml' % jobname] = ''
+                if 'run' in item['job']:
+                    files['%s.yaml' % item['job']['run']] = ''
+                for fn in zuul.configloader.as_list(
+                        item['job'].get('pre-run', [])):
+                    files['%s.yaml' % fn] = ''
+                for fn in zuul.configloader.as_list(
+                        item['job'].get('post-run', [])):
+                    files['%s.yaml' % fn] = ''
 
         root = os.path.join(self.test_root, "config")
         if not os.path.exists(root):
diff --git a/tests/fixtures/config/ansible/git/common-config/zuul.yaml b/tests/fixtures/config/ansible/git/common-config/zuul.yaml
index 67d1c70..28bfce1 100644
--- a/tests/fixtures/config/ansible/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/ansible/git/common-config/zuul.yaml
@@ -79,10 +79,11 @@
     failure-url: https://failure.example.com/zuul-logs/{build.uuid}/
 
 - job:
-    parent: base-urls
     name: python27
-    pre-run: playbooks/pre
-    post-run: playbooks/post
+    parent: base-urls
+    run: playbooks/python27.yaml
+    pre-run: playbooks/pre.yaml
+    post-run: playbooks/post.yaml
     vars:
       flagpath: '{{zuul._test.test_root}}/{{zuul.build}}.flag'
     roles:
@@ -93,11 +94,13 @@
 - job:
     parent: python27
     name: timeout
+    run: playbooks/timeout.yaml
     timeout: 1
 
 - job:
     parent: python27
     name: check-vars
+    run: playbooks/check-vars.yaml
     nodeset:
       nodes:
         - name: ubuntu-xenial
@@ -113,6 +116,7 @@
 - job:
     parent: python27
     name: check-secret-names
+    run: playbooks/check-secret-names.yaml
     nodeset:
       nodes:
         - name: ubuntu-xenial
@@ -124,9 +128,11 @@
 - job:
     parent: base-urls
     name: hello
+    run: playbooks/hello-post.yaml
     post-run: playbooks/hello-post
 
 - job:
     parent: python27
     name: failpost
+    run: playbooks/post-broken.yaml
     post-run: playbooks/post-broken
diff --git a/tests/fixtures/config/ansible/git/org_project/.zuul.yaml b/tests/fixtures/config/ansible/git/org_project/.zuul.yaml
index e144325..447f6cd 100644
--- a/tests/fixtures/config/ansible/git/org_project/.zuul.yaml
+++ b/tests/fixtures/config/ansible/git/org_project/.zuul.yaml
@@ -1,10 +1,12 @@
 - job:
     parent: python27
     name: faillocal
+    run: playbooks/faillocal.yaml
 
 - job:
     parent: hello
     name: hello-world
+    run: playbooks/hello-world.yaml
 
 - project:
     name: org/project
diff --git a/tests/fixtures/config/base-jobs/git/org_project/.zuul.yaml b/tests/fixtures/config/base-jobs/git/org_project/.zuul.yaml
index 9844c14..a7e3bdb 100644
--- a/tests/fixtures/config/base-jobs/git/org_project/.zuul.yaml
+++ b/tests/fixtures/config/base-jobs/git/org_project/.zuul.yaml
@@ -1,9 +1,11 @@
 - job:
     name: my-job
+    run: playbooks/my-job.yaml
 
 - job:
     name: other-job
     parent: other-base
+    run: playbooks/other-job.yaml
 
 - project:
     name: org/project
diff --git a/tests/fixtures/config/branch-variants/git/project-config/zuul.yaml b/tests/fixtures/config/branch-variants/git/project-config/zuul.yaml
index 89d98a9..161e5a1 100644
--- a/tests/fixtures/config/branch-variants/git/project-config/zuul.yaml
+++ b/tests/fixtures/config/branch-variants/git/project-config/zuul.yaml
@@ -11,6 +11,26 @@
       gerrit:
         Verified: -1
 
+- pipeline:
+    name: gate
+    manager: dependent
+    trigger:
+      gerrit:
+        - event: comment-added
+          approval:
+            - Approved: 1
+    success:
+      gerrit:
+        Verified: 2
+        submit: true
+    failure:
+      gerrit:
+        Verified: -2
+    start:
+      gerrit:
+        Verified: 0
+    precedence: high
+
 - job:
     name: base
     parent: null
@@ -23,3 +43,14 @@
     name: project-config
     check:
       jobs: []
+    gate:
+      jobs:
+        - noop
+
+- project:
+    name: puppet-integration
+    check:
+      jobs: []
+    gate:
+      jobs:
+        - noop
diff --git a/tests/fixtures/config/branch-variants/git/puppet-integration/.zuul.yaml b/tests/fixtures/config/branch-variants/git/puppet-integration/.zuul.yaml
index 2545208..322927f 100644
--- a/tests/fixtures/config/branch-variants/git/puppet-integration/.zuul.yaml
+++ b/tests/fixtures/config/branch-variants/git/puppet-integration/.zuul.yaml
@@ -11,6 +11,8 @@
     name: puppet-lint
     parent: puppet-module-base
     run: playbooks/run-lint
+    tags:
+      - master
 
 - project-template:
     name: puppet-check-jobs
diff --git a/tests/fixtures/config/branch-variants/git/puppet-integration/stable.zuul.yaml b/tests/fixtures/config/branch-variants/git/puppet-integration/stable.zuul.yaml
new file mode 100644
index 0000000..4701b80
--- /dev/null
+++ b/tests/fixtures/config/branch-variants/git/puppet-integration/stable.zuul.yaml
@@ -0,0 +1,26 @@
+- job:
+    name: puppet-base
+    pre-run: playbooks/prepare-node-common
+
+- job:
+    name: puppet-module-base
+    parent: puppet-base
+    pre-run: playbooks/prepare-node-unit
+
+- job:
+    name: puppet-lint
+    parent: puppet-module-base
+    run: playbooks/run-lint
+    tags:
+      - stable
+
+- project-template:
+    name: puppet-check-jobs
+    check:
+      jobs:
+        - puppet-lint
+
+- project:
+    name: puppet-integration
+    templates:
+      - puppet-check-jobs
diff --git a/tests/fixtures/config/central-jobs/git/central-jobs/README b/tests/fixtures/config/central-jobs/git/central-jobs/README
new file mode 100644
index 0000000..9daeafb
--- /dev/null
+++ b/tests/fixtures/config/central-jobs/git/central-jobs/README
@@ -0,0 +1 @@
+test
diff --git a/tests/fixtures/config/central-jobs/git/central-jobs/playbooks/central-job.yaml b/tests/fixtures/config/central-jobs/git/central-jobs/playbooks/central-job.yaml
new file mode 100644
index 0000000..f679dce
--- /dev/null
+++ b/tests/fixtures/config/central-jobs/git/central-jobs/playbooks/central-job.yaml
@@ -0,0 +1,2 @@
+- hosts: all
+  tasks: []
diff --git a/tests/fixtures/config/central-jobs/git/central-jobs/zuul.yaml b/tests/fixtures/config/central-jobs/git/central-jobs/zuul.yaml
new file mode 100644
index 0000000..2bf782e
--- /dev/null
+++ b/tests/fixtures/config/central-jobs/git/central-jobs/zuul.yaml
@@ -0,0 +1,9 @@
+- job:
+    name: central-job
+    run: playbooks/central-job.yaml
+
+- project-template:
+    name: central-jobs
+    check:
+      jobs:
+        - central-job
diff --git a/tests/fixtures/config/central-jobs/git/common-config/zuul.yaml b/tests/fixtures/config/central-jobs/git/common-config/zuul.yaml
new file mode 100644
index 0000000..c31af45
--- /dev/null
+++ b/tests/fixtures/config/central-jobs/git/common-config/zuul.yaml
@@ -0,0 +1,52 @@
+- pipeline:
+    name: check
+    manager: independent
+    trigger:
+      gerrit:
+        - event: patchset-created
+    success:
+      gerrit:
+        Verified: 1
+    failure:
+      gerrit:
+        Verified: -1
+
+- pipeline:
+    name: gate
+    manager: dependent
+    trigger:
+      gerrit:
+        - event: comment-added
+          approval:
+            - Approved: 1
+    success:
+      gerrit:
+        Verified: 2
+        submit: true
+    failure:
+      gerrit:
+        Verified: -2
+    start:
+      gerrit:
+        Verified: 0
+    precedence: high
+
+- job:
+    name: base
+    parent: null
+
+- project:
+    name: common-config
+    check:
+      jobs: []
+    gate:
+      jobs:
+        - noop
+
+- project:
+    name: org/project
+    check:
+      jobs: []
+    gate:
+      jobs:
+        - noop
diff --git a/tests/fixtures/config/central-jobs/git/org_project/README b/tests/fixtures/config/central-jobs/git/org_project/README
new file mode 100644
index 0000000..9daeafb
--- /dev/null
+++ b/tests/fixtures/config/central-jobs/git/org_project/README
@@ -0,0 +1 @@
+test
diff --git a/tests/fixtures/config/central-jobs/main.yaml b/tests/fixtures/config/central-jobs/main.yaml
new file mode 100644
index 0000000..08f4d5d
--- /dev/null
+++ b/tests/fixtures/config/central-jobs/main.yaml
@@ -0,0 +1,9 @@
+- tenant:
+    name: tenant-one
+    source:
+      gerrit:
+        config-projects:
+          - common-config
+        untrusted-projects:
+          - central-jobs
+          - org/project
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 2d5c51f..97b6b28 100644
--- a/tests/fixtures/config/data-return/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/data-return/git/common-config/zuul.yaml
@@ -18,13 +18,16 @@
 
 - job:
     name: data-return
+    run: playbooks/data-return.yaml
 
 - job:
     name: data-return-relative
     success-url: docs/index.html
+    run: playbooks/data-return-relative.yaml
 
 - job:
     name: child
+    run: playbooks/child.yaml
 
 - project:
     name: org/project
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 4179226..6a96b50 100644
--- a/tests/fixtures/config/dependency-graph/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/dependency-graph/git/common-config/zuul.yaml
@@ -25,24 +25,31 @@
 
 - job:
     name: A
+    run: playbooks/A.yaml
 
 - job:
     name: B
+    run: playbooks/B.yaml
 
 - job:
     name: C
+    run: playbooks/C.yaml
 
 - job:
     name: D
+    run: playbooks/D.yaml
 
 - job:
     name: E
+    run: playbooks/E.yaml
 
 - job:
     name: F
+    run: playbooks/F.yaml
 
 - job:
     name: G
+    run: playbooks/G.yaml
 
 - project:
     name: org/project
diff --git a/tests/fixtures/config/disk-accountant/git/common-config/zuul.yaml b/tests/fixtures/config/disk-accountant/git/common-config/zuul.yaml
index 893ea05..9ad8de5 100644
--- a/tests/fixtures/config/disk-accountant/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/disk-accountant/git/common-config/zuul.yaml
@@ -18,6 +18,7 @@
 
 - job:
     name: dd-big-empty-file
+    run: playbooks/dd-big-empty-file.yaml
 
 - project:
     name: 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 117e381..dbd63c5 100755
--- a/tests/fixtures/config/duplicate-pipeline/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/duplicate-pipeline/git/common-config/zuul.yaml
@@ -32,6 +32,7 @@
 
 - job:
     name: project-test1
+    run: playbooks/project-test1.yaml
 
 - project:
     name: org/project
diff --git a/tests/fixtures/config/final/git/common-config/zuul.yaml b/tests/fixtures/config/final/git/common-config/zuul.yaml
index f08d66e..944626c 100644
--- a/tests/fixtures/config/final/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/final/git/common-config/zuul.yaml
@@ -20,9 +20,9 @@
     final: true
     vars:
       dont_override_this: dummy
+    run: playbooks/job-final.yaml
 
 - project:
     name: org/project
     check:
       jobs: []
-
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 34d1136..784b5f2 100644
--- a/tests/fixtures/config/git-driver/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/git-driver/git/common-config/zuul.yaml
@@ -17,6 +17,7 @@
 
 - job:
     name: project-test1
+    run: playbooks/project-test1.yaml
 
 - project:
     name: org/project
diff --git a/tests/fixtures/config/implicit-roles/git/org_norole-project/.zuul.yaml b/tests/fixtures/config/implicit-roles/git/org_norole-project/.zuul.yaml
index 74c8e8e..f66c616 100644
--- a/tests/fixtures/config/implicit-roles/git/org_norole-project/.zuul.yaml
+++ b/tests/fixtures/config/implicit-roles/git/org_norole-project/.zuul.yaml
@@ -1,11 +1,13 @@
 - job:
     name: implicit-role-fail
+    run: playbooks/implicit-role-fail.yaml
 
 - job:
     name: explicit-role-fail
     attempts: 1
     roles:
       - zuul: org/norole-project
+    run: playbooks/explicit-role-fail.yaml
 
 - project:
     name: org/norole-project
diff --git a/tests/fixtures/config/implicit-roles/git/org_role-project/.zuul.yaml b/tests/fixtures/config/implicit-roles/git/org_role-project/.zuul.yaml
index 42cae95..e6e902e 100644
--- a/tests/fixtures/config/implicit-roles/git/org_role-project/.zuul.yaml
+++ b/tests/fixtures/config/implicit-roles/git/org_role-project/.zuul.yaml
@@ -1,11 +1,13 @@
 - job:
     name: implicit-role-ok
+    run: playbooks/implicit-role-ok.yaml
 
 - job:
     name: explicit-role-ok
     roles:
       - zuul: org/role-project
         name: role-name
+    run: playbooks/explicit-role-ok.yaml
 
 - project:
     name: org/role-project
diff --git a/tests/fixtures/config/in-repo-join/git/common-config/zuul.yaml b/tests/fixtures/config/in-repo-join/git/common-config/zuul.yaml
index 561fc39..a8ee256 100644
--- a/tests/fixtures/config/in-repo-join/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/in-repo-join/git/common-config/zuul.yaml
@@ -38,6 +38,7 @@
 
 - job:
     name: common-config-test
+    run: playbooks/common-config-test.yaml
 
 - project:
     name: org/project
diff --git a/tests/fixtures/config/in-repo-join/git/org_project/.zuul.yaml b/tests/fixtures/config/in-repo-join/git/org_project/.zuul.yaml
index 280342c..3845b26 100644
--- a/tests/fixtures/config/in-repo-join/git/org_project/.zuul.yaml
+++ b/tests/fixtures/config/in-repo-join/git/org_project/.zuul.yaml
@@ -1,2 +1,3 @@
 - job:
     name: project-test1
+    run: playbooks/project-test1.yaml
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 a97af51..c98651c 100644
--- a/tests/fixtures/config/in-repo/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/in-repo/git/common-config/zuul.yaml
@@ -62,10 +62,9 @@
         approval:
           - Code-Review: 2
             username: maintainer
-    require:
       github:
         review:
-          - username: '^(herp|derp)$'
+          - username: ^(herp|derp)$
             type: approved
     trigger: {}
 
@@ -75,9 +74,11 @@
 
 - job:
     name: common-config-test
+    run: playbooks/common-config-test.yaml
 
 - job:
     name: template-job
+    run: playbooks/template-job.yaml
 
 - project-template:
     name: common-config-template
diff --git a/tests/fixtures/config/in-repo/git/org_project/.zuul.yaml b/tests/fixtures/config/in-repo/git/org_project/.zuul.yaml
index e1c27bb..2c39a10 100644
--- a/tests/fixtures/config/in-repo/git/org_project/.zuul.yaml
+++ b/tests/fixtures/config/in-repo/git/org_project/.zuul.yaml
@@ -1,5 +1,6 @@
 - job:
     name: project-test1
+    run: playbooks/project-test1.yaml
 
 - project:
     name: org/project
diff --git a/tests/fixtures/config/inventory/git/common-config/zuul.yaml b/tests/fixtures/config/inventory/git/common-config/zuul.yaml
index e5727a2..900abd6 100644
--- a/tests/fixtures/config/inventory/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/inventory/git/common-config/zuul.yaml
@@ -41,7 +41,9 @@
       nodes:
         - name: ubuntu-xenial
           label: ubuntu-xenial
+    run: playbooks/single-inventory.yaml
 
 - job:
     name: group-inventory
     nodeset: nodeset1
+    run: playbooks/group-inventory.yaml
diff --git a/tests/fixtures/config/job-output/git/common-config/zuul.yaml b/tests/fixtures/config/job-output/git/common-config/zuul.yaml
index f182d8d..4df0020 100644
--- a/tests/fixtures/config/job-output/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/job-output/git/common-config/zuul.yaml
@@ -19,6 +19,7 @@
 - job:
     parent: base
     name: job-output
+    run: playbooks/job-output.yaml
 
 - job:
     name: job-output-failure
diff --git a/tests/fixtures/config/merges/git/common-config/zuul.yaml b/tests/fixtures/config/merges/git/common-config/zuul.yaml
index 1ea5048..94dbca7 100644
--- a/tests/fixtures/config/merges/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/merges/git/common-config/zuul.yaml
@@ -38,13 +38,16 @@
 
 - job:
     name: project-test1
+    run: playbooks/project-test1.yaml
 
 - job:
     name: project-test2
+    run: playbooks/project-test2.yaml
 
 - job:
     name: project-merge
     hold-following-changes: true
+    run: playbooks/project-merge.yaml
 
 - project:
     name: org/project-merge
diff --git a/tests/fixtures/config/multi-driver/git/org_common-config/zuul.yaml b/tests/fixtures/config/multi-driver/git/org_common-config/zuul.yaml
index 7a5c190..94a9ecb 100644
--- a/tests/fixtures/config/multi-driver/git/org_common-config/zuul.yaml
+++ b/tests/fixtures/config/multi-driver/git/org_common-config/zuul.yaml
@@ -12,12 +12,12 @@
         - event: patchset-created
     success:
       github:
-        status: 'success'
+        status: success
       gerrit:
         Verified: 1
     failure:
       github:
-        status: 'failure'
+        status: failure
       gerrit:
         Verified: 1
     start:
@@ -32,9 +32,11 @@
 
 - job:
     name: project-gerrit
+    run: playbooks/project-gerrit.yaml
 
 - job:
     name: project1-github
+    run: playbooks/project1-github.yaml
 
 - project:
     name: org/project
diff --git a/tests/fixtures/config/multi-tenant-semaphore/git/tenant-one-config/zuul.yaml b/tests/fixtures/config/multi-tenant-semaphore/git/tenant-one-config/zuul.yaml
index 5e377e7..ca936b9 100644
--- a/tests/fixtures/config/multi-tenant-semaphore/git/tenant-one-config/zuul.yaml
+++ b/tests/fixtures/config/multi-tenant-semaphore/git/tenant-one-config/zuul.yaml
@@ -1,6 +1,7 @@
 - job:
     name: project1-test1
     semaphore: test-semaphore
+    run: playbooks/project1-test1.yaml
 
 - project:
     name: org/project1
diff --git a/tests/fixtures/config/multi-tenant-semaphore/git/tenant-two-config/zuul.yaml b/tests/fixtures/config/multi-tenant-semaphore/git/tenant-two-config/zuul.yaml
index a310532..a2866b3 100644
--- a/tests/fixtures/config/multi-tenant-semaphore/git/tenant-two-config/zuul.yaml
+++ b/tests/fixtures/config/multi-tenant-semaphore/git/tenant-two-config/zuul.yaml
@@ -1,6 +1,7 @@
 - job:
     name: project2-test1
     semaphore: test-semaphore
+    run: playbooks/project2-test1.yaml
 
 - project:
     name: org/project2
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 273469c..31f1e27 100644
--- a/tests/fixtures/config/multi-tenant/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/multi-tenant/git/common-config/zuul.yaml
@@ -21,3 +21,4 @@
       nodes:
         - name: controller
           label: ubuntu-trusty
+    run: playbooks/python27.yaml
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 9a1b928..278c12c 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
@@ -27,6 +27,7 @@
 
 - job:
     name: project1-test1
+    run: playbooks/project1-test1.yaml
 
 - project:
     name: org/project1
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 9496a49..2b795b0 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
@@ -27,6 +27,7 @@
 
 - job:
     name: project2-test1
+    run: playbooks/project2-test1.yaml
 
 - project:
     name: org/project2
diff --git a/tests/fixtures/config/openstack/git/project-config/zuul.yaml b/tests/fixtures/config/openstack/git/project-config/zuul.yaml
index de6321d..93bdb11 100644
--- a/tests/fixtures/config/openstack/git/project-config/zuul.yaml
+++ b/tests/fixtures/config/openstack/git/project-config/zuul.yaml
@@ -41,10 +41,12 @@
       nodes:
         - name: controller
           label: ubuntu-xenial
+    run: playbooks/base.yaml
 
 - job:
     name: python27
     parent: base
+    run: playbooks/python27.yaml
 
 - job:
     name: python27
@@ -54,10 +56,12 @@
       nodes:
         - name: controller
           label: ubuntu-trusty
+    run: playbooks/python27.yaml
 
 - job:
     name: python35
     parent: base
+    run: playbooks/python35.yaml
 
 - project-template:
     name: python-jobs
@@ -72,6 +76,7 @@
     required-projects:
       - openstack/keystone
       - openstack/nova
+    run: playbooks/dsvm.yaml
 
 - project:
     name: openstack/nova
diff --git a/tests/fixtures/config/post-playbook/git/common-config/zuul.yaml b/tests/fixtures/config/post-playbook/git/common-config/zuul.yaml
index 92a5515..16d7dee 100644
--- a/tests/fixtures/config/post-playbook/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/post-playbook/git/common-config/zuul.yaml
@@ -22,3 +22,4 @@
     post-run: playbooks/post
     vars:
       waitpath: '{{zuul._test.test_root}}/{{zuul.build}}/test_wait'
+    run: playbooks/python27.yaml
diff --git a/tests/fixtures/config/pragma/git/common-config/zuul.yaml b/tests/fixtures/config/pragma/git/common-config/zuul.yaml
new file mode 100644
index 0000000..7a8b45e
--- /dev/null
+++ b/tests/fixtures/config/pragma/git/common-config/zuul.yaml
@@ -0,0 +1,53 @@
+- pipeline:
+    name: check
+    manager: independent
+    trigger:
+      gerrit:
+        - event: patchset-created
+    success:
+      gerrit:
+        Verified: 1
+    failure:
+      gerrit:
+        Verified: -1
+
+- pipeline:
+    name: gate
+    manager: dependent
+    post-review: True
+    trigger:
+      gerrit:
+        - event: comment-added
+          approval:
+            - Approved: 1
+    success:
+      gerrit:
+        Verified: 2
+        submit: true
+    failure:
+      gerrit:
+        Verified: -2
+    start:
+      gerrit:
+        Verified: 0
+    precedence: high
+
+- job:
+    name: base
+    parent: null
+
+- project:
+    name: common-config
+    check:
+      jobs: []
+    gate:
+      jobs:
+        - noop
+
+- project:
+    name: org/project
+    check:
+      jobs: []
+    gate:
+      jobs:
+        - noop
diff --git a/tests/fixtures/config/pragma/git/org_project/README b/tests/fixtures/config/pragma/git/org_project/README
new file mode 100644
index 0000000..9daeafb
--- /dev/null
+++ b/tests/fixtures/config/pragma/git/org_project/README
@@ -0,0 +1 @@
+test
diff --git a/tests/fixtures/config/pragma/git/org_project/nopragma.yaml b/tests/fixtures/config/pragma/git/org_project/nopragma.yaml
new file mode 100644
index 0000000..95a306b
--- /dev/null
+++ b/tests/fixtures/config/pragma/git/org_project/nopragma.yaml
@@ -0,0 +1,2 @@
+- job:
+    name: test-job
diff --git a/tests/fixtures/config/pragma/git/org_project/playbooks/test-job.yaml b/tests/fixtures/config/pragma/git/org_project/playbooks/test-job.yaml
new file mode 100644
index 0000000..f679dce
--- /dev/null
+++ b/tests/fixtures/config/pragma/git/org_project/playbooks/test-job.yaml
@@ -0,0 +1,2 @@
+- hosts: all
+  tasks: []
diff --git a/tests/fixtures/config/pragma/git/org_project/pragma.yaml b/tests/fixtures/config/pragma/git/org_project/pragma.yaml
new file mode 100644
index 0000000..89852b0
--- /dev/null
+++ b/tests/fixtures/config/pragma/git/org_project/pragma.yaml
@@ -0,0 +1,5 @@
+- pragma:
+    implied-branch-matchers: False
+
+- job:
+    name: test-job
diff --git a/tests/fixtures/config/pragma/main.yaml b/tests/fixtures/config/pragma/main.yaml
new file mode 100644
index 0000000..208e274
--- /dev/null
+++ b/tests/fixtures/config/pragma/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/pre-playbook/git/common-config/zuul.yaml b/tests/fixtures/config/pre-playbook/git/common-config/zuul.yaml
index 16d1966..7817745 100644
--- a/tests/fixtures/config/pre-playbook/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/pre-playbook/git/common-config/zuul.yaml
@@ -20,3 +20,4 @@
     name: python27
     pre-run: playbooks/pre
     post-run: playbooks/post
+    run: playbooks/python27.yaml
diff --git a/tests/fixtures/config/push-reqs/git/org_common-config/zuul.yaml b/tests/fixtures/config/push-reqs/git/org_common-config/zuul.yaml
index 63af1c9..e7d52d9 100644
--- a/tests/fixtures/config/push-reqs/git/org_common-config/zuul.yaml
+++ b/tests/fixtures/config/push-reqs/git/org_common-config/zuul.yaml
@@ -47,7 +47,7 @@
     manager: independent
     require:
       github:
-        status: 'zuul:check:success'
+        status: zuul:check:success
     trigger:
       github:
         - event: push
@@ -82,6 +82,7 @@
 
 - job:
     name: job1
+    run: playbooks/job1.yaml
 
 - project:
     name: org/project1
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 90c9ac2..e41cfad 100644
--- a/tests/fixtures/config/requirements/email/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/requirements/email/git/common-config/zuul.yaml
@@ -36,9 +36,11 @@
 
 - job:
     name: project1-job
+    run: playbooks/project1-job.yaml
 
 - job:
     name: project2-job
+    run: playbooks/project2-job.yaml
 
 - project:
     name: org/project1
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 5f266a4..96e04d9 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
@@ -38,9 +38,11 @@
 
 - job:
     name: project1-job
+    run: playbooks/project1-job.yaml
 
 - job:
     name: project2-job
+    run: playbooks/project2-job.yaml
 
 - project:
     name: org/project1
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 4287a94..182a036 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
@@ -38,9 +38,11 @@
 
 - job:
     name: project1-job
+    run: playbooks/project1-job.yaml
 
 - job:
     name: project2-job
+    run: playbooks/project2-job.yaml
 
 - project:
     name: org/project1
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 aabfb6a..7cbba03 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
@@ -36,9 +36,11 @@
 
 - job:
     name: project1-job
+    run: playbooks/project1-job.yaml
 
 - job:
     name: project2-job
+    run: playbooks/project2-job.yaml
 
 - project:
     name: org/project1
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 2661eed..0ce66de 100644
--- a/tests/fixtures/config/requirements/reject/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/requirements/reject/git/common-config/zuul.yaml
@@ -52,9 +52,11 @@
 
 - job:
     name: project1-job
+    run: playbooks/project1-job.yaml
 
 - job:
     name: project2-job
+    run: playbooks/project2-job.yaml
 
 - project:
     name: org/project1
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 715b89f..9f018c9 100644
--- a/tests/fixtures/config/requirements/state/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/requirements/state/git/common-config/zuul.yaml
@@ -55,6 +55,7 @@
 
 - job:
     name: project-job
+    run: playbooks/project-job.yaml
 
 - project:
     name: current-project
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 778ac16..05203ab 100644
--- a/tests/fixtures/config/requirements/username/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/requirements/username/git/common-config/zuul.yaml
@@ -36,9 +36,11 @@
 
 - job:
     name: project1-job
+    run: playbooks/project1-job.yaml
 
 - job:
     name: project2-job
+    run: playbooks/project2-job.yaml
 
 - project:
     name: org/project1
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 b5d7498..88f64e3 100644
--- a/tests/fixtures/config/requirements/vote1/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/requirements/vote1/git/common-config/zuul.yaml
@@ -38,9 +38,11 @@
 
 - job:
     name: project1-job
+    run: playbooks/project1-job.yaml
 
 - job:
     name: project2-job
+    run: playbooks/project2-job.yaml
 
 - project:
     name: org/project1
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 3f41868..31b2329 100644
--- a/tests/fixtures/config/requirements/vote2/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/requirements/vote2/git/common-config/zuul.yaml
@@ -42,9 +42,11 @@
 
 - job:
     name: project1-job
+    run: playbooks/project1-job.yaml
 
 - job:
     name: project2-job
+    run: playbooks/project2-job.yaml
 
 - project:
     name: org/project1
diff --git a/tests/fixtures/config/roles/git/common-config/zuul.yaml b/tests/fixtures/config/roles/git/common-config/zuul.yaml
index 7ae6263..ba34ad6 100644
--- a/tests/fixtures/config/roles/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/roles/git/common-config/zuul.yaml
@@ -38,6 +38,7 @@
 
 - job:
     name: common-config-test
+    run: playbooks/common-config-test.yaml
 
 - project:
     name: common-config
diff --git a/tests/fixtures/config/roles/git/org_project/.zuul.yaml b/tests/fixtures/config/roles/git/org_project/.zuul.yaml
index 35c2153..0986b82 100644
--- a/tests/fixtures/config/roles/git/org_project/.zuul.yaml
+++ b/tests/fixtures/config/roles/git/org_project/.zuul.yaml
@@ -2,6 +2,7 @@
     name: project-test
     roles:
       - zuul: bare-role
+    run: playbooks/project-test.yaml
 
 - project:
     name: org/project
diff --git a/tests/fixtures/config/secret-inheritance/git/common-config/playbooks/trusted-secrets-trusted-child.yaml b/tests/fixtures/config/secret-inheritance/git/common-config/playbooks/trusted-secrets-trusted-child.yaml
new file mode 100644
index 0000000..f679dce
--- /dev/null
+++ b/tests/fixtures/config/secret-inheritance/git/common-config/playbooks/trusted-secrets-trusted-child.yaml
@@ -0,0 +1,2 @@
+- hosts: all
+  tasks: []
diff --git a/tests/fixtures/config/secret-inheritance/git/common-config/playbooks/trusted-secrets.yaml b/tests/fixtures/config/secret-inheritance/git/common-config/playbooks/trusted-secrets.yaml
new file mode 100644
index 0000000..f679dce
--- /dev/null
+++ b/tests/fixtures/config/secret-inheritance/git/common-config/playbooks/trusted-secrets.yaml
@@ -0,0 +1,2 @@
+- hosts: all
+  tasks: []
diff --git a/tests/fixtures/config/secret-inheritance/git/common-config/playbooks/untrusted-secrets-trusted-child.yaml b/tests/fixtures/config/secret-inheritance/git/common-config/playbooks/untrusted-secrets-trusted-child.yaml
new file mode 100644
index 0000000..f679dce
--- /dev/null
+++ b/tests/fixtures/config/secret-inheritance/git/common-config/playbooks/untrusted-secrets-trusted-child.yaml
@@ -0,0 +1,2 @@
+- hosts: all
+  tasks: []
diff --git a/tests/fixtures/config/secret-inheritance/git/common-config/zuul.yaml b/tests/fixtures/config/secret-inheritance/git/common-config/zuul.yaml
new file mode 100644
index 0000000..ad16d4e
--- /dev/null
+++ b/tests/fixtures/config/secret-inheritance/git/common-config/zuul.yaml
@@ -0,0 +1,106 @@
+- pipeline:
+    name: check
+    manager: independent
+    trigger:
+      gerrit:
+        - event: patchset-created
+    success:
+      gerrit:
+        Verified: 1
+    failure:
+      gerrit:
+        Verified: -1
+
+- pipeline:
+    name: gate
+    manager: dependent
+    post-review: True
+    trigger:
+      gerrit:
+        - event: comment-added
+          approval:
+            - Approved: 1
+    success:
+      gerrit:
+        Verified: 2
+        submit: true
+    failure:
+      gerrit:
+        Verified: -2
+    start:
+      gerrit:
+        Verified: 0
+    precedence: high
+
+- job:
+    name: base
+    parent: null
+
+- job:
+    name: trusted-secrets
+    run: playbooks/trusted-secrets.yaml
+    secrets:
+      - trusted-secret
+
+- job:
+    name: trusted-secrets-trusted-child
+    run: playbooks/trusted-secrets-trusted-child.yaml
+    parent: trusted-secrets
+
+- job:
+    name: untrusted-secrets-trusted-child
+    run: playbooks/untrusted-secrets-trusted-child.yaml
+    parent: untrusted-secrets
+    
+- project:
+    name: common-config
+    check:
+      jobs:
+        - trusted-secrets
+        - trusted-secrets-trusted-child
+        - trusted-secrets-untrusted-child
+    gate:
+      jobs:
+        - untrusted-secrets
+        - untrusted-secrets-trusted-child
+        - untrusted-secrets-untrusted-child
+
+- secret:
+    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/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=
diff --git a/tests/fixtures/config/secret-inheritance/git/org_project/.zuul.yaml b/tests/fixtures/config/secret-inheritance/git/org_project/.zuul.yaml
new file mode 100644
index 0000000..b384669
--- /dev/null
+++ b/tests/fixtures/config/secret-inheritance/git/org_project/.zuul.yaml
@@ -0,0 +1,66 @@
+- job:
+    name: untrusted-secrets
+    run: playbooks/untrusted-secrets.yaml
+    secrets:
+      - untrusted-secret
+
+- job:
+    name: trusted-secrets-untrusted-child
+    run: playbooks/trusted-secrets-untrusted-child.yaml
+    parent: trusted-secrets
+
+- job:
+    name: untrusted-secrets-untrusted-child
+    run: playbooks/untrusted-secrets-untrusted-child.yaml
+    parent: untrusted-secrets
+
+- project:
+    name: org/project
+    check:
+      jobs:
+        - trusted-secrets
+        - trusted-secrets-trusted-child
+        - trusted-secrets-untrusted-child
+        - untrusted-secrets
+        - untrusted-secrets-trusted-child
+        - untrusted-secrets-untrusted-child
+
+- secret:
+    name: untrusted-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/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=
diff --git a/tests/fixtures/config/secret-inheritance/git/org_project/README b/tests/fixtures/config/secret-inheritance/git/org_project/README
new file mode 100644
index 0000000..9daeafb
--- /dev/null
+++ b/tests/fixtures/config/secret-inheritance/git/org_project/README
@@ -0,0 +1 @@
+test
diff --git a/tests/fixtures/config/secret-inheritance/git/org_project/playbooks/trusted-secrets-untrusted-child.yaml b/tests/fixtures/config/secret-inheritance/git/org_project/playbooks/trusted-secrets-untrusted-child.yaml
new file mode 100644
index 0000000..f679dce
--- /dev/null
+++ b/tests/fixtures/config/secret-inheritance/git/org_project/playbooks/trusted-secrets-untrusted-child.yaml
@@ -0,0 +1,2 @@
+- hosts: all
+  tasks: []
diff --git a/tests/fixtures/config/secret-inheritance/git/org_project/playbooks/untrusted-secrets-untrusted-child.yaml b/tests/fixtures/config/secret-inheritance/git/org_project/playbooks/untrusted-secrets-untrusted-child.yaml
new file mode 100644
index 0000000..f679dce
--- /dev/null
+++ b/tests/fixtures/config/secret-inheritance/git/org_project/playbooks/untrusted-secrets-untrusted-child.yaml
@@ -0,0 +1,2 @@
+- hosts: all
+  tasks: []
diff --git a/tests/fixtures/config/secret-inheritance/git/org_project/playbooks/untrusted-secrets.yaml b/tests/fixtures/config/secret-inheritance/git/org_project/playbooks/untrusted-secrets.yaml
new file mode 100644
index 0000000..f679dce
--- /dev/null
+++ b/tests/fixtures/config/secret-inheritance/git/org_project/playbooks/untrusted-secrets.yaml
@@ -0,0 +1,2 @@
+- hosts: all
+  tasks: []
diff --git a/tests/fixtures/config/secret-inheritance/main.yaml b/tests/fixtures/config/secret-inheritance/main.yaml
new file mode 100644
index 0000000..208e274
--- /dev/null
+++ b/tests/fixtures/config/secret-inheritance/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/secret-leaks/git/common-config/zuul.yaml b/tests/fixtures/config/secret-leaks/git/common-config/zuul.yaml
index 4ab198f..9321df8 100644
--- a/tests/fixtures/config/secret-leaks/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/secret-leaks/git/common-config/zuul.yaml
@@ -55,12 +55,14 @@
 - job:
     parent: base
     name: secret-file
+    run: playbooks/secret-file.yaml
     secrets:
       - test_secret
 
 - job:
     parent: base
     name: secret-file-fail
+    run: playbooks/secret-file-fail.yaml
     secrets:
       - test_secret
 
diff --git a/tests/fixtures/config/semaphore/git/common-config/zuul.yaml b/tests/fixtures/config/semaphore/git/common-config/zuul.yaml
index c8bd322..52a0e7d 100644
--- a/tests/fixtures/config/semaphore/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/semaphore/git/common-config/zuul.yaml
@@ -25,22 +25,27 @@
 
 - job:
     name: project-test1
+    run: playbooks/project-test1.yaml
 
 - job:
     name: semaphore-one-test1
     semaphore: test-semaphore
+    run: playbooks/semaphore-one-test1.yaml
 
 - job:
     name: semaphore-one-test2
     semaphore: test-semaphore
+    run: playbooks/semaphore-one-test2.yaml
 
 - job:
     name: semaphore-two-test1
     semaphore: test-semaphore-two
+    run: playbooks/semaphore-two-test1.yaml
 
 - job:
     name: semaphore-two-test2
     semaphore: test-semaphore-two
+    run: playbooks/semaphore-two-test2.yaml
 
 - project:
     name: org/project
diff --git a/tests/fixtures/config/shadow/git/local-config/zuul.yaml b/tests/fixtures/config/shadow/git/local-config/zuul.yaml
index 87f46b7..8935d8a 100644
--- a/tests/fixtures/config/shadow/git/local-config/zuul.yaml
+++ b/tests/fixtures/config/shadow/git/local-config/zuul.yaml
@@ -14,9 +14,11 @@
 - job:
     name: base
     parent: null
+    run: playbooks/base.yaml
 
 - job:
     name: test2
+    run: playbooks/test2.yaml
 
 - project:
     name: org/project
diff --git a/tests/fixtures/config/shadow/git/stdlib/.zuul.yaml b/tests/fixtures/config/shadow/git/stdlib/.zuul.yaml
index 6a6f9c9..5132653 100644
--- a/tests/fixtures/config/shadow/git/stdlib/.zuul.yaml
+++ b/tests/fixtures/config/shadow/git/stdlib/.zuul.yaml
@@ -1,10 +1,13 @@
 - job:
     name: base
+    run: playbooks/base.yaml
 
 - job:
     name: test1
     parent: base
+    run: playbooks/test1.yaml
 
 - job:
     name: test2
     parent: base
+    run: playbooks/test2.yaml
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 2160ef9..b2f15f9 100644
--- a/tests/fixtures/config/single-tenant/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/single-tenant/git/common-config/zuul.yaml
@@ -52,6 +52,7 @@
       nodes:
         - name: controller
           label: label1
+    run: playbooks/project-merge.yaml
 
 - job:
     name: project-test1
@@ -60,6 +61,7 @@
       nodes:
         - name: controller
           label: label1
+    run: playbooks/project-test1.yaml
 
 - job:
     name: project-test1
@@ -68,6 +70,7 @@
       nodes:
         - name: controller
           label: label2
+    run: playbooks/project-test1.yaml
 
 - job:
     name: project-post
@@ -75,6 +78,7 @@
       nodes:
         - name: static
           label: ubuntu-xenial
+    run: playbooks/project-post.yaml
 
 - job:
     name: project-test2
@@ -82,6 +86,7 @@
       nodes:
         - name: controller
           label: label1
+    run: playbooks/project-test2.yaml
 
 - job:
     name: project1-project2-integration
@@ -89,11 +94,13 @@
       nodes:
         - name: controller
           label: label1
+    run: playbooks/project1-project2-integration.yaml
 
 - job:
     name: project-testfile
     files:
       - .*-requires
+    run: playbooks/project-testfile.yaml
 
 - project:
     name: org/project
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 9d15599..12e1c24 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
@@ -4,3 +4,4 @@
 
 - job:
     name: project-test1
+    run: playbooks/project-test1.yaml
diff --git a/tests/fixtures/config/split-config/git/org_project1/.zuul.d/jobs.yaml b/tests/fixtures/config/split-config/git/org_project1/.zuul.d/jobs.yaml
index 33d74f3..20cd16a 100644
--- a/tests/fixtures/config/split-config/git/org_project1/.zuul.d/jobs.yaml
+++ b/tests/fixtures/config/split-config/git/org_project1/.zuul.d/jobs.yaml
@@ -1,2 +1,3 @@
 - job:
     name: project1-project2-integration
+    run: playbooks/project1-project2-integration.yaml
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 8fce9e7..82c85c7 100644
--- a/tests/fixtures/config/sql-driver/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/sql-driver/git/common-config/zuul.yaml
@@ -7,12 +7,12 @@
     success:
       gerrit:
         Verified: 1
-      resultsdb:
+      resultsdb: null
     failure:
       gerrit:
         Verified: -1
-      resultsdb:
-      resultsdb_failures:
+      resultsdb: null
+      resultsdb_failures: null
 
 - job:
     name: base
@@ -20,15 +20,19 @@
 
 - job:
     name: project-merge
+    run: playbooks/project-merge.yaml
 
 - job:
     name: project-test1
+    run: playbooks/project-test1.yaml
 
 - job:
     name: project-test2
+    run: playbooks/project-test2.yaml
 
 - job:
     name: project-test3
+    run: playbooks/project-test3.yaml
 
 - project:
     name: org/project
diff --git a/tests/fixtures/config/streamer/git/common-config/zuul.yaml b/tests/fixtures/config/streamer/git/common-config/zuul.yaml
index f9925fe..8e67bfb 100644
--- a/tests/fixtures/config/streamer/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/streamer/git/common-config/zuul.yaml
@@ -19,3 +19,4 @@
     name: python27
     vars:
       waitpath: '{{zuul._test.test_root}}/{{zuul.build}}/test_wait'
+    run: playbooks/python27.yaml
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 8929240..b9f4bff 100644
--- a/tests/fixtures/config/success-url/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/success-url/git/common-config/zuul.yaml
@@ -23,10 +23,12 @@
 - job:
     name: docs-draft-test
     success-url: http://docs-draft.example.org/{change.number:.2}/{change.number}/{change.patchset}/{pipeline.name}/{job.name}/{build.uuid:.7}/publish-docs/
+    run: playbooks/docs-draft-test.yaml
 
 - job:
     name: docs-draft-test2
     success-url: http://docs-draft.example.org/{NOPE}/{build.parameters[BAD]}/publish-docs/
+    run: playbooks/docs-draft-test2.yaml
 
 - project:
     name: org/docs
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 f9de1ad..7ad791d 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
@@ -4,18 +4,24 @@
 
 - job:
     name: project-test1
+    run: playbooks/project-test1.yaml
 
 - job:
     name: project-test2
+    run: playbooks/project-test2.yaml
 
 - job:
     name: layered-project-test3
+    run: playbooks/layered-project-test3.yaml
 
 - job:
     name: layered-project-test4
+    run: playbooks/layered-project-test4.yaml
 
 - job:
     name: layered-project-foo-test5
+    run: playbooks/layered-project-foo-test5.yaml
 
 - job:
     name: project-test6
+    run: playbooks/project-test6.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
index 31abadf..1deffb3 100644
--- a/tests/fixtures/config/unprotected-branches/git/org_project1/zuul.yaml
+++ b/tests/fixtures/config/unprotected-branches/git/org_project1/zuul.yaml
@@ -1,5 +1,6 @@
 - job:
     name: project-test
+    run: playbooks/project-test.yaml
 
 - project:
     name: org/project1
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 d70a384..6a0865e 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
@@ -49,9 +49,11 @@
 
 - job:
     name: project-test1
+    run: playbooks/project-test1.yaml
 
 - job:
     name: project-test2
+    run: playbooks/project-test2.yaml
 
 - project:
     name: review.example.com/org/project1
@@ -65,7 +67,6 @@
       jobs:
         - project-test2
 
-
 - project:
     name: review.example.com/org/project2
     common_check:
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 eb65279..14a9b11 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
@@ -17,9 +17,11 @@
 
 - job:
     name: project-test1
+    run: playbooks/project-test1.yaml
 
 - job:
     name: project-test2
+    run: playbooks/project-test2.yaml
 
 - project:
     name: org/project
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 3dd8324..3fcc43c 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
@@ -51,9 +51,11 @@
 
 - job:
     name: project-check
+    run: playbooks/project-check.yaml
 
 - job:
     name: project-gate
+    run: playbooks/project-gate.yaml
 
 - project:
     name: org/project
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 a5c5a1c..045e0a9 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
@@ -49,9 +49,11 @@
 
 - job:
     name: project-check
+    run: playbooks/project-check.yaml
 
 - job:
     name: project-gate
+    run: playbooks/project-gate.yaml
 
 - project:
     name: org/project
diff --git a/tests/fixtures/layouts/autohold.yaml b/tests/fixtures/layouts/autohold.yaml
index 578f886..32b6822 100644
--- a/tests/fixtures/layouts/autohold.yaml
+++ b/tests/fixtures/layouts/autohold.yaml
@@ -14,6 +14,7 @@
 - job:
     name: base
     parent: null
+    run: playbooks/base.yaml
 
 - job:
     name: project-test2
@@ -21,6 +22,7 @@
       nodes:
         - name: controller
           label: label1
+    run: playbooks/project-test2.yaml
 
 - project:
     name: org/project
diff --git a/tests/fixtures/layouts/basic-github.yaml b/tests/fixtures/layouts/basic-github.yaml
index d7b323a..217e874 100644
--- a/tests/fixtures/layouts/basic-github.yaml
+++ b/tests/fixtures/layouts/basic-github.yaml
@@ -8,10 +8,10 @@
             - opened
             - changed
             - reopened
-          branch: '^master$'
+          branch: ^master$
         - event: pull_request
           action: comment
-          comment: 'test me'
+          comment: test me
     success:
       github: {}
     failure:
@@ -20,12 +20,15 @@
 - job:
     name: base
     parent: null
+    run: playbooks/base.yaml
 
 - job:
     name: project-test1
+    run: playbooks/project-test1.yaml
 
 - job:
     name: project-test2
+    run: playbooks/project-test2.yaml
 
 - project:
     name: org/project
diff --git a/tests/fixtures/layouts/crd-github.yaml b/tests/fixtures/layouts/crd-github.yaml
index 9696226..6ef881f 100644
--- a/tests/fixtures/layouts/crd-github.yaml
+++ b/tests/fixtures/layouts/crd-github.yaml
@@ -30,24 +30,31 @@
 - job:
     name: base
     parent: null
+    run: playbooks/base.yaml
 
 - job:
     name: project1-test
+    run: playbooks/project1-test.yaml
 
 - job:
     name: project2-test
+    run: playbooks/project2-test.yaml
 
 - job:
     name: project3-test
+    run: playbooks/project3-test.yaml
 
 - job:
     name: project4-test
+    run: playbooks/project4-test.yaml
 
 - job:
     name: project5-test
+    run: playbooks/project5-test.yaml
 
 - job:
     name: project6-test
+    run: playbooks/project6-test.yaml
 
 - project:
     name: org/project1
diff --git a/tests/fixtures/layouts/delayed-repo-init.yaml b/tests/fixtures/layouts/delayed-repo-init.yaml
index c89e2fa..0c9a152 100644
--- a/tests/fixtures/layouts/delayed-repo-init.yaml
+++ b/tests/fixtures/layouts/delayed-repo-init.yaml
@@ -43,18 +43,23 @@
 - job:
     name: base
     parent: null
+    run: playbooks/base.yaml
 
 - job:
     name: project-merge
+    run: playbooks/project-merge.yaml
 
 - job:
     name: project-test1
+    run: playbooks/project-test1.yaml
 
 - job:
     name: project-test2
+    run: playbooks/project-test2.yaml
 
 - job:
     name: project-post
+    run: playbooks/project-post.yaml
 
 - project:
     name: org/new-project
diff --git a/tests/fixtures/layouts/dependent-github.yaml b/tests/fixtures/layouts/dependent-github.yaml
index eb74163..6ad6bd2 100644
--- a/tests/fixtures/layouts/dependent-github.yaml
+++ b/tests/fixtures/layouts/dependent-github.yaml
@@ -6,29 +6,33 @@
       github:
         - event: pull_request
           action: labeled
-          label: 'merge'
+          label: merge
     success:
       github:
         merge: true
-        unlabel: 'merge'
+        unlabel: merge
     failure:
       github:
-        unlabel: 'merge'
+        unlabel: merge
 
 - job:
     name: base
     parent: null
+    run: playbooks/base.yaml
 
 - job:
     name: project-test1
+    run: playbooks/project-test1.yaml
 
 - job:
     name: project-test2
+    run: playbooks/project-test2.yaml
 
 - job:
     name: project-merge
     failure-message: Unable to merge change
     hold-following-changes: true
+    run: playbooks/project-merge.yaml
 
 - project:
     name: org/project
diff --git a/tests/fixtures/layouts/dequeue-github.yaml b/tests/fixtures/layouts/dequeue-github.yaml
index ae61cd5..72d8145 100644
--- a/tests/fixtures/layouts/dequeue-github.yaml
+++ b/tests/fixtures/layouts/dequeue-github.yaml
@@ -11,9 +11,11 @@
 - job:
     name: base
     parent: null
+    run: playbooks/base.yaml
 
 - job:
     name: one-job-project-merge
+    run: playbooks/one-job-project-merge.yaml
 
 - project:
     name: org/one-job-project
diff --git a/tests/fixtures/layouts/disable_at.yaml b/tests/fixtures/layouts/disable_at.yaml
index 8c24c1b..a090d11 100644
--- a/tests/fixtures/layouts/disable_at.yaml
+++ b/tests/fixtures/layouts/disable_at.yaml
@@ -18,6 +18,7 @@
 - job:
     name: base
     parent: null
+    run: playbooks/base.yaml
 
 - job:
     name: project-test1
@@ -25,6 +26,7 @@
       nodes:
         - name: controller
           label: label1
+    run: playbooks/project-test1.yaml
 
 - project:
     name: org/project
diff --git a/tests/fixtures/layouts/dont-ignore-ref-deletes.yaml b/tests/fixtures/layouts/dont-ignore-ref-deletes.yaml
index bb98b57..9a9e592 100644
--- a/tests/fixtures/layouts/dont-ignore-ref-deletes.yaml
+++ b/tests/fixtures/layouts/dont-ignore-ref-deletes.yaml
@@ -10,6 +10,7 @@
 - job:
     name: base
     parent: null
+    run: playbooks/base.yaml
 
 - job:
     name: project-post
@@ -17,6 +18,7 @@
       nodes:
         - name: static
           label: ubuntu-xenial
+    run: playbooks/project-post.yaml
 
 - project:
     name: org/project
diff --git a/tests/fixtures/layouts/files-github.yaml b/tests/fixtures/layouts/files-github.yaml
index ec35259..ed053f9 100644
--- a/tests/fixtures/layouts/files-github.yaml
+++ b/tests/fixtures/layouts/files-github.yaml
@@ -9,11 +9,13 @@
 - job:
     name: base
     parent: null
+    run: playbooks/base.yaml
 
 - job:
     name: project-test1
     files:
-      - '.*-requires'
+      - .*-requires
+    run: playbooks/project-test1.yaml
 
 - project:
     name: org/project
diff --git a/tests/fixtures/layouts/footer-message.yaml b/tests/fixtures/layouts/footer-message.yaml
index 4ee25f6..746d384 100644
--- a/tests/fixtures/layouts/footer-message.yaml
+++ b/tests/fixtures/layouts/footer-message.yaml
@@ -28,10 +28,11 @@
 - job:
     name: base
     parent: null
+    run: playbooks/base.yaml
 
 - job:
     name: project-test1
-#    success-url: http://logs.exxxample.com/{change.number}/{change.patchset}/{pipeline.name}/{job.name}
+    run: playbooks/project-test1.yaml
 
 - project:
     name: org/project
diff --git a/tests/fixtures/layouts/idle.yaml b/tests/fixtures/layouts/idle.yaml
index 4cc07ae..4f3efe4 100644
--- a/tests/fixtures/layouts/idle.yaml
+++ b/tests/fixtures/layouts/idle.yaml
@@ -8,6 +8,7 @@
 - job:
     name: base
     parent: null
+    run: playbooks/base.yaml
 
 - job:
     name: project-bitrot
@@ -15,10 +16,10 @@
       nodes:
         - name: static
           label: ubuntu-xenial
+    run: playbooks/project-bitrot.yaml
 
 - project:
     name: org/project
     periodic:
       jobs:
         - project-bitrot
-
diff --git a/tests/fixtures/layouts/ignore-dependencies.yaml b/tests/fixtures/layouts/ignore-dependencies.yaml
index 89a82b3..b869dab 100644
--- a/tests/fixtures/layouts/ignore-dependencies.yaml
+++ b/tests/fixtures/layouts/ignore-dependencies.yaml
@@ -15,27 +15,35 @@
 - job:
     name: base
     parent: null
+    run: playbooks/base.yaml
 
 - job:
     name: project1-merge
+    run: playbooks/project1-merge.yaml
 
 - job:
     name: project1-test1
+    run: playbooks/project1-test1.yaml
 
 - job:
     name: project1-test2
+    run: playbooks/project1-test2.yaml
 
 - job:
     name: project2-merge
+    run: playbooks/project2-merge.yaml
 
 - job:
     name: project2-test1
+    run: playbooks/project2-test1.yaml
 
 - job:
     name: project2-test2
+    run: playbooks/project2-test2.yaml
 
 - job:
     name: project1-project2-integration
+    run: playbooks/project1-project2-integration.yaml
 
 - project:
     name: org/project1
diff --git a/tests/fixtures/layouts/inheritance.yaml b/tests/fixtures/layouts/inheritance.yaml
index 3fe7fd4..35f1402 100644
--- a/tests/fixtures/layouts/inheritance.yaml
+++ b/tests/fixtures/layouts/inheritance.yaml
@@ -14,23 +14,28 @@
 - job:
     name: base
     parent: null
+    run: playbooks/base.yaml
 
 - job:
     name: project-test-irrelevant-starts-empty
+    run: playbooks/project-test-irrelevant-starts-empty.yaml
 
 - job:
     name: project-test-irrelevant-starts-full
     irrelevant-files:
       - ^README$
       - ^ignoreme$
+    run: playbooks/project-test-irrelevant-starts-full.yaml
 
 - job:
     name: project-test-nomatch-starts-empty
+    run: playbooks/project-test-nomatch-starts-empty.yaml
 
 - job:
     name: project-test-nomatch-starts-full
     irrelevant-files:
       - ^README$
+    run: playbooks/project-test-nomatch-starts-full.yaml
 
 - project:
     name: org/project
diff --git a/tests/fixtures/layouts/irrelevant-files.yaml b/tests/fixtures/layouts/irrelevant-files.yaml
index 97f58e7..80be9af 100644
--- a/tests/fixtures/layouts/irrelevant-files.yaml
+++ b/tests/fixtures/layouts/irrelevant-files.yaml
@@ -14,9 +14,11 @@
 - job:
     name: base
     parent: null
+    run: playbooks/base.yaml
 
 - job:
     name: project-test-irrelevant-files
+    run: playbooks/project-test-irrelevant-files.yaml
 
 - project:
     name: org/project
diff --git a/tests/fixtures/layouts/job-variants.yaml b/tests/fixtures/layouts/job-variants.yaml
new file mode 100644
index 0000000..356034f
--- /dev/null
+++ b/tests/fixtures/layouts/job-variants.yaml
@@ -0,0 +1,64 @@
+- pipeline:
+    name: check
+    manager: independent
+    trigger:
+      gerrit:
+        - event: patchset-created
+    success:
+      gerrit:
+        Verified: 1
+    failure:
+      gerrit:
+        Verified: -1
+
+- job:
+    name: base
+    parent: null
+    pre-run: base-pre
+    post-run: base-post
+    nodeset:
+      nodes:
+        - name: controller
+          label: base
+    run: playbooks/base.yaml
+
+- job:
+    name: python27
+    parent: base
+    timeout: 40
+    pre-run: py27-pre
+    post-run:
+      - py27-post-a
+      - py27-post-b
+    nodeset:
+      nodes:
+        - name: controller
+          label: new
+    run: playbooks/python27.yaml
+
+- job:
+    name: python27
+    timeout: 50
+    branches:
+      - stable/diablo
+    pre-run: py27-diablo-pre
+    run: py27-diablo
+    post-run: py27-diablo-post
+    nodeset:
+      nodes:
+        - name: controller
+          label: old
+
+- job:
+    name: python27
+    branches:
+      - stable/essex
+    pre-run: py27-essex-pre
+    post-run: py27-essex-post
+    run: playbooks/python27.yaml
+
+- project:
+    name: org/project
+    check:
+      jobs:
+        - python27
diff --git a/tests/fixtures/layouts/job-vars.yaml b/tests/fixtures/layouts/job-vars.yaml
index 22fc5c2..e46f084 100644
--- a/tests/fixtures/layouts/job-vars.yaml
+++ b/tests/fixtures/layouts/job-vars.yaml
@@ -14,6 +14,7 @@
 - job:
     name: base
     parent: null
+    run: playbooks/base.yaml
 
 - job:
     name: parentjob
@@ -24,6 +25,7 @@
       override: 0
       child1override: 0
       parent: 0
+    run: playbooks/parentjob.yaml
 
 - job:
     name: child1
@@ -34,6 +36,7 @@
       override: 1
       child1override: 1
       child1: 1
+    run: playbooks/child1.yaml
 
 - job:
     name: child2
@@ -43,10 +46,12 @@
     vars:
       override: 2
       child2: 2
+    run: playbooks/child2.yaml
 
 - job:
     name: child3
     parent: parentjob
+    run: playbooks/child3.yaml
 
 - project:
     name: org/project
diff --git a/tests/fixtures/layouts/labeling-github.yaml b/tests/fixtures/layouts/labeling-github.yaml
index 2441a9c..fbbf068 100644
--- a/tests/fixtures/layouts/labeling-github.yaml
+++ b/tests/fixtures/layouts/labeling-github.yaml
@@ -7,24 +7,26 @@
         - event: pull_request
           action: labeled
           label:
-            - 'test'
+            - test
         - event: pull_request
           action: unlabeled
           label:
-            - 'do not test'
+            - do not test
     success:
       github:
         label:
-          - 'tests passed'
+          - tests passed
         unlabel:
-          - 'test'
+          - test
 
 - job:
     name: base
     parent: null
+    run: playbooks/base.yaml
 
 - job:
     name: project-labels
+    run: playbooks/project-labels.yaml
 
 - project:
     name: org/project
diff --git a/tests/fixtures/layouts/live-reconfiguration-add-job.yaml b/tests/fixtures/layouts/live-reconfiguration-add-job.yaml
index 57d2a5f..c871f1d 100644
--- a/tests/fixtures/layouts/live-reconfiguration-add-job.yaml
+++ b/tests/fixtures/layouts/live-reconfiguration-add-job.yaml
@@ -22,40 +22,46 @@
 - job:
     name: base
     parent: null
+    run: playbooks/base.yaml
 
 - job:
     name: project-merge
     hold-following-changes: true
+    run: playbooks/project-merge.yaml
 
 - job:
     name: project-test1
+    run: playbooks/project-test1.yaml
 
 - job:
     name: project-test2
+    run: playbooks/project-test2.yaml
 
 - job:
     name: project-test3
+    run: playbooks/project-test3.yaml
 
 - job:
     name: project-testfile
     files:
-      - '.*-requires'
+      - .*-requires
+    run: playbooks/project-testfile.yaml
 
 - project:
     name: org/project
     merge-mode: cherry-pick
     gate:
       jobs:
-      - project-merge
-      - project-test1:
-          dependencies:
-            - project-merge
-      - project-test2:
-          dependencies:
-            - project-merge
-      - project-test3:
-          dependencies:
-            - project-merge
-      - project-testfile:
-          dependencies:
-            - project-merge
+        - project-merge
+        - project-test1:
+            dependencies:
+              - project-merge
+        - project-test2:
+            dependencies:
+              - project-merge
+        - project-test3:
+            dependencies:
+              - project-merge
+        - project-testfile:
+            dependencies:
+              - project-merge
diff --git a/tests/fixtures/layouts/live-reconfiguration-del-project.yaml b/tests/fixtures/layouts/live-reconfiguration-del-project.yaml
index b149af0..259de84 100644
--- a/tests/fixtures/layouts/live-reconfiguration-del-project.yaml
+++ b/tests/fixtures/layouts/live-reconfiguration-del-project.yaml
@@ -14,19 +14,24 @@
 - job:
     name: base
     parent: null
+    run: playbooks/base.yaml
 
 - job:
     name: project-merge
     hold-following-changes: true
+    run: playbooks/project-merge.yaml
 
 - job:
     name: project-test1
+    run: playbooks/project-test1.yaml
 
 - job:
     name: project-test2
+    run: playbooks/project-test2.yaml
 
 - job:
     name: project-testfile
+    run: playbooks/project-testfile.yaml
 
 - project:
     name: org/project
diff --git a/tests/fixtures/layouts/live-reconfiguration-failed-job.yaml b/tests/fixtures/layouts/live-reconfiguration-failed-job.yaml
index c4719f4..e5cc651 100644
--- a/tests/fixtures/layouts/live-reconfiguration-failed-job.yaml
+++ b/tests/fixtures/layouts/live-reconfiguration-failed-job.yaml
@@ -14,26 +14,30 @@
 - job:
     name: base
     parent: null
+    run: playbooks/base.yaml
 
 - job:
     name: project-merge
     hold-following-changes: true
+    run: playbooks/project-merge.yaml
 
 - job:
     name: project-test2
+    run: playbooks/project-test2.yaml
 
 - job:
     name: project-testfile
+    run: playbooks/project-testfile.yaml
 
 - project:
     name: org/project
     merge-mode: cherry-pick
     check:
       jobs:
-      - project-merge
-      - project-test2:
-          dependencies:
-            - project-merge
-      - project-testfile:
-          dependencies:
-            - project-merge
+        - project-merge
+        - project-test2:
+            dependencies:
+              - project-merge
+        - project-testfile:
+            dependencies:
+              - project-merge
diff --git a/tests/fixtures/layouts/live-reconfiguration-shared-queue.yaml b/tests/fixtures/layouts/live-reconfiguration-shared-queue.yaml
index e363b4c..49a1c2c 100644
--- a/tests/fixtures/layouts/live-reconfiguration-shared-queue.yaml
+++ b/tests/fixtures/layouts/live-reconfiguration-shared-queue.yaml
@@ -35,19 +35,24 @@
 - job:
     name: base
     parent: null
+    run: playbooks/base.yaml
 
 - job:
     name: project-merge
     hold-following-changes: true
+    run: playbooks/project-merge.yaml
 
 - job:
     name: project-test1
+    run: playbooks/project-test1.yaml
 
 - job:
     name: project-test2
+    run: playbooks/project-test2.yaml
 
 - job:
     name: project1-project2-integration
+    run: playbooks/project1-project2-integration.yaml
 
 - project:
     name: org/project1
diff --git a/tests/fixtures/layouts/matcher-test.yaml b/tests/fixtures/layouts/matcher-test.yaml
index b511a2f..3239b52 100644
--- a/tests/fixtures/layouts/matcher-test.yaml
+++ b/tests/fixtures/layouts/matcher-test.yaml
@@ -35,6 +35,7 @@
 - job:
     name: base
     parent: null
+    run: playbooks/base.yaml
 
 - job:
     name: project-test1
@@ -42,6 +43,7 @@
       nodes:
         - name: controller
           label: label1
+    run: playbooks/project-test1.yaml
 
 - job:
     name: ignore-branch
@@ -50,6 +52,7 @@
       nodes:
         - name: controller
           label: label2
+    run: playbooks/ignore-branch.yaml
 
 - project:
     name: org/project
diff --git a/tests/fixtures/layouts/merge-failure.yaml b/tests/fixtures/layouts/merge-failure.yaml
index 7c5121c..3828a06 100644
--- a/tests/fixtures/layouts/merge-failure.yaml
+++ b/tests/fixtures/layouts/merge-failure.yaml
@@ -23,7 +23,7 @@
     name: gate
     manager: dependent
     failure-message: Build failed.  For information on how to proceed, see http://wiki.example.org/Test_Failures
-    merge-failure-message: "The merge failed! For more information..."
+    merge-failure-message: The merge failed! For more information...
     trigger:
       gerrit:
         - event: comment-added
@@ -49,16 +49,20 @@
 - job:
     name: base
     parent: null
+    run: playbooks/base.yaml
 
 - job:
     name: project-merge
     hold-following-changes: true
+    run: playbooks/project-merge.yaml
 
 - job:
     name: project-test1
+    run: playbooks/project-test1.yaml
 
 - job:
     name: project-test2
+    run: playbooks/project-test2.yaml
 
 - project:
     name: org/project
diff --git a/tests/fixtures/layouts/merging-github.yaml b/tests/fixtures/layouts/merging-github.yaml
index c9673b9..20df0d4 100644
--- a/tests/fixtures/layouts/merging-github.yaml
+++ b/tests/fixtures/layouts/merging-github.yaml
@@ -2,12 +2,12 @@
     name: merge
     description: Pipeline for merging the pull request
     manager: independent
-    merge-failure-message: 'Merge failed'
+    merge-failure-message: Merge failed
     trigger:
       github:
         - event: pull_request
           action: comment
-          comment: 'merge me'
+          comment: merge me
     success:
       github:
         merge: true
@@ -16,6 +16,7 @@
 - job:
     name: base
     parent: null
+    run: playbooks/base.yaml
 
 - project:
     name: org/project
diff --git a/tests/fixtures/layouts/multiple-templates.yaml b/tests/fixtures/layouts/multiple-templates.yaml
index 7272cad..ece8396 100644
--- a/tests/fixtures/layouts/multiple-templates.yaml
+++ b/tests/fixtures/layouts/multiple-templates.yaml
@@ -14,9 +14,11 @@
 - job:
     name: base
     parent: null
+    run: playbooks/base.yaml
 
 - job:
     name: py27
+    run: playbooks/py27.yaml
 
 - project-template:
     name: python-jobs
diff --git a/tests/fixtures/layouts/no-jobs-project.yaml b/tests/fixtures/layouts/no-jobs-project.yaml
index 8f965e2..e23f36c 100644
--- a/tests/fixtures/layouts/no-jobs-project.yaml
+++ b/tests/fixtures/layouts/no-jobs-project.yaml
@@ -14,11 +14,13 @@
 - job:
     name: base
     parent: null
+    run: playbooks/base.yaml
 
 - job:
     name: project-testfile
     files:
       - .*-requires
+    run: playbooks/project-testfile.yaml
 
 - project:
     name: org/no-jobs-project
diff --git a/tests/fixtures/layouts/no-jobs.yaml b/tests/fixtures/layouts/no-jobs.yaml
index 301b27a..7d483ec 100644
--- a/tests/fixtures/layouts/no-jobs.yaml
+++ b/tests/fixtures/layouts/no-jobs.yaml
@@ -35,9 +35,11 @@
 - job:
     name: base
     parent: null
+    run: playbooks/base.yaml
 
 - job:
     name: gate-noop
+    run: playbooks/gate-noop.yaml
 
 - project:
     name: org/project
diff --git a/tests/fixtures/layouts/no-run.yaml b/tests/fixtures/layouts/no-run.yaml
new file mode 100644
index 0000000..bccee9c
--- /dev/null
+++ b/tests/fixtures/layouts/no-run.yaml
@@ -0,0 +1,22 @@
+- pipeline:
+    name: check
+    manager: independent
+    trigger:
+      gerrit:
+        - event: patchset-created
+    success:
+      gerrit:
+        Verified: 1
+    failure:
+      gerrit:
+        Verified: -1
+
+- job:
+    name: base
+    parent: null
+
+- project:
+    name: org/project
+    check:
+      jobs:
+        - base
diff --git a/tests/fixtures/layouts/no-timer.yaml b/tests/fixtures/layouts/no-timer.yaml
index 7aaa1ed..67a3244 100644
--- a/tests/fixtures/layouts/no-timer.yaml
+++ b/tests/fixtures/layouts/no-timer.yaml
@@ -23,9 +23,11 @@
 - job:
     name: base
     parent: null
+    run: playbooks/base.yaml
 
 - job:
     name: project-test1
+    run: playbooks/project-test1.yaml
 
 - job:
     name: project-bitrot
@@ -33,6 +35,7 @@
       nodes:
         - name: static
           label: ubuntu-xenial
+    run: playbooks/project-bitrot.yaml
 
 - project:
     name: org/project
diff --git a/tests/fixtures/layouts/nonvoting-job.yaml b/tests/fixtures/layouts/nonvoting-job.yaml
index 6a912bf..5b8e9be 100644
--- a/tests/fixtures/layouts/nonvoting-job.yaml
+++ b/tests/fixtures/layouts/nonvoting-job.yaml
@@ -22,17 +22,21 @@
 - job:
     name: base
     parent: null
+    run: playbooks/base.yaml
 
 - job:
     name: nonvoting-project-merge
     hold-following-changes: true
+    run: playbooks/nonvoting-project-merge.yaml
 
 - job:
     name: nonvoting-project-test1
+    run: playbooks/nonvoting-project-test1.yaml
 
 - job:
     name: nonvoting-project-test2
     voting: false
+    run: playbooks/nonvoting-project-test2.yaml
 
 - project:
     name: org/nonvoting-project
diff --git a/tests/fixtures/layouts/nonvoting-pipeline.yaml b/tests/fixtures/layouts/nonvoting-pipeline.yaml
index d8468dd..afe0528 100644
--- a/tests/fixtures/layouts/nonvoting-pipeline.yaml
+++ b/tests/fixtures/layouts/nonvoting-pipeline.yaml
@@ -12,13 +12,16 @@
 - job:
     name: base
     parent: null
+    run: playbooks/base.yaml
 
 - job:
     name: project-merge
     hold-following-changes: true
+    run: playbooks/project-merge.yaml
 
 - job:
     name: experimental-project-test
+    run: playbooks/experimental-project-test.yaml
 
 - project:
     name: org/experimental-project
diff --git a/tests/fixtures/layouts/one-job-project.yaml b/tests/fixtures/layouts/one-job-project.yaml
index 4b682d3..d5346f6 100644
--- a/tests/fixtures/layouts/one-job-project.yaml
+++ b/tests/fixtures/layouts/one-job-project.yaml
@@ -43,13 +43,16 @@
 - job:
     name: base
     parent: null
+    run: playbooks/base.yaml
 
 - job:
     name: one-job-project-merge
     hold-following-changes: true
+    run: playbooks/one-job-project-merge.yaml
 
 - job:
     name: one-job-project-post
+    run: playbooks/one-job-project-post.yaml
 
 - project:
     name: org/one-job-project
diff --git a/tests/fixtures/layouts/parent-matchers.yaml b/tests/fixtures/layouts/parent-matchers.yaml
new file mode 100644
index 0000000..2080215
--- /dev/null
+++ b/tests/fixtures/layouts/parent-matchers.yaml
@@ -0,0 +1,38 @@
+- pipeline:
+    name: check
+    manager: independent
+    trigger:
+      gerrit:
+        - event: patchset-created
+    success:
+      gerrit:
+        Verified: 1
+    failure:
+      gerrit:
+        Verified: -1
+
+- job:
+    name: base
+    parent: null
+    run: playbooks/base.yaml
+
+- job:
+    name: parent-job
+    files: foo.txt
+    run: playbooks/parent-job.yaml
+
+- job:
+    name: parent-job
+    files: bar.txt
+    run: playbooks/parent-job.yaml
+
+- job:
+    name: child-job
+    parent: parent-job
+    run: playbooks/child-job.yaml
+
+- project:
+    name: org/project
+    check:
+      jobs:
+        - child-job
diff --git a/tests/fixtures/layouts/push-tag-github.yaml b/tests/fixtures/layouts/push-tag-github.yaml
index 5805127..d689201 100644
--- a/tests/fixtures/layouts/push-tag-github.yaml
+++ b/tests/fixtures/layouts/push-tag-github.yaml
@@ -4,7 +4,7 @@
     trigger:
       github:
         - event: push
-          ref: '^refs/heads/master$'
+          ref: ^refs/heads/master$
 
 - pipeline:
     name: tag
@@ -17,12 +17,15 @@
 - job:
     name: base
     parent: null
+    run: playbooks/base.yaml
 
 - job:
     name: project-post
+    run: playbooks/project-post.yaml
 
 - job:
     name: project-tag
+    run: playbooks/project-tag.yaml
 
 - project:
     name: org/project
diff --git a/tests/fixtures/layouts/rate-limit.yaml b/tests/fixtures/layouts/rate-limit.yaml
index 1f32dbf..b432d51 100644
--- a/tests/fixtures/layouts/rate-limit.yaml
+++ b/tests/fixtures/layouts/rate-limit.yaml
@@ -27,15 +27,19 @@
 - job:
     name: base
     parent: null
+    run: playbooks/base.yaml
 
 - job:
     name: project-merge
+    run: playbooks/project-merge.yaml
 
 - job:
     name: project-test1
+    run: playbooks/project-test1.yaml
 
 - job:
     name: project-test2
+    run: playbooks/project-test2.yaml
 
 - project:
     name: org/project
diff --git a/tests/fixtures/layouts/repo-checkout-four-project.yaml b/tests/fixtures/layouts/repo-checkout-four-project.yaml
index 17303f5..11212e8 100644
--- a/tests/fixtures/layouts/repo-checkout-four-project.yaml
+++ b/tests/fixtures/layouts/repo-checkout-four-project.yaml
@@ -35,6 +35,7 @@
 - job:
     name: base
     parent: null
+    run: playbooks/base.yaml
 
 - job:
     name: integration
@@ -43,6 +44,7 @@
       - org/project2
       - org/project3
       - org/project4
+    run: playbooks/integration.yaml
 
 - project:
     name: org/project1
diff --git a/tests/fixtures/layouts/repo-checkout-no-timer-override.yaml b/tests/fixtures/layouts/repo-checkout-no-timer-override.yaml
index 4680869..ca49292 100644
--- a/tests/fixtures/layouts/repo-checkout-no-timer-override.yaml
+++ b/tests/fixtures/layouts/repo-checkout-no-timer-override.yaml
@@ -10,6 +10,7 @@
 - job:
     name: base
     parent: null
+    run: playbooks/base.yaml
 
 - job:
     name: integration
@@ -17,6 +18,7 @@
     override-branch: stable/havana
     required-projects:
       - org/project1
+    run: playbooks/integration.yaml
 
 - project:
     name: org/project1
diff --git a/tests/fixtures/layouts/repo-checkout-no-timer.yaml b/tests/fixtures/layouts/repo-checkout-no-timer.yaml
index ed20bb1..6b88801 100644
--- a/tests/fixtures/layouts/repo-checkout-no-timer.yaml
+++ b/tests/fixtures/layouts/repo-checkout-no-timer.yaml
@@ -10,12 +10,14 @@
 - job:
     name: base
     parent: null
+    run: playbooks/base.yaml
 
 - job:
     name: integration
     override-branch: stable/havana
     required-projects:
       - org/project1
+    run: playbooks/integration.yaml
 
 - project:
     name: org/project1
diff --git a/tests/fixtures/layouts/repo-checkout-post.yaml b/tests/fixtures/layouts/repo-checkout-post.yaml
index 191569c..2e702bc 100644
--- a/tests/fixtures/layouts/repo-checkout-post.yaml
+++ b/tests/fixtures/layouts/repo-checkout-post.yaml
@@ -9,12 +9,14 @@
 - job:
     name: base
     parent: null
+    run: playbooks/base.yaml
 
 - job:
     name: integration
     required-projects:
       - org/project1
       - org/project2
+    run: playbooks/integration.yaml
 
 - project:
     name: org/project1
diff --git a/tests/fixtures/layouts/repo-checkout-six-project.yaml b/tests/fixtures/layouts/repo-checkout-six-project.yaml
index 9a81eae..6079612 100644
--- a/tests/fixtures/layouts/repo-checkout-six-project.yaml
+++ b/tests/fixtures/layouts/repo-checkout-six-project.yaml
@@ -35,6 +35,7 @@
 - job:
     name: base
     parent: null
+    run: playbooks/base.yaml
 
 - job:
     name: integration
@@ -46,6 +47,7 @@
         override-branch: master
       - org/project5
       - org/project6
+    run: playbooks/integration.yaml
 
 - project:
     name: org/project1
diff --git a/tests/fixtures/layouts/repo-checkout-timer-override.yaml b/tests/fixtures/layouts/repo-checkout-timer-override.yaml
index 99fc4f5..af5bd3c 100644
--- a/tests/fixtures/layouts/repo-checkout-timer-override.yaml
+++ b/tests/fixtures/layouts/repo-checkout-timer-override.yaml
@@ -8,6 +8,7 @@
 - job:
     name: base
     parent: null
+    run: playbooks/base.yaml
 
 - job:
     name: integration
@@ -15,6 +16,7 @@
     override-branch: stable/havana
     required-projects:
       - org/project1
+    run: playbooks/integration.yaml
 
 - project:
     name: org/project1
diff --git a/tests/fixtures/layouts/repo-checkout-timer.yaml b/tests/fixtures/layouts/repo-checkout-timer.yaml
index e707732..739c066 100644
--- a/tests/fixtures/layouts/repo-checkout-timer.yaml
+++ b/tests/fixtures/layouts/repo-checkout-timer.yaml
@@ -8,11 +8,13 @@
 - job:
     name: base
     parent: null
+    run: playbooks/base.yaml
 
 - job:
     name: integration
     required-projects:
       - org/project1
+    run: playbooks/integration.yaml
 
 - project:
     name: org/project1
diff --git a/tests/fixtures/layouts/repo-checkout-two-project.yaml b/tests/fixtures/layouts/repo-checkout-two-project.yaml
index 7910ae7..64c6ee9 100644
--- a/tests/fixtures/layouts/repo-checkout-two-project.yaml
+++ b/tests/fixtures/layouts/repo-checkout-two-project.yaml
@@ -35,12 +35,14 @@
 - job:
     name: base
     parent: null
+    run: playbooks/base.yaml
 
 - job:
     name: integration
     required-projects:
       - org/project1
       - org/project2
+    run: playbooks/integration.yaml
 
 - project:
     name: org/project1
diff --git a/tests/fixtures/layouts/repo-deleted.yaml b/tests/fixtures/layouts/repo-deleted.yaml
index 3a7f6b3..2ee8ebd 100644
--- a/tests/fixtures/layouts/repo-deleted.yaml
+++ b/tests/fixtures/layouts/repo-deleted.yaml
@@ -35,10 +35,12 @@
 - job:
     name: base
     parent: null
+    run: playbooks/base.yaml
 
 - job:
     name: project-merge
     hold-following-changes: true
+    run: playbooks/project-merge.yaml
 
 - job:
     name: project-test1
@@ -46,6 +48,7 @@
       nodes:
         - name: controller
           label: label1
+    run: playbooks/project-test1.yaml
 
 - job:
     name: project-test1
@@ -54,9 +57,11 @@
       nodes:
         - name: controller
           label: label2
+    run: playbooks/project-test1.yaml
 
 - job:
     name: project-test2
+    run: playbooks/project-test2.yaml
 
 - project:
     name: org/delete-project
diff --git a/tests/fixtures/layouts/reporting-github.yaml b/tests/fixtures/layouts/reporting-github.yaml
index 159f205..c909cf4 100644
--- a/tests/fixtures/layouts/reporting-github.yaml
+++ b/tests/fixtures/layouts/reporting-github.yaml
@@ -8,11 +8,11 @@
           action: opened
     start:
       github:
-        status: 'pending'
+        status: pending
         comment: false
     success:
       github:
-        status: 'success'
+        status: success
 
 - pipeline:
     name: reporting
@@ -22,13 +22,13 @@
       github:
         - event: pull_request
           action: comment
-          comment: 'reporting check'
+          comment: reporting check
     start:
       github: {}
     success:
       github:
         comment: false
-        status: 'success'
+        status: success
         status-url: http://logs.example.com/{tenant.name}/{pipeline.name}/{change.project}/{change.number}/{buildset.uuid}/
     failure:
       github:
@@ -42,14 +42,14 @@
       github:
         - event: pull_request
           action: comment
-          comment: 'long pipeline'
+          comment: long pipeline
     start:
       github:
-        status: 'pending'
+        status: pending
     success:
       github:
         comment: false
-        status: 'success'
+        status: success
         status-url: http://logs.example.com/{tenant.name}/{pipeline.name}/{change.project}/{change.number}/{buildset.uuid}/
     failure:
       github:
@@ -67,23 +67,25 @@
     start:
       github:
         comment: true
-        status: 'pending'
+        status: pending
     success:
       github:
         comment: true
-        status: 'success'
+        status: success
         merge: true
     failure:
       github:
         comment: true
-        status: 'failure'
+        status: failure
 
 - job:
     name: base
     parent: null
+    run: playbooks/base.yaml
 
 - job:
     name: project-test1
+    run: playbooks/project-test1.yaml
 
 - project:
     name: org/project
diff --git a/tests/fixtures/layouts/reporting-multiple-github.yaml b/tests/fixtures/layouts/reporting-multiple-github.yaml
index 0126ec5..67a237a 100644
--- a/tests/fixtures/layouts/reporting-multiple-github.yaml
+++ b/tests/fixtures/layouts/reporting-multiple-github.yaml
@@ -11,26 +11,29 @@
           action: opened
     start:
       github:
-        status: 'pending'
+        status: pending
         comment: false
       github_ent:
-        status: 'pending'
+        status: pending
         comment: false
     success:
       github:
-        status: 'success'
+        status: success
       github_ent:
-        status: 'success'
+        status: success
 
 - job:
     name: base
     parent: null
+    run: playbooks/base.yaml
 
 - job:
     name: project1-test1
+    run: playbooks/project1-test1.yaml
 
 - job:
     name: project2-test2
+    run: playbooks/project2-test2.yaml
 
 - project:
     name: org/project1
diff --git a/tests/fixtures/layouts/requirements-github.yaml b/tests/fixtures/layouts/requirements-github.yaml
index f2ecd16..92bd9cb 100644
--- a/tests/fixtures/layouts/requirements-github.yaml
+++ b/tests/fixtures/layouts/requirements-github.yaml
@@ -3,12 +3,12 @@
     manager: independent
     require:
       github:
-        status: "zuul:check:success"
+        status: zuul:check:success
     trigger:
       github:
         - event: pull_request
           action: comment
-          comment: 'test me'
+          comment: test me
     success:
       github:
         comment: true
@@ -20,8 +20,8 @@
       github:
         - event: pull_request
           action: comment
-          comment: 'trigger me'
-          require-status: "zuul:check:success"
+          comment: trigger me
+          require-status: zuul:check:success
     success:
       github:
         comment: true
@@ -33,13 +33,13 @@
       github:
         - event: pull_request
           action: status
-          status: 'zuul:check:success'
+          status: zuul:check:success
     success:
       github:
-        status: 'success'
+        status: success
     failure:
       github:
-        status: 'failure'
+        status: failure
 
 - pipeline:
     name: reviewusername
@@ -47,13 +47,13 @@
     require:
       github:
         review:
-          - username: '^(herp|derp)$'
+          - username: ^(herp|derp)$
             type: approved
     trigger:
       github:
         - event: pull_request
           action: comment
-          comment: 'test me'
+          comment: test me
     success:
       github:
         comment: true
@@ -75,7 +75,7 @@
       github:
         - event: pull_request
           action: comment
-          comment: 'test me'
+          comment: test me
     success:
       github:
         comment: true
@@ -86,7 +86,7 @@
     require:
       github:
         review:
-          - username: 'derp'
+          - username: derp
             type: approved
             permission: write
     reject:
@@ -98,7 +98,7 @@
       github:
         - event: pull_request
           action: comment
-          comment: 'test me'
+          comment: test me
     success:
       github:
         comment: true
@@ -116,7 +116,7 @@
       github:
         - event: pull_request
           action: comment
-          comment: 'test me'
+          comment: test me
     success:
       github:
         comment: true
@@ -134,7 +134,7 @@
       github:
         - event: pull_request
           action: comment
-          comment: 'test me'
+          comment: test me
     success:
       github:
         comment: true
@@ -149,7 +149,7 @@
       github:
         - event: pull_request
           action: comment
-          comment: 'test me'
+          comment: test me
     success:
       github:
         comment: true
@@ -178,7 +178,7 @@
       github:
         - event: pull_request
           action: comment
-          comment: 'test me'
+          comment: test me
     success:
       github:
         comment: true
@@ -186,36 +186,47 @@
 - job:
     name: base
     parent: null
+    run: playbooks/base.yaml
 
 - job:
     name: project1-pipeline
+    run: playbooks/project1-pipeline.yaml
 
 - job:
     name: project2-trigger
+    run: playbooks/project2-trigger.yaml
 
 - job:
     name: project3-reviewusername
+    run: playbooks/project3-reviewusername.yaml
 
 - job:
     name: project4-reviewreq
+    run: playbooks/project4-reviewreq.yaml
 
 - job:
     name: project5-reviewuserstate
+    run: playbooks/project5-reviewuserstate.yaml
 
 - job:
     name: project6-newerthan
+    run: playbooks/project6-newerthan.yaml
 
 - job:
     name: project7-olderthan
+    run: playbooks/project7-olderthan.yaml
 
 - job:
     name: project8-requireopen
+    run: playbooks/project8-requireopen.yaml
 
 - job:
     name: project9-requirecurrent
+    run: playbooks/project9-requirecurrent.yaml
 
 - job:
     name: project10-label
+    run: playbooks/project10-label.yaml
 
 - project:
     name: org/project1
diff --git a/tests/fixtures/layouts/reviews-github.yaml b/tests/fixtures/layouts/reviews-github.yaml
index f186fbe..abc3d99 100644
--- a/tests/fixtures/layouts/reviews-github.yaml
+++ b/tests/fixtures/layouts/reviews-github.yaml
@@ -5,18 +5,20 @@
       github:
         - event: pull_request_review
           action: submitted
-          state: 'approve'
+          state: approve
     success:
       github:
         label:
-          - 'tests passed'
+          - tests passed
 
 - job:
     name: base
     parent: null
+    run: playbooks/base.yaml
 
 - job:
     name: project-reviews
+    run: playbooks/project-reviews.yaml
 
 - project:
     name: org/project
diff --git a/tests/fixtures/layouts/smtp.yaml b/tests/fixtures/layouts/smtp.yaml
index 0654448..77391a0 100644
--- a/tests/fixtures/layouts/smtp.yaml
+++ b/tests/fixtures/layouts/smtp.yaml
@@ -41,10 +41,12 @@
 - job:
     name: base
     parent: null
+    run: playbooks/base.yaml
 
 - job:
     name: project-merge
     hold-following-changes: true
+    run: playbooks/project-merge.yaml
 
 - job:
     name: project-test1
@@ -52,6 +54,7 @@
       nodes:
         - name: controller
           label: label1
+    run: playbooks/project-test1.yaml
 
 - job:
     name: project-test1
@@ -60,9 +63,11 @@
       nodes:
         - name: controller
           label: label2
+    run: playbooks/project-test1.yaml
 
 - job:
     name: project-test2
+    run: playbooks/project-test2.yaml
 
 - project:
     name: org/project
diff --git a/tests/fixtures/layouts/tags.yaml b/tests/fixtures/layouts/tags.yaml
index f86f5ab..2fda2db 100644
--- a/tests/fixtures/layouts/tags.yaml
+++ b/tests/fixtures/layouts/tags.yaml
@@ -14,11 +14,13 @@
 - job:
     name: base
     parent: null
+    run: playbooks/base.yaml
 
 - job:
     name: merge
     tags:
       - merge
+    run: playbooks/merge.yaml
 
 - project:
     name: org/project1
diff --git a/tests/fixtures/layouts/three-projects.yaml b/tests/fixtures/layouts/three-projects.yaml
index 51cd406..33e81ac 100644
--- a/tests/fixtures/layouts/three-projects.yaml
+++ b/tests/fixtures/layouts/three-projects.yaml
@@ -35,19 +35,24 @@
 - job:
     name: base
     parent: null
+    run: playbooks/base.yaml
 
 - job:
     name: project-merge
     hold-following-changes: true
+    run: playbooks/project-merge.yaml
 
 - job:
     name: project-test1
+    run: playbooks/project-test1.yaml
 
 - job:
     name: project-test2
+    run: playbooks/project-test2.yaml
 
 - job:
     name: project1-project2-integration
+    run: playbooks/project1-project2-integration.yaml
 
 - project:
     name: org/project1
diff --git a/tests/fixtures/layouts/timer-smtp.yaml b/tests/fixtures/layouts/timer-smtp.yaml
index a27b183..d9e4282 100644
--- a/tests/fixtures/layouts/timer-smtp.yaml
+++ b/tests/fixtures/layouts/timer-smtp.yaml
@@ -13,14 +13,17 @@
 - job:
     name: base
     parent: null
+    run: playbooks/base.yaml
 
 - job:
     name: project-bitrot-stable-old
     success-url: http://logs.example.com/{job.name}/{build.number}
+    run: playbooks/project-bitrot-stable-old.yaml
 
 - job:
     name: project-bitrot-stable-older
     success-url: http://logs.example.com/{job.name}/{build.number}
+    run: playbooks/project-bitrot-stable-older.yaml
 
 - project:
     name: org/project
diff --git a/tests/fixtures/layouts/timer.yaml b/tests/fixtures/layouts/timer.yaml
index 8c0cc2b..e9e9a17 100644
--- a/tests/fixtures/layouts/timer.yaml
+++ b/tests/fixtures/layouts/timer.yaml
@@ -21,12 +21,15 @@
 - job:
     name: base
     parent: null
+    run: playbooks/base.yaml
 
 - job:
     name: project-test1
+    run: playbooks/project-test1.yaml
 
 - job:
     name: project-test2
+    run: playbooks/project-test2.yaml
 
 - job:
     name: project-bitrot
@@ -34,6 +37,7 @@
       nodes:
         - name: static
           label: ubuntu-xenial
+    run: playbooks/project-bitrot.yaml
 
 - project:
     name: org/project
diff --git a/tests/fixtures/layouts/untrusted-secrets.yaml b/tests/fixtures/layouts/untrusted-secrets.yaml
index b90d3d7..337587a 100644
--- a/tests/fixtures/layouts/untrusted-secrets.yaml
+++ b/tests/fixtures/layouts/untrusted-secrets.yaml
@@ -14,10 +14,12 @@
 - job:
     name: base
     parent: null
+    run: playbooks/base.yaml
 
 - job:
     name: project1-test
     post-review: true
+    run: playbooks/project1-test.yaml
 
 - project:
     name: org/project1
diff --git a/tests/fixtures/zuul-connections-cgit.conf b/tests/fixtures/zuul-connections-cgit.conf
new file mode 100644
index 0000000..39dc0bb
--- /dev/null
+++ b/tests/fixtures/zuul-connections-cgit.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
+
+[connection gerrit]
+driver=gerrit
+server=review.example.com
+user=jenkins
+sshkey=none
+gitweb_url_template=https://cgit.example.com/cgit/{project.name}/commit/?id={sha}
+
+[connection outgoing_smtp]
+driver=smtp
+server=localhost
+port=25
+default_from=zuul@example.com
+default_to=you@example.com
diff --git a/tests/fixtures/zuul-connections-gitweb.conf b/tests/fixtures/zuul-connections-gitweb.conf
new file mode 100644
index 0000000..172208e
--- /dev/null
+++ b/tests/fixtures/zuul-connections-gitweb.conf
@@ -0,0 +1,26 @@
+[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
+
+[connection gerrit]
+driver=gerrit
+server=review.example.com
+user=jenkins
+sshkey=none
+
+[connection outgoing_smtp]
+driver=smtp
+server=localhost
+port=25
+default_from=zuul@example.com
+default_to=you@example.com
diff --git a/tests/unit/test_connection.py b/tests/unit/test_connection.py
index 719f307..c882d3a 100644
--- a/tests/unit/test_connection.py
+++ b/tests/unit/test_connection.py
@@ -338,3 +338,32 @@
         self.assertNotIn("sql", self.connections.connections)
         self.assertNotIn("timer", self.connections.connections)
         self.assertNotIn("zuul", self.connections.connections)
+
+
+class TestConnectionsCgit(ZuulTestCase):
+    config_file = 'zuul-connections-cgit.conf'
+    tenant_config_file = 'config/single-tenant/main.yaml'
+
+    def test_cgit_web_url(self):
+        self.assertIn("gerrit", self.connections.connections)
+        conn = self.connections.connections['gerrit']
+        source = conn.source
+        proj = source.getProject('foo/bar')
+        url = conn._getWebUrl(proj, '1')
+        self.assertEqual(url,
+                         'https://cgit.example.com/cgit/foo/bar/commit/?id=1')
+
+
+class TestConnectionsGitweb(ZuulTestCase):
+    config_file = 'zuul-connections-gitweb.conf'
+    tenant_config_file = 'config/single-tenant/main.yaml'
+
+    def test_gitweb_url(self):
+        self.assertIn("gerrit", self.connections.connections)
+        conn = self.connections.connections['gerrit']
+        source = conn.source
+        proj = source.getProject('foo/bar')
+        url = conn._getWebUrl(proj, '1')
+        url_should_be = 'https://review.example.com/' \
+                        'gitweb?p=foo/bar.git;a=commitdiff;h=1'
+        self.assertEqual(url, url_should_be)
diff --git a/tests/unit/test_model.py b/tests/unit/test_model.py
index 628a45c..784fcb3 100644
--- a/tests/unit/test_model.py
+++ b/tests/unit/test_model.py
@@ -96,30 +96,6 @@
     def test_job_sets_defaults_for_boolean_attributes(self):
         self.assertIsNotNone(self.job.voting)
 
-    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 = model.Job('base')
-        base.timeout = 30
-        base.pre_run = [base_pre]
-        base.run = [base_run]
-        base.post_run = [base_post]
-
-        py27 = model.Job('py27')
-        self.assertIsNone(py27.timeout)
-        py27.inheritFrom(base)
-        self.assertEqual(30, py27.timeout)
-        self.assertEqual(['base-pre'],
-                         [x.path for x in py27.pre_run])
-        self.assertEqual(['base-run'],
-                         [x.path for x in py27.run])
-        self.assertEqual(['base-post'],
-                         [x.path for x in py27.post_run])
-
     def test_job_variants(self):
         # This simulates freezing a job.
 
@@ -170,342 +146,6 @@
                 "Unable to modify final job"):
             job.applyVariant(bad_final)
 
-    def test_job_inheritance_configloader(self):
-        # TODO(jeblair): move this to a configloader test
-        tenant = model.Tenant('tenant')
-        layout = model.Layout(tenant)
-
-        pipeline = model.Pipeline('gate', layout)
-        layout.addPipeline(pipeline)
-        queue = model.ChangeQueue(pipeline)
-        project = model.Project('project', self.source)
-        tpc = model.TenantProjectConfig(project)
-        tenant.addUntrustedProject(tpc)
-
-        base = configloader.JobParser.fromYaml(tenant, layout, {
-            '_source_context': self.context,
-            '_start_mark': self.start_mark,
-            'name': 'base',
-            'parent': None,
-            'timeout': 30,
-            'pre-run': 'base-pre',
-            'post-run': 'base-post',
-            'nodeset': {
-                'nodes': [{
-                    'name': 'controller',
-                    'label': 'base',
-                }],
-            },
-        })
-        layout.addJob(base)
-        python27 = configloader.JobParser.fromYaml(tenant, layout, {
-            '_source_context': self.context,
-            '_start_mark': self.start_mark,
-            'name': 'python27',
-            'parent': 'base',
-            'pre-run': 'py27-pre',
-            'post-run': ['py27-post-a', 'py27-post-b'],
-            'nodeset': {
-                'nodes': [{
-                    'name': 'controller',
-                    'label': 'new',
-                }],
-            },
-            'timeout': 40,
-        })
-        layout.addJob(python27)
-        python27diablo = configloader.JobParser.fromYaml(tenant, layout, {
-            '_source_context': self.context,
-            '_start_mark': self.start_mark,
-            'name': 'python27',
-            'branches': [
-                'stable/diablo'
-            ],
-            'pre-run': 'py27-diablo-pre',
-            'run': 'py27-diablo',
-            'post-run': 'py27-diablo-post',
-            'nodeset': {
-                'nodes': [{
-                    'name': 'controller',
-                    'label': 'old',
-                }],
-            },
-            'timeout': 50,
-        })
-        layout.addJob(python27diablo)
-
-        python27essex = configloader.JobParser.fromYaml(tenant, layout, {
-            '_source_context': self.context,
-            '_start_mark': self.start_mark,
-            'name': 'python27',
-            'branches': [
-                'stable/essex'
-            ],
-            'pre-run': 'py27-essex-pre',
-            'post-run': 'py27-essex-post',
-        })
-        layout.addJob(python27essex)
-
-        project_template_parser = configloader.ProjectTemplateParser(
-            tenant, layout)
-        project_parser = configloader.ProjectParser(
-            tenant, layout, project_template_parser)
-        project_config = project_parser.fromYaml([{
-            '_source_context': self.context,
-            '_start_mark': self.start_mark,
-            'name': 'project',
-            'gate': {
-                'jobs': [
-                    'python27'
-                ]
-            }
-        }])
-        layout.addProjectConfig(project_config)
-
-        change = model.Change(project)
-        # Test master
-        change.branch = 'master'
-        item = queue.enqueueChange(change)
-        item.layout = layout
-
-        self.assertTrue(base.changeMatches(change))
-        self.assertTrue(python27.changeMatches(change))
-        self.assertFalse(python27diablo.changeMatches(change))
-        self.assertFalse(python27essex.changeMatches(change))
-
-        item.freezeJobGraph()
-        self.assertEqual(len(item.getJobs()), 1)
-        job = item.getJobs()[0]
-        self.assertEqual(job.name, 'python27')
-        self.assertEqual(job.timeout, 40)
-        nodes = job.nodeset.getNodes()
-        self.assertEqual(len(nodes), 1)
-        self.assertEqual(nodes[0].label, 'new')
-        self.assertEqual([x.path for x in job.pre_run],
-                         ['base-pre',
-                          'py27-pre'])
-        self.assertEqual([x.path for x in job.post_run],
-                         ['py27-post-a',
-                          'py27-post-b',
-                          'base-post'])
-        self.assertEqual([x.path for x in job.run],
-                         ['playbooks/python27',
-                          'playbooks/base'])
-
-        # Test diablo
-        change.branch = 'stable/diablo'
-        item = queue.enqueueChange(change)
-        item.layout = layout
-
-        self.assertTrue(base.changeMatches(change))
-        self.assertTrue(python27.changeMatches(change))
-        self.assertTrue(python27diablo.changeMatches(change))
-        self.assertFalse(python27essex.changeMatches(change))
-
-        item.freezeJobGraph()
-        self.assertEqual(len(item.getJobs()), 1)
-        job = item.getJobs()[0]
-        self.assertEqual(job.name, 'python27')
-        self.assertEqual(job.timeout, 50)
-        nodes = job.nodeset.getNodes()
-        self.assertEqual(len(nodes), 1)
-        self.assertEqual(nodes[0].label, 'old')
-        self.assertEqual([x.path for x in job.pre_run],
-                         ['base-pre',
-                          'py27-pre',
-                          'py27-diablo-pre'])
-        self.assertEqual([x.path for x in job.post_run],
-                         ['py27-diablo-post',
-                          'py27-post-a',
-                          'py27-post-b',
-                          'base-post'])
-        self.assertEqual([x.path for x in job.run],
-                         ['py27-diablo']),
-
-        # Test essex
-        change.branch = 'stable/essex'
-        item = queue.enqueueChange(change)
-        item.layout = layout
-
-        self.assertTrue(base.changeMatches(change))
-        self.assertTrue(python27.changeMatches(change))
-        self.assertFalse(python27diablo.changeMatches(change))
-        self.assertTrue(python27essex.changeMatches(change))
-
-        item.freezeJobGraph()
-        self.assertEqual(len(item.getJobs()), 1)
-        job = item.getJobs()[0]
-        self.assertEqual(job.name, 'python27')
-        self.assertEqual([x.path for x in job.pre_run],
-                         ['base-pre',
-                          'py27-pre',
-                          'py27-essex-pre'])
-        self.assertEqual([x.path for x in job.post_run],
-                         ['py27-essex-post',
-                          'py27-post-a',
-                          'py27-post-b',
-                          'base-post'])
-        self.assertEqual([x.path for x in job.run],
-                         ['playbooks/python27',
-                          'playbooks/base'])
-
-    def test_job_auth_inheritance(self):
-        tenant = self.tenant
-        layout = self.layout
-
-        conf = yaml.safe_load('''
-- secret:
-    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/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
-
-        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)
-
-        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')
         layout = model.Layout(tenant)
@@ -554,7 +194,8 @@
             'name': 'project',
             'gate': {
                 'jobs': [
-                    {'python27': {'timeout': 70}}
+                    {'python27': {'timeout': 70,
+                                  'run': 'playbooks/python27.yaml'}}
                 ]
             }
         }])
diff --git a/tests/unit/test_scheduler.py b/tests/unit/test_scheduler.py
index a97e47e..b0df2b2 100755
--- a/tests/unit/test_scheduler.py
+++ b/tests/unit/test_scheduler.py
@@ -2430,6 +2430,71 @@
         self.assertEqual(rp, set(['org/project', 'org/project0',
                                   'org/project3']))
 
+    @simple_layout('layouts/job-variants.yaml')
+    def test_job_branch_variants(self):
+        self.create_branch('org/project', 'stable/diablo')
+        self.create_branch('org/project', 'stable/essex')
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+
+        B = self.fake_gerrit.addFakeChange('org/project', 'stable/diablo', 'B')
+        self.fake_gerrit.addEvent(B.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+
+        C = self.fake_gerrit.addFakeChange('org/project', 'stable/essex', 'C')
+        self.fake_gerrit.addEvent(C.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+        self.assertHistory([
+            dict(name='python27', result='SUCCESS'),
+            dict(name='python27', result='SUCCESS'),
+            dict(name='python27', result='SUCCESS'),
+        ])
+
+        p = self.history[0].parameters
+        self.assertEqual(p['timeout'], 40)
+        self.assertEqual(len(p['nodes']), 1)
+        self.assertEqual(p['nodes'][0]['label'], 'new')
+        self.assertEqual([x['path'] for x in p['pre_playbooks']],
+                         ['base-pre', 'py27-pre'])
+        self.assertEqual([x['path'] for x in p['post_playbooks']],
+                         ['py27-post-a', 'py27-post-b', 'base-post'])
+        self.assertEqual([x['path'] for x in p['playbooks']],
+                         ['playbooks/python27.yaml'])
+
+        p = self.history[1].parameters
+        self.assertEqual(p['timeout'], 50)
+        self.assertEqual(len(p['nodes']), 1)
+        self.assertEqual(p['nodes'][0]['label'], 'old')
+        self.assertEqual([x['path'] for x in p['pre_playbooks']],
+                         ['base-pre', 'py27-pre', 'py27-diablo-pre'])
+        self.assertEqual([x['path'] for x in p['post_playbooks']],
+                         ['py27-diablo-post', 'py27-post-a', 'py27-post-b',
+                          'base-post'])
+        self.assertEqual([x['path'] for x in p['playbooks']],
+                         ['py27-diablo'])
+
+        p = self.history[2].parameters
+        self.assertEqual(p['timeout'], 40)
+        self.assertEqual(len(p['nodes']), 1)
+        self.assertEqual(p['nodes'][0]['label'], 'new')
+        self.assertEqual([x['path'] for x in p['pre_playbooks']],
+                         ['base-pre', 'py27-pre', 'py27-essex-pre'])
+        self.assertEqual([x['path'] for x in p['post_playbooks']],
+                         ['py27-essex-post', 'py27-post-a', 'py27-post-b',
+                          'base-post'])
+        self.assertEqual([x['path'] for x in p['playbooks']],
+                         ['playbooks/python27.yaml'])
+
+    @simple_layout("layouts/no-run.yaml")
+    def test_job_without_run(self):
+        "Test that a job without a run playbook errors"
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+        self.assertIn('Job base does not specify a run playbook',
+                      A.messages[-1])
+
     def test_queue_names(self):
         "Test shared change queue names"
         tenant = self.sched.abide.tenants.get('tenant-one')
@@ -4912,6 +4977,39 @@
         self.gearman_server.release()
         self.waitUntilSettled()
 
+    @simple_layout('layouts/parent-matchers.yaml')
+    def test_parent_matchers(self):
+        "Test that if a job's parent does not match, the job does not run"
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+
+        self.assertHistory([])
+
+        files = {'foo.txt': ''}
+        B = self.fake_gerrit.addFakeChange('org/project', 'master', 'B',
+                                           files=files)
+        self.fake_gerrit.addEvent(B.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+
+        files = {'bar.txt': ''}
+        C = self.fake_gerrit.addFakeChange('org/project', 'master', 'C',
+                                           files=files)
+        self.fake_gerrit.addEvent(C.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+
+        files = {'foo.txt': '', 'bar.txt': ''}
+        D = self.fake_gerrit.addFakeChange('org/project', 'master', 'D',
+                                           files=files)
+        self.fake_gerrit.addEvent(D.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+
+        self.assertHistory([
+            dict(name='child-job', result='SUCCESS', changes='2,1'),
+            dict(name='child-job', result='SUCCESS', changes='3,1'),
+            dict(name='child-job', result='SUCCESS', changes='4,1'),
+        ])
+
 
 class TestExecutor(ZuulTestCase):
     tenant_config_file = 'config/single-tenant/main.yaml'
@@ -5790,6 +5888,7 @@
 
             - job:
                 name: project-test2
+                run: playbooks/project-test2.yaml
                 semaphore: test-semaphore
 
             - project:
diff --git a/tests/unit/test_v3.py b/tests/unit/test_v3.py
index 92353fb..c04604d 100755
--- a/tests/unit/test_v3.py
+++ b/tests/unit/test_v3.py
@@ -128,6 +128,7 @@
             - job:
                 name: project-test
                 parent: job-final
+                run: playbooks/project-test.yaml
 
             - project:
                 name: org/project
@@ -153,7 +154,7 @@
         # 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])
+        self.assertIn('Unable to modify final job', A.messages[0])
 
 
 class TestBranchVariants(ZuulTestCase):
@@ -208,6 +209,135 @@
         self.executor_server.release()
         self.waitUntilSettled()
 
+    def test_branch_variants_divergent(self):
+        # Test branches can diverge and become independent
+        self.executor_server.hold_jobs_in_build = True
+        # This creates a new branch with a copy of the config in master
+        self.create_branch('puppet-integration', 'stable')
+        self.fake_gerrit.addEvent(
+            self.fake_gerrit.getFakeBranchCreatedEvent(
+                'puppet-integration', 'stable'))
+        self.waitUntilSettled()
+
+        with open(os.path.join(FIXTURE_DIR,
+                               'config/branch-variants/git/',
+                               'puppet-integration/stable.zuul.yaml')) as f:
+            config = f.read()
+
+        file_dict = {'.zuul.yaml': config}
+        C = self.fake_gerrit.addFakeChange('puppet-integration', 'stable', 'C',
+                                           files=file_dict)
+        C.addApproval('Code-Review', 2)
+        self.fake_gerrit.addEvent(C.addApproval('Approved', 1))
+        self.waitUntilSettled()
+        self.fake_gerrit.addEvent(C.getChangeMergedEvent())
+        self.waitUntilSettled()
+
+        A = self.fake_gerrit.addFakeChange('puppet-integration', 'master', 'A')
+        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+        B = self.fake_gerrit.addFakeChange('puppet-integration', 'stable', 'B')
+        self.fake_gerrit.addEvent(B.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+
+        self.assertEqual(self.builds[0].parameters['zuul']['jobtags'],
+                         ['master'])
+
+        self.assertEqual(self.builds[1].parameters['zuul']['jobtags'],
+                         ['stable'])
+
+        self.executor_server.hold_jobs_in_build = False
+        self.executor_server.release()
+        self.waitUntilSettled()
+
+
+class TestCentralJobs(ZuulTestCase):
+    tenant_config_file = 'config/central-jobs/main.yaml'
+
+    def setUp(self):
+        super(TestCentralJobs, self).setUp()
+        self.create_branch('org/project', 'stable')
+        self.fake_gerrit.addEvent(
+            self.fake_gerrit.getFakeBranchCreatedEvent(
+                'org/project', 'stable'))
+        self.waitUntilSettled()
+
+    def _updateConfig(self, config, branch):
+        file_dict = {'.zuul.yaml': config}
+        C = self.fake_gerrit.addFakeChange('org/project', branch, 'C',
+                                           files=file_dict)
+        C.addApproval('Code-Review', 2)
+        self.fake_gerrit.addEvent(C.addApproval('Approved', 1))
+        self.waitUntilSettled()
+        self.fake_gerrit.addEvent(C.getChangeMergedEvent())
+        self.waitUntilSettled()
+
+    def _test_central_job_on_branch(self, branch, other_branch):
+        # Test that a job defined on a branchless repo only runs on
+        # the branch applied
+        config = textwrap.dedent(
+            """
+            - project:
+                name: org/project
+                check:
+                  jobs:
+                    - central-job
+            """)
+        self._updateConfig(config, branch)
+
+        A = self.fake_gerrit.addFakeChange('org/project', branch, 'A')
+        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+
+        self.assertHistory([
+            dict(name='central-job', result='SUCCESS', changes='2,1')])
+
+        # No jobs should run for this change.
+        B = self.fake_gerrit.addFakeChange('org/project', other_branch, 'B')
+        self.fake_gerrit.addEvent(B.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+
+        self.assertHistory([
+            dict(name='central-job', result='SUCCESS', changes='2,1')])
+
+    def test_central_job_on_stable(self):
+        self._test_central_job_on_branch('master', 'stable')
+
+    def test_central_job_on_master(self):
+        self._test_central_job_on_branch('stable', 'master')
+
+    def _test_central_template_on_branch(self, branch, other_branch):
+        # Test that a project-template defined on a branchless repo
+        # only runs on the branch applied
+        config = textwrap.dedent(
+            """
+            - project:
+                name: org/project
+                templates: ['central-jobs']
+            """)
+        self._updateConfig(config, branch)
+
+        A = self.fake_gerrit.addFakeChange('org/project', branch, 'A')
+        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+
+        self.assertHistory([
+            dict(name='central-job', result='SUCCESS', changes='2,1')])
+
+        # No jobs should run for this change.
+        B = self.fake_gerrit.addFakeChange('org/project', other_branch, 'B')
+        self.fake_gerrit.addEvent(B.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+
+        self.assertHistory([
+            dict(name='central-job', result='SUCCESS', changes='2,1')])
+
+    def test_central_template_on_stable(self):
+        self._test_central_template_on_branch('master', 'stable')
+
+    def test_central_template_on_master(self):
+        self._test_central_template_on_branch('stable', 'master')
+
 
 class TestInRepoConfig(ZuulTestCase):
     # A temporary class to hold new tests while others are disabled
@@ -269,6 +399,7 @@
 
             - job:
                 name: project-test2
+                run: playbooks/project-test2.yaml
 
             - project:
                 name: org/project
@@ -314,6 +445,8 @@
             dict(name='project-test2', result='SUCCESS', changes='2,1')])
 
     def test_dynamic_template(self):
+        # Tests that a project can't update a template in another
+        # project.
         in_repo_conf = textwrap.dedent(
             """
             - job:
@@ -335,8 +468,12 @@
                                            files=file_dict)
         self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
         self.waitUntilSettled()
-        self.assertHistory([
-            dict(name='template-job', result='SUCCESS', changes='1,1')])
+
+        self.assertEqual(A.patchsets[0]['approvals'][0]['value'], "-1")
+        self.assertIn('Project template common-config-template '
+                      'is already defined',
+                      A.messages[0],
+                      "A should have failed the check pipeline")
 
     def test_dynamic_config_non_existing_job(self):
         """Test that requesting a non existent job fails"""
@@ -419,9 +556,11 @@
             """
             - job:
                 name: project-test1
+                run: playbooks/project-test1.yaml
 
             - job:
                 name: project-test2
+                run: playbooks/project-test2.yaml
 
             - project:
                 name: org/project
@@ -452,9 +591,11 @@
             """
             - job:
                 name: project-test1
+                run: playbooks/project-test1.yaml
 
             - job:
                 name: project-test2
+                run: playbooks/project-test2.yaml
 
             - project:
                 name: org/project
@@ -495,6 +636,7 @@
 
             - job:
                 name: project-test2
+                run: playbooks/project-test2.yaml
 
             - project:
                 name: org/project
@@ -567,6 +709,7 @@
 
             - job:
                 name: project-test2
+                run: playbooks/project-test2.yaml
 
             - project:
                 name: org/project
@@ -1034,6 +1177,7 @@
             """
             - job:
                 name: project-test1
+                run: playbooks/project-test1.yaml
             - project-template:
                 name: some-jobs
                 tenant-one-gate:
@@ -1092,6 +1236,7 @@
             """
             - job:
                 name: project-test1
+                run: playbooks/project-test1.yaml
 
             - project:
                 name: org/project1
@@ -1146,6 +1291,7 @@
             """
             - job:
                 name: project-test1
+                run: playbooks/project-test1.yaml
 
             - job:
                 name: project-test2
@@ -1262,9 +1408,11 @@
             """
             - job:
                 name: project-test1
+                run: playbooks/project-test1.yaml
 
             - job:
                 name: project-test2
+                run: playbooks/project-test2.yaml
 
             - project:
                 name: org/project
@@ -1308,6 +1456,7 @@
             """
             - job:
                 name: project-test1
+                run: playbooks/project-test1.yaml
 
             - project:
                 name: org/project
@@ -1441,13 +1590,14 @@
             """
             - job:
                 name: %s
+                run: playbooks/%s.yaml
 
             - project:
                 name: org/plugin-project
                 check:
                   jobs:
                     - %s
-            """ % (job_name, job_name))
+            """ % (job_name, job_name, job_name))
 
         file_dict = {'.zuul.yaml': conf}
         A = self.fake_gerrit.addFakeChange('org/plugin-project', 'master', 'A',
@@ -1649,6 +1799,7 @@
             - job:
                 name: project-test
                 parent: parent
+                run: playbooks/project-test.yaml
                 roles:
                   - zuul: org/project
 
@@ -1685,6 +1836,7 @@
             """
             - job:
                 name: project-test
+                run: playbooks/project-test.yaml
                 roles:
                   - zuul: common-config
 
@@ -1866,6 +2018,57 @@
                          "B should not fail because of timeout limit")
 
 
+class TestPragma(ZuulTestCase):
+    tenant_config_file = 'config/pragma/main.yaml'
+
+    def test_no_pragma(self):
+        self.create_branch('org/project', 'stable')
+        with open(os.path.join(FIXTURE_DIR,
+                               'config/pragma/git/',
+                               'org_project/nopragma.yaml')) as f:
+            config = f.read()
+        file_dict = {'.zuul.yaml': config}
+        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))
+        self.waitUntilSettled()
+        self.fake_gerrit.addEvent(A.getChangeMergedEvent())
+        self.waitUntilSettled()
+
+        # This is an untrusted repo with 2 branches, so it should have
+        # an implied branch matcher for the job.
+        tenant = self.sched.abide.tenants.get('tenant-one')
+        jobs = tenant.layout.getJobs('test-job')
+        self.assertEqual(len(jobs), 1)
+        for job in tenant.layout.getJobs('test-job'):
+            self.assertIsNotNone(job.branch_matcher)
+
+    def test_pragma(self):
+        self.create_branch('org/project', 'stable')
+        with open(os.path.join(FIXTURE_DIR,
+                               'config/pragma/git/',
+                               'org_project/pragma.yaml')) as f:
+            config = f.read()
+        file_dict = {'.zuul.yaml': config}
+        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))
+        self.waitUntilSettled()
+        self.fake_gerrit.addEvent(A.getChangeMergedEvent())
+        self.waitUntilSettled()
+
+        # This is an untrusted repo with 2 branches, so it would
+        # normally have an implied branch matcher, but our pragma
+        # overrides it.
+        tenant = self.sched.abide.tenants.get('tenant-one')
+        jobs = tenant.layout.getJobs('test-job')
+        self.assertEqual(len(jobs), 1)
+        for job in tenant.layout.getJobs('test-job'):
+            self.assertIsNone(job.branch_matcher)
+
+
 class TestBaseJobs(ZuulTestCase):
     tenant_config_file = 'config/base-jobs/main.yaml'
 
@@ -1906,6 +2109,118 @@
         self.assertHistory([])
 
 
+class TestSecretInheritance(ZuulTestCase):
+    tenant_config_file = 'config/secret-inheritance/main.yaml'
+
+    def _getSecrets(self, job, pbtype):
+        secrets = []
+        build = self.getJobFromHistory(job)
+        for pb in build.parameters[pbtype]:
+            secrets.append(pb['secrets'])
+        return secrets
+
+    def _checkTrustedSecrets(self):
+        secret = {'longpassword': 'test-passwordtest-password',
+                  'password': 'test-password',
+                  'username': 'test-username'}
+        self.assertEqual(
+            self._getSecrets('trusted-secrets', 'playbooks'),
+            [{'trusted-secret': secret}])
+        self.assertEqual(
+            self._getSecrets('trusted-secrets', 'pre_playbooks'), [])
+        self.assertEqual(
+            self._getSecrets('trusted-secrets', 'post_playbooks'), [])
+
+        self.assertEqual(
+            self._getSecrets('trusted-secrets-trusted-child',
+                             'playbooks'), [{}])
+        self.assertEqual(
+            self._getSecrets('trusted-secrets-trusted-child',
+                             'pre_playbooks'), [])
+        self.assertEqual(
+            self._getSecrets('trusted-secrets-trusted-child',
+                             'post_playbooks'), [])
+
+        self.assertEqual(
+            self._getSecrets('trusted-secrets-untrusted-child',
+                             'playbooks'), [{}])
+        self.assertEqual(
+            self._getSecrets('trusted-secrets-untrusted-child',
+                             'pre_playbooks'), [])
+        self.assertEqual(
+            self._getSecrets('trusted-secrets-untrusted-child',
+                             'post_playbooks'), [])
+
+    def _checkUntrustedSecrets(self):
+        secret = {'longpassword': 'test-passwordtest-password',
+                  'password': 'test-password',
+                  'username': 'test-username'}
+        self.assertEqual(
+            self._getSecrets('untrusted-secrets', 'playbooks'),
+            [{'untrusted-secret': secret}])
+        self.assertEqual(
+            self._getSecrets('untrusted-secrets', 'pre_playbooks'), [])
+        self.assertEqual(
+            self._getSecrets('untrusted-secrets', 'post_playbooks'), [])
+
+        self.assertEqual(
+            self._getSecrets('untrusted-secrets-trusted-child',
+                             'playbooks'), [{}])
+        self.assertEqual(
+            self._getSecrets('untrusted-secrets-trusted-child',
+                             'pre_playbooks'), [])
+        self.assertEqual(
+            self._getSecrets('untrusted-secrets-trusted-child',
+                             'post_playbooks'), [])
+
+        self.assertEqual(
+            self._getSecrets('untrusted-secrets-untrusted-child',
+                             'playbooks'), [{}])
+        self.assertEqual(
+            self._getSecrets('untrusted-secrets-untrusted-child',
+                             'pre_playbooks'), [])
+        self.assertEqual(
+            self._getSecrets('untrusted-secrets-untrusted-child',
+                             'post_playbooks'), [])
+
+    def test_trusted_secret_inheritance_check(self):
+        A = self.fake_gerrit.addFakeChange('common-config', 'master', 'A')
+        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+        self.assertHistory([
+            dict(name='trusted-secrets', result='SUCCESS', changes='1,1'),
+            dict(name='trusted-secrets-trusted-child',
+                 result='SUCCESS', changes='1,1'),
+            dict(name='trusted-secrets-untrusted-child',
+                 result='SUCCESS', changes='1,1'),
+        ], ordered=False)
+
+        self._checkTrustedSecrets()
+
+    def test_untrusted_secret_inheritance_gate(self):
+        A = self.fake_gerrit.addFakeChange('common-config', 'master', 'A')
+        A.addApproval('Code-Review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('Approved', 1))
+        self.waitUntilSettled()
+        self.assertHistory([
+            dict(name='untrusted-secrets', result='SUCCESS', changes='1,1'),
+            dict(name='untrusted-secrets-trusted-child',
+                 result='SUCCESS', changes='1,1'),
+            dict(name='untrusted-secrets-untrusted-child',
+                 result='SUCCESS', changes='1,1'),
+        ], ordered=False)
+
+        self._checkUntrustedSecrets()
+
+    def test_untrusted_secret_inheritance_check(self):
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
+        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+        # This configuration tries to run untrusted secrets in an
+        # non-post-review pipeline and should therefore run no jobs.
+        self.assertHistory([])
+
+
 class TestSecretLeaks(AnsibleZuulTestCase):
     tenant_config_file = 'config/secret-leaks/main.yaml'
 
diff --git a/zuul/configloader.py b/zuul/configloader.py
index 2093c70..01a87fd 100644
--- a/zuul/configloader.py
+++ b/zuul/configloader.py
@@ -238,7 +238,7 @@
 class ZuulSafeLoader(yaml.SafeLoader):
     zuul_node_types = frozenset(('job', 'nodeset', 'secret', 'pipeline',
                                  'project', 'project-template',
-                                 'semaphore'))
+                                 'semaphore', 'pragma'))
 
     def __init__(self, stream, context):
         wrapped_stream = io.StringIO(stream)
@@ -313,6 +313,30 @@
                                                  private_key).decode('utf8')
 
 
+class PragmaParser(object):
+    pragma = {
+        'implied-branch-matchers': bool,
+        '_source_context': model.SourceContext,
+        '_start_mark': ZuulMark,
+    }
+
+    schema = vs.Schema(pragma)
+
+    def __init__(self):
+        self.log = logging.getLogger("zuul.PragmaParser")
+
+    def fromYaml(self, conf):
+        with configuration_exceptions('project-template', conf):
+            self.schema(conf)
+
+        bm = conf.get('implied-branch-matchers')
+        if bm is None:
+            return
+
+        source_context = conf['_source_context']
+        source_context.implied_branch_matchers = bm
+
+
 class NodeSetParser(object):
     @staticmethod
     def getSchema(anonymous=False):
@@ -453,29 +477,26 @@
     ]
 
     @staticmethod
-    def _getImpliedBranches(reference, job, project_pipeline):
-        # If the current job definition is not in the same branch as
-        # the reference definition of this job, and this is a project
-        # repo, add an implicit branch matcher for this branch
-        # (assuming there are no explicit branch matchers).  But only
-        # for top-level job definitions and variants.  Never for
-        # project-templates.  They, and in-project project-pipeline
-        # job variants, should more closely attach to their branch if
-        # they appear in a project-repo.  That's handled in the
-        # ProjectParser.
-        if (reference and
-            reference.source_context and
-            reference.source_context.branch != job.source_context.branch):
-            same_branch = False
-        else:
-            same_branch = True
-
-        if (job.source_context and
-            (not job.source_context.trusted) and
-            (not project_pipeline) and
-            (not same_branch)):
+    def _getImpliedBranches(tenant, job):
+        # If the user has set a pragma directive for this, use the
+        # value (if unset, the value is None).
+        if job.source_context.implied_branch_matchers is True:
             return [job.source_context.branch]
-        return None
+        elif job.source_context.implied_branch_matchers is False:
+            return None
+
+        # If this is a trusted project, don't create implied branch
+        # matchers.
+        if job.source_context.trusted:
+            return None
+
+        # If this project only has one branch, don't create implied
+        # branch matchers.  This way central job repos can work.
+        branches = tenant.getProjectBranches(job.source_context.project)
+        if len(branches) == 1:
+            return None
+
+        return [job.source_context.branch]
 
     @staticmethod
     def fromYaml(tenant, layout, conf, project_pipeline=False,
@@ -492,34 +513,22 @@
         # them (e.g., "job.run = ..." rather than
         # "job.run.append(...)").
 
-        reference = layout.jobs.get(name, [None])[0]
-
         job = model.Job(name)
         job.source_context = conf.get('_source_context')
         job.source_line = conf.get('_start_mark').line + 1
 
-        is_variant = layout.hasJob(name)
-        if not is_variant:
-            if 'parent' in conf:
-                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")
+        if 'parent' in conf:
+            if conf['parent'] is not None:
+                # Parent job is explicitly specified, so inherit from it.
+                job.parent = conf['parent']
             else:
-                parent = layout.getJob(tenant.default_base_job)
-                job.inheritFrom(parent)
-        else:
-            if 'parent' in conf:
-                # TODO(jeblair): warn the user that we're ignoring the
-                # parent setting on this variant job definition.
-                pass
+                # 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")
+                job.parent = job.BASE_JOB_MARKER
 
         # Secrets are part of the playbook context so we must establish
         # them earlier than playbooks.
@@ -598,12 +607,6 @@
             run = model.PlaybookContext(job.source_context, conf['run'],
                                         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, secrets)
-                job.implied_run = (run,) + job.implied_run
 
         for k in JobParser.simple_attributes:
             a = k.replace('-', '_')
@@ -639,14 +642,11 @@
                 job_project = model.JobProject(project_name,
                                                project_override_branch)
                 new_projects[project_name] = job_project
-            job.updateProjects(new_projects)
+            job.required_projects = new_projects
 
         tags = conf.get('tags')
         if tags:
-            # Tags are merged via a union rather than a
-            # destructive copy because they are intended to
-            # accumulate onto any previously applied tags.
-            job.tags = job.tags.union(set(tags))
+            job.tags = set(tags)
 
         job.dependencies = frozenset(as_list(conf.get('dependencies')))
 
@@ -654,7 +654,7 @@
         if variables:
             if 'zuul' in variables:
                 raise Exception("Variables named 'zuul' are not allowed.")
-            job.updateVariables(variables)
+            job.variables = variables
 
         allowed_projects = conf.get('allowed-projects', None)
         if allowed_projects:
@@ -666,18 +666,9 @@
                 allowed.append(project.name)
             job.allowed_projects = frozenset(allowed)
 
-        # If the current job definition is not in the same branch as
-        # the reference definition of this job, and this is a project
-        # repo, add an implicit branch matcher for this branch
-        # (assuming there are no explicit branch matchers).  But only
-        # for top-level job definitions and variants.
-        # Project-pipeline job variants should more closely attach to
-        # their branch if they appear in a project-repo.
-
         branches = None
-        if (project_pipeline or 'branches' not in conf):
-            branches = JobParser._getImpliedBranches(
-                reference, job, project_pipeline)
+        if ('branches' not in conf):
+            branches = JobParser._getImpliedBranches(tenant, job)
         if (not branches) and ('branches' in conf):
             branches = as_list(conf['branches'])
         if branches:
@@ -748,8 +739,8 @@
         if validate:
             with configuration_exceptions('project-template', conf):
                 self.schema(conf)
-        project_template = model.ProjectConfig(conf['name'])
         source_context = conf['_source_context']
+        project_template = model.ProjectConfig(conf['name'], source_context)
         start_mark = conf['_start_mark']
         for pipeline in self.layout.pipelines.values():
             conf_pipeline = conf.get(pipeline.name)
@@ -1144,13 +1135,14 @@
         # tpcs is TenantProjectConfigs
         config_tpcs, untrusted_tpcs = \
             TenantParser._loadTenantProjects(
-                project_key_dir, connections, conf)
+                tenant, project_key_dir, connections, conf)
         for tpc in config_tpcs:
             tenant.addConfigProject(tpc)
         for tpc in untrusted_tpcs:
             tenant.addUntrustedProject(tpc)
 
         for tpc in config_tpcs + untrusted_tpcs:
+            TenantParser._getProjectBranches(tenant, tpc)
             TenantParser._resolveShadowProjects(tenant, tpc)
 
         tenant.config_projects_config, tenant.untrusted_projects_config = \
@@ -1174,6 +1166,15 @@
         tpc.shadow_projects = frozenset(shadow_projects)
 
     @staticmethod
+    def _getProjectBranches(tenant, tpc):
+        branches = sorted(tpc.project.source.getProjectBranches(
+            tpc.project, tenant))
+        if 'master' in branches:
+            branches.remove('master')
+            branches = ['master'] + branches
+        tpc.branches = branches
+
+    @staticmethod
     def _loadProjectKeys(project_key_dir, connection_name, project):
         project.private_key_file = (
             os.path.join(project_key_dir, connection_name,
@@ -1223,7 +1224,7 @@
                 encryption.deserialize_rsa_keypair(f.read())
 
     @staticmethod
-    def _getProject(source, conf, current_include):
+    def _getProject(tenant, source, conf, current_include):
         if isinstance(conf, str):
             # Return a project object whether conf is a dict or a str
             project = source.getProject(conf)
@@ -1255,13 +1256,13 @@
         return tenant_project_config
 
     @staticmethod
-    def _getProjects(source, conf, current_include):
+    def _getProjects(tenant, source, conf, current_include):
         # Return a project object whether conf is a dict or a str
         projects = []
         if isinstance(conf, str):
             # A simple project name string
             projects.append(TenantParser._getProject(
-                source, conf, current_include))
+                tenant, source, conf, current_include))
         elif len(conf.keys()) > 1 and 'projects' in conf:
             # This is a project group
             if 'include' in conf:
@@ -1272,19 +1273,19 @@
                 exclude = set(as_list(conf['exclude']))
                 current_include = current_include - exclude
             for project in conf['projects']:
-                sub_projects = TenantParser._getProjects(source, project,
-                                                         current_include)
+                sub_projects = TenantParser._getProjects(
+                    tenant, source, project, current_include)
                 projects.extend(sub_projects)
         elif len(conf.keys()) == 1:
             # A project with overrides
             projects.append(TenantParser._getProject(
-                source, conf, current_include))
+                tenant, source, conf, current_include))
         else:
             raise Exception("Unable to parse project %s", conf)
         return projects
 
     @staticmethod
-    def _loadTenantProjects(project_key_dir, connections, conf_tenant):
+    def _loadTenantProjects(tenant, project_key_dir, connections, conf_tenant):
         config_projects = []
         untrusted_projects = []
 
@@ -1297,7 +1298,7 @@
             current_include = default_include
             for conf_repo in conf_source.get('config-projects', []):
                 # tpcs = TenantProjectConfigs
-                tpcs = TenantParser._getProjects(source, conf_repo,
+                tpcs = TenantParser._getProjects(tenant, source, conf_repo,
                                                  current_include)
                 for tpc in tpcs:
                     TenantParser._loadProjectKeys(
@@ -1306,7 +1307,7 @@
 
             current_include = frozenset(default_include - set(['pipeline']))
             for conf_repo in conf_source.get('untrusted-projects', []):
-                tpcs = TenantParser._getProjects(source, conf_repo,
+                tpcs = TenantParser._getProjects(tenant, source, conf_repo,
                                                  current_include)
                 for tpc in tpcs:
                     TenantParser._loadProjectKeys(
@@ -1374,11 +1375,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.
-            branches = sorted(project.source.getProjectBranches(
-                project, tenant))
-            if 'master' in branches:
-                branches.remove('master')
-                branches = ['master'] + branches
+            branches = tenant.getProjectBranches(project)
             for branch in branches:
                 new_project_unparsed_branch_config[project][branch] = \
                     model.UnparsedTenantConfig()
@@ -1484,6 +1481,12 @@
     @staticmethod
     def _parseLayoutItems(layout, tenant, data, scheduler, connections,
                           skip_pipelines=False, skip_semaphores=False):
+        # Handle pragma items first since they modify the source context
+        # used by other classes.
+        pragma_parser = PragmaParser()
+        for config_pragma in data.pragmas:
+            pragma_parser.fromYaml(config_pragma)
+
         if not skip_pipelines:
             for config_pipeline in data.pipelines:
                 classes = TenantParser._getLoadClasses(
@@ -1520,6 +1523,16 @@
                         "Skipped adding job %s which shadows an existing job" %
                         (job,))
 
+        # Now that all the jobs are loaded, verify their parents exist
+        for config_job in data.jobs:
+            classes = TenantParser._getLoadClasses(tenant, config_job)
+            if 'job' not in classes:
+                continue
+            with configuration_exceptions('job', config_job):
+                parent = config_job.get('parent')
+                if parent:
+                    layout.getJob(parent)
+
         if not skip_semaphores:
             for config_semaphore in data.semaphores:
                 classes = TenantParser._getLoadClasses(
@@ -1543,8 +1556,9 @@
             classes = TenantParser._getLoadClasses(tenant, config_template)
             if 'project-template' not in classes:
                 continue
-            layout.addProjectTemplate(project_template_parser.fromYaml(
-                config_template))
+            with configuration_exceptions('project-template', config_template):
+                layout.addProjectTemplate(project_template_parser.fromYaml(
+                    config_template))
 
         project_parser = ProjectParser(tenant, layout, project_template_parser)
         for config_projects in data.projects.values():
diff --git a/zuul/driver/gerrit/gerritconnection.py b/zuul/driver/gerrit/gerritconnection.py
index c3f9ee2..59051bb 100644
--- a/zuul/driver/gerrit/gerritconnection.py
+++ b/zuul/driver/gerrit/gerritconnection.py
@@ -299,6 +299,12 @@
 
         self.baseurl = self.connection_config.get('baseurl',
                                                   'https://%s' % self.server)
+        default_gitweb_url_template = '{baseurl}/gitweb?' \
+                                      'p={project.name}.git;' \
+                                      'a=commitdiff;h={sha}'
+        url_template = self.connection_config.get('gitweb_url_template',
+                                                  default_gitweb_url_template)
+        self.gitweb_url_template = url_template
 
         self._change_cache = {}
         self.projects = {}
@@ -338,7 +344,7 @@
             change.ref = event.ref
             change.oldrev = event.oldrev
             change.newrev = event.newrev
-            change.url = self._getGitwebUrl(project, sha=event.newrev)
+            change.url = self._getWebUrl(project, sha=event.newrev)
         elif event.ref and not event.ref.startswith('refs/'):
             # Pre 2.13 Gerrit ref-updated events don't have branch prefixes.
             project = self.source.getProject(event.project_name)
@@ -347,7 +353,7 @@
             change.ref = 'refs/heads/' + event.ref
             change.oldrev = event.oldrev
             change.newrev = event.newrev
-            change.url = self._getGitwebUrl(project, sha=event.newrev)
+            change.url = self._getWebUrl(project, sha=event.newrev)
         elif event.ref and event.ref.startswith('refs/heads/'):
             # From the timer trigger or Post 2.13 Gerrit
             project = self.source.getProject(event.project_name)
@@ -356,7 +362,7 @@
             change.branch = event.ref[len('refs/heads/'):]
             change.oldrev = event.oldrev
             change.newrev = event.newrev
-            change.url = self._getGitwebUrl(project, sha=event.newrev)
+            change.url = self._getWebUrl(project, sha=event.newrev)
         elif event.ref:
             # catch-all ref (ie, not a branch or head)
             project = self.source.getProject(event.project_name)
@@ -364,7 +370,7 @@
             change.ref = event.ref
             change.oldrev = event.oldrev
             change.newrev = event.newrev
-            change.url = self._getGitwebUrl(project, sha=event.newrev)
+            change.url = self._getWebUrl(project, sha=event.newrev)
         else:
             self.log.warning("Unable to get change for %s" % (event,))
             change = None
@@ -848,11 +854,11 @@
                                      project.name)
         return url
 
-    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
+    def _getWebUrl(self, project: Project, sha: str=None) -> str:
+        return self.gitweb_url_template.format(
+            baseurl=self.baseurl,
+            project=project.getSafeAttributes(),
+            sha=sha)
 
     def onLoad(self):
         self.log.debug("Starting Gerrit Connection/Watchers")
diff --git a/zuul/driver/git/gitconnection.py b/zuul/driver/git/gitconnection.py
index 0624088..f93824d 100644
--- a/zuul/driver/git/gitconnection.py
+++ b/zuul/driver/git/gitconnection.py
@@ -51,7 +51,7 @@
     def getProjectBranches(self, project, tenant):
         # TODO(jeblair): implement; this will need to handle local or
         # remote git urls.
-        raise NotImplemented()
+        return ['master']
 
     def getGitUrl(self, project):
         url = '%s/%s' % (self.baseurl, project.name)
diff --git a/zuul/driver/github/githubconnection.py b/zuul/driver/github/githubconnection.py
index 55d3031..1186aca 100644
--- a/zuul/driver/github/githubconnection.py
+++ b/zuul/driver/github/githubconnection.py
@@ -135,6 +135,7 @@
     """Move events from GitHub into the scheduler"""
 
     log = logging.getLogger("zuul.GithubEventConnector")
+    delay = 3.0
 
     def __init__(self, connection):
         super(GithubEventConnector, self).__init__()
@@ -147,9 +148,17 @@
         self.connection.addEvent(None)
 
     def _handleEvent(self):
-        json_body, event_type = self.connection.getEvent()
+        ts, json_body, event_type = self.connection.getEvent()
         if self._stopped:
             return
+        # Github can produce inconsistent data immediately after an
+        # event, So ensure that we do not deliver the event to Zuul
+        # until at least a certain amount of time has passed.  Note
+        # that if we receive several events in succession, we will
+        # only need to delay for the first event.  In essence, Zuul
+        # should always be a constant number of seconds behind Github.
+        now = time.time()
+        time.sleep(max((ts + self.delay) - now, 0.0))
 
         # If there's any installation mapping information in the body then
         # update the project mapping before any requests are made.
@@ -543,7 +552,7 @@
         return token
 
     def addEvent(self, data, event=None):
-        return self.event_queue.put((data, event))
+        return self.event_queue.put((time.time(), data, event))
 
     def getEvent(self):
         return self.event_queue.get()
diff --git a/zuul/driver/sql/sqlreporter.py b/zuul/driver/sql/sqlreporter.py
index 7785c48..f9537ac 100644
--- a/zuul/driver/sql/sqlreporter.py
+++ b/zuul/driver/sql/sqlreporter.py
@@ -33,8 +33,8 @@
             return
 
         with self.connection.engine.begin() as conn:
-            change = getattr(item.change, 'number', '')
-            patchset = getattr(item.change, 'patchset', '')
+            change = getattr(item.change, 'number', None)
+            patchset = getattr(item.change, 'patchset', None)
             ref = getattr(item.change, 'ref', '')
             oldrev = getattr(item.change, 'oldrev', '')
             newrev = getattr(item.change, 'newrev', '')
diff --git a/zuul/executor/server.py b/zuul/executor/server.py
index e7a6dbc..d72d1ee 100644
--- a/zuul/executor/server.py
+++ b/zuul/executor/server.py
@@ -353,6 +353,13 @@
         self.pre_playbooks = []
         self.post_playbooks = []
         self.job_output_file = os.path.join(self.log_root, 'job-output.txt')
+        # We need to create the job-output.txt upfront in order to close the
+        # gap between url reporting and ansible creating the file. Otherwise
+        # there is a period of time where the user can click on the live log
+        # link on the status page but the log streaming fails because the file
+        # is not there yet.
+        with open(self.job_output_file, 'w'):
+            pass
         self.trusted_projects = []
         self.trusted_project_index = {}
 
@@ -928,43 +935,38 @@
                     "Ansible plugin dir %s found adjacent to playbook %s in "
                     "non-trusted repo." % (entry, path))
 
-    def findPlaybook(self, path, required=False, trusted=False):
-        for ext in ['.yaml', '.yml']:
+    def findPlaybook(self, path, trusted=False):
+        for ext in ['', '.yaml', '.yml']:
             fn = path + ext
             if os.path.exists(fn):
                 if not trusted:
                     playbook_dir = os.path.dirname(os.path.abspath(fn))
                     self._blockPluginDirs(playbook_dir)
                 return fn
-        if required:
-            raise ExecutorError("Unable to find playbook %s" % path)
-        return None
+        raise ExecutorError("Unable to find playbook %s" % path)
 
     def preparePlaybooks(self, args):
         self.writeAnsibleConfig(self.jobdir.setup_playbook)
 
         for playbook in args['pre_playbooks']:
             jobdir_playbook = self.jobdir.addPrePlaybook()
-            self.preparePlaybook(jobdir_playbook, playbook,
-                                 args, required=True)
+            self.preparePlaybook(jobdir_playbook, playbook, args)
 
         for playbook in args['playbooks']:
             jobdir_playbook = self.jobdir.addPlaybook()
-            self.preparePlaybook(jobdir_playbook, playbook,
-                                 args, required=False)
+            self.preparePlaybook(jobdir_playbook, playbook, args)
             if jobdir_playbook.path is not None:
                 self.jobdir.playbook = jobdir_playbook
                 break
 
         if self.jobdir.playbook is None:
-            raise ExecutorError("No valid playbook found")
+            raise ExecutorError("No playbook specified")
 
         for playbook in args['post_playbooks']:
             jobdir_playbook = self.jobdir.addPostPlaybook()
-            self.preparePlaybook(jobdir_playbook, playbook,
-                                 args, required=True)
+            self.preparePlaybook(jobdir_playbook, playbook, args)
 
-    def preparePlaybook(self, jobdir_playbook, playbook, args, required):
+    def preparePlaybook(self, jobdir_playbook, playbook, args):
         self.log.debug("Prepare playbook repo for %s" %
                        (playbook['project'],))
         # Check out the playbook repo if needed and set the path to
@@ -999,7 +1001,6 @@
 
         jobdir_playbook.path = self.findPlaybook(
             path,
-            required=required,
             trusted=playbook['trusted'])
 
         # If this playbook doesn't exist, don't bother preparing
diff --git a/zuul/lib/log_streamer.py b/zuul/lib/log_streamer.py
index a7b6c36..ce8537f 100644
--- a/zuul/lib/log_streamer.py
+++ b/zuul/lib/log_streamer.py
@@ -49,6 +49,11 @@
     MAX_REQUEST_LEN = 1024
     REQUEST_TIMEOUT = 10
 
+    # NOTE(Shrews): We only use this to log exceptions since a new process
+    # is used per-request (and having multiple processes write to the same
+    # log file constantly is bad).
+    log = logging.getLogger("zuul.log_streamer.RequestHandler")
+
     def get_command(self):
         poll = select.poll()
         bitmask = (select.POLLIN | select.POLLERR |
@@ -78,7 +83,14 @@
                 pass
 
     def handle(self):
-        build_uuid = self.get_command()
+        try:
+            build_uuid = self.get_command()
+        except Exception:
+            self.log.exception("Failure during get_command:")
+            msg = 'Internal streaming error'
+            self.request.sendall(msg.encode("utf-8"))
+            return
+
         build_uuid = build_uuid.rstrip()
 
         # validate build ID
@@ -100,7 +112,13 @@
             self.request.sendall(msg.encode("utf-8"))
             return
 
-        self.stream_log(log_file)
+        try:
+            self.stream_log(log_file)
+        except Exception:
+            self.log.exception("Streaming failure for build UUID %s:",
+                               build_uuid)
+            msg = 'Internal streaming error'
+            self.request.sendall(msg.encode("utf-8"))
 
     def stream_log(self, log_file):
         log = None
@@ -224,12 +242,20 @@
 
         # We start the actual serving within a thread so we can return to
         # the owner.
-        self.thd = threading.Thread(target=self.server.serve_forever)
+        self.thd = threading.Thread(target=self._run)
         self.thd.daemon = True
         self.thd.start()
 
+    def _run(self):
+        try:
+            self.server.serve_forever()
+        except Exception:
+            self.log.exception("Abnormal termination:")
+            raise
+
     def stop(self):
         if self.thd.isAlive():
             self.server.shutdown()
             self.server.server_close()
+            self.thd.join()
             self.log.debug("LogStreamer stopped")
diff --git a/zuul/model.py b/zuul/model.py
index ac2a75e..cf63f64 100644
--- a/zuul/model.py
+++ b/zuul/model.py
@@ -79,6 +79,12 @@
                    STATE_DELETING])
 
 
+class NoMatchingParentError(Exception):
+    """A job referenced a parent, but that parent had no variants which
+    matched the current change."""
+    pass
+
+
 class Attributes(object):
     """A class to hold attributes for string formatting."""
 
@@ -352,6 +358,9 @@
     def __repr__(self):
         return '<Project %s>' % (self.name)
 
+    def getSafeAttributes(self):
+        return Attributes(name=self.name)
+
 
 class Node(object):
     """A single node for use by a job.
@@ -629,6 +638,7 @@
         self.branch = branch
         self.path = path
         self.trusted = trusted
+        self.implied_branch_matchers = None
 
     def __str__(self):
         return '%s/%s@%s' % (self.project, self.path, self.branch)
@@ -697,6 +707,13 @@
                 self.roles == other.roles and
                 self.secrets == other.secrets)
 
+    def copy(self):
+        r = PlaybookContext(self.source_context,
+                            self.path,
+                            self.roles,
+                            self.secrets)
+        return r
+
     def toDict(self):
         # Render to a dict to use in passing json to the executor
         secrets = {}
@@ -785,6 +802,8 @@
     (e.g., "job.run = ..." rather than "job.run.append(...)").
     """
 
+    BASE_JOB_MARKER = object()
+
     def __init__(self, name):
         # These attributes may override even the final form of a job
         # in the context of a project-pipeline.  They can not affect
@@ -811,6 +830,7 @@
         # declared "final", these may not be overriden in a
         # project-pipeline.
         self.execution_attributes = dict(
+            parent=None,
             timeout=None,
             variables={},
             nodeset=NodeSet(),
@@ -818,7 +838,6 @@
             pre_run=(),
             post_run=(),
             run=(),
-            implied_run=(),
             semaphore=None,
             attempts=3,
             final=False,
@@ -888,11 +907,11 @@
     def getSafeAttributes(self):
         return Attributes(name=self.name)
 
-    def setRun(self):
-        msg = 'self %s' % (repr(self),)
-        self.inheritance_path = self.inheritance_path + (msg,)
-        if not self.run:
-            self.run = self.implied_run
+    def isBase(self):
+        return self.parent is self.BASE_JOB_MARKER
+
+    def setBase(self):
+        self.inheritance_path = self.inheritance_path + (repr(self),)
 
     def addRoles(self, roles):
         newroles = []
@@ -964,24 +983,6 @@
             else:
                 a[k] = bv
 
-    def inheritFrom(self, other):
-        """Copy the inheritable attributes which have been set on the other
-        job to this job."""
-        if not isinstance(other, Job):
-            raise Exception("Job unable to inherit from %s" % (other,))
-
-        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):
-                setattr(self, k, getattr(other, k))
-
-        msg = 'inherit from %s' % (repr(other),)
-        self.inheritance_path = other.inheritance_path + (msg,)
-
     def copy(self):
         job = Job(self.name)
         for k in self.attributes:
@@ -989,10 +990,22 @@
                 setattr(job, k, copy.deepcopy(self._get(k)))
         return job
 
+    def freezePlaybooks(self, pblist):
+        """Take a list of playbooks, and return a copy of it updated with this
+        job's roles.
+
+        """
+
+        ret = []
+        for old_pb in pblist:
+            pb = old_pb.copy()
+            pb.roles = self.roles
+            ret.append(pb)
+        return tuple(ret)
+
     def applyVariant(self, other):
         """Copy the attributes which have been set on the other job to this
         job."""
-
         if not isinstance(other, Job):
             raise Exception("Job unable to inherit from %s" % (other,))
 
@@ -1004,8 +1017,9 @@
                                     "%s=%s with variant %s" % (
                                         repr(self), k, other._get(k),
                                         repr(other)))
-                if k not in set(['pre_run', 'post_run', 'roles', 'variables',
-                                 'required_projects']):
+                if k not in set(['pre_run', 'run', 'post_run', 'roles',
+                                 'variables', 'required_projects']):
+                    # TODO(jeblair): determine if deepcopy is required
                     setattr(self, k, copy.deepcopy(other._get(k)))
 
         # Don't set final above so that we don't trip an error halfway
@@ -1013,12 +1027,19 @@
         if other.final != self.attributes['final']:
             self.final = other.final
 
-        if other._get('pre_run') is not None:
-            self.pre_run = self.pre_run + other.pre_run
-        if other._get('post_run') is not None:
-            self.post_run = other.post_run + self.post_run
+        # We must update roles before any playbook contexts
         if other._get('roles') is not None:
             self.addRoles(other.roles)
+
+        if other._get('run') is not None:
+            other_run = self.freezePlaybooks(other.run)
+            self.run = other_run
+        if other._get('pre_run') is not None:
+            other_pre_run = self.freezePlaybooks(other.pre_run)
+            self.pre_run = self.pre_run + other_pre_run
+        if other._get('post_run') is not None:
+            other_post_run = self.freezePlaybooks(other.post_run)
+            self.post_run = other_post_run + self.post_run
         if other._get('variables') is not None:
             self.updateVariables(other.variables)
         if other._get('required_projects') is not None:
@@ -1032,8 +1053,7 @@
         if other._get('tags') is not None:
             self.tags = self.tags.union(other.tags)
 
-        msg = 'apply variant %s' % (repr(other),)
-        self.inheritance_path = self.inheritance_path + (msg,)
+        self.inheritance_path = self.inheritance_path + (repr(other),)
 
     def changeMatches(self, change):
         if self.branch_matcher and not self.branch_matcher.matches(change):
@@ -1077,7 +1097,8 @@
                 if not job.branch_matcher and implied_branch:
                     job = job.copy()
                     job.setBranchMatcher([implied_branch])
-                joblist.append(job)
+                if job not in joblist:
+                    joblist.append(job)
 
 
 class JobGraph(object):
@@ -2184,7 +2205,7 @@
         self.project = project
         self.load_classes = set()
         self.shadow_projects = set()
-
+        self.branches = []
         # The tenant's default setting of exclude_unprotected_branches will
         # be overridden by this one if not None.
         self.exclude_unprotected_branches = None
@@ -2192,8 +2213,11 @@
 
 class ProjectConfig(object):
     # Represents a project cofiguration
-    def __init__(self, name):
+    def __init__(self, name, source_context=None):
         self.name = name
+        # If this is a template, it will have a source_context, but
+        # not if it is a project definition.
+        self.source_context = source_context
         self.merge_mode = None
         # The default branch for the project (usually master).
         self.default_branch = None
@@ -2312,6 +2336,7 @@
     """A collection of yaml lists that has not yet been parsed into objects."""
 
     def __init__(self):
+        self.pragmas = []
         self.pipelines = []
         self.jobs = []
         self.project_templates = []
@@ -2322,6 +2347,7 @@
 
     def copy(self):
         r = UnparsedTenantConfig()
+        r.pragmas = copy.deepcopy(self.pragmas)
         r.pipelines = copy.deepcopy(self.pipelines)
         r.jobs = copy.deepcopy(self.jobs)
         r.project_templates = copy.deepcopy(self.project_templates)
@@ -2333,6 +2359,7 @@
 
     def extend(self, conf):
         if isinstance(conf, UnparsedTenantConfig):
+            self.pragmas.extend(conf.pragmas)
             self.pipelines.extend(conf.pipelines)
             self.jobs.extend(conf.jobs)
             self.project_templates.extend(conf.project_templates)
@@ -2367,6 +2394,8 @@
                 self.secrets.append(value)
             elif key == 'semaphore':
                 self.semaphores.append(value)
+            elif key == 'pragma':
+                self.pragmas.append(value)
             else:
                 raise ConfigItemUnknownError()
 
@@ -2386,7 +2415,10 @@
         # elements are aspects of that job with different matchers
         # that override some attribute of the job.  These aspects all
         # inherit from the reference definition.
-        self.jobs = {'noop': [Job('noop')]}
+        noop = Job('noop')
+        noop.parent = noop.BASE_JOB_MARKER
+        noop.run = 'noop.yaml'
+        self.jobs = {'noop': [noop]}
         self.nodesets = {}
         self.secrets = {}
         self.semaphores = {}
@@ -2454,37 +2486,73 @@
         self.pipelines[pipeline.name] = pipeline
 
     def addProjectTemplate(self, project_template):
-        if project_template.name in self.project_templates:
-            # TODO(jeblair): issue a warning to the logs on loading
-            # the config, and an error when this hits in a proposed
-            # change.
-            return
-        self.project_templates[project_template.name] = project_template
+        template = self.project_templates.get(project_template.name)
+        if template:
+            if (project_template.source_context.project !=
+                template.source_context.project):
+                raise Exception("Project template %s is already defined" %
+                                (project_template.name,))
+            for pipeline in project_template.pipelines:
+                template.pipelines[pipeline].job_list.\
+                    inheritFrom(project_template.pipelines[pipeline].job_list,
+                                None)
+        else:
+            self.project_templates[project_template.name] = project_template
 
     def addProjectConfig(self, project_config):
         self.project_configs[project_config.name] = project_config
 
+    def collectJobs(self, jobname, change, path=None, jobs=None, stack=None):
+        if stack is None:
+            stack = []
+        if jobs is None:
+            jobs = []
+        if path is None:
+            path = []
+        path.append(jobname)
+        matched = False
+        for variant in self.getJobs(jobname):
+            if not variant.changeMatches(change):
+                continue
+            if not variant.isBase():
+                parent = variant.parent
+                if not jobs and parent is None:
+                    parent = self.tenant.default_base_job
+            else:
+                parent = None
+            if parent and parent not in path:
+                if parent in stack:
+                    raise Exception("Dependency cycle in jobs: %s" % stack)
+                self.collectJobs(parent, change, path, jobs, stack + [jobname])
+            matched = True
+            jobs.append(variant)
+        if not matched:
+            raise NoMatchingParentError()
+        return jobs
+
     def _createJobGraph(self, item, job_list, job_graph):
         change = item.change
         pipeline = item.pipeline
         for jobname in job_list.jobs:
             # This is the final job we are constructing
             frozen_job = None
-            # Whether the change matches any globally defined variant
-            matched = False
-            for variant in self.getJobs(jobname):
-                if variant.changeMatches(change):
-                    if frozen_job is None:
-                        frozen_job = variant.copy()
-                        frozen_job.setRun()
-                    else:
-                        frozen_job.applyVariant(variant)
-                    matched = True
-            if not matched:
+            try:
+                variants = self.collectJobs(jobname, change)
+            except NoMatchingParentError:
+                variants = None
+            if not variants:
                 # A change must match at least one defined job variant
                 # (that is to say that it must match more than just
                 # the job that is defined in the tree).
                 continue
+            for variant in variants:
+                if frozen_job is None:
+                    frozen_job = variant.copy()
+                    frozen_job.setBase()
+                else:
+                    frozen_job.applyVariant(variant)
+                    frozen_job.name = variant.name
+            frozen_job.name = jobname
             # Whether the change matches any of the project pipeline
             # variants
             matched = False
@@ -2504,6 +2572,9 @@
                 raise Exception("Pre-review pipeline %s does not allow "
                                 "post-review job %s" % (
                                     pipeline.name, frozen_job.name))
+            if not frozen_job.run:
+                raise Exception("Job %s does not specify a run playbook" % (
+                    frozen_job.name,))
             job_graph.addJob(frozen_job)
 
     def createJobGraph(self, item):
@@ -2706,6 +2777,18 @@
         raise Exception("Project %s is neither trusted nor untrusted" %
                         (project,))
 
+    def getProjectBranches(self, project):
+        """Return a project's branches (filtered by this tenant config)
+
+        :arg Project project: The project object.
+
+        :returns: A list of branch names.
+        :rtype: [str]
+
+        """
+        tpc = self.project_configs[project.canonical_name]
+        return tpc.branches
+
     def addConfigProject(self, tpc):
         self.config_projects.append(tpc.project)
         self._addProject(tpc)