Bug 1332506 - Treeherder actions defined in-tree. r=dustin
authorJonas Finnemann Jensen <jopsen@gmail.com>
Tue, 31 Jan 2017 15:34:05 -0800
changeset 390523 b7ec9bebd877596f5a846005072832db3b0a7e57
parent 390522 e7862da54b607c4907502a9256514057d92631e8
child 390524 9601b50364588fa377038e53659847b80fdc9454
push id1468
push userasasaki@mozilla.com
push dateMon, 05 Jun 2017 19:31:07 +0000
treeherdermozilla-release@0641fc6ee9d1 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersdustin
bugs1332506
milestone54.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1332506 - Treeherder actions defined in-tree. r=dustin Framework for defining actions in-tree that can be displayed and triggered from Treeherder. MozReview-Commit-ID: 3rvwgy2i4xu
taskcluster/actions/__init__.py
taskcluster/actions/hello-action.py
taskcluster/actions/registry.py
taskcluster/docs/in-tree-actions.rst
taskcluster/docs/index.rst
taskcluster/mach_commands.py
taskcluster/taskgraph/decision.py
new file mode 100644
--- /dev/null
+++ b/taskcluster/actions/__init__.py
@@ -0,0 +1,10 @@
+from registry import (
+    register_task_action, register_callback_action, render_actions_json, trigger_action_callback,
+)
+
+__all__ = [
+    'register_task_action',
+    'register_callback_action',
+    'render_actions_json',
+    'trigger_action_callback',
+]
new file mode 100644
--- /dev/null
+++ b/taskcluster/actions/hello-action.py
@@ -0,0 +1,30 @@
+from .registry import register_callback_action
+
+
+@register_callback_action(
+    title='Say Hello',
+    symbol='hw',
+    description="""
+    Simple **proof-of-concept** action that prints a hello action.
+    """,
+    order=10000,  # Put this at the very bottom/end of any menu (default)
+    context=[{}],  # Applies to any task
+    available=lambda parameters: True,  # available regardless decision parameters (default)
+    schema={
+        'type': 'string',
+        'maxLength': 255,
+        'default': 'World',
+        'title': 'Target Name',
+        'description': """
+A name wish to say hello to...
+This should normally be **your name**.
+
+But you can also use the default value `'World'`.
+        """.strip(),
+    },
+)
+def hello_world_action(parameters, input, task_group_id, task_id, task):
+    print "This message was triggered from context-menu of taskId: {}".format(task_id)
+    print ""
+    print "Hello {}".format(input)
+    print "--- Action is now executed"
new file mode 100644
--- /dev/null
+++ b/taskcluster/actions/registry.py
@@ -0,0 +1,305 @@
+import json
+import os
+import inspect
+from types import FunctionType
+from collections import namedtuple
+from taskgraph.util.docker import docker_image
+from taskgraph.parameters import Parameters
+
+
+GECKO = os.path.realpath(os.path.join(__file__, '..', '..', '..'))
+
+actions = []
+callbacks = {}
+
+Action = namedtuple('Action', [
+    'title', 'description', 'order', 'context', 'schema', 'task_template_builder',
+])
+
+
+def is_json(data):
+    """ Return ``True``, if ``data`` is a JSON serializable data structure. """
+    try:
+        json.dumps(data)
+    except ValueError:
+        return False
+    return True
+
+
+def register_task_action(title, description, order, context, schema):
+    """
+    Register an action task that can be triggered from supporting
+    user interfaces, such as Treeherder.
+
+    Most actions will create intermediate action tasks that call back into
+    in-tree python code. To write such an action please use
+    :func:`register_callback_action`.
+
+    This function is to be used a decorator for a function that returns a task
+    template, see :doc:`specification <action-spec>` for details on the
+    templating features. The decorated function will be given decision task
+    parameters, which can be embedded in the task template that is returned.
+
+    Parameters
+    ----------
+    title : str
+        A human readable title for the action to be used as label on a button
+        or text on a link for triggering the action.
+    description : str
+        A human readable description of the action in **markdown**.
+        This will be display as tooltip and in dialog window when the action
+        is triggered. This is a good place to describe how to use the action.
+    order : int
+        Order of the action in menus, this is relative to the ``order`` of
+        other actions declared.
+    context : list of dict
+        List of tag-sets specifying which tasks the action is can take as input.
+        If no tag-sets is specified as input the action is related to the
+        entire task-group, and won't be triggered with a given task.
+
+        Otherwise, if ``context = [{'k': 'b', 'p': 'l'}, {'k': 't'}]`` will only
+        be displayed in the context menu for tasks that has
+        ``task.tags.k == 'b' && task.tags.p = 'l'`` or ``task.tags.k = 't'``.
+        Esentially, this allows filtering on ``task.tags``.
+    schema : dict
+        JSON schema specifying input accepted by the action.
+        This is optional and can be left ``null`` if no input is taken.
+
+    Returns
+    -------
+    function
+        To be used as decorator for the function that builds the task template.
+        The decorated function will be given decision parameters and may return
+        ``None`` instead of a task template, if the action is disabled.
+    """
+    assert isinstance(title, basestring), 'title must be a string'
+    assert isinstance(description, basestring), 'description must be a string'
+    assert isinstance(order, int), 'order must be an integer'
+    assert is_json(schema), 'schema must be a JSON compatible  object'
+    mem = {"registered": False}  # workaround nonlocal missing in 2.x
+
+    def register_task_template_builder(task_template_builder):
+        assert not mem['registered'], 'register_task_action must be used as decorator'
+        actions.append(Action(
+            title.strip(), description.strip(), order, context, schema, task_template_builder,
+        ))
+        mem['registered'] = True
+    return register_task_template_builder
+
+
+def register_callback_action(title, symbol, description, order=10000, context=[],
+                             available=lambda parameters: True, schema=None):
+    """
+    Register an action callback that can be triggered from supporting
+    user interfaces, such as Treeherder.
+
+    This function is to be used as a decorator for a callback that takes
+    parameters as follows:
+
+    ``parameters``:
+        Decision task parameters, see ``taskgraph.parameters.Parameters``.
+    ``input``:
+        Input matching specified JSON schema, ``None`` if no ``schema``
+        parameter is given to ``register_callback_action``.
+    ``task_group_id``:
+        The id of the task-group this was triggered for.
+    ``task_id`` and `task``:
+        task identifier and task definition for task the action was triggered
+        for, ``None`` if no ``context`` parameters was given to
+        ``register_callback_action``.
+
+    Parameters
+    ----------
+    title : str
+        A human readable title for the action to be used as label on a button
+        or text on a link for triggering the action.
+    symbol : str
+        Treeherder symbol for the action callback, this is the symbol that the
+        task calling your callback will be displayed as. This is usually 1-3
+        letters abbreviating the action title.
+    description : str
+        A human readable description of the action in **markdown**.
+        This will be display as tooltip and in dialog window when the action
+        is triggered. This is a good place to describe how to use the action.
+    order : int
+        Order of the action in menus, this is relative to the ``order`` of
+        other actions declared.
+    context : list of dict
+        List of tag-sets specifying which tasks the action is can take as input.
+        If no tag-sets is specified as input the action is related to the
+        entire task-group, and won't be triggered with a given task.
+
+        Otherwise, if ``context = [{'k': 'b', 'p': 'l'}, {'k': 't'}]`` will only
+        be displayed in the context menu for tasks that has
+        ``task.tags.k == 'b' && task.tags.p = 'l'`` or ``task.tags.k = 't'``.
+        Esentially, this allows filtering on ``task.tags``.
+    available : function
+        An optional function that given decision parameters decides if the
+        action is available. Defaults to a function that always returns ``True``.
+    schema : dict
+        JSON schema specifying input accepted by the action.
+        This is optional and can be left ``null`` if no input is taken.
+
+    Returns
+    -------
+    function
+        To be used as decorator for the callback function.
+    """
+    mem = {"registered": False}  # workaround nonlocal missing in 2.x
+
+    def register_callback(cb):
+        assert isinstance(cb, FunctionType), 'callback must be a function'
+        assert isinstance(symbol, basestring), 'symbol must be a string'
+        assert 1 <= len(symbol) <= 25, 'symbol must be between 1 and 25 characters'
+        assert not mem['registered'], 'register_callback_action must be used as decorator'
+        assert cb.__name__ not in callbacks, 'callback name {} is not unique'.format(cb.__name__)
+        source_path = os.path.relpath(inspect.stack()[1][1], GECKO)
+
+        @register_task_action(title, description, order, context, schema)
+        def build_callback_action_task(parameters):
+            if not available(parameters):
+                return None
+            return {
+                'created': {'$fromNow': ''},
+                'deadline': {'$fromNow': '12 hours'},
+                'expires': {'$fromNow': '14 days'},
+                'metadata': {
+                    'owner': 'mozilla-taskcluster-maintenance@mozilla.com',
+                    'source': '{}raw-file/{}/{}'.format(
+                        parameters['head_repository'], parameters['head_rev'], source_path,
+                    ),
+                    'name': 'Action: {}'.format(title),
+                    'description': 'Task executing callback for action.\n\n---\n' + description,
+                },
+                'workerType': 'gecko-decision',
+                'provisionerId': 'aws-provisioner-v1',
+                'scopes': [
+                    'assume:repo:hg.mozilla.org/projects/{}:*'.format(parameters['project']),
+                ],
+                'tags': {
+                    'createdForUser': parameters['owner'],
+                    'kind': 'action-callback',
+                },
+                'routes': [
+                    'tc-treeherder.v2.{}.{}.{}'.format(
+                        parameters['project'], parameters['head_rev'], parameters['pushlog_id']),
+                    'tc-treeherder-stage.v2.{}.{}.{}'.format(
+                        parameters['project'], parameters['head_rev'], parameters['pushlog_id']),
+                ],
+                'payload': {
+                    'env': {
+                        'GECKO_BASE_REPOSITORY': 'https://hg.mozilla.org/mozilla-unified',
+                        'GECKO_HEAD_REPOSITORY': parameters['head_repository'],
+                        'GECKO_HEAD_REF': parameters['head_ref'],
+                        'GECKO_HEAD_REV': parameters['head_rev'],
+                        'HG_STORE_PATH': '/home/worker/checkouts/hg-store',
+                        'ACTION_TASK_GROUP_ID': {'$eval': 'taskGroupId'},
+                        'ACTION_TASK_ID': {'$dumps': {'$eval': 'taskId'}},
+                        'ACTION_TASK': {'$dumps': {'$eval': 'task'}},
+                        'ACTION_INPUT': {'$dumps': {'$eval': 'input'}},
+                        'ACTION_CALLBACK': cb.__name__,
+                        'ACTION_PARAMETERS': {'$dumps': {'$eval': 'parameters'}},
+                    },
+                    'cache': {
+                        'level-{}-checkouts'.format(parameters['level']):
+                            '/home/worker/checkouts',
+                    },
+                    'features': {
+                        'taskclusterProxy': True,
+                        'chainOfTrust': True,
+                    },
+                    'image': docker_image('decision'),
+                    'maxRunTime': 1800,
+                    'command': [
+                        '/home/worker/bin/run-task', '--vcs-checkout=/home/worker/checkouts/gecko',
+                        '--', 'bash', '-cx',
+                        """\
+cd /home/worker/checkouts/gecko &&
+ln -s /home/worker/artifacts artifacts &&
+./mach --log-no-times taskgraph action-callback """ + ' '.join([
+                            "--pushlog-id='{}'".format(parameters['pushlog_id']),
+                            "--pushdate='{}'".format(parameters['pushdate']),
+                            "--project='{}'".format(parameters['project']),
+                            "--message='{}'".format(parameters['message'].replace("'", "'\\''")),
+                            "--owner='{}'".format(parameters['owner']),
+                            "--level='{}'".format(parameters['level']),
+                            "--base-repository='https://hg.mozilla.org/mozilla-central'",
+                            "--head-repository='{}'".format(parameters['head_repository']),
+                            "--head-ref='{}'".format(parameters['head_ref']),
+                            "--head-rev='{}'".format(parameters['head_rev']),
+                            "--revision-hash='{}'\n".format(parameters['head_rev']),
+                        ]),
+                    ],
+                },
+                'extra': {
+                      'treeherder': {
+                        'groupName': 'action-callback',
+                        'groupSymbol': 'AC',
+                        'symbol': symbol,
+                      },
+                },
+            }
+        mem['registered'] = True
+        callbacks[cb.__name__] = cb
+    return register_callback
+
+
+def render_actions_json(parameters):
+    """
+    Render JSON object for the ``public/actions.json`` artifact.
+
+    Parameters
+    ----------
+    parameters : taskgraph.parameters.Parameters
+        Decision task parameters.
+
+    Returns
+    -------
+    dict
+        JSON object representation of the ``public/actions.json`` artifact.
+    """
+    global actions
+    assert isinstance(parameters, Parameters), 'requires instance of Parameters'
+    result = []
+    for action in sorted(actions, key=lambda action: action.order):
+        task = action.task_template_builder(parameters)
+        if task:
+            assert is_json(task), 'task must be a JSON compatible object'
+            result.append({
+                'title': action.title,
+                'description': action.description,
+                'context': action.context,
+                'schema': action.schema,
+                'task': task,
+            })
+    return {
+        'version': 1,
+        'variables': {
+            'parameters': dict(**parameters),
+        },
+        'actions': result,
+    }
+
+
+def trigger_action_callback():
+    """
+    Trigger action callback using arguments from environment variables.
+    """
+    global callbacks
+    task_group_id = os.environ.get('ACTION_TASK_GROUP_ID', None)
+    task_id = json.loads(os.environ.get('ACTION_TASK_ID', 'null'))
+    task = json.loads(os.environ.get('ACTION_TASK', 'null'))
+    input = json.loads(os.environ.get('ACTION_INPUT', 'null'))
+    callback = os.environ.get('ACTION_CALLBACK', None)
+    parameters = json.loads(os.environ.get('ACTION_PARAMETERS', 'null'))
+    cb = callbacks.get(callback, None)
+    if not cb:
+        raise Exception('Unknown callback: {}'.format(callback))
+    cb(Parameters(**parameters), input, task_group_id, task_id, task)
+
+
+# Load all modules from this folder, relying on the side-effects of register_
+# functions to populate the action registry.
+for f in os.listdir(os.path.dirname(__file__)):
+        if f.endswith('.py') and f not in ('__init__.py', 'registry.py'):
+            __import__('actions.' + f[:-3])
new file mode 100644
--- /dev/null
+++ b/taskcluster/docs/in-tree-actions.rst
@@ -0,0 +1,229 @@
+Writing Treeherder Actions in-tree
+==================================
+This document shows how to define an action in-tree such that it shows up in
+supported user interfaces like Treeherder. For details on interface between
+in-tree logic and external user interfaces, see
+:doc:`the specification for actions.json <action-spec>`.
+
+
+Creating a Callback Action
+--------------------------
+A *callback action* is an action that calls back into in-tree logic. That is
+you register the action with title, description, context, input schema and a
+python callback. When the action is triggered in a user interface,
+input matching the schema is collected, passed to a new task which then calls
+your python callback, enabling it to do pretty much anything it wants to.
+
+To create a new action you must create a file
+``/taskcluster/actions/my-action.py``, that at minimum contains::
+
+  from registry import register_callback_action
+
+  @register_callback_action(
+      title='Say Hello',
+      symbol='hw',  # Show the callback task in treeherder as 'hw'
+      description="Simple **proof-of-concept** callback action",
+      order=10000,  # Order in which it should appear relative to other actions
+  )
+  def hello_world_action(parameters, input, task_group_id, task_id, task):
+      # parameters is an instance of taskgraph.parameters.Parameters
+      # it carries decision task parameters from the original decision task.
+      # input, task_id, and task should all be None
+      print "Hello was triggered from taskGroupId: " + taskGroupId
+
+The example above defines an action that is available in the context-menu for
+the entire task-group (result-set or push in Treeherder terminology). To create
+an action that shows up in the context menu for a task we must specify the
+``context`` parameter.
+
+
+Setting the Action Context
+--------------------------
+The context parameter should be a list of tag-sets, such as
+``context=[{"platform": "linux"}]``, which will make the task show up in the
+context-menu for any task with ``task.tags.platform = 'linux'``. Below is
+some examples of context parameters and the resulting conditions on
+``task.tags`` (tags used below are just illustrative).
+
+``context=[{"platform": "linux"}]``:
+  Requires ``task.tags.platform = 'linux'``.
+``context=[{"kind": "test", "platform": "linux"}]``:
+  Requires ``task.tags.platform = 'linux'`` **and** ``task.tags.kind = 'test'``.
+``context=[{"kind": "test"}, {"platform": "linux"}]``:
+  Requires ``task.tags.platform = 'linux'`` **or** ``task.tags.kind = 'test'``.
+``context=[{}]``:
+  Requires nothing and the action will show up in the context menu for all tasks.
+``context=[]``:
+  Is the same as not setting the context parameter, which will make the action
+  show up in the context menu for the task-group.
+  (ie. the action is not specific to some task)
+
+The example action below will be shown in the context-menu for tasks with
+``task.tags.platform = 'linux'``::
+
+  from registry import register_callback_action
+
+  @register_callback_action(
+      title='Retrigger',
+      symbol='re-c',  # Show the callback task in treeherder as 're-c'
+      description="Create a clone of the task",
+      order=1,
+      context=[{'platform': 'linux'}]
+  )
+  def retrigger_action(parameters, input, task_group_id, task_id, task):
+      # input will be None
+      print "Retriggering: {}".format(task_id)
+      print "task definition: {}".format(task)
+
+When the ``context`` parameter is set, the ``task_id`` and ``task`` parameters
+will provided to the callback. In this case the ``task_id`` and ``task``
+parameters will be the ``taskId`` and *task definition* of the task from whose
+context-menu the action was triggered.
+
+Typically, the ``context`` parameter is used for actions that operates on
+tasks, such as retriggering, running a specific test case, creating a loaner,
+bisection, etc. You can think of the context as a place the action should
+appear, but it's also very much a form of input the action can use.
+
+
+Specifying an Input Schema
+--------------------------
+In call examples so far the ``input`` parameter for the callback have been
+``None``, to make an action that takes input you must specify an input schema.
+This is done by passing a JSON schema as the ``schema`` parameter.
+
+When designing a schema for the input it is important to exploit as many of the
+JSON schema validation features as reasonably possible. Furthermore, it is
+*strongly* encouraged that the ``title`` and ``description`` properties in
+JSON schemas is used to provide a detailed explanation of what the input
+value will do. Authors can reasonably expect JSON schema ``description``
+properties to be rendered as markdown before being presented.
+
+The example below illustrates how to specify an input schema. Notice that while
+this example doesn't specify a ``context`` it is perfectly legal to specify
+both ``input`` and ``context``::
+
+  from registry import register_callback_action
+
+  @register_callback_action(
+      title='Run All Tasks',
+      symbol='ra-c',  # Show the callback task in treeherder as 'ra-c'
+      description="**Run all tasks** that have been _optimized_ away.",
+      order=1,
+      input={
+          'title': 'Action Options',
+          'description': 'Options for how you wish to run all tasks',
+          'properties': {
+              'priority': {
+                  'title': 'priority'
+                  'description': 'Priority that should be given to the tasks',
+                  'type': 'string',
+                  'enum': ['low', 'normal', 'high'],
+                  'default': 'low',
+              },
+              'runTalos': {
+                  'title': 'Run Talos'
+                  'description': 'Do you wish to also include talos tasks?',
+                  'type': 'boolean',
+                  'default': 'false',
+              }
+          },
+          'required': ['priority', 'runTalos'],
+          'additionalProperties': False,
+      },
+  )
+  def retrigger_action(parameters, input, task_group_id, task_id, task):
+      print "Create all pruned tasks with priority: {}".format(input['priority'])
+      if input['runTalos']:
+          print "Also running talos jobs..."
+
+When the ``schema`` parameter is given the callback will always be called with
+an ``input`` parameter that satisfies the previously given JSON schema.
+It is encouraged to set ``additionalProperties: false``, as well as specifying
+all properties as ``required`` in the JSON schema. Furthermore, it's good
+practice to provide ``default`` values for properties, user interface generators
+will often take advantage of such properties.
+
+Once you have specified input and context as applicable for your action you can
+do pretty much anything you want from within your callback. Whether you want
+to create one or more tasks or run a specific piece of code like a test.
+
+
+Conditional Availability
+------------------------
+The decision parameters ``taskgraph.parameters.Parameters`` passed to
+the callback is also available when the decision task generates the list of
+actions to be displayed in the user interface. When registering an action
+callback the ``availability`` parameter can be used to specify a lambda function
+that given the decision parameters determines if the action should be available.
+The feature is illustrated below::
+
+  from registry import register_callback_action
+
+  @register_callback_action(
+      title='Say Hello',
+      symbol='hw',  # Show the callback task in treeherder as 'hw'
+      description="Simple **proof-of-concept** callback action",
+      order=2,
+      # Define an action that is only included if this is a push to try
+      available=lambda parameters: parameters.get('project', None) == 'try',
+  )
+  def try_only_action(parameters, input, task_group_id, task_id, task):
+      print "My try-only action"
+
+Properties of ``parameters``  is documented in the
+:doc:`parameters section <parameters>`. You can also checkout the
+``parameters.yml`` artifact created by decisions tasks.
+
+
+Skipping the Action Callback
+----------------------------
+It is possible to define an action that doesn't take a callback, instead you'll
+then have to provide a task template. For details on how the task template
+language works refer to :doc:`the specification for actions.json <action-spec>`,
+the example below illustrates how to create such an action::
+
+  from registry import register_task_action
+
+  @register_task_action(
+      title='Retrigger',
+      description="Create a clone of the task",
+      order=1,
+      context=[{'platform': 'linux'}],
+      input={
+          'title': 'priority'
+          'description': 'Priority that should be given to the tasks',
+          'type': 'string',
+          'enum': ['low', 'normal', 'high'],
+          'default': 'low',
+      },
+  def task_template_builder(parameters):
+      # The task template builder may return None to signal that the action
+      # isn't available.
+      if parameters.get('project', None) != 'try':
+        return None
+      return {
+          'created': {'$fromNow': ''},
+          'deadline': {'$fromNow': '1 hour'},
+          'expires': {'$fromNow': '14 days'},
+          'provisionerId': '...',
+          'workerType': '...',
+          'priority': '${input}',
+          'payload': {
+              'command': '...',
+              'env': {
+                  'TASK_DEFINITION': {'$json': {'eval': 'task'}}
+              },
+              ...
+          },
+          # It's now your responsibility to include treeherder routes, as well
+          # additional metadata for treeherder in task.extra.treeherder.
+          ...
+      },
+  )
+
+This kind of actions is rarely useful due to the limited expressiveness of the
+template language. For further details on the template see
+:doc:`the specification for actions.json <action-spec>`. Obviously, this kind of
+action can be slightly more responsive because it doesn't create an intermediate
+task in-order to trigger a python callback inside the tree.
--- a/taskcluster/docs/index.rst
+++ b/taskcluster/docs/index.rst
@@ -23,10 +23,11 @@ check out the :doc:`how-to section <how-
 
     taskgraph
     loading
     transforms
     yaml-templates
     docker-images
     cron
     how-tos
+    in-tree-actions
     action-spec
     reference
--- a/taskcluster/mach_commands.py
+++ b/taskcluster/mach_commands.py
@@ -297,16 +297,22 @@ class MachCommands(MachCommandBase):
         import taskgraph.action
         try:
             self.setup_logging()
             return taskgraph.action.add_talos(options['decision_task_id'], options['times'])
         except Exception:
             traceback.print_exc()
             sys.exit(1)
 
+    @SubCommand('taskgraph', 'action-callback',
+                description='Run action callback used by action tasks')
+    def action_callback(self, **options):
+        import actions
+        actions.trigger_action_callback()
+
     def setup_logging(self, quiet=False, verbose=True):
         """
         Set up Python logging for all loggers, sending results to stderr (so
         that command output can be redirected easily) and adding the typical
         mach timestamp.
         """
         # remove the old terminal handler
         old = self.log_manager.replace_terminal_handler(None)
--- a/taskcluster/taskgraph/decision.py
+++ b/taskcluster/taskgraph/decision.py
@@ -12,16 +12,17 @@ import re
 
 import time
 import yaml
 
 from .generator import TaskGraphGenerator
 from .create import create_tasks
 from .parameters import Parameters
 from .taskgraph import TaskGraph
+from actions import render_actions_json
 
 from taskgraph.util.templates import Templates
 from taskgraph.util.time import (
     json_time_from_now,
     current_json_time,
 )
 
 logger = logging.getLogger(__name__)
@@ -82,16 +83,19 @@ def taskgraph_decision(options):
         parameters=parameters)
 
     # write out the parameters used to generate this graph
     write_artifact('parameters.yml', dict(**parameters))
 
     # write out the yml file for action tasks
     write_artifact('action.yml', get_action_yml(parameters))
 
+    # write out the public/actions.json file
+    write_artifact('actions.json', render_actions_json(parameters))
+
     # write out the full graph for reference
     full_task_json = tgg.full_task_graph.to_json()
     write_artifact('full-task-graph.json', full_task_json)
 
     # this is just a test to check whether the from_json() function is working
     _, _ = TaskGraph.from_json(full_task_json)
 
     # write out the target task set to allow reproducing this as input