Merge "Log items in loops better" into feature/zuulv3
diff --git a/doc/source/user/jobs.rst b/doc/source/user/jobs.rst
index c2c376e..aad43d7 100644
--- a/doc/source/user/jobs.rst
+++ b/doc/source/user/jobs.rst
@@ -107,8 +107,178 @@
 ~~~~~~~~~~~~~~
 
 Zuul supplies not only the variables specified by the job definition
-to Ansible, but also some variables from the executor itself.  They
-are:
+to Ansible, but also some variables from the Zuul itself.
+
+When a pipeline is triggered by an action, it enqueues items which may
+vary based on the pipeline's configuration.  For example, when a new
+change is created, that change may be enqueued into the pipeline,
+while a tag may be enqueued into the pipeline when it is pushed.
+
+Information about these items is available to jobs.  All of the items
+enqueued in a pipeline are git references, and therefore share some
+attributes in common.  But other attributes may vary based on the type
+of item.
+
+All items provide the following information as Ansible variables:
+
+**zuul.build**
+  The UUID of the build.  A build is a single execution of a job.
+  When an item is enqueued into a pipeline, this usually results in
+  one build of each job configured for that item's project.  However,
+  items may be re-enqueued in which case another build may run.  In
+  dependent pipelines, the same job may run multiple times for the
+  same item as circumstances change ahead in the queue.  Each time a
+  job is run, for whatever reason, it is acompanied with a new
+  unique id.
+
+**zuul.buildset**
+  The build set UUID.  When Zuul runs jobs for an item, the collection
+  of those jobs is known as a buildset.  If the configuration of items
+  ahead in a dependent pipeline changes, Zuul creates a new buildset
+  and restarts all of the jobs.
+
+**zuul.ref**
+  The git ref of the item.  This will be the full path (e.g.,
+  'refs/heads/master' or 'refs/changes/...').
+
+**zuul.pipeline**
+  The name of the pipeline in which the job is being run.
+
+**zuul.job**
+  The name of the job being run.
+
+**zuul.project**
+  The item's project.  This is a data structure with the following
+  fields:
+
+**zuul.project.name**
+  The name of the project, excluding hostname.  E.g., `org/project`.
+
+**zuul.project.canonical_hostname**
+  The canonical hostname where the project lives.  E.g.,
+  `git.example.com`.
+
+**zuul.project.canonical_name**
+  The full canonical name of the project including hostname.  E.g.,
+  `git.example.com/org/project`.
+
+**zuul.tenant**
+  The name of the current Zuul tenant.
+
+**zuul.jobtags**
+  A list of tags associated with the job.  Not to be confused with git
+  tags, these are simply free-form text fields that can be used by the
+  job for reporting or classification purposes.
+
+**zuul.items**
+
+  A list of dictionaries, each representing an item being tested with
+  this change with the format:
+
+  **project.name**
+    The name of the project, excluding hostname.  E.g., `org/project`.
+  
+  **project.canonical_hostname**
+    The canonical hostname where the project lives.  E.g.,
+    `git.example.com`.
+  
+  **project.canonical_name**
+    The full canonical name of the project including hostname.  E.g.,
+    `git.example.com/org/project`.
+  
+  **branch**
+    The target branch of the change (without the `refs/heads/` prefix).
+  
+  **change**
+    The identifier for the change.
+  
+  **patchset**
+    The patchset identifier for the change.  If a change is revised,
+    this will have a different value.
+
+Change Items
+++++++++++++
+
+A change to the repository.  Most often, this will be a git reference
+which has not yet been merged into the repository (e.g., a gerrit
+change or a GitHub pull request).  The following additional variables
+are available:
+
+**zuul.branch**
+  The target branch of the change (without the `refs/heads/` prefix).
+
+**zuul.change**
+  The identifier for the change.
+
+**zuul.patchset**
+  The patchset identifier for the change.  If a change is revised,
+  this will have a different value.
+
+Branch Items
+++++++++++++
+
+This represents a branch tip.  This item may have been enqueued
+because the branch was updated (via a change having merged, or a
+direct push).  Or it may have been enqueued by a timer for the purpose
+of verifying the current condition of the branch.  The following
+additional variables are available:
+
+**zuul.branch**
+  The name of the item's branch (without the `refs/heads/` prefix).
+
+**zuul.oldrev**
+  If the item was enqueued as the result of a change merging or being
+  pushed to the branch, the git sha of the old revision will be
+  included here.  Otherwise, this value will not be present.
+
+**zuul.newrev**
+  If the item was enqueued as the result of a change merging or being
+  pushed to the branch, the git sha of the new revision will be
+  included here.  Otherwise, this value will not be present.
+
+Tag Items
++++++++++
+
+This represents a git tag.  The item may have been enqueued because a
+tag was created or deleted.  The following additional variables are
+available:
+
+**zuul.tag**
+  The name of the item's tag (without the `refs/tags/` prefix).
+
+**zuul.oldrev**
+  If the item was enqueued as the result of a tag being created or
+  deleted the git sha of the old revision will be included here.
+  Otherwise, this value will not be present.
+
+**zuul.newrev**
+  If the item was enqueued as the result of a tag being created or
+  deleted the git sha of the new revision will be included here.
+  Otherwise, this value will not be present.
+
+Ref Items
++++++++++
+
+This represents a git reference that is neither a change, branch, or
+tag.  Note that all items include a `ref` attribute which may be used
+to identify the ref.  The following additional variables are
+available:
+
+**zuul.oldrev**
+  If the item was enqueued as the result of a ref being created,
+  deleted, or changed the git sha of the old revision will be included
+  here.  Otherwise, this value will not be present.
+
+**zuul.newrev**
+  If the item was enqueued as the result of a ref being created,
+  deleted, or changed the git sha of the new revision will be included
+  here.  Otherwise, this value will not be present.
+
+Working Directory
++++++++++++++++++
+
+Additionally, some information about the working directory and the
+executor running the job is available:
 
 **zuul.executor.hostname**
   The hostname of the executor.
diff --git a/etc/status/public_html/jquery.zuul.js b/etc/status/public_html/jquery.zuul.js
index aec7a46..1937cd5 100644
--- a/etc/status/public_html/jquery.zuul.js
+++ b/etc/status/public_html/jquery.zuul.js
@@ -96,7 +96,15 @@
             job: function(job) {
                 var $job_line = $('<span />');
 
-                if (job.url !== null) {
+                if (job.result !== null) {
+                    $job_line.append(
+                        $('<a />')
+                            .addClass('zuul-job-name')
+                            .attr('href', job.report_url)
+                            .text(job.name)
+                    );
+                }
+                else if (job.url !== null) {
                     $job_line.append(
                         $('<a />')
                             .addClass('zuul-job-name')
diff --git a/tests/base.py b/tests/base.py
index 2568188..2c478ad 100755
--- a/tests/base.py
+++ b/tests/base.py
@@ -1068,8 +1068,10 @@
         self.__dict__.update(kw)
 
     def __repr__(self):
-        return ("<Completed build, result: %s name: %s uuid: %s changes: %s>" %
-                (self.result, self.name, self.uuid, self.changes))
+        return ("<Completed build, result: %s name: %s uuid: %s "
+                "changes: %s ref: %s>" %
+                (self.result, self.name, self.uuid,
+                 self.changes, self.ref))
 
 
 class FakeStatsd(threading.Thread):
@@ -1344,6 +1346,7 @@
         self.executor_server.build_history.append(
             BuildHistory(name=build.name, result=result, changes=build.changes,
                          node=build.node, uuid=build.unique,
+                         ref=build.parameters['zuul']['ref'],
                          parameters=build.parameters, jobdir=build.jobdir,
                          pipeline=build.parameters['ZUUL_PIPELINE'])
         )
diff --git a/tests/fixtures/config/ansible/git/bare-role/tasks/main.yaml b/tests/fixtures/config/ansible/git/bare-role/tasks/main.yaml
index 75943b1..cd8917d 100644
--- a/tests/fixtures/config/ansible/git/bare-role/tasks/main.yaml
+++ b/tests/fixtures/config/ansible/git/bare-role/tasks/main.yaml
@@ -1,3 +1,3 @@
 - file:
-    path: "{{zuul._test.test_root}}/{{zuul.uuid}}.bare-role.flag"
+    path: "{{zuul._test.test_root}}/{{zuul.build}}.bare-role.flag"
     state: touch
diff --git a/tests/fixtures/config/ansible/git/common-config/playbooks/post.yaml b/tests/fixtures/config/ansible/git/common-config/playbooks/post.yaml
index 2e512b1..7fd8a2b 100644
--- a/tests/fixtures/config/ansible/git/common-config/playbooks/post.yaml
+++ b/tests/fixtures/config/ansible/git/common-config/playbooks/post.yaml
@@ -1,5 +1,5 @@
 - hosts: all
   tasks:
     - file:
-        path: "{{zuul._test.test_root}}/{{zuul.uuid}}.post.flag"
+        path: "{{zuul._test.test_root}}/{{zuul.build}}.post.flag"
         state: touch
diff --git a/tests/fixtures/config/ansible/git/common-config/playbooks/pre.yaml b/tests/fixtures/config/ansible/git/common-config/playbooks/pre.yaml
index f4222ff..268cd65 100644
--- a/tests/fixtures/config/ansible/git/common-config/playbooks/pre.yaml
+++ b/tests/fixtures/config/ansible/git/common-config/playbooks/pre.yaml
@@ -1,5 +1,5 @@
 - hosts: all
   tasks:
     - file:
-        path: "{{zuul._test.test_root}}/{{zuul.uuid}}.pre.flag"
+        path: "{{zuul._test.test_root}}/{{zuul.build}}.pre.flag"
         state: touch
diff --git a/tests/fixtures/config/ansible/git/common-config/playbooks/python27.yaml b/tests/fixtures/config/ansible/git/common-config/playbooks/python27.yaml
index 3371a20..6669f23 100644
--- a/tests/fixtures/config/ansible/git/common-config/playbooks/python27.yaml
+++ b/tests/fixtures/config/ansible/git/common-config/playbooks/python27.yaml
@@ -4,10 +4,10 @@
         path: "{{flagpath}}"
         state: touch
     - copy:
-        src: "{{zuul._test.test_root}}/{{zuul.uuid}}.flag"
-        dest: "{{zuul._test.test_root}}/{{zuul.uuid}}.copied"
+        src: "{{zuul._test.test_root}}/{{zuul.build}}.flag"
+        dest: "{{zuul._test.test_root}}/{{zuul.build}}.copied"
     - copy:
         content: "{{test_secret.username}} {{test_secret.password}}"
-        dest: "{{zuul._test.test_root}}/{{zuul.uuid}}.secrets"
+        dest: "{{zuul._test.test_root}}/{{zuul.build}}.secrets"
   roles:
     - bare-role
diff --git a/tests/fixtures/config/ansible/git/common-config/zuul.yaml b/tests/fixtures/config/ansible/git/common-config/zuul.yaml
index aa57d08..1a1b22f 100644
--- a/tests/fixtures/config/ansible/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/ansible/git/common-config/zuul.yaml
@@ -59,7 +59,7 @@
     pre-run: playbooks/pre
     post-run: playbooks/post
     vars:
-      flagpath: '{{zuul._test.test_root}}/{{zuul.uuid}}.flag'
+      flagpath: '{{zuul._test.test_root}}/{{zuul.build}}.flag'
     roles:
       - zuul: bare-role
     auth:
diff --git a/tests/fixtures/config/ansible/git/org_project/playbooks/faillocal.yaml b/tests/fixtures/config/ansible/git/org_project/playbooks/faillocal.yaml
index 6689e18..5b0c18d 100644
--- a/tests/fixtures/config/ansible/git/org_project/playbooks/faillocal.yaml
+++ b/tests/fixtures/config/ansible/git/org_project/playbooks/faillocal.yaml
@@ -1,5 +1,5 @@
 - hosts: all
   tasks:
     - copy:
-        src: "{{zuul._test.test_root}}/{{zuul.uuid}}.flag"
-        dest: "{{zuul._test.test_root}}/{{zuul.uuid}}.failed"
+        src: "{{zuul._test.test_root}}/{{zuul.build}}.flag"
+        dest: "{{zuul._test.test_root}}/{{zuul.build}}.failed"
diff --git a/tests/fixtures/config/pre-playbook/git/common-config/playbooks/post.yaml b/tests/fixtures/config/pre-playbook/git/common-config/playbooks/post.yaml
index 2e512b1..7fd8a2b 100644
--- a/tests/fixtures/config/pre-playbook/git/common-config/playbooks/post.yaml
+++ b/tests/fixtures/config/pre-playbook/git/common-config/playbooks/post.yaml
@@ -1,5 +1,5 @@
 - hosts: all
   tasks:
     - file:
-        path: "{{zuul._test.test_root}}/{{zuul.uuid}}.post.flag"
+        path: "{{zuul._test.test_root}}/{{zuul.build}}.post.flag"
         state: touch
diff --git a/tests/fixtures/config/pre-playbook/git/common-config/playbooks/pre.yaml b/tests/fixtures/config/pre-playbook/git/common-config/playbooks/pre.yaml
index 13c2208..4875ad4 100644
--- a/tests/fixtures/config/pre-playbook/git/common-config/playbooks/pre.yaml
+++ b/tests/fixtures/config/pre-playbook/git/common-config/playbooks/pre.yaml
@@ -1,8 +1,8 @@
 - hosts: all
   tasks:
     - copy:
-        src: "{{zuul._test.test_root}}/{{zuul.uuid}}.flag"
-        dest: "{{zuul._test.test_root}}/{{zuul.uuid}}.failed"
+        src: "{{zuul._test.test_root}}/{{zuul.build}}.flag"
+        dest: "{{zuul._test.test_root}}/{{zuul.build}}.failed"
     - file:
-        path: "{{zuul._test.test_root}}/{{zuul.uuid}}.pre.flag"
+        path: "{{zuul._test.test_root}}/{{zuul.build}}.pre.flag"
         state: touch
diff --git a/tests/fixtures/config/pre-playbook/git/common-config/playbooks/python27.yaml b/tests/fixtures/config/pre-playbook/git/common-config/playbooks/python27.yaml
index dbb64a5..08c7bd3 100644
--- a/tests/fixtures/config/pre-playbook/git/common-config/playbooks/python27.yaml
+++ b/tests/fixtures/config/pre-playbook/git/common-config/playbooks/python27.yaml
@@ -1,5 +1,5 @@
 - hosts: all
   tasks:
     - file:
-        path: "{{zuul._test.test_root}}/{{zuul.uuid}}.main.flag"
+        path: "{{zuul._test.test_root}}/{{zuul.build}}.main.flag"
         state: touch
diff --git a/tests/fixtures/config/roles/git/bare-role/tasks/main.yaml b/tests/fixtures/config/roles/git/bare-role/tasks/main.yaml
index 75943b1..cd8917d 100644
--- a/tests/fixtures/config/roles/git/bare-role/tasks/main.yaml
+++ b/tests/fixtures/config/roles/git/bare-role/tasks/main.yaml
@@ -1,3 +1,3 @@
 - file:
-    path: "{{zuul._test.test_root}}/{{zuul.uuid}}.bare-role.flag"
+    path: "{{zuul._test.test_root}}/{{zuul.build}}.bare-role.flag"
     state: touch
diff --git a/tests/fixtures/config/streamer/git/common-config/zuul.yaml b/tests/fixtures/config/streamer/git/common-config/zuul.yaml
index d8df96a..6f4fa7e 100644
--- a/tests/fixtures/config/streamer/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/streamer/git/common-config/zuul.yaml
@@ -14,4 +14,4 @@
 - job:
     name: python27
     vars:
-      waitpath: '{{zuul._test.test_root}}/{{zuul.uuid}}/test_wait'
+      waitpath: '{{zuul._test.test_root}}/{{zuul.build}}/test_wait'
diff --git a/tests/fixtures/layouts/idle.yaml b/tests/fixtures/layouts/idle.yaml
index 60f8ed1..49c45ac 100644
--- a/tests/fixtures/layouts/idle.yaml
+++ b/tests/fixtures/layouts/idle.yaml
@@ -6,20 +6,14 @@
         - time: '* * * * * */1'
 
 - job:
-    name: project-bitrot-stable-old
+    name: project-bitrot
     nodes:
       - name: static
         label: ubuntu-xenial
 
-- job:
-    name: project-bitrot-stable-older
-    nodes:
-      - name: static
-        label: ubuntu-trusty
-
 - project:
     name: org/project
     periodic:
       jobs:
-        - project-bitrot-stable-old
-        - project-bitrot-stable-older
+        - project-bitrot
+
diff --git a/tests/fixtures/layouts/no-timer.yaml b/tests/fixtures/layouts/no-timer.yaml
index 12eaa35..05f17d2 100644
--- a/tests/fixtures/layouts/no-timer.yaml
+++ b/tests/fixtures/layouts/no-timer.yaml
@@ -24,17 +24,11 @@
     name: project-test1
 
 - job:
-    name: project-bitrot-stable-old
+    name: project-bitrot
     nodes:
       - name: static
         label: ubuntu-xenial
 
-- job:
-    name: project-bitrot-stable-older
-    nodes:
-      - name: static
-        label: ubuntu-trusty
-
 - project:
     name: org/project
     check:
@@ -42,5 +36,4 @@
         - project-test1
     periodic:
       jobs:
-        - project-bitrot-stable-old
-        - project-bitrot-stable-older
+        - project-bitrot
diff --git a/tests/fixtures/layouts/repo-checkout-timer-override.yaml b/tests/fixtures/layouts/repo-checkout-timer-override.yaml
new file mode 100644
index 0000000..594d74c
--- /dev/null
+++ b/tests/fixtures/layouts/repo-checkout-timer-override.yaml
@@ -0,0 +1,19 @@
+- pipeline:
+    name: periodic
+    manager: independent
+    trigger:
+      timer:
+        - time: '* * * * * */1'
+
+- job:
+    name: integration
+    branches: master
+    override-branch: stable/havana
+    required-projects:
+      - org/project1
+
+- project:
+    name: org/project1
+    periodic:
+      jobs:
+        - integration
diff --git a/tests/fixtures/layouts/repo-checkout-timer.yaml b/tests/fixtures/layouts/repo-checkout-timer.yaml
index d5917d1..3c4d030 100644
--- a/tests/fixtures/layouts/repo-checkout-timer.yaml
+++ b/tests/fixtures/layouts/repo-checkout-timer.yaml
@@ -7,7 +7,6 @@
 
 - job:
     name: integration
-    override-branch: stable/havana
     required-projects:
       - org/project1
 
diff --git a/tests/fixtures/layouts/timer.yaml b/tests/fixtures/layouts/timer.yaml
index 883c32e..dbce516 100644
--- a/tests/fixtures/layouts/timer.yaml
+++ b/tests/fixtures/layouts/timer.yaml
@@ -25,17 +25,11 @@
     name: project-test2
 
 - job:
-    name: project-bitrot-stable-old
+    name: project-bitrot
     nodes:
       - name: static
         label: ubuntu-xenial
 
-- job:
-    name: project-bitrot-stable-older
-    nodes:
-      - name: static
-        label: ubuntu-trusty
-
 - project:
     name: org/project
     check:
@@ -44,5 +38,4 @@
         - project-test2
     periodic:
       jobs:
-        - project-bitrot-stable-old
-        - project-bitrot-stable-older
+        - project-bitrot
diff --git a/tests/unit/test_executor.py b/tests/unit/test_executor.py
index 7b76802..4700bd1 100755
--- a/tests/unit/test_executor.py
+++ b/tests/unit/test_executor.py
@@ -248,6 +248,46 @@
 
         self.assertBuildStates(states, projects)
 
+    def test_periodic_override(self):
+        # This test can not use simple_layout because it must start
+        # with a configuration which does not include a
+        # timer-triggered job so that we have an opportunity to set
+        # the hold flag before the first job.
+
+        # This tests that we can override the branch in a timer
+        # trigger (mostly to ensure backwards compatability for jobs).
+        self.executor_server.hold_jobs_in_build = True
+        # Start timer trigger - also org/project
+        self.commitConfigUpdate('common-config',
+                                'layouts/repo-checkout-timer-override.yaml')
+        self.sched.reconfigure(self.config)
+
+        p1 = 'review.example.com/org/project1'
+        projects = [p1]
+        self.create_branch('org/project1', 'stable/havana')
+
+        # The pipeline triggers every second, so we should have seen
+        # several by now.
+        time.sleep(5)
+        self.waitUntilSettled()
+
+        # Stop queuing timer triggered jobs so that the assertions
+        # below don't race against more jobs being queued.
+        self.commitConfigUpdate('common-config',
+                                'layouts/repo-checkout-no-timer.yaml')
+        self.sched.reconfigure(self.config)
+
+        self.assertEquals(1, len(self.builds), "One build is running")
+
+        upstream = self.getUpstreamRepos(projects)
+        states = [
+            {p1: dict(commit=str(upstream[p1].commit('stable/havana')),
+                      branch='stable/havana'),
+             },
+        ]
+
+        self.assertBuildStates(states, projects)
+
     def test_periodic(self):
         # This test can not use simple_layout because it must start
         # with a configuration which does not include a
@@ -274,14 +314,19 @@
                                 'layouts/repo-checkout-no-timer.yaml')
         self.sched.reconfigure(self.config)
 
-        self.assertEquals(1, len(self.builds), "One build is running")
+        self.assertEquals(2, len(self.builds), "Two builds are running")
 
         upstream = self.getUpstreamRepos(projects)
         states = [
             {p1: dict(commit=str(upstream[p1].commit('stable/havana')),
                       branch='stable/havana'),
              },
+            {p1: dict(commit=str(upstream[p1].commit('master')),
+                      branch='master'),
+             },
         ]
+        if self.builds[0].parameters['zuul']['ref'] == 'refs/heads/master':
+            states = list(reversed(states))
 
         self.assertBuildStates(states, projects)
 
diff --git a/tests/unit/test_github_driver.py b/tests/unit/test_github_driver.py
index 0cfe3da..8493570 100644
--- a/tests/unit/test_github_driver.py
+++ b/tests/unit/test_github_driver.py
@@ -12,11 +12,14 @@
 # License for the specific language governing permissions and limitations
 # under the License.
 
+import os
 import re
 from testtools.matchers import MatchesRegex, StartsWith
 import urllib
 import time
 
+import git
+
 from tests.base import ZuulTestCase, simple_layout, random_sha1
 
 
@@ -94,7 +97,16 @@
     def test_tag_event(self):
         self.executor_server.hold_jobs_in_build = True
 
-        sha = random_sha1()
+        self.create_branch('org/project', 'tagbranch')
+        files = {'README.txt': 'test'}
+        self.addCommitToRepo('org/project', 'test tag',
+                             files, branch='tagbranch', tag='newtag')
+        path = os.path.join(self.upstream_root, 'org/project')
+        repo = git.Repo(path)
+        tag = repo.tags['newtag']
+        sha = tag.commit.hexsha
+        del repo
+
         self.fake_github.emitEvent(
             self.fake_github.getPushEvent('org/project', 'refs/tags/newtag',
                                           new_rev=sha))
diff --git a/tests/unit/test_scheduler.py b/tests/unit/test_scheduler.py
index 0229d65..d4290a9 100755
--- a/tests/unit/test_scheduler.py
+++ b/tests/unit/test_scheduler.py
@@ -1834,17 +1834,17 @@
         self.commitConfigUpdate('common-config', 'layouts/no-timer.yaml')
         self.sched.reconfigure(self.config)
 
-        self.assertEqual(len(self.builds), 2, "Two timer jobs")
+        self.assertEqual(len(self.builds), 1, "One timer job")
 
         A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
         self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
         self.waitUntilSettled()
-        self.assertEqual(len(self.builds), 3, "One change plus two timer jobs")
+        self.assertEqual(len(self.builds), 2, "One change plus one timer job")
 
         self.fake_gerrit.addEvent(A.getChangeAbandonedEvent())
         self.waitUntilSettled()
 
-        self.assertEqual(len(self.builds), 2, "Two timer jobs remain")
+        self.assertEqual(len(self.builds), 1, "One timer job remains")
 
         self.executor_server.release()
         self.waitUntilSettled()
@@ -2791,13 +2791,13 @@
         self.assertEqual(len(self.history), 2)
 
         results = {self.getJobFromHistory('merge',
-                   project='org/project1').uuid: 'extratag merge',
+                   project='org/project1').uuid: ['extratag', 'merge'],
                    self.getJobFromHistory('merge',
-                   project='org/project2').uuid: 'merge'}
+                   project='org/project2').uuid: ['merge']}
 
         for build in self.history:
             self.assertEqual(results.get(build.uuid, ''),
-                             build.parameters['zuul'].get('tags'))
+                             build.parameters['zuul'].get('jobtags'))
 
     def test_timer(self):
         "Test that a periodic job is triggered"
@@ -2805,6 +2805,7 @@
         # with a configuration which does not include a
         # timer-triggered job so that we have an opportunity to set
         # the hold flag before the first job.
+        self.create_branch('org/project', 'stable')
         self.executor_server.hold_jobs_in_build = True
         self.commitConfigUpdate('common-config', 'layouts/timer.yaml')
         self.sched.reconfigure(self.config)
@@ -2831,10 +2832,12 @@
         self.executor_server.release()
         self.waitUntilSettled()
 
-        self.assertEqual(self.getJobFromHistory(
-            'project-bitrot-stable-old').result, 'SUCCESS')
-        self.assertEqual(self.getJobFromHistory(
-            'project-bitrot-stable-older').result, 'SUCCESS')
+        self.assertHistory([
+            dict(name='project-bitrot', result='SUCCESS',
+                 ref='refs/heads/master'),
+            dict(name='project-bitrot', result='SUCCESS',
+                 ref='refs/heads/stable'),
+        ], ordered=False)
 
         data = json.loads(data)
         status_jobs = set()
@@ -2844,8 +2847,7 @@
                     for change in head:
                         for job in change['jobs']:
                             status_jobs.add(job['name'])
-        self.assertIn('project-bitrot-stable-old', status_jobs)
-        self.assertIn('project-bitrot-stable-older', status_jobs)
+        self.assertIn('project-bitrot', status_jobs)
 
     def test_idle(self):
         "Test that frequent periodic jobs work"
@@ -2874,12 +2876,12 @@
                                     'layouts/no-timer.yaml')
             self.sched.reconfigure(self.config)
             self.waitUntilSettled()
-            self.assertEqual(len(self.builds), 2,
+            self.assertEqual(len(self.builds), 1,
                              'Timer builds iteration #%d' % x)
             self.executor_server.release('.*')
             self.waitUntilSettled()
             self.assertEqual(len(self.builds), 0)
-            self.assertEqual(len(self.history), x * 2)
+            self.assertEqual(len(self.history), x)
 
     @simple_layout('layouts/smtp.yaml')
     def test_check_smtp_pool(self):
diff --git a/tests/unit/test_v3.py b/tests/unit/test_v3.py
index b162469..fb80660 100644
--- a/tests/unit/test_v3.py
+++ b/tests/unit/test_v3.py
@@ -667,7 +667,8 @@
         self.assertFalse(os.path.exists(pre_flag_path))
         post_flag_path = os.path.join(self.test_root, build.uuid +
                                       '.post.flag')
-        self.assertTrue(os.path.exists(post_flag_path))
+        self.assertTrue(os.path.exists(post_flag_path),
+                        "The file %s should exist" % post_flag_path)
 
 
 class TestBrokenConfig(ZuulTestCase):
diff --git a/zuul/driver/gerrit/gerritconnection.py b/zuul/driver/gerrit/gerritconnection.py
index a5eb12e..8f8465a 100644
--- a/zuul/driver/gerrit/gerritconnection.py
+++ b/zuul/driver/gerrit/gerritconnection.py
@@ -26,7 +26,7 @@
 import voluptuous as v
 
 from zuul.connection import BaseConnection
-from zuul.model import Ref
+from zuul.model import Ref, Tag, Branch
 from zuul import exceptions
 from zuul.driver.gerrit.gerritmodel import GerritChange, GerritTriggerEvent
 
@@ -293,7 +293,34 @@
         if event.change_number:
             change = self._getChange(event.change_number, event.patch_number,
                                      refresh=refresh)
+        elif event.ref and event.ref.startswith('refs/tags/'):
+            project = self.source.getProject(event.project_name)
+            change = Tag(project)
+            change.tag = event.ref[len('refs/tags/'):]
+            change.ref = event.ref
+            change.oldrev = event.oldrev
+            change.newrev = event.newrev
+            change.url = self._getGitwebUrl(project, sha=event.newrev)
+        elif event.ref and not event.ref.startswith('refs/'):
+            # Gerrit ref-updated events don't have branch prefixes.
+            project = self.source.getProject(event.project_name)
+            change = Branch(project)
+            change.branch = event.ref
+            change.ref = 'refs/heads/' + event.ref
+            change.oldrev = event.oldrev
+            change.newrev = event.newrev
+            change.url = self._getGitwebUrl(project, sha=event.newrev)
+        elif event.ref and event.ref.startswith('refs/heads/'):
+            # From the timer trigger
+            project = self.source.getProject(event.project_name)
+            change = Branch(project)
+            change.ref = event.ref
+            change.branch = event.branch
+            change.oldrev = event.oldrev
+            change.newrev = event.newrev
+            change.url = self._getGitwebUrl(project, sha=event.newrev)
         elif event.ref:
+            # catch-all ref (ie, not a branch or head)
             project = self.source.getProject(event.project_name)
             change = Ref(project)
             change.ref = event.ref
@@ -301,14 +328,8 @@
             change.newrev = event.newrev
             change.url = self._getGitwebUrl(project, sha=event.newrev)
         else:
-            project = self.source.getProject(event.project_name)
-            change = Ref(project)
-            branch = event.branch or 'master'
-            change.ref = 'refs/heads/%s' % branch
-            refs = self.getInfoRefs(project)
-            change.oldrev = refs[change.ref]
-            change.newrev = refs[change.ref]
-            change.url = self._getGitwebUrl(project, sha=change.newrev)
+            self.log.warning("Unable to get change for %s" % (event,))
+            change = None
         return change
 
     def _getChange(self, number, patchset, refresh=False, history=None):
diff --git a/zuul/driver/github/githubconnection.py b/zuul/driver/github/githubconnection.py
index a4a4c12..ff113ce 100644
--- a/zuul/driver/github/githubconnection.py
+++ b/zuul/driver/github/githubconnection.py
@@ -32,7 +32,7 @@
 from github3.exceptions import MethodNotAllowed
 
 from zuul.connection import BaseConnection
-from zuul.model import Ref
+from zuul.model import Ref, Branch, Tag
 from zuul.exceptions import MergeFailure
 from zuul.driver.github.githubmodel import PullRequest, GithubTriggerEvent
 
@@ -506,16 +506,21 @@
             change.source_event = event
             change.is_current_patchset = (change.pr.get('head').get('sha') ==
                                           event.patch_number)
-        elif event.ref:
-            change = Ref(project)
+        else:
+            if event.ref and event.ref.startswith('refs/tags/'):
+                change = Tag(project)
+                change.tag = event.ref[len('refs/tags/'):]
+            elif event.ref and event.ref.startswith('refs/heads/'):
+                change = Branch(project)
+                change.branch = event.ref[len('refs/heads/'):]
+            else:
+                change = Ref(project)
             change.ref = event.ref
             change.oldrev = event.oldrev
             change.newrev = event.newrev
             change.url = self.getGitwebUrl(project, sha=event.newrev)
             change.source_event = event
             change.files = self.getPushedFileNames(event)
-        else:
-            change = Ref(project)
         return change
 
     def _getChange(self, project, number, patchset=None, refresh=False,
diff --git a/zuul/driver/timer/__init__.py b/zuul/driver/timer/__init__.py
index cdaea74..4489808 100644
--- a/zuul/driver/timer/__init__.py
+++ b/zuul/driver/timer/__init__.py
@@ -80,15 +80,18 @@
 
     def _onTrigger(self, tenant, pipeline_name, timespec):
         for project_name in tenant.layout.project_configs.keys():
-            project_hostname, project_name = project_name.split('/', 1)
-            event = TimerTriggerEvent()
-            event.type = 'timer'
-            event.timespec = timespec
-            event.forced_pipeline = pipeline_name
-            event.project_hostname = project_hostname
-            event.project_name = project_name
-            self.log.debug("Adding event %s" % event)
-            self.sched.addEvent(event)
+            (trusted, project) = tenant.getProject(project_name)
+            for branch in project.source.getProjectBranches(project):
+                event = TimerTriggerEvent()
+                event.type = 'timer'
+                event.timespec = timespec
+                event.forced_pipeline = pipeline_name
+                event.project_hostname = project.canonical_hostname
+                event.project_name = project.name
+                event.ref = 'refs/heads/%s' % branch
+                event.branch = branch
+                self.log.debug("Adding event %s" % event)
+                self.sched.addEvent(event)
 
     def stop(self):
         if self.apsched:
diff --git a/zuul/executor/client.py b/zuul/executor/client.py
index f764778..52cc403 100644
--- a/zuul/executor/client.py
+++ b/zuul/executor/client.py
@@ -155,20 +155,41 @@
             canonical_hostname=item.change.project.canonical_hostname,
             canonical_name=item.change.project.canonical_name)
 
-        zuul_params = dict(uuid=uuid,
-                           ref=item.current_build_set.ref,
+        zuul_params = dict(build=uuid,
+                           buildset=item.current_build_set.uuid,
+                           ref=item.change.ref,
                            pipeline=pipeline.name,
                            job=job.name,
                            project=project,
                            tenant=tenant.name,
-                           tags=' '.join(sorted(job.tags)))
-
+                           jobtags=sorted(job.tags))
         if hasattr(item.change, 'branch'):
             zuul_params['branch'] = item.change.branch
+        if hasattr(item.change, 'tag'):
+            zuul_params['tag'] = item.change.tag
         if hasattr(item.change, 'number'):
             zuul_params['change'] = item.change.number
         if hasattr(item.change, 'patchset'):
             zuul_params['patchset'] = item.change.patchset
+        if hasattr(item.change, 'oldrev'):
+            zuul_params['oldrev'] = item.change.oldrev
+        if hasattr(item.change, 'newrev'):
+            zuul_params['newrev'] = item.change.newrev
+        zuul_params['items'] = []
+        for i in all_items:
+            d = dict()
+            d['project'] = dict(
+                name=i.change.project.name,
+                canonical_hostname=i.change.project.canonical_hostname,
+                canonical_name=i.change.project.canonical_name)
+            if hasattr(i.change, 'number'):
+                d['change'] = i.change.number
+            if hasattr(i.change, 'patchset'):
+                d['patchset'] = i.change.number
+            if hasattr(i.change, 'branch'):
+                d['branch'] = i.change.branch
+            zuul_params['items'].append(d)
+
         # Legacy environment variables
         params = dict(ZUUL_UUID=uuid,
                       ZUUL_PROJECT=item.change.project.name)
diff --git a/zuul/executor/server.py b/zuul/executor/server.py
index 9b5ecc7..ef5363c 100644
--- a/zuul/executor/server.py
+++ b/zuul/executor/server.py
@@ -858,8 +858,18 @@
 
         for project in args['projects']:
             repo = repos[project['canonical_name']]
+            # If this project is the Zuul project and this is a ref
+            # rather than a change, checkout the ref.
+            if (project['canonical_name'] ==
+                args['zuul']['project']['canonical_name'] and
+                (not args['zuul'].get('branch')) and
+                args['zuul'].get('ref')):
+                ref = args['zuul']['ref']
+            else:
+                ref = None
             self.checkoutBranch(repo,
                                 project['name'],
+                                ref,
                                 args['branch'],
                                 args['override_branch'],
                                 project['override_branch'],
@@ -929,7 +939,7 @@
             repo.setRef('refs/heads/' + branch, commit)
         return True
 
-    def checkoutBranch(self, repo, project_name, zuul_branch,
+    def checkoutBranch(self, repo, project_name, ref, zuul_branch,
                        job_branch, project_override_branch,
                        project_default_branch):
         branches = repo.getBranches()
@@ -941,6 +951,16 @@
             self.log.info("Checking out %s job branch %s",
                           project_name, job_branch)
             repo.checkoutLocalBranch(job_branch)
+        elif ref and ref.startswith('refs/heads/'):
+            b = ref[len('refs/heads/'):]
+            self.log.info("Checking out %s branch ref %s",
+                          project_name, b)
+            repo.checkoutLocalBranch(b)
+        elif ref and ref.startswith('refs/tags/'):
+            t = ref[len('refs/tags/'):]
+            self.log.info("Checking out %s tag ref %s",
+                          project_name, t)
+            repo.checkout(t)
         elif zuul_branch and zuul_branch in branches:
             self.log.info("Checking out %s zuul branch %s",
                           project_name, zuul_branch)
diff --git a/zuul/model.py b/zuul/model.py
index bc9eeb7..ed77864 100644
--- a/zuul/model.py
+++ b/zuul/model.py
@@ -1842,19 +1842,17 @@
         oldrev = None
         newrev = None
         refspec = None
+        branch = None
         if hasattr(self.change, 'number'):
             number = self.change.number
             patchset = self.change.patchset
             refspec = self.change.refspec
-            branch = self.change.branch
-        elif hasattr(self.change, 'newrev'):
+        if hasattr(self.change, 'newrev'):
             oldrev = self.change.oldrev
             newrev = self.change.newrev
-            branch = self.change.ref
-        else:
-            oldrev = None
-            newrev = None
-            branch = None
+        if hasattr(self.change, 'branch'):
+            branch = self.change.branch
+
         source = self.change.project.source
         connection_name = source.connection.connection_name
         project = self.change.project
@@ -1880,32 +1878,26 @@
         self.ref = None
         self.oldrev = None
         self.newrev = None
-
         self.files = []
 
-    def getBasePath(self):
-        base_path = ''
-        if hasattr(self, 'ref'):
-            base_path = "%s/%s" % (self.newrev[:2], self.newrev)
-
-        return base_path
-
     def _id(self):
         return self.newrev
 
     def __repr__(self):
         rep = None
         if self.newrev == '0000000000000000000000000000000000000000':
-            rep = '<Ref 0x%x deletes %s from %s' % (
-                  id(self), self.ref, self.oldrev)
+            rep = '<%s 0x%x deletes %s from %s' % (
+                type(self).__name__,
+                id(self), self.ref, self.oldrev)
         elif self.oldrev == '0000000000000000000000000000000000000000':
-            rep = '<Ref 0x%x creates %s on %s>' % (
-                  id(self), self.ref, self.newrev)
+            rep = '<%s 0x%x creates %s on %s>' % (
+                type(self).__name__,
+                id(self), self.ref, self.newrev)
         else:
             # Catch all
-            rep = '<Ref 0x%x %s updated %s..%s>' % (
-                  id(self), self.ref, self.oldrev, self.newrev)
-
+            rep = '<%s 0x%x %s updated %s..%s>' % (
+                type(self).__name__,
+                id(self), self.ref, self.oldrev, self.newrev)
         return rep
 
     def equals(self, other):
@@ -1938,11 +1930,24 @@
                           newrev=self.newrev)
 
 
-class Change(Ref):
+class Branch(Ref):
+    """An existing branch state for a Project."""
+    def __init__(self, project):
+        super(Branch, self).__init__(project)
+        self.branch = None
+
+
+class Tag(Ref):
+    """An existing tag state for a Project."""
+    def __init__(self, project):
+        super(Tag, self).__init__(project)
+        self.tag = None
+
+
+class Change(Branch):
     """A proposed new state for a Project."""
     def __init__(self, project):
         super(Change, self).__init__(project)
-        self.branch = None
         self.number = None
         self.url = None
         self.patchset = None
@@ -1966,12 +1971,6 @@
     def __repr__(self):
         return '<Change 0x%x %s>' % (id(self), self._id())
 
-    def getBasePath(self):
-        if hasattr(self, 'refspec'):
-            return "%s/%s/%s" % (
-                str(self.number)[-2:], self.number, self.patchset)
-        return super(Change, self).getBasePath()
-
     def equals(self, other):
         if self.number == other.number and self.patchset == other.patchset:
             return True