← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~cjwatson/launchpad:charmhub-upload-job into launchpad:master

 

Colin Watson has proposed merging ~cjwatson/launchpad:charmhub-upload-job into launchpad:master.

Commit message:
Add a job for uploading builds to Charmhub

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/+git/launchpad/+merge/407975
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:charmhub-upload-job into launchpad:master.
diff --git a/database/schema/security.cfg b/database/schema/security.cfg
index 53b3232..b654cba 100644
--- a/database/schema/security.cfg
+++ b/database/schema/security.cfg
@@ -2809,6 +2809,7 @@ public.webhookjob                       = SELECT, INSERT
 type=user
 groups=script
 public.account                          = SELECT
+public.archive                          = SELECT
 public.builder                          = SELECT
 public.buildfarmjob                     = SELECT, INSERT
 public.buildqueue                       = SELECT, INSERT, UPDATE
diff --git a/lib/lp/charms/configure.zcml b/lib/lp/charms/configure.zcml
index 122c79b..71d5a92 100644
--- a/lib/lp/charms/configure.zcml
+++ b/lib/lp/charms/configure.zcml
@@ -122,5 +122,17 @@
         <allow interface="lp.charms.interfaces.charmrecipejob.ICharmRecipeJob" />
         <allow interface="lp.charms.interfaces.charmrecipejob.ICharmRecipeRequestBuildsJob" />
     </class>
+    <class class="lp.charms.model.charmrecipebuildjob.CharmRecipeBuildJob">
+        <allow interface="lp.charms.interfaces.charmrecipebuildjob.ICharmRecipeBuildJob" />
+    </class>
+    <securedutility
+        component="lp.charms.model.charmrecipebuildjob.CharmhubUploadJob"
+        provides="lp.charms.interfaces.charmrecipebuildjob.ICharmhubUploadJobSource">
+        <allow interface="lp.charms.interfaces.charmrecipebuildjob.ICharmhubUploadJobSource" />
+    </securedutility>
+    <class class="lp.charms.model.charmrecipebuildjob.CharmhubUploadJob">
+        <allow interface="lp.charms.interfaces.charmrecipebuildjob.ICharmRecipeBuildJob" />
+        <allow interface="lp.charms.interfaces.charmrecipebuildjob.ICharmhubUploadJob" />
+    </class>
 
 </configure>
diff --git a/lib/lp/charms/emailtemplates/charmrecipebuild-releasefailed.txt b/lib/lp/charms/emailtemplates/charmrecipebuild-releasefailed.txt
new file mode 100644
index 0000000..e7006dc
--- /dev/null
+++ b/lib/lp/charms/emailtemplates/charmrecipebuild-releasefailed.txt
@@ -0,0 +1,3 @@
+Launchpad asked Charmhub to release this charm, but it failed:
+
+  %(store_error_message)s
diff --git a/lib/lp/charms/emailtemplates/charmrecipebuild-reviewfailed.txt b/lib/lp/charms/emailtemplates/charmrecipebuild-reviewfailed.txt
new file mode 100644
index 0000000..f23686b
--- /dev/null
+++ b/lib/lp/charms/emailtemplates/charmrecipebuild-reviewfailed.txt
@@ -0,0 +1,4 @@
+Launchpad uploaded this charm to Charmhub, but Charmhub failed to review
+it:
+
+  %(store_error_message)s
diff --git a/lib/lp/charms/emailtemplates/charmrecipebuild-unauthorized.txt b/lib/lp/charms/emailtemplates/charmrecipebuild-unauthorized.txt
new file mode 100644
index 0000000..953eeb9
--- /dev/null
+++ b/lib/lp/charms/emailtemplates/charmrecipebuild-unauthorized.txt
@@ -0,0 +1,6 @@
+Launchpad could not upload this charm to Charmhub because of an
+authorization failure.  If you still want Launchpad to upload this charm
+to Charmhub on your behalf, you will need to reauthorize it.  You can do
+that by following the instructions here:
+
+  %(recipe_authorize_url)s
diff --git a/lib/lp/charms/emailtemplates/charmrecipebuild-uploadfailed.txt b/lib/lp/charms/emailtemplates/charmrecipebuild-uploadfailed.txt
new file mode 100644
index 0000000..446543a
--- /dev/null
+++ b/lib/lp/charms/emailtemplates/charmrecipebuild-uploadfailed.txt
@@ -0,0 +1,7 @@
+Launchpad failed to upload this charm to Charmhub:
+
+  %(store_error_message)s
+
+You can retry the upload here:
+
+  %(build_url)s
diff --git a/lib/lp/charms/interfaces/charmhubclient.py b/lib/lp/charms/interfaces/charmhubclient.py
index 4e9a528..57c069f 100644
--- a/lib/lp/charms/interfaces/charmhubclient.py
+++ b/lib/lp/charms/interfaces/charmhubclient.py
@@ -8,6 +8,7 @@ __all__ = [
     "BadRequestPackageUploadResponse",
     "BadReviewStatusResponse",
     "ICharmhubClient",
+    "CharmhubError",
     "ReleaseFailedResponse",
     "ReviewFailedResponse",
     "UnauthorizedUploadResponse",
diff --git a/lib/lp/charms/interfaces/charmrecipebuild.py b/lib/lp/charms/interfaces/charmrecipebuild.py
index 45b57a4..6f162cc 100644
--- a/lib/lp/charms/interfaces/charmrecipebuild.py
+++ b/lib/lp/charms/interfaces/charmrecipebuild.py
@@ -10,7 +10,10 @@ __all__ = [
     "ICharmRecipeBuildSet",
     ]
 
-from lazr.restful.fields import Reference
+from lazr.restful.fields import (
+    CollectionField,
+    Reference,
+    )
 from zope.interface import (
     Attribute,
     Interface,
@@ -107,6 +110,16 @@ class ICharmRecipeBuildView(IPackageBuild):
             "The revision ID of the branch used for this build, if "
             "available."))
 
+    store_upload_jobs = CollectionField(
+        title=_("Store upload jobs for this build."),
+        # Really ICharmhubUploadJob.
+        value_type=Reference(schema=Interface),
+        readonly=True)
+
+    # Really ICharmhubUploadJob.
+    last_store_upload_job = Reference(
+        title=_("Last store upload job for this build."), schema=Interface)
+
     store_upload_metadata = Attribute(
         _("A dict of data about store upload progress."))
 
diff --git a/lib/lp/charms/interfaces/charmrecipebuildjob.py b/lib/lp/charms/interfaces/charmrecipebuildjob.py
new file mode 100644
index 0000000..b0d1e20
--- /dev/null
+++ b/lib/lp/charms/interfaces/charmrecipebuildjob.py
@@ -0,0 +1,73 @@
+# Copyright 2021 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Charm recipe build job interfaces."""
+
+__metaclass__ = type
+__all__ = [
+    'ICharmRecipeBuildJob',
+    'ICharmhubUploadJob',
+    'ICharmhubUploadJobSource',
+    ]
+
+from lazr.restful.fields import Reference
+from zope.interface import (
+    Attribute,
+    Interface,
+    )
+from zope.schema import (
+    Int,
+    TextLine,
+    )
+
+from lp import _
+from lp.charms.interfaces.charmrecipebuild import ICharmRecipeBuild
+from lp.services.job.interfaces.job import (
+    IJob,
+    IJobSource,
+    IRunnableJob,
+    )
+
+
+class ICharmRecipeBuildJob(Interface):
+    """A job related to a charm recipe build."""
+
+    job = Reference(
+        title=_("The common Job attributes."), schema=IJob,
+        required=True, readonly=True)
+
+    build = Reference(
+        title=_("The charm recipe build to use for this job."),
+        schema=ICharmRecipeBuild, required=True, readonly=True)
+
+    metadata = Attribute(_("A dict of data about the job."))
+
+
+class ICharmhubUploadJob(IRunnableJob):
+    """A Job that uploads a charm recipe build to Charmhub."""
+
+    store_metadata = Attribute(
+        _("Combined metadata for this job and the matching build"))
+
+    error_message = TextLine(
+        title=_("Error message"), required=False, readonly=True)
+
+    error_detail = TextLine(
+        title=_("Error message detail"), required=False, readonly=True)
+
+    store_revision = Int(
+        title=_("The revision assigned to this build by Charmhub"),
+        required=False, readonly=True)
+
+    status_url = TextLine(
+        title=_("The URL on Charmhub to get the status of this build"),
+        required=False, readonly=True)
+
+
+class ICharmhubUploadJobSource(IJobSource):
+
+    def create(build):
+        """Upload a charm recipe build to Charmhub.
+
+        :param build: The charm recipe build to upload.
+        """
diff --git a/lib/lp/charms/mail/charmrecipebuild.py b/lib/lp/charms/mail/charmrecipebuild.py
index 9f997cb..6b4de1a 100644
--- a/lib/lp/charms/mail/charmrecipebuild.py
+++ b/lib/lp/charms/mail/charmrecipebuild.py
@@ -33,6 +33,62 @@ class CharmRecipeBuildMailer(BaseMailer):
             config.canonical.noreply_from_address, "charm-recipe-build-status",
             build)
 
+    @classmethod
+    def forUnauthorizedUpload(cls, build):
+        """Create a mailer for notifying about unauthorized Charmhub uploads.
+
+        :param build: The relevant build.
+        """
+        requester = build.requester
+        recipients = {requester: RecipientReason.forBuildRequester(requester)}
+        return cls(
+            "Charmhub authorization failed for %(recipe_name)s",
+            "charmrecipebuild-unauthorized.txt", recipients,
+            config.canonical.noreply_from_address,
+            "charm-recipe-build-upload-unauthorized", build)
+
+    @classmethod
+    def forUploadFailure(cls, build):
+        """Create a mailer for notifying about Charmhub upload failures.
+
+        :param build: The relevant build.
+        """
+        requester = build.requester
+        recipients = {requester: RecipientReason.forBuildRequester(requester)}
+        return cls(
+            "Charmhub upload failed for %(recipe_name)s",
+            "charmrecipebuild-uploadfailed.txt", recipients,
+            config.canonical.noreply_from_address,
+            "charm-recipe-build-upload-failed", build)
+
+    @classmethod
+    def forUploadReviewFailure(cls, build):
+        """Create a mailer for notifying about Charmhub upload review failures.
+
+        :param build: The relevant build.
+        """
+        requester = build.requester
+        recipients = {requester: RecipientReason.forBuildRequester(requester)}
+        return cls(
+            "Charmhub upload review failed for %(recipe_name)s",
+            "charmrecipebuild-reviewfailed.txt", recipients,
+            config.canonical.noreply_from_address,
+            "charm-recipe-build-upload-review-failed", build)
+
+    @classmethod
+    def forReleaseFailure(cls, build):
+        """Create a mailer for notifying about Charmhub release failures.
+
+        :param build: The relevant build.
+        """
+        requester = build.requester
+        recipients = {requester: RecipientReason.forBuildRequester(requester)}
+        return cls(
+            "Charmhub release failed for %(recipe_name)s",
+            "charmrecipebuild-releasefailed.txt", recipients,
+            config.canonical.noreply_from_address,
+            "charm-recipe-build-release-failed", build)
+
     def __init__(self, subject, template_name, recipients, from_address,
                  notification_type, build):
         super(CharmRecipeBuildMailer, self).__init__(
@@ -50,6 +106,11 @@ class CharmRecipeBuildMailer(BaseMailer):
     def _getTemplateParams(self, email, recipient):
         """See `BaseMailer`."""
         build = self.build
+        upload_job = build.last_store_upload_job
+        if upload_job is None:
+            error_message = ""
+        else:
+            error_message = upload_job.error_message or ""
         params = super(CharmRecipeBuildMailer, self)._getTemplateParams(
             email, recipient)
         params.update({
@@ -60,9 +121,12 @@ class CharmRecipeBuildMailer(BaseMailer):
             "build_title": build.title,
             "build_url": canonical_url(build),
             "builder_url": "",
+            "store_error_message": error_message,
             "distroseries": build.distro_series,
             "log_url": "",
             "project_name": build.recipe.project.name,
+            "recipe_authorize_url": canonical_url(
+                build.recipe, view_name="+authorize"),
             "recipe_name": build.recipe.name,
             "upload_log_url": "",
             })
diff --git a/lib/lp/charms/model/charmhubclient.py b/lib/lp/charms/model/charmhubclient.py
index 9b2627f..6de887c 100644
--- a/lib/lp/charms/model/charmhubclient.py
+++ b/lib/lp/charms/model/charmhubclient.py
@@ -238,6 +238,9 @@ class CharmhubClient:
                 raise BadReviewStatusResponse(response.text)
             [revision] = response_data["revisions"]
             if revision["status"] == "approved":
+                if revision["revision"] is None:
+                    raise ReviewFailedResponse(
+                        "Review passed but did not assign a revision.")
                 return revision["revision"]
             elif revision["status"] == "rejected":
                 error_message = "\n".join(
diff --git a/lib/lp/charms/model/charmrecipebuild.py b/lib/lp/charms/model/charmrecipebuild.py
index 3a38f2a..c6b10ec 100644
--- a/lib/lp/charms/model/charmrecipebuild.py
+++ b/lib/lp/charms/model/charmrecipebuild.py
@@ -43,6 +43,10 @@ from lp.charms.interfaces.charmrecipebuild import (
     ICharmRecipeBuildSet,
     )
 from lp.charms.mail.charmrecipebuild import CharmRecipeBuildMailer
+from lp.charms.model.charmrecipebuildjob import (
+    CharmRecipeBuildJob,
+    CharmRecipeBuildJobType,
+    )
 from lp.registry.interfaces.pocket import PackagePublishingPocket
 from lp.registry.interfaces.series import SeriesStatus
 from lp.registry.model.distribution import Distribution
@@ -58,6 +62,7 @@ from lp.services.database.interfaces import (
     IStore,
     )
 from lp.services.database.stormbase import StormBase
+from lp.services.job.model.job import Job
 from lp.services.librarian.model import (
     LibraryFileAlias,
     LibraryFileContent,
@@ -324,6 +329,25 @@ class CharmRecipeBuild(PackageBuildMixin, StormBase):
             return self.eta
         return self.date_finished
 
+    @property
+    def store_upload_jobs(self):
+        jobs = Store.of(self).find(
+            CharmRecipeBuildJob,
+            CharmRecipeBuildJob.build == self,
+            CharmRecipeBuildJob.job_type ==
+                CharmRecipeBuildJobType.CHARMHUB_UPLOAD)
+        jobs.order_by(Desc(CharmRecipeBuildJob.job_id))
+
+        def preload_jobs(rows):
+            load_related(Job, rows, ["job_id"])
+
+        return DecoratedResultSet(
+            jobs, lambda job: job.makeDerived(), pre_iter_hook=preload_jobs)
+
+    @cachedproperty
+    def last_store_upload_job(self):
+        return self.store_upload_jobs.first()
+
     def getFiles(self):
         """See `ICharmRecipeBuild`."""
         result = Store.of(self).find(
diff --git a/lib/lp/charms/model/charmrecipebuildjob.py b/lib/lp/charms/model/charmrecipebuildjob.py
new file mode 100644
index 0000000..bc810eb
--- /dev/null
+++ b/lib/lp/charms/model/charmrecipebuildjob.py
@@ -0,0 +1,318 @@
+# Copyright 2021 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Charm recipe build jobs."""
+
+__all__ = [
+    "CharmhubUploadJob",
+    "CharmRecipeBuildJob",
+    "CharmRecipeBuildJobType",
+    ]
+
+from datetime import timedelta
+
+from lazr.delegates import delegate_to
+from lazr.enum import (
+    DBEnumeratedType,
+    DBItem,
+    )
+from storm.databases.postgres import JSON
+from storm.locals import (
+    Int,
+    Reference,
+    )
+import transaction
+from zope.component import getUtility
+from zope.interface import (
+    implementer,
+    provider,
+    )
+
+from lp.app.errors import NotFoundError
+from lp.charms.interfaces.charmhubclient import (
+    BadReviewStatusResponse,
+    CharmhubError,
+    ICharmhubClient,
+    ReleaseFailedResponse,
+    ReviewFailedResponse,
+    UnauthorizedUploadResponse,
+    UploadFailedResponse,
+    UploadNotReviewedYetResponse,
+    )
+from lp.charms.interfaces.charmrecipebuildjob import (
+    ICharmhubUploadJob,
+    ICharmhubUploadJobSource,
+    ICharmRecipeBuildJob,
+    )
+from lp.charms.mail.charmrecipebuild import CharmRecipeBuildMailer
+from lp.services.config import config
+from lp.services.database.enumcol import EnumCol
+from lp.services.database.interfaces import (
+    IMasterStore,
+    IStore,
+    )
+from lp.services.database.stormbase import StormBase
+from lp.services.job.model.job import (
+    EnumeratedSubclass,
+    Job,
+    )
+from lp.services.job.runner import BaseRunnableJob
+from lp.services.propertycache import get_property_cache
+
+
+class CharmRecipeBuildJobType(DBEnumeratedType):
+    """Values that `ICharmRecipeBuildJob.job_type` can take."""
+
+    CHARMHUB_UPLOAD = DBItem(0, """
+        Charmhub upload
+
+        This job uploads a charm recipe build to Charmhub.
+        """)
+
+
+@implementer(ICharmRecipeBuildJob)
+class CharmRecipeBuildJob(StormBase):
+    """See `ICharmRecipeBuildJob`."""
+
+    __storm_table__ = "CharmRecipeBuildJob"
+
+    job_id = Int(name="job", primary=True, allow_none=False)
+    job = Reference(job_id, "Job.id")
+
+    build_id = Int(name="build", allow_none=False)
+    build = Reference(build_id, "CharmRecipeBuild.id")
+
+    job_type = EnumCol(enum=CharmRecipeBuildJobType, notNull=True)
+
+    metadata = JSON("json_data", allow_none=False)
+
+    def __init__(self, build, job_type, metadata, **job_args):
+        """Constructor.
+
+        Extra keyword arguments are used to construct the underlying Job
+        object.
+
+        :param build: The `ICharmRecipeBuild` this job relates to.
+        :param job_type: The `CharmRecipeBuildJobType` of this job.
+        :param metadata: The type-specific variables, as a JSON-compatible
+            dict.
+        """
+        super(CharmRecipeBuildJob, self).__init__()
+        self.job = Job(**job_args)
+        self.build = build
+        self.job_type = job_type
+        self.metadata = metadata
+
+    def makeDerived(self):
+        return CharmRecipeBuildJobDerived.makeSubclass(self)
+
+
+@delegate_to(ICharmRecipeBuildJob)
+class CharmRecipeBuildJobDerived(
+        BaseRunnableJob, metaclass=EnumeratedSubclass):
+
+    def __init__(self, charm_recipe_build_job):
+        self.context = charm_recipe_build_job
+
+    def __repr__(self):
+        """An informative representation of the job."""
+        recipe = self.build.recipe
+        return "<%s for ~%s/%s/+charm/%s/+build/%d>" % (
+            self.__class__.__name__, recipe.owner.name, recipe.project.name,
+            recipe.name, self.build.id)
+
+    @classmethod
+    def get(cls, job_id):
+        """Get a job by id.
+
+        :return: The `CharmRecipeBuildJob` with the specified id, as the
+            current `CharmRecipeBuildJobDerived` subclass.
+        :raises: `NotFoundError` if there is no job with the specified id,
+            or its `job_type` does not match the desired subclass.
+        """
+        charm_recipe_build_job = IStore(CharmRecipeBuildJob).get(
+            CharmRecipeBuildJob, job_id)
+        if charm_recipe_build_job.job_type != cls.class_job_type:
+            raise NotFoundError(
+                "No object found with id %d and type %s" %
+                (job_id, cls.class_job_type.title))
+        return cls(charm_recipe_build_job)
+
+    @classmethod
+    def iterReady(cls):
+        """See `IJobSource`."""
+        jobs = IMasterStore(CharmRecipeBuildJob).find(
+            CharmRecipeBuildJob,
+            CharmRecipeBuildJob.job_type == cls.class_job_type,
+            CharmRecipeBuildJob.job == Job.id,
+            Job.id.is_in(Job.ready_jobs))
+        return (cls(job) for job in jobs)
+
+    def getOopsVars(self):
+        """See `IRunnableJob`."""
+        oops_vars = super(CharmRecipeBuildJobDerived, self).getOopsVars()
+        recipe = self.context.build.recipe
+        oops_vars.extend([
+            ("job_id", self.context.job.id),
+            ("job_type", self.context.job_type.title),
+            ("build_id", self.context.build.id),
+            ("recipe_owner_name", recipe.owner.name),
+            ("recipe_project_name", recipe.project.name),
+            ("recipe_name", recipe.name),
+            ])
+        return oops_vars
+
+
+class RetryableCharmhubError(CharmhubError):
+    pass
+
+
+@implementer(ICharmhubUploadJob)
+@provider(ICharmhubUploadJobSource)
+class CharmhubUploadJob(CharmRecipeBuildJobDerived):
+    """A Job that uploads a charm recipe build to Charmhub."""
+
+    class_job_type = CharmRecipeBuildJobType.CHARMHUB_UPLOAD
+
+    user_error_types = (
+        UnauthorizedUploadResponse,
+        ReviewFailedResponse,
+        ReleaseFailedResponse,
+        )
+
+    retry_error_types = (UploadNotReviewedYetResponse, RetryableCharmhubError)
+    max_retries = 30
+
+    config = config.ICharmhubUploadJobSource
+
+    @classmethod
+    def create(cls, build):
+        """See `ICharmhubUploadJobSource`."""
+        charm_recipe_build_job = CharmRecipeBuildJob(
+            build, cls.class_job_type, {})
+        job = cls(charm_recipe_build_job)
+        job.celeryRunOnCommit()
+        del get_property_cache(build).last_store_upload_job
+        return job
+
+    @property
+    def store_metadata(self):
+        """See `ICharmhubUploadJob`."""
+        intermediate = {}
+        intermediate.update(self.metadata)
+        intermediate.update(self.build.store_upload_metadata or {})
+        return intermediate
+
+    @property
+    def error_message(self):
+        """See `ICharmhubUploadJob`."""
+        return self.metadata.get("error_message")
+
+    @error_message.setter
+    def error_message(self, message):
+        """See `ICharmhubUploadJob`."""
+        self.metadata["error_message"] = message
+
+    @property
+    def error_detail(self):
+        """See `ICharmhubUploadJob`."""
+        return self.metadata.get("error_detail")
+
+    @error_detail.setter
+    def error_detail(self, detail):
+        """See `ICharmhubUploadJob`."""
+        self.metadata["error_detail"] = detail
+
+    @property
+    def store_revision(self):
+        """See `ICharmhubUploadJob`."""
+        return self.store_metadata.get("store_revision")
+
+    @store_revision.setter
+    def store_revision(self, revision):
+        """See `ICharmhubUploadJob`."""
+        if self.build.store_upload_metadata is None:
+            self.build.store_upload_metadata = {}
+        self.build.store_upload_metadata["store_revision"] = revision
+
+    @property
+    def status_url(self):
+        """See `ICharmhubUploadJob`."""
+        return self.store_metadata.get("status_url")
+
+    @status_url.setter
+    def status_url(self, url):
+        if self.build.store_upload_metadata is None:
+            self.build.store_upload_metadata = {}
+        self.build.store_upload_metadata["status_url"] = url
+
+    def getOopsVars(self):
+        """See `IRunnableJob`."""
+        oops_vars = super(CharmhubUploadJob, self).getOopsVars()
+        oops_vars.append(("error_detail", self.error_detail))
+        return oops_vars
+
+    @property
+    def retry_delay(self):
+        """See `BaseRunnableJob`."""
+        if "status_url" in self.store_metadata and self.store_revision is None:
+            # At the moment we have to poll the status endpoint to find out
+            # if Charmhub has finished scanning.  Try to deal with easy
+            # cases quickly without hammering our job runners or Charmhub
+            # too badly.
+            delays = (15, 15, 30, 30)
+            try:
+                return timedelta(seconds=delays[self.attempt_count - 1])
+            except IndexError:
+                pass
+        return timedelta(minutes=1)
+
+    def run(self):
+        """See `IRunnableJob`."""
+        client = getUtility(ICharmhubClient)
+        try:
+            try:
+                if "status_url" not in self.store_metadata:
+                    self.status_url = client.upload(self.build)
+                    # We made progress, so reset attempt_count.
+                    self.attempt_count = 1
+                if self.store_revision is None:
+                    self.store_revision = client.checkStatus(self.status_url)
+                    if self.store_revision is None:
+                        raise AssertionError(
+                            "checkStatus returned successfully but with no "
+                            "revision")
+                    # We made progress, so reset attempt_count.
+                    self.attempt_count = 1
+                if self.build.recipe.store_channels:
+                    client.release(self.build, self.store_revision)
+                self.error_message = None
+            except self.retry_error_types:
+                raise
+            except Exception as e:
+                if (isinstance(e, CharmhubError) and e.can_retry and
+                        self.attempt_count <= self.max_retries):
+                    raise RetryableCharmhubError(e.args[0], detail=e.detail)
+                self.error_message = str(e)
+                self.error_detail = getattr(e, "detail", None)
+                mailer_factory = None
+                if isinstance(e, UnauthorizedUploadResponse):
+                    mailer_factory = (
+                        CharmRecipeBuildMailer.forUnauthorizedUpload)
+                elif isinstance(e, UploadFailedResponse):
+                    mailer_factory = CharmRecipeBuildMailer.forUploadFailure
+                elif isinstance(
+                        e, (BadReviewStatusResponse, ReviewFailedResponse)):
+                    mailer_factory = (
+                        CharmRecipeBuildMailer.forUploadReviewFailure)
+                elif isinstance(e, ReleaseFailedResponse):
+                    mailer_factory = CharmRecipeBuildMailer.forReleaseFailure
+                if mailer_factory is not None:
+                    mailer_factory(self.build).sendAll()
+                raise
+        except Exception:
+            # The normal job infrastructure will abort the transaction, but
+            # we want to commit instead: the only database changes we make
+            # are to this job's metadata and should be preserved.
+            transaction.commit()
+            raise
diff --git a/lib/lp/charms/tests/test_charmhubclient.py b/lib/lp/charms/tests/test_charmhubclient.py
index 864c0d6..ce61daa 100644
--- a/lib/lp/charms/tests/test_charmhubclient.py
+++ b/lib/lp/charms/tests/test_charmhubclient.py
@@ -425,6 +425,30 @@ class TestCharmhubClient(TestCaseWithFactory):
             self.client.checkStatus, build, status_url)
 
     @responses.activate
+    def test_checkStatus_approved_no_revision(self):
+        self._setUpSecretStorage()
+        build = self.makeUploadableCharmRecipeBuild()
+        status_url = (
+            "http://charmhub.example/v1/charm/test-charm/revisions/review";
+            "?upload-id=123")
+        responses.add(
+            "GET", status_url,
+            json={
+                "revisions": [
+                    {
+                        "upload-id": "123",
+                        "status": "approved",
+                        "revision": None,
+                        "errors": None,
+                        },
+                    ],
+                })
+        self.assertRaisesWithContent(
+            ReviewFailedResponse,
+            "Review passed but did not assign a revision.",
+            self.client.checkStatus, build, status_url)
+
+    @responses.activate
     def test_checkStatus_approved(self):
         self._setUpSecretStorage()
         build = self.makeUploadableCharmRecipeBuild()
diff --git a/lib/lp/charms/tests/test_charmrecipebuildjob.py b/lib/lp/charms/tests/test_charmrecipebuildjob.py
new file mode 100644
index 0000000..97d2f21
--- /dev/null
+++ b/lib/lp/charms/tests/test_charmrecipebuildjob.py
@@ -0,0 +1,565 @@
+# Copyright 2021 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Tests for charm recipe build jobs."""
+
+__metaclass__ = type
+
+from datetime import timedelta
+
+from fixtures import FakeLogger
+import transaction
+from zope.interface import implementer
+from zope.security.proxy import removeSecurityProxy
+
+from lp.buildmaster.enums import BuildStatus
+from lp.charms.interfaces.charmhubclient import (
+    ICharmhubClient,
+    ReleaseFailedResponse,
+    ReviewFailedResponse,
+    UnauthorizedUploadResponse,
+    UploadFailedResponse,
+    UploadNotReviewedYetResponse,
+    )
+from lp.charms.interfaces.charmrecipe import (
+    CHARM_RECIPE_ALLOW_CREATE,
+    CHARM_RECIPE_WEBHOOKS_FEATURE_FLAG,
+    )
+from lp.charms.interfaces.charmrecipebuildjob import (
+    ICharmhubUploadJob,
+    ICharmRecipeBuildJob,
+    )
+from lp.charms.model.charmrecipebuild import CharmRecipeBuild
+from lp.charms.model.charmrecipebuildjob import (
+    CharmhubUploadJob,
+    CharmRecipeBuildJob,
+    CharmRecipeBuildJobType,
+    )
+from lp.services.config import config
+from lp.services.database.interfaces import IStore
+from lp.services.features.testing import FeatureFixture
+from lp.services.job.interfaces.job import JobStatus
+from lp.services.job.runner import JobRunner
+from lp.testing import TestCaseWithFactory
+from lp.testing.dbuser import dbuser
+from lp.testing.fakemethod import FakeMethod
+from lp.testing.fixture import ZopeUtilityFixture
+from lp.testing.layers import (
+    DatabaseFunctionalLayer,
+    LaunchpadZopelessLayer,
+    )
+from lp.testing.mail_helpers import pop_notifications
+
+
+def run_isolated_jobs(jobs):
+    """Run a sequence of jobs, ensuring transaction isolation.
+
+    We abort the transaction after each job to make sure that there is no
+    relevant uncommitted work.
+    """
+    for job in jobs:
+        JobRunner([job]).runAll()
+        transaction.abort()
+
+
+@implementer(ICharmhubClient)
+class FakeCharmhubClient:
+
+    def __init__(self):
+        self.upload = FakeMethod()
+        self.checkStatus = FakeMethod()
+        self.release = FakeMethod()
+
+
+class TestCharmRecipeBuildJob(TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super().setUp()
+        self.useFixture(FeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"}))
+
+    def test_provides_interface(self):
+        # `CharmRecipeBuildJob` objects provide `ICharmRecipeBuildJob`.
+        build = self.factory.makeCharmRecipeBuild()
+        self.assertProvides(
+            CharmRecipeBuildJob(
+                build, CharmRecipeBuildJobType.CHARMHUB_UPLOAD, {}),
+            ICharmRecipeBuildJob)
+
+
+class TestCharmhubUploadJob(TestCaseWithFactory):
+
+    layer = LaunchpadZopelessLayer
+
+    def setUp(self):
+        super().setUp()
+        self.useFixture(FeatureFixture({
+            CHARM_RECIPE_ALLOW_CREATE: "on",
+            CHARM_RECIPE_WEBHOOKS_FEATURE_FLAG: "on",
+            }))
+        self.status_url = (
+            "http://charmhub.example/v1/charm/test-charm/revisions/review";
+            "?upload-id=123")
+
+    def test_provides_interface(self):
+        # `CharmhubUploadJob` objects provide `ICharmhubUploadJob`.
+        build = self.factory.makeCharmRecipeBuild()
+        job = CharmhubUploadJob.create(build)
+        self.assertProvides(job, ICharmhubUploadJob)
+
+    def test___repr__(self):
+        # `CharmhubUploadJob` objects have an informative __repr__.
+        build = self.factory.makeCharmRecipeBuild()
+        job = CharmhubUploadJob.create(build)
+        self.assertEqual(
+            "<CharmhubUploadJob for ~%s/%s/+charm/%s/+build/%d>" % (
+                build.recipe.owner.name, build.recipe.project.name,
+                build.recipe.name, build.id),
+            repr(job))
+
+    def makeCharmRecipeBuild(self, **kwargs):
+        # Make a build with a builder and a webhook.
+        build = self.factory.makeCharmRecipeBuild(
+            builder=self.factory.makeBuilder(), **kwargs)
+        build.updateStatus(BuildStatus.FULLYBUILT)
+        self.factory.makeWebhook(
+            target=build.recipe, event_types=["charm-recipe:build:0.1"])
+        return build
+
+    def test_run(self):
+        # The job uploads the build to Charmhub and records the Charmhub
+        # revision.
+        build = self.makeCharmRecipeBuild()
+        self.assertContentEqual([], build.store_upload_jobs)
+        job = CharmhubUploadJob.create(build)
+        client = FakeCharmhubClient()
+        client.upload.result = self.status_url
+        client.checkStatus.result = 1
+        self.useFixture(ZopeUtilityFixture(client, ICharmhubClient))
+        with dbuser(config.ICharmhubUploadJobSource.dbuser):
+            run_isolated_jobs([job])
+        self.assertEqual([((build,), {})], client.upload.calls)
+        self.assertEqual([((self.status_url,), {})], client.checkStatus.calls)
+        self.assertEqual([], client.release.calls)
+        self.assertContentEqual([job], build.store_upload_jobs)
+        self.assertEqual(1, job.store_revision)
+        self.assertIsNone(job.error_message)
+        self.assertEqual([], pop_notifications())
+
+    def test_run_failed(self):
+        # A failed run sets the store upload status to FAILED.
+        build = self.makeCharmRecipeBuild()
+        self.assertContentEqual([], build.store_upload_jobs)
+        job = CharmhubUploadJob.create(build)
+        client = FakeCharmhubClient()
+        client.upload.failure = ValueError("An upload failure")
+        self.useFixture(ZopeUtilityFixture(client, ICharmhubClient))
+        with dbuser(config.ICharmhubUploadJobSource.dbuser):
+            run_isolated_jobs([job])
+        self.assertEqual([((build,), {})], client.upload.calls)
+        self.assertEqual([], client.checkStatus.calls)
+        self.assertEqual([], client.release.calls)
+        self.assertContentEqual([job], build.store_upload_jobs)
+        self.assertIsNone(job.store_revision)
+        self.assertEqual("An upload failure", job.error_message)
+        self.assertEqual([], pop_notifications())
+
+    def test_run_unauthorized_notifies(self):
+        # A run that gets 401 from Charmhub sends mail.
+        requester = self.factory.makePerson(name="requester")
+        requester_team = self.factory.makeTeam(
+            owner=requester, name="requester-team", members=[requester])
+        project = self.factory.makeProduct(name="test-project")
+        build = self.makeCharmRecipeBuild(
+            requester=requester_team, name="test-charm", owner=requester_team,
+            project=project)
+        self.assertContentEqual([], build.store_upload_jobs)
+        job = CharmhubUploadJob.create(build)
+        client = FakeCharmhubClient()
+        client.upload.failure = UnauthorizedUploadResponse(
+            "Authorization failed.")
+        self.useFixture(ZopeUtilityFixture(client, ICharmhubClient))
+        with dbuser(config.ICharmhubUploadJobSource.dbuser):
+            run_isolated_jobs([job])
+        self.assertEqual([((build,), {})], client.upload.calls)
+        self.assertEqual([], client.checkStatus.calls)
+        self.assertEqual([], client.release.calls)
+        self.assertContentEqual([job], build.store_upload_jobs)
+        self.assertIsNone(job.store_revision)
+        self.assertEqual("Authorization failed.", job.error_message)
+        [notification] = pop_notifications()
+        self.assertEqual(
+            config.canonical.noreply_from_address, notification["From"])
+        self.assertEqual(
+            "Requester <%s>" % requester.preferredemail.email,
+            notification["To"])
+        subject = notification["Subject"].replace("\n ", " ")
+        self.assertEqual(
+            "Charmhub authorization failed for test-charm", subject)
+        self.assertEqual(
+            "Requester @requester-team",
+            notification["X-Launchpad-Message-Rationale"])
+        self.assertEqual(
+            requester_team.name, notification["X-Launchpad-Message-For"])
+        self.assertEqual(
+            "charm-recipe-build-upload-unauthorized",
+            notification["X-Launchpad-Notification-Type"])
+        body, footer = (
+            notification.get_payload(decode=True).decode().split("\n-- \n"))
+        self.assertIn(
+            "http://launchpad.test/~requester-team/test-project/+charm/";
+            "test-charm/+authorize",
+            body)
+        self.assertEqual(
+            "http://launchpad.test/~requester-team/test-project/+charm/";
+            "test-charm/+build/%d\n"
+            "Your team Requester Team is the requester of the build.\n" %
+            build.id, footer)
+
+    def test_run_502_retries(self):
+        # A run that gets a 502 error from Charmhub schedules itself to be
+        # retried.
+        build = self.makeCharmRecipeBuild()
+        self.assertContentEqual([], build.store_upload_jobs)
+        job = CharmhubUploadJob.create(build)
+        client = FakeCharmhubClient()
+        client.upload.failure = UploadFailedResponse(
+            "Proxy error", can_retry=True)
+        self.useFixture(ZopeUtilityFixture(client, ICharmhubClient))
+        with dbuser(config.ICharmhubUploadJobSource.dbuser):
+            run_isolated_jobs([job])
+        self.assertEqual([((build,), {})], client.upload.calls)
+        self.assertEqual([], client.checkStatus.calls)
+        self.assertEqual([], client.release.calls)
+        self.assertContentEqual([job], build.store_upload_jobs)
+        self.assertIsNone(job.store_revision)
+        self.assertIsNone(job.error_message)
+        self.assertEqual([], pop_notifications())
+        self.assertEqual(JobStatus.WAITING, job.job.status)
+        # Try again.  The upload part of the job is retried, and this time
+        # it succeeds.
+        job.scheduled_start = None
+        client.upload.calls = []
+        client.upload.failure = None
+        client.upload.result = self.status_url
+        client.checkStatus.result = 1
+        with dbuser(config.ICharmhubUploadJobSource.dbuser):
+            run_isolated_jobs([job])
+        self.assertEqual([((build,), {})], client.upload.calls)
+        self.assertEqual([((self.status_url,), {})], client.checkStatus.calls)
+        self.assertEqual([], client.release.calls)
+        self.assertContentEqual([job], build.store_upload_jobs)
+        self.assertEqual(1, job.store_revision)
+        self.assertIsNone(job.error_message)
+        self.assertEqual([], pop_notifications())
+        self.assertEqual(JobStatus.COMPLETED, job.job.status)
+
+    def test_run_upload_failure_notifies(self):
+        # A run that gets some other upload failure from Charmhub sends
+        # mail.
+        requester = self.factory.makePerson(name="requester")
+        requester_team = self.factory.makeTeam(
+            owner=requester, name="requester-team", members=[requester])
+        project = self.factory.makeProduct(name="test-project")
+        build = self.makeCharmRecipeBuild(
+            requester=requester_team, name="test-charm", owner=requester_team,
+            project=project)
+        self.assertContentEqual([], build.store_upload_jobs)
+        job = CharmhubUploadJob.create(build)
+        client = FakeCharmhubClient()
+        client.upload.failure = UploadFailedResponse(
+            "Failed to upload", detail="The proxy exploded.\n")
+        self.useFixture(ZopeUtilityFixture(client, ICharmhubClient))
+        with dbuser(config.ICharmhubUploadJobSource.dbuser):
+            run_isolated_jobs([job])
+        self.assertEqual([((build,), {})], client.upload.calls)
+        self.assertEqual([], client.checkStatus.calls)
+        self.assertEqual([], client.release.calls)
+        self.assertContentEqual([job], build.store_upload_jobs)
+        self.assertIsNone(job.store_revision)
+        self.assertEqual("Failed to upload", job.error_message)
+        [notification] = pop_notifications()
+        self.assertEqual(
+            config.canonical.noreply_from_address, notification["From"])
+        self.assertEqual(
+            "Requester <%s>" % requester.preferredemail.email,
+            notification["To"])
+        subject = notification["Subject"].replace("\n ", " ")
+        self.assertEqual("Charmhub upload failed for test-charm", subject)
+        self.assertEqual(
+            "Requester @requester-team",
+            notification["X-Launchpad-Message-Rationale"])
+        self.assertEqual(
+            requester_team.name, notification["X-Launchpad-Message-For"])
+        self.assertEqual(
+            "charm-recipe-build-upload-failed",
+            notification["X-Launchpad-Notification-Type"])
+        body, footer = (
+            notification.get_payload(decode=True).decode().split("\n-- \n"))
+        self.assertIn("Failed to upload", body)
+        build_url = (
+            "http://launchpad.test/~requester-team/test-project/+charm/";
+            "test-charm/+build/%d" %
+            build.id)
+        self.assertIn(build_url, body)
+        self.assertEqual(
+            "%s\nYour team Requester Team is the requester of the build.\n" %
+            build_url, footer)
+        self.assertIn(
+            ("error_detail", "The proxy exploded.\n"), job.getOopsVars())
+
+    def test_run_review_pending_retries(self):
+        # A run that finds that Charmhub has not yet finished reviewing the
+        # charm schedules itself to be retried.
+        build = self.makeCharmRecipeBuild()
+        self.assertContentEqual([], build.store_upload_jobs)
+        job = CharmhubUploadJob.create(build)
+        client = FakeCharmhubClient()
+        client.upload.result = self.status_url
+        client.checkStatus.failure = UploadNotReviewedYetResponse()
+        self.useFixture(ZopeUtilityFixture(client, ICharmhubClient))
+        with dbuser(config.ICharmhubUploadJobSource.dbuser):
+            run_isolated_jobs([job])
+        self.assertEqual([((build,), {})], client.upload.calls)
+        self.assertEqual([((self.status_url,), {})], client.checkStatus.calls)
+        self.assertEqual([], client.release.calls)
+        self.assertContentEqual([job], build.store_upload_jobs)
+        self.assertIsNone(job.store_revision)
+        self.assertIsNone(job.error_message)
+        self.assertEqual([], pop_notifications())
+        self.assertEqual(JobStatus.WAITING, job.job.status)
+        # Try again.  The upload part of the job is not retried, and this
+        # time the review completes.
+        job.scheduled_start = None
+        client.upload.calls = []
+        client.checkStatus.calls = []
+        client.checkStatus.failure = None
+        client.checkStatus.result = 1
+        with dbuser(config.ICharmhubUploadJobSource.dbuser):
+            run_isolated_jobs([job])
+        self.assertEqual([], client.upload.calls)
+        self.assertEqual([((self.status_url,), {})], client.checkStatus.calls)
+        self.assertEqual([], client.release.calls)
+        self.assertContentEqual([job], build.store_upload_jobs)
+        self.assertEqual(1, job.store_revision)
+        self.assertIsNone(job.error_message)
+        self.assertEqual([], pop_notifications())
+        self.assertEqual(JobStatus.COMPLETED, job.job.status)
+
+    def test_run_review_failure_notifies(self):
+        # A run that gets a review failure from Charmhub sends mail.
+        requester = self.factory.makePerson(name="requester")
+        requester_team = self.factory.makeTeam(
+            owner=requester, name="requester-team", members=[requester])
+        project = self.factory.makeProduct(name="test-project")
+        build = self.makeCharmRecipeBuild(
+            requester=requester_team, name="test-charm", owner=requester_team,
+            project=project)
+        self.assertContentEqual([], build.store_upload_jobs)
+        job = CharmhubUploadJob.create(build)
+        client = FakeCharmhubClient()
+        client.upload.result = self.status_url
+        client.checkStatus.failure = ReviewFailedResponse(
+            "Review failed.\nCharm is terrible.")
+        self.useFixture(ZopeUtilityFixture(client, ICharmhubClient))
+        with dbuser(config.ICharmhubUploadJobSource.dbuser):
+            run_isolated_jobs([job])
+        self.assertEqual([((build,), {})], client.upload.calls)
+        self.assertEqual([((self.status_url,), {})], client.checkStatus.calls)
+        self.assertEqual([], client.release.calls)
+        self.assertContentEqual([job], build.store_upload_jobs)
+        self.assertIsNone(job.store_revision)
+        self.assertEqual(
+            "Review failed.\nCharm is terrible.", job.error_message)
+        [notification] = pop_notifications()
+        self.assertEqual(
+            config.canonical.noreply_from_address, notification["From"])
+        self.assertEqual(
+            "Requester <%s>" % requester.preferredemail.email,
+            notification["To"])
+        subject = notification["Subject"].replace("\n ", " ")
+        self.assertEqual(
+            "Charmhub upload review failed for test-charm", subject)
+        self.assertEqual(
+            "Requester @requester-team",
+            notification["X-Launchpad-Message-Rationale"])
+        self.assertEqual(
+            requester_team.name, notification["X-Launchpad-Message-For"])
+        self.assertEqual(
+            "charm-recipe-build-upload-review-failed",
+            notification["X-Launchpad-Notification-Type"])
+        body, footer = (
+            notification.get_payload(decode=True).decode().split("\n-- \n"))
+        self.assertIn("Review failed.", body)
+        self.assertEqual(
+            "http://launchpad.test/~requester-team/test-project/+charm/";
+            "test-charm/+build/%d\n"
+            "Your team Requester Team is the requester of the build.\n" %
+            build.id, footer)
+
+    def test_run_release(self):
+        # A run configured to automatically release the charm to certain
+        # channels does so.
+        build = self.makeCharmRecipeBuild(store_channels=["stable", "edge"])
+        self.assertContentEqual([], build.store_upload_jobs)
+        job = CharmhubUploadJob.create(build)
+        client = FakeCharmhubClient()
+        client.upload.result = self.status_url
+        client.checkStatus.result = 1
+        self.useFixture(ZopeUtilityFixture(client, ICharmhubClient))
+        with dbuser(config.ICharmhubUploadJobSource.dbuser):
+            run_isolated_jobs([job])
+        self.assertEqual([((build,), {})], client.upload.calls)
+        self.assertEqual([((self.status_url,), {})], client.checkStatus.calls)
+        self.assertEqual([((build, 1), {})], client.release.calls)
+        self.assertContentEqual([job], build.store_upload_jobs)
+        self.assertEqual(1, job.store_revision)
+        self.assertIsNone(job.error_message)
+        self.assertEqual([], pop_notifications())
+
+    def test_run_release_failure_notifies(self):
+        # A run configured to automatically release the charm to certain
+        # channels but that fails to do so sends mail.
+        requester = self.factory.makePerson(name="requester")
+        requester_team = self.factory.makeTeam(
+            owner=requester, name="requester-team", members=[requester])
+        project = self.factory.makeProduct(name="test-project")
+        build = self.makeCharmRecipeBuild(
+            requester=requester_team, name="test-charm", owner=requester_team,
+            project=project, store_channels=["stable", "edge"])
+        self.assertContentEqual([], build.store_upload_jobs)
+        job = CharmhubUploadJob.create(build)
+        client = FakeCharmhubClient()
+        client.upload.result = self.status_url
+        client.checkStatus.result = 1
+        client.release.failure = ReleaseFailedResponse("Failed to release")
+        self.useFixture(ZopeUtilityFixture(client, ICharmhubClient))
+        with dbuser(config.ICharmhubUploadJobSource.dbuser):
+            JobRunner([job]).runAll()
+        self.assertEqual([((build,), {})], client.upload.calls)
+        self.assertEqual([((self.status_url,), {})], client.checkStatus.calls)
+        self.assertEqual([((build, 1), {})], client.release.calls)
+        self.assertContentEqual([job], build.store_upload_jobs)
+        self.assertEqual(1, job.store_revision)
+        self.assertEqual("Failed to release", job.error_message)
+        [notification] = pop_notifications()
+        self.assertEqual(
+            config.canonical.noreply_from_address, notification["From"])
+        self.assertEqual(
+            "Requester <%s>" % requester.preferredemail.email,
+            notification["To"])
+        subject = notification["Subject"].replace("\n", " ")
+        self.assertEqual("Charmhub release failed for test-charm", subject)
+        self.assertEqual(
+            "Requester @requester-team",
+            notification["X-Launchpad-Message-Rationale"])
+        self.assertEqual(
+            requester_team.name, notification["X-Launchpad-Message-For"])
+        self.assertEqual(
+            "charm-recipe-build-release-failed",
+            notification["X-Launchpad-Notification-Type"])
+        body, footer = (
+            notification.get_payload(decode=True).decode().split("\n-- \n"))
+        self.assertIn("Failed to release", body)
+        self.assertEqual(
+            "http://launchpad.test/~requester-team/test-project/+charm/";
+            "test-charm/+build/%d\n"
+            "Your team Requester Team is the requester of the build.\n" %
+            build.id, footer)
+
+    def test_retry_delay(self):
+        # The job is retried every minute, unless it just made one of its
+        # first four attempts to poll the status endpoint, in which case the
+        # delays are 15/15/30/30 seconds.
+        self.useFixture(FakeLogger())
+        build = self.makeCharmRecipeBuild()
+        job = CharmhubUploadJob.create(build)
+        client = FakeCharmhubClient()
+        client.upload.failure = UploadFailedResponse(
+            "Proxy error", can_retry=True)
+        self.useFixture(ZopeUtilityFixture(client, ICharmhubClient))
+        with dbuser(config.ICharmhubUploadJobSource.dbuser):
+            run_isolated_jobs([job])
+        self.assertNotIn("status_url", job.metadata)
+        self.assertEqual(timedelta(seconds=60), job.retry_delay)
+        job.scheduled_start = None
+        client.upload.failure = None
+        client.upload.result = self.status_url
+        client.checkStatus.failure = UploadNotReviewedYetResponse()
+        for expected_delay in (15, 15, 30, 30, 60):
+            with dbuser(config.ICharmhubUploadJobSource.dbuser):
+                run_isolated_jobs([job])
+            self.assertIn("status_url", job.build.store_upload_metadata)
+            self.assertEqual(
+                timedelta(seconds=expected_delay), job.retry_delay)
+            job.scheduled_start = None
+        client.checkStatus.failure = None
+        client.checkStatus.result = 1
+        with dbuser(config.ICharmhubUploadJobSource.dbuser):
+            run_isolated_jobs([job])
+        self.assertIsNone(job.error_message)
+        self.assertEqual([], pop_notifications())
+        self.assertEqual(JobStatus.COMPLETED, job.job.status)
+
+    def test_retry_after_upload_does_not_upload(self):
+        # If the job has uploaded, but failed to release, it should
+        # not attempt to upload again on the next run.
+        self.useFixture(FakeLogger())
+        build = self.makeCharmRecipeBuild(store_channels=["stable", "edge"])
+        self.assertContentEqual([], build.store_upload_jobs)
+        job = CharmhubUploadJob.create(build)
+        client = FakeCharmhubClient()
+        client.upload.result = self.status_url
+        client.checkStatus.result = 1
+        client.release.failure = ReleaseFailedResponse(
+            "Proxy error", can_retry=True)
+        self.useFixture(ZopeUtilityFixture(client, ICharmhubClient))
+        with dbuser(config.ICharmhubUploadJobSource.dbuser):
+            run_isolated_jobs([job])
+
+        previous_upload = client.upload.calls
+        previous_checkStatus = client.checkStatus.calls
+        len_previous_release = len(client.release.calls)
+
+        # Check we uploaded as expected
+        self.assertEqual(1, job.store_revision)
+        self.assertEqual(timedelta(seconds=60), job.retry_delay)
+        self.assertEqual(1, len(client.upload.calls))
+        self.assertIsNone(job.error_message)
+
+        # Run the job again
+        with dbuser(config.ICharmhubUploadJobSource.dbuser):
+            run_isolated_jobs([job])
+
+        # We should not have called `upload`, but moved straight to `release`
+        self.assertEqual(previous_upload, client.upload.calls)
+        self.assertEqual(previous_checkStatus, client.checkStatus.calls)
+        self.assertEqual(len_previous_release + 1, len(client.release.calls))
+        self.assertIsNone(job.error_message)
+
+    def test_with_build_metadata_as_none(self):
+        db_build = self.factory.makeCharmRecipeBuild()
+        removeSecurityProxy(db_build).store_upload_metadata = None
+        store = IStore(CharmRecipeBuild)
+        store.flush()
+        loaded_build = store.find(CharmRecipeBuild, id=db_build.id).one()
+
+        job = CharmhubUploadJob.create(loaded_build)
+        self.assertEqual({}, job.store_metadata)
+
+    def test_with_build_metadata_as_none_set_status(self):
+        db_build = self.factory.makeCharmRecipeBuild()
+        removeSecurityProxy(db_build).store_upload_metadata = None
+        store = IStore(CharmRecipeBuild)
+        store.flush()
+        loaded_build = store.find(CharmRecipeBuild, id=db_build.id).one()
+
+        job = CharmhubUploadJob.create(loaded_build)
+        job.status_url = 'http://example.org'
+        store.flush()
+
+        loaded_build = store.find(CharmRecipeBuild, id=db_build.id).one()
+        self.assertEqual(
+            'http://example.org',
+            loaded_build.store_upload_metadata['status_url'])
diff --git a/lib/lp/services/config/schema-lazr.conf b/lib/lp/services/config/schema-lazr.conf
index 413e38b..18e4ffe 100644
--- a/lib/lp/services/config/schema-lazr.conf
+++ b/lib/lp/services/config/schema-lazr.conf
@@ -1909,6 +1909,11 @@ runner_class: TwistedJobRunner
 module: lp.code.interfaces.branchjob
 dbuser: upgrade-branches
 
+[ICharmhubUploadJobSource]
+module: lp.charms.interfaces.charmrecipebuildjob
+dbuser: charm-build-job
+crontab_group: MAIN
+
 [ICharmRecipeRequestBuildsJobSource]
 module: lp.charms.interfaces.charmrecipejob
 dbuser: charm-build-job