diff --git a/reviewboard/static/rb/js/extensions/models/publishingDraftReviewHookModel.js b/reviewboard/static/rb/js/extensions/models/publishingDraftReviewHookModel.js
new file mode 100644
index 0000000000000000000000000000000000000000..5d0c6d3cf65003abb13293f8cdfd9d0d4464f46a
--- /dev/null
+++ b/reviewboard/static/rb/js/extensions/models/publishingDraftReviewHookModel.js
@@ -0,0 +1,35 @@
+/**
+ * Hook to allow, warn about, or abort a draft review being published.
+ *
+ * Extensions should provide a callback method (via the shouldPublish
+ * attribute) that will be called with the current DraftReview instance
+ * as an argument and should return either 'allow', 'warn', or 'abort' as
+ * well as a message that will be shown for 'warn' and 'block'.
+ *
+ * Warnings will be presented as a confirmation prompt dialog that will
+ * show the user the message and allow them to either abort the review or
+ * force the publish.
+ *
+ * Abortions will be presented as an alert box which informs the users
+ * as to why the publish was aborted using the message provided.
+ *
+ * The required validatePublish attribute should be a callback function
+ * with the signature validatePublish(draftReview, setValidation). Where
+ * setValidation is a callback method provided by the framework with the
+ * signature setValidation(isValid, message, allowContinue). Call this method
+ * with the appropriate arguments based on whether the draft valid; set
+ * allowContinue to true to display a warning instead of aborting the publish.
+ */
+RB.PublishingDraftReviewHook = RB.ExtensionHook.extend({
+    hookPoint: new RB.ExtensionHookPoint(),
+
+    defaults: _.defaults({
+        validatePublish: null
+    }, RB.ExtensionHook.prototype.defaults),
+
+    setUpHook: function() {
+        console.assert(_.isFunction(this.get('validatePublish')),
+                       'PublishingDraftReviewHook instance does not have a ' +
+                       '"validatePublish" attribute set.');
+    }
+});
diff --git a/reviewboard/static/rb/js/resources/models/draftReviewModel.js b/reviewboard/static/rb/js/resources/models/draftReviewModel.js
index da767fc3a42df5aab0f2a317174883dea797dc13..bbf012fee20c43295090276e5b9c7b686aefec63 100644
--- a/reviewboard/static/rb/js/resources/models/draftReviewModel.js
+++ b/reviewboard/static/rb/js/resources/models/draftReviewModel.js
@@ -10,38 +10,75 @@ RB.DraftReview = RB.Review.extend(_.extend({
     /*
      * Publishes the review.
      *
-     * Before publish, the "publishing" event will be triggered.
+     * First, all registered PublishingDraftReviewHooks will be called
+     * to confirm whether the review should be published. These hooks
+     * can allow, block, or warn about a review being published.
+     *
+     * Once the publish is allowed, the "publishing" event will be triggered.
      *
      * After the publish has succeeded, the "published" event will be
      * triggered.
      */
     publish: function(options, context) {
+        var hookValidations;
         options = options || {};
 
-        this.trigger('publishing');
+        hookValidations =
+            RB.PublishingDraftReviewHook.eachAsync(function(deferred, hook) {
+                var validatePublish;
+                function setValidation(isValid, message, allowContinue) {
+                    var forcePublish;
 
-        this.ready({
-            ready: function() {
-                this.set('public', true);
-                this.save({
-                    attrs: options.attrs,
-                    success: function() {
-                        this.trigger('published');
+                    if(isValid) {
+                        deferred.resolve();
+                    } else if(allowContinue) {
+                        forcePublish = confirm(
+                            message + '\n' +
+                            gettext("Do you want to continue publishing?"));
 
-                        if (_.isFunction(options.success)) {
-                            options.success.call(context);
+                        if(forcePublish) {
+                            deferred.resolve();
                         }
-                    },
-                    error: function(model, xhr) {
-                        model.trigger('publishError', xhr.errorText);
-
-                        if (_.isFunction(options.error)) {
-                            options.error.call(context, model, xhr);
+                        else {
+                            deferred.reject();
                         }
                     }
-                }, this);
-            },
-            error: error
-        }, this);
+                    else {
+                        alert(message);
+                        deferred.reject();
+                    }
+                }
+
+                validatePublish = (hook.get('validatePublish'));
+                validatePublish(this, setValidation);
+            }, this);
+
+        hookValidations.done(_.bind(function() {
+            this.trigger('publishing');
+
+            this.ready({
+                ready: function() {
+                    this.set('public', true);
+                    this.save({
+                        attrs: options.attrs,
+                        success: function() {
+                            this.trigger('published');
+
+                            if (_.isFunction(options.success)) {
+                                options.success.call(context);
+                            }
+                        },
+                        error: function(model, xhr) {
+                            model.trigger('publishError', xhr.errorText);
+
+                            if (_.isFunction(options.error)) {
+                                options.error.call(context, model, xhr);
+                            }
+                        }
+                    }, this);
+                },
+                error: error
+            }, this);
+        }, this));
     }
 }, RB.DraftResourceModelMixin));
diff --git a/reviewboard/staticbundles.py b/reviewboard/staticbundles.py
index 302f017460e8eb6f33620f54b28dd6a366714ca8..15fc304f064a90fab12e4617a4cf2cbb2c8e272d 100644
--- a/reviewboard/staticbundles.py
+++ b/reviewboard/staticbundles.py
@@ -116,6 +116,7 @@ PIPELINE_JS = dict({
             'rb/js/extensions/models/commentDialogHookModel.js',
             'rb/js/extensions/models/reviewDialogCommentHookModel.js',
             'rb/js/extensions/models/reviewDialogHookModel.js',
+            'rb/js/extensions/models/publishingDraftReviewHookModel.js',
             'rb/js/pages/models/pageManagerModel.js',
             'rb/js/resources/utils/serializers.js',
             'rb/js/resources/models/baseResourceModel.js',
