diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php
--- a/src/__phutil_library_map__.php
+++ b/src/__phutil_library_map__.php
@@ -1324,6 +1324,7 @@
     'HarbormasterLogWorker' => 'applications/harbormaster/worker/HarbormasterLogWorker.php',
     'HarbormasterManagementArchiveLogsWorkflow' => 'applications/harbormaster/management/HarbormasterManagementArchiveLogsWorkflow.php',
     'HarbormasterManagementBuildWorkflow' => 'applications/harbormaster/management/HarbormasterManagementBuildWorkflow.php',
+    'HarbormasterManagementPublishWorkflow' => 'applications/harbormaster/management/HarbormasterManagementPublishWorkflow.php',
     'HarbormasterManagementRebuildLogWorkflow' => 'applications/harbormaster/management/HarbormasterManagementRebuildLogWorkflow.php',
     'HarbormasterManagementRestartWorkflow' => 'applications/harbormaster/management/HarbormasterManagementRestartWorkflow.php',
     'HarbormasterManagementUpdateWorkflow' => 'applications/harbormaster/management/HarbormasterManagementUpdateWorkflow.php',
@@ -6675,6 +6676,7 @@
     'HarbormasterLogWorker' => 'HarbormasterWorker',
     'HarbormasterManagementArchiveLogsWorkflow' => 'HarbormasterManagementWorkflow',
     'HarbormasterManagementBuildWorkflow' => 'HarbormasterManagementWorkflow',
+    'HarbormasterManagementPublishWorkflow' => 'HarbormasterManagementWorkflow',
     'HarbormasterManagementRebuildLogWorkflow' => 'HarbormasterManagementWorkflow',
     'HarbormasterManagementRestartWorkflow' => 'HarbormasterManagementWorkflow',
     'HarbormasterManagementUpdateWorkflow' => 'HarbormasterManagementWorkflow',
diff --git a/src/applications/harbormaster/engine/HarbormasterBuildEngine.php b/src/applications/harbormaster/engine/HarbormasterBuildEngine.php
--- a/src/applications/harbormaster/engine/HarbormasterBuildEngine.php
+++ b/src/applications/harbormaster/engine/HarbormasterBuildEngine.php
@@ -489,6 +489,8 @@
       }
     }
 
+    $old = clone $buildable;
+
     // Don't update the buildable status if we're still preparing builds: more
     // builds may still be scheduled shortly, so even if every build we know
     // about so far has passed, that doesn't mean the buildable has actually
@@ -515,8 +517,7 @@
         $new_status = HarbormasterBuildableStatus::STATUS_BUILDING;
       }
 
-      $old_status = $buildable->getBuildableStatus();
-      $did_update = ($old_status != $new_status);
+      $did_update = ($old->getBuildableStatus() !== $new_status);
       if ($did_update) {
         $buildable->setBuildableStatus($new_status);
         $buildable->save();
@@ -530,81 +531,45 @@
       return;
     }
 
-    // If we changed the buildable status, try to post a transaction to the
-    // object about it. We can safely do this outside of the locked region.
+    $this->publishBuildable($old, $buildable);
+  }
 
-    // NOTE: We only post transactions for automatic buildables, not for
-    // manual ones: manual builds are test builds, whoever is doing tests
-    // can look at the results themselves, and other users generally don't
-    // care about the outcome.
+  public function publishBuildable(
+    HarbormasterBuildable $old,
+    HarbormasterBuildable $new) {
 
-    $should_publish =
-      ($did_update) &&
-      ($new_status != HarbormasterBuildableStatus::STATUS_BUILDING) &&
-      (!$buildable->getIsManualBuildable());
+    $viewer = $this->getViewer();
 
-    if (!$should_publish) {
-      return;
-    }
+    // Publish the buildable. We publish buildables even if they haven't
+    // changed status in Harbormaster because applications may care about
+    // different things than Harbormaster does. For example, Differential
+    // does not care about local lint and unit tests when deciding whether
+    // a revision should move out of draft or not.
+
+    // NOTE: We're publishing both automatic and manual buildables. Buildable
+    // objects should generally ignore manual buildables, but it's up to them
+    // to decide.
 
     $object = id(new PhabricatorObjectQuery())
       ->setViewer($viewer)
-      ->withPHIDs(array($buildable->getBuildablePHID()))
+      ->withPHIDs(array($new->getBuildablePHID()))
       ->executeOne();
     if (!$object) {
       return;
     }
 
-    $publish_phid = $object->getHarbormasterPublishablePHID();
-    if (!$publish_phid) {
-      return;
-    }
-
-    if ($publish_phid === $object->getPHID()) {
-      $publish = $object;
-    } else {
-      $publish = id(new PhabricatorObjectQuery())
-        ->setViewer($viewer)
-        ->withPHIDs(array($publish_phid))
-        ->executeOne();
-      if (!$publish) {
-        return;
-      }
-    }
+    $engine = HarbormasterBuildableEngine::newForObject($object, $viewer);
 
-    if (!($publish instanceof PhabricatorApplicationTransactionInterface)) {
-      return;
-    }
-
-    $template = $publish->getApplicationTransactionTemplate();
-    if (!$template) {
-      return;
-    }
-
-    $template
-      ->setTransactionType(PhabricatorTransactions::TYPE_BUILDABLE)
-      ->setMetadataValue(
-        'harbormaster:buildablePHID',
-        $buildable->getPHID())
-      ->setOldValue($old_status)
-      ->setNewValue($new_status);
+    $daemon_source = PhabricatorContentSource::newForSource(
+      PhabricatorDaemonContentSource::SOURCECONST);
 
     $harbormaster_phid = id(new PhabricatorHarbormasterApplication())
       ->getPHID();
 
-    $daemon_source = PhabricatorContentSource::newForSource(
-      PhabricatorDaemonContentSource::SOURCECONST);
-
-    $editor = $publish->getApplicationTransactionEditor()
-      ->setActor($viewer)
+    $engine
       ->setActingAsPHID($harbormaster_phid)
       ->setContentSource($daemon_source)
-      ->setContinueOnNoEffect(true)
-      ->setContinueOnMissingFields(true);
-
-    $editor->applyTransactions(
-      $publish->getApplicationTransactionObject(),
-      array($template));
+      ->publishBuildable($old, $new);
   }
 
   private function releaseAllArtifacts(HarbormasterBuild $build) {
diff --git a/src/applications/harbormaster/engine/HarbormasterBuildableEngine.php b/src/applications/harbormaster/engine/HarbormasterBuildableEngine.php
--- a/src/applications/harbormaster/engine/HarbormasterBuildableEngine.php
+++ b/src/applications/harbormaster/engine/HarbormasterBuildableEngine.php
@@ -1,4 +1,101 @@
 <?php
 
 abstract class HarbormasterBuildableEngine
-  extends Phobject {}
+  extends Phobject {
+
+  private $viewer;
+  private $actingAsPHID;
+  private $contentSource;
+  private $object;
+
+  final public function setViewer(PhabricatorUser $viewer) {
+    $this->viewer = $viewer;
+    return $this;
+  }
+
+  final public function getViewer() {
+    return $this->viewer;
+  }
+
+  final public function setActingAsPHID($acting_as_phid) {
+    $this->actingAsPHID = $acting_as_phid;
+    return $this;
+  }
+
+  final public function getActingAsPHID() {
+    return $this->actingAsPHID;
+  }
+
+  final public function setContentSource(
+    PhabricatorContentSource $content_source) {
+    $this->contentSource = $content_source;
+    return $this;
+  }
+
+  final public function getContentSource() {
+    return $this->contentSource;
+  }
+
+  final public function setObject(HarbormasterBuildableInterface $object) {
+    $this->object = $object;
+    return $this;
+  }
+
+  final public function getObject() {
+    return $this->object;
+  }
+
+  final public function publishBuildable(
+    HarbormasterBuildable $old,
+    HarbormasterBuildable $new) {
+    return;
+  }
+
+  final public static function newForObject(
+    HarbormasterBuildableInterface $object,
+    PhabricatorUser $viewer) {
+    return $object->newBuildableEngine()
+      ->setViewer($viewer)
+      ->setObject($object);
+  }
+
+  final protected function newEditor() {
+    $publishable = $this->getObject();
+
+    $viewer = $this->getViewer();
+
+    $editor = $publishable->getApplicationTransactionEditor()
+      ->setActor($viewer)
+      ->setContinueOnNoEffect(true)
+      ->setContinueOnMissingFields(true);
+
+    $acting_as_phid = $this->getActingAsPHID();
+    if ($acting_as_phid !== null) {
+      $editor->setActingAsPHID($acting_as_phid);
+    }
+
+    $content_source = $this->getContentSource();
+    if ($content_source !== null) {
+      $editor->setContentSource($content_source);
+    }
+
+    return $editor;
+  }
+
+  final protected function newTransaction() {
+    $publishable = $this->getObject();
+
+    return $publishable->getApplicationTransactionTemplate();
+  }
+
+  final protected function applyTransactions(array $xactions) {
+    $publishable = $this->getObject();
+    $editor = $this->newEditor();
+
+    $editor->applyTransactions(
+      $publishable->getApplicationTransactionObject(),
+      $xactions);
+  }
+
+
+}
diff --git a/src/applications/harbormaster/management/HarbormasterManagementPublishWorkflow.php b/src/applications/harbormaster/management/HarbormasterManagementPublishWorkflow.php
new file mode 100644
--- /dev/null
+++ b/src/applications/harbormaster/management/HarbormasterManagementPublishWorkflow.php
@@ -0,0 +1,87 @@
+<?php
+
+final class HarbormasterManagementPublishWorkflow
+  extends HarbormasterManagementWorkflow {
+
+  protected function didConstruct() {
+    $this
+      ->setName('publish')
+      ->setExamples(pht('**publish** __buildable__ ...'))
+      ->setSynopsis(
+        pht(
+          'Publish a buildable. This is primarily useful for developing '.
+          'and debugging applications which have buildable objects.'))
+      ->setArguments(
+        array(
+          array(
+            'name' =>  'buildable',
+            'wildcard' => true,
+          ),
+        ));
+  }
+
+  public function execute(PhutilArgumentParser $args) {
+    $viewer = $this->getViewer();
+
+    $buildable_names = $args->getArg('buildable');
+    if (!$buildable_names) {
+      throw new PhutilArgumentUsageException(
+        pht(
+          'Name one or more buildables to publish, like "B123".'));
+    }
+
+    $query = id(new PhabricatorObjectQuery())
+      ->setViewer($viewer)
+      ->withNames($buildable_names);
+
+    $query->execute();
+
+    $result_map = $query->getNamedResults();
+
+    foreach ($buildable_names as $name) {
+      if (!isset($result_map[$name])) {
+        throw new PhutilArgumentUsageException(
+          pht(
+            'Argument "%s" does not name a buildable. Provide one or more '.
+            'valid buildable monograms or PHIDs.',
+            $name));
+      }
+    }
+
+    foreach ($result_map as $name => $result) {
+      if (!($result instanceof HarbormasterBuildable)) {
+        throw new PhutilArgumentUsageException(
+          pht(
+            'Object "%s" is not a HarbormasterBuildable (it is a "%s"). '.
+            'Name one or more buildables to publish, like "B123".',
+            get_class($result)));
+      }
+    }
+
+    foreach ($result_map as $buildable) {
+      echo tsprintf(
+        "%s\n",
+        pht(
+          'Publishing "%s"...',
+        $buildable->getMonogram()));
+
+      // Reload the buildable to pick up builds.
+      $buildable = id(new HarbormasterBuildableQuery())
+        ->setViewer($viewer)
+        ->withIDs(array($buildable->getID()))
+        ->needBuilds(true)
+        ->executeOne();
+
+      $engine = id(new HarbormasterBuildEngine())
+        ->setViewer($viewer)
+        ->publishBuildable($buildable, $buildable);
+    }
+
+    echo tsprintf(
+      "%s\n",
+      pht('Done.'));
+
+    return 0;
+  }
+
+}