diff --git a/playbooks/javascript/post.yaml b/playbooks/javascript/post.yaml
new file mode 100644
index 000000000..fc32a991d
--- /dev/null
+++ b/playbooks/javascript/post.yaml
@@ -0,0 +1,3 @@
+- hosts: all
+  roles:
+    - fetch-javascript-output
diff --git a/playbooks/javascript/pre.yaml b/playbooks/javascript/pre.yaml
new file mode 100644
index 000000000..747029e83
--- /dev/null
+++ b/playbooks/javascript/pre.yaml
@@ -0,0 +1,9 @@
+- hosts: all
+  roles:
+    - role: bindep
+      bindep_profile: test
+      bindep_dir: "{{ zuul_work_dir }}"
+    - test-setup
+    - install-nodejs
+    - revoke-sudo
+    - install-javascript-packages
diff --git a/playbooks/javascript/tarball.yaml b/playbooks/javascript/tarball.yaml
new file mode 100644
index 000000000..79c3f872d
--- /dev/null
+++ b/playbooks/javascript/tarball.yaml
@@ -0,0 +1,4 @@
+- hosts: all
+  roles:
+    - role: npm
+      npm_command: pack
diff --git a/roles/fetch-javascript-output/README.rst b/roles/fetch-javascript-output/README.rst
new file mode 100644
index 000000000..cf8518f2e
--- /dev/null
+++ b/roles/fetch-javascript-output/README.rst
@@ -0,0 +1,8 @@
+Collect logs from a javascript build
+
+**Role Variables**
+
+.. zuul:rolevar:: zuul_work_dir
+   :default: {{ zuul.project.src_dir }}
+
+   Directory to work in
diff --git a/roles/fetch-javascript-output/defaults/main.yaml b/roles/fetch-javascript-output/defaults/main.yaml
new file mode 100644
index 000000000..9739eb171
--- /dev/null
+++ b/roles/fetch-javascript-output/defaults/main.yaml
@@ -0,0 +1 @@
+zuul_work_dir: "{{ zuul.project.src_dir }}"
diff --git a/roles/fetch-javascript-output/tasks/main.yaml b/roles/fetch-javascript-output/tasks/main.yaml
new file mode 100644
index 000000000..5adf33375
--- /dev/null
+++ b/roles/fetch-javascript-output/tasks/main.yaml
@@ -0,0 +1,62 @@
+- name: Set log path for multiple nodes
+  set_fact:
+    log_path: "{{ zuul.executor.log_root }}/{{ inventory_hostname }}/npm"
+  when: groups['all'] | length > 1
+
+- name: Set log path for single node
+  set_fact:
+    log_path: "{{ zuul.executor.log_root }}/npm"
+  when: log_path is not defined
+
+- name: Ensure local tox dir
+  file:
+    path: "{{ log_path }}"
+    state: directory
+  delegate_to: localhost
+
+- name: Check for shrinkwrap
+  stat:
+    path: "{{ zuul_work_dir }}/npm-shrinkwrap.json"
+  register: shrinkwrap
+
+- name: Run npm prune because of https://github.com/npm/npm/issues/6298
+  when: not shrinkwrap.stat.exists
+  command: npm prune
+  environment:
+    DISPLAY: ':99'
+  args:
+    chdir: "{{ zuul_work_dir }}"
+
+- name: Run npm shrinkwrap
+  when: not shrinkwrap.stat.exists
+  command: npm shrinkwrap
+  environment:
+    DISPLAY: ':99'
+  args:
+    chdir: "{{ zuul_work_dir }}"
+
+- name: Check for reports
+  stat:
+    path: "{{ zuul_work_dir }}/reports"
+  register: reports_stat
+
+- name: Collect npm reports
+  synchronize:
+    dest: "{{ log_path }}"
+    mode: pull
+    src: "{{ zuul_work_dir }}/reports"
+    verify_host: true
+  when: reports_stat.stat.exists
+
+- name: Check for karma.subunit files
+  stat:
+    path: "{{ zuul_work_dir }}/karma.subunit"
+  register: karma_stat
+
+- name: Collect karma subunit files
+  synchronize:
+    dest: "{{ log_path }}"
+    mode: pull
+    src: "{{ zuul_work_dir }}/karma.subunit"
+    verify_host: true
+  when: karma_stat.stat.exists
diff --git a/roles/fetch-javascript-tarball/README.rst b/roles/fetch-javascript-tarball/README.rst
new file mode 100644
index 000000000..10df25868
--- /dev/null
+++ b/roles/fetch-javascript-tarball/README.rst
@@ -0,0 +1,8 @@
+Fetch a Javascript tarball back to be published.
+
+**Role Variables**
+
+.. zuul:rolevar:: zuul_work_dir
+   :default: {{ zuul.project.src_dir }}
+
+   Directory to run npm in.
diff --git a/roles/fetch-javascript-tarball/defaults/main.yaml b/roles/fetch-javascript-tarball/defaults/main.yaml
new file mode 100644
index 000000000..9739eb171
--- /dev/null
+++ b/roles/fetch-javascript-tarball/defaults/main.yaml
@@ -0,0 +1 @@
+zuul_work_dir: "{{ zuul.project.src_dir }}"
diff --git a/roles/fetch-javascript-tarball/tasks/main.yaml b/roles/fetch-javascript-tarball/tasks/main.yaml
new file mode 100644
index 000000000..14de937b7
--- /dev/null
+++ b/roles/fetch-javascript-tarball/tasks/main.yaml
@@ -0,0 +1,20 @@
+- name: Rename tarball for uploading
+  shell: |
+    mkdir -p dist
+    cp *.tgz dist/{{ zuul.project.short_name }}-{{ project_ver }}.tar.gz
+    cp *.tgz dist/{{ zuul.project.short_name }}-latest.tar.gz
+  args:
+    chdir: "{{ zuul_work_dir }}"
+
+- name: Ensure artifacts directory exists
+  file:
+    path: "{{ zuul.executor.work_root }}/artifacts"
+    state: directory
+  delegate_to: localhost
+
+- name: Collect artifacts
+  synchronize:
+    dest: "{{ zuul.executor.work_root }}/artifacts/"
+    mode: pull
+    src: "{{ zuul_work_dir }}/dist/"
+    verify_host: true
diff --git a/roles/install-javascript-packages/README.rst b/roles/install-javascript-packages/README.rst
new file mode 100644
index 000000000..96bb947ac
--- /dev/null
+++ b/roles/install-javascript-packages/README.rst
@@ -0,0 +1,8 @@
+Install javascript dependencies needed for a project
+
+**Role Variables**
+
+.. zuul:rolevar:: zuul_work_dir
+   :default: {{ zuul.project.src_dir }}
+
+   The directory to work in.
diff --git a/roles/install-javascript-packages/defaults/main.yaml b/roles/install-javascript-packages/defaults/main.yaml
new file mode 100644
index 000000000..9739eb171
--- /dev/null
+++ b/roles/install-javascript-packages/defaults/main.yaml
@@ -0,0 +1 @@
+zuul_work_dir: "{{ zuul.project.src_dir }}"
diff --git a/roles/install-javascript-packages/tasks/main.yaml b/roles/install-javascript-packages/tasks/main.yaml
new file mode 100644
index 000000000..2fbcdcd46
--- /dev/null
+++ b/roles/install-javascript-packages/tasks/main.yaml
@@ -0,0 +1,6 @@
+- name: Install npm dependencies
+  command: npm install --verbose
+  environment:
+    DISPLAY: ':99'
+  args:
+    chdir: "{{ zuul_work_dir }}"
diff --git a/roles/install-nodejs/README.rst b/roles/install-nodejs/README.rst
new file mode 100644
index 000000000..09f7e0679
--- /dev/null
+++ b/roles/install-nodejs/README.rst
@@ -0,0 +1,6 @@
+Install NodeJS from nodesource
+
+**Role Variables**
+
+.. zuul:rolevar:: node_version
+   :default: 6
diff --git a/roles/install-nodejs/defaults/main.yaml b/roles/install-nodejs/defaults/main.yaml
new file mode 100644
index 000000000..f53b7ceef
--- /dev/null
+++ b/roles/install-nodejs/defaults/main.yaml
@@ -0,0 +1,2 @@
+---
+node_version: 6
diff --git a/roles/install-nodejs/tasks/main.yaml b/roles/install-nodejs/tasks/main.yaml
new file mode 100644
index 000000000..c03abbc4c
--- /dev/null
+++ b/roles/install-nodejs/tasks/main.yaml
@@ -0,0 +1,40 @@
+- name: Update apt cache
+  apt:
+    update_cache: yes
+  become: yes
+
+- name: Install prereqs
+  package:
+    name: apt-transport-https
+    state: present
+  become: yes
+
+- name: Add nodesource repository key
+  apt_key:
+    url: "https://deb.nodesource.com/gpgkey/nodesource.gpg.key"
+  become: yes
+
+- name: Add nodesource apt source repository
+  apt_repository:
+    repo: "deb-src https://deb.nodesource.com/node_{{ node_version }}.x {{ ansible_distribution_release }} main"
+    state: present
+  become: yes
+
+- name: Add nodesource apt repository
+  apt_repository:
+    repo: "deb https://deb.nodesource.com/node_{{ node_version }}.x {{ ansible_distribution_release }} main"
+    state: present
+    update_cache: yes
+  become: yes
+
+- name: Install NodeJS from nodesource
+  package:
+    name: nodejs
+    state: latest
+  become: yes
+
+- name: Output node version
+  command: node --version
+
+- name: Output npm version
+  command: npm --version
diff --git a/roles/npm/README.rst b/roles/npm/README.rst
new file mode 100644
index 000000000..fcadbfc74
--- /dev/null
+++ b/roles/npm/README.rst
@@ -0,0 +1,15 @@
+Run npm command in a source directory. Assumes the appropriate version
+of npm has been installed.
+
+**Role Variables**
+
+.. zuul:rolevar:: npm_command
+
+   Command to run. If it's a standard npm lifecycle command, it will be run as
+   ``npm {{ npm_command }}``. Otherwise it will be run as
+   ``npm run {{ npm_command }}``.
+
+.. zuul:rolevar:: zuul_work_dir
+   :default: {{ zuul.project.src_dir }}
+
+   Directory to run npm in.
diff --git a/roles/npm/defaults/main.yaml b/roles/npm/defaults/main.yaml
new file mode 100644
index 000000000..9739eb171
--- /dev/null
+++ b/roles/npm/defaults/main.yaml
@@ -0,0 +1 @@
+zuul_work_dir: "{{ zuul.project.src_dir }}"
diff --git a/roles/npm/tasks/main.yaml b/roles/npm/tasks/main.yaml
new file mode 100644
index 000000000..a14a0516a
--- /dev/null
+++ b/roles/npm/tasks/main.yaml
@@ -0,0 +1,24 @@
+- name: Require npm_command variable
+  fail:
+    msg: npm_command is required for this role
+  when: npm_command is not defined
+
+- name: Run npm lifecycle command
+  when: npm_command in npm_lifecycle_phases
+  command: "npm {{ npm_command }} --verbose"
+  # Need to set DISPLAY to the value that will be set when the virtual
+  # framebuffer is set up for doing browser tests.
+  environment:
+    DISPLAY: ':99'
+  args:
+    chdir: "{{ zuul_work_dir }}"
+
+- name: Run npm custom command
+  when: npm_command not in npm_lifecycle_phases
+  command: "npm run {{ npm_command }} --verbose"
+  # Need to set DISPLAY to the value that will be set when the virtual
+  # framebuffer is set up for doing browser tests.
+  environment:
+    DISPLAY: ':99'
+  args:
+    chdir: "{{ zuul_work_dir }}"
diff --git a/roles/npm/vars/main.yaml b/roles/npm/vars/main.yaml
new file mode 100644
index 000000000..9b933df3c
--- /dev/null
+++ b/roles/npm/vars/main.yaml
@@ -0,0 +1,9 @@
+npm_lifecycle_phases:
+  - install
+  - pack
+  - publish
+  - restart
+  - start
+  - stop
+  - test
+  - version
diff --git a/roles/version-from-git/README.rst b/roles/version-from-git/README.rst
new file mode 100644
index 000000000..3b5542fc1
--- /dev/null
+++ b/roles/version-from-git/README.rst
@@ -0,0 +1,20 @@
+Sets three facts based on information in a git repo.
+
+scm_sha
+  The short sha found in the repository.
+
+project_ver
+  A string describing the project's version. It will either be the value of
+  {{ zuul.tag }} or {{ scm_tag }}.{{ commits_since_tag }}.{{ scm_sha }}
+  otherwise where ``scm_tag`` is either the most recent tag or the value of
+  ``scm_sha`` if there are no commits in the repo.
+
+commits_since_tag
+  Number of commits since the most recent tag.
+
+**Role Variables**
+
+.. zuul:rolevar:: zuul_work_dir
+   :default: {{ zuul.project.src_dir }}
+
+   Directory to run git in.
diff --git a/roles/version-from-git/defaults/main.yaml b/roles/version-from-git/defaults/main.yaml
new file mode 100644
index 000000000..9739eb171
--- /dev/null
+++ b/roles/version-from-git/defaults/main.yaml
@@ -0,0 +1 @@
+zuul_work_dir: "{{ zuul.project.src_dir }}"
diff --git a/roles/version-from-git/tasks/main.yaml b/roles/version-from-git/tasks/main.yaml
new file mode 100644
index 000000000..420ef7deb
--- /dev/null
+++ b/roles/version-from-git/tasks/main.yaml
@@ -0,0 +1,56 @@
+- name: Get SCM_SHA info
+  command: git rev-parse --short HEAD
+  failed_when: false
+  register: scm_sha_output
+  args:
+    chdir: "{{ zuul_work_dir }}"
+
+- name: Set scm_sha fact
+  set_fact:
+    scm_sha: "{{ scm_sha_output.stdout }}"
+
+- name: Get SCM_TAG info
+  command: git describe --abbrev=0 --tags
+  failed_when: false
+  register: scm_tag_output
+  when: zuul.tag is not defined
+  args:
+    chdir: "{{ zuul_work_dir }}"
+
+- name: Set scm_sha fact from output
+  set_fact:
+    scm_tag: "{{ scm_tag_output.stdout }}"
+  when: zuul.tag is not defined and scm_tag_output.stdout
+
+- name: Set scm_tag fact from zuul
+  set_fact:
+    scm_tag: "{{ zuul.tag }}"
+  when: zuul.tag is defined
+
+- name: Use git sha if there is no tag
+  set_fact:
+    scm_tag: "{{ scm_sha }}"
+  when: zuul.tag is not defined and not scm_tag_output.stdout
+
+- name: Get commits since tag
+  # assumes format is like this  '0.0.4-2-g135721c'
+  shell: |
+    git describe | awk '{split($0,a,"-"); print a[2]}'
+  failed_when: false
+  register: commits_since_tag_output
+  args:
+    chdir: "{{ zuul_work_dir }}"
+
+- name: Set commits_since_tag fact
+  set_fact:
+    commits_since_tag: "{{ commits_since_tag_output.stdout }}"
+
+- name: Set project_ver to scm_tag if there are no commits
+  when: not commits_since_tag
+  set_fact:
+    project_ver: "{{ scm_tag }}"
+
+- name: Set project_ver if there are commits since the tag
+  when: not commits_since_tag
+  set_fact:
+    project_ver: "{{ scm_tag }}.{{ commits_since_tag }}.{{ scm_sha }}"
diff --git a/zuul.yaml b/zuul.yaml
index 85cd0e18d..bf1e29654 100644
--- a/zuul.yaml
+++ b/zuul.yaml
@@ -167,3 +167,42 @@
       Do additional setup needed for multi-node jobs such as setting up
       overlay networks and setting up known-hosts and ssh keys
     pre-run: playbooks/multinode/pre
+
+- job:
+    name: javascript-base
+    description: |
+      Base job for javascript operations
+
+      Responds to these variables:
+
+      .. zuul:jobvar:: node_version
+         :default: 6
+
+         The version of Node to use.
+
+      .. zuul:jobvar: zuul_work_dir
+         :default: {{ zuul.project.src_dir }}
+
+         Path to operate in.
+    pre-run: playbooks/javascript/pre
+    post-run: playbooks/javascript/post
+
+- job:
+    name: build-javascript-tarball
+    parent: javascript-base
+    description: |
+      Build a source tarball for a Javascript project
+
+      Responds to these variables:
+
+      .. zuul:jobvar:: node_version
+         :default: 6
+
+         The version of Node to use.
+
+      .. zuul:jobvar: zuul_work_dir
+         :default: {{ zuul.project.src_dir }}
+
+         Path to operate in.
+
+    run: playbooks/javascript/tarball