diff --git a/app/assets/javascripts/ml/experiment_tracking/graphql/mutations/promote_model_version.mutation.graphql b/app/assets/javascripts/ml/experiment_tracking/graphql/mutations/promote_model_version.mutation.graphql
new file mode 100644
index 0000000000000000000000000000000000000000..bdab5fdf052e41043f12b8f58976f63e302c7a44
--- /dev/null
+++ b/app/assets/javascripts/ml/experiment_tracking/graphql/mutations/promote_model_version.mutation.graphql
@@ -0,0 +1,26 @@
+mutation promoteModelVersion(
+  $projectPath: ID!
+  $modelId: MlModelID!
+  $version: String!
+  $description: String
+  $candidateId: MlCandidateID!
+) {
+  mlModelVersionCreate(
+    input: {
+      projectPath: $projectPath
+      modelId: $modelId
+      version: $version
+      description: $description
+      candidateId: $candidateId
+    }
+  ) {
+    modelVersion {
+      id
+      _links {
+        showPath
+        importPath
+      }
+    }
+    errors
+  }
+}
diff --git a/app/assets/javascripts/ml/experiment_tracking/routes/candidates/promote/promote_run.vue b/app/assets/javascripts/ml/experiment_tracking/routes/candidates/promote/promote_run.vue
new file mode 100644
index 0000000000000000000000000000000000000000..c191447c785c7014b41fddca42148a5b43d72734
--- /dev/null
+++ b/app/assets/javascripts/ml/experiment_tracking/routes/candidates/promote/promote_run.vue
@@ -0,0 +1,215 @@
+<script>
+import { GlAlert, GlButton, GlForm, GlFormGroup, GlFormInput } from '@gitlab/ui';
+import { __, s__, sprintf } from '~/locale';
+import { visitUrl } from '~/lib/utils/url_utility';
+import * as Sentry from '~/sentry/sentry_browser_wrapper';
+import { semverRegex } from '~/lib/utils/regexp';
+import PageHeading from '~/vue_shared/components/page_heading.vue';
+import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import createModelVersionMutation from '~/ml/experiment_tracking/graphql/mutations/promote_model_version.mutation.graphql';
+
+export default {
+  name: 'PromoteRun',
+  components: {
+    PageHeading,
+    GlAlert,
+    GlButton,
+    GlForm,
+    GlFormGroup,
+    GlFormInput,
+    MarkdownEditor,
+  },
+  props: {
+    candidate: {
+      type: Object,
+      required: true,
+    },
+  },
+  data() {
+    return {
+      version: null,
+      description: '',
+      errorMessage: null,
+      versionData: null,
+      markdownDocPath: helpPagePath('user/markdown'),
+      markdownEditorRestrictedToolBarItems: ['full-screen'],
+    };
+  },
+  computed: {
+    versionDescription() {
+      if (this.candidate.latestVersion) {
+        return sprintf(
+          s__('MlModelRegistry|Must be a semantic version. Latest version is %{latestVersion}'),
+          {
+            latestVersion: this.candidate.latestVersion,
+          },
+        );
+      }
+      return s__('MlModelRegistry|Must be a semantic version.');
+    },
+    autocompleteDataSources() {
+      return gl.GfmAutoComplete?.dataSources;
+    },
+    isSemver() {
+      return semverRegex.test(this.version);
+    },
+    invalidFeedback() {
+      if (this.version === null) {
+        return this.versionDescription;
+      }
+      if (!this.isSemver) {
+        return this.$options.i18n.versionInvalid;
+      }
+      return null;
+    },
+    modelGid() {
+      return this.candidate.modelGid;
+    },
+    submitDisabled() {
+      return this.version === null || !this.isSemver;
+    },
+  },
+  methods: {
+    async createModelVersion() {
+      const { data } = await this.$apollo.mutate({
+        mutation: createModelVersionMutation,
+        variables: {
+          projectPath: this.candidate.projectPath,
+          modelId: this.candidate.modelGid,
+          version: this.version,
+          description: this.description,
+          candidateId: this.candidate.info.gid,
+        },
+      });
+
+      return data;
+    },
+    async create() {
+      try {
+        if (!this.versionData) {
+          this.versionData = await this.createModelVersion();
+        }
+        const errors = this.versionData?.mlModelVersionCreate?.errors || [];
+
+        if (errors.length) {
+          this.errorMessage = errors.join(', ');
+          this.versionData = null;
+        } else {
+          const { showPath } = this.versionData.mlModelVersionCreate.modelVersion._links;
+          visitUrl(showPath);
+        }
+      } catch (error) {
+        Sentry.captureException(error);
+        this.errorMessage = error;
+      }
+    },
+    cancel() {
+      visitUrl(this.modelPath);
+    },
+    hideAlert() {
+      this.errorMessage = null;
+    },
+    setDescription(newText) {
+      if (!this.isSubmitting) {
+        this.description = newText;
+      }
+    },
+  },
+  descriptionFormFieldProps: {
+    placeholder: s__('MlModelRegistry|Enter a model version description'),
+    id: 'model-version-description',
+    name: 'model-version-description',
+  },
+  i18n: {
+    actionPrimaryText: s__('MlModelRegistry|Promote'),
+    actionSecondaryText: __('Cancel'),
+    versionDescription: s__('MlModelRegistry|Enter a semantic version.'),
+    versionValid: s__('MlModelRegistry|Version is valid semantic version.'),
+    versionInvalid: s__('MlModelRegistry|Version is not a valid semantic version.'),
+    versionPlaceholder: s__('MlModelRegistry|For example 1.0.0'),
+    descriptionPlaceholder: s__('MlModelRegistry|Enter some description'),
+    title: s__('MlModelRegistry|Promote run'),
+    description: s__('MlModelRegistry|Complete the form below to promote run to a model version.'),
+    optionalText: s__('MlModelRegistry|(Optional)'),
+    versionLabelText: s__('MlModelRegistry|Version'),
+    versionDescriptionText: s__('MlModelRegistry|Description'),
+  },
+};
+</script>
+
+<template>
+  <div>
+    <gl-alert
+      v-if="errorMessage"
+      class="gl-mt-5"
+      data-testid="create-alert"
+      variant="danger"
+      @dismiss="hideAlert"
+      >{{ errorMessage }}
+    </gl-alert>
+
+    <page-heading :heading="$options.i18n.title">
+      <template #description>
+        {{ $options.i18n.description }}
+      </template>
+    </page-heading>
+
+    <gl-form>
+      <gl-form-group
+        data-testid="versionDescriptionId"
+        :label="$options.i18n.versionLabelText"
+        label-for="versionId"
+        :state="isSemver"
+        :invalid-feedback="!version ? '' : invalidFeedback"
+        :valid-feedback="isSemver ? $options.i18n.versionValid : ''"
+        :description="versionDescription"
+      >
+        <gl-form-input
+          id="versionId"
+          v-model="version"
+          data-testid="versionId"
+          type="text"
+          required
+          :placeholder="$options.i18n.versionPlaceholder"
+          autocomplete="off"
+        />
+      </gl-form-group>
+      <gl-form-group
+        :label="$options.i18n.versionDescriptionText"
+        label-for="descriptionId"
+        class="common-note-form gfm-form js-main-target-form new-note gl-grow"
+        optional
+        :optional-text="$options.i18n.optionalText"
+      >
+        <markdown-editor
+          ref="markdownEditor"
+          data-testid="descriptionId"
+          :value="description"
+          enable-autocomplete
+          :autocomplete-data-sources="autocompleteDataSources"
+          :enable-content-editor="true"
+          :form-field-props="$options.descriptionFormFieldProps"
+          :render-markdown-path="candidate.markdownPreviewPath"
+          :markdown-docs-path="markdownDocPath"
+          :disable-attachments="false"
+          :placeholder="$options.i18n.descriptionPlaceholder"
+          :restricted-tool-bar-items="markdownEditorRestrictedToolBarItems"
+          @input="setDescription"
+        />
+      </gl-form-group>
+      <div class="gl-flex gl-gap-3">
+        <gl-button
+          :disabled="submitDisabled"
+          data-testid="primary-button"
+          variant="confirm"
+          @click="create"
+          >{{ $options.i18n.actionPrimaryText }}
+        </gl-button>
+        <gl-button data-testid="secondary-button" variant="default" @click="cancel"
+          >{{ $options.i18n.actionSecondaryText }}
+        </gl-button>
+      </div>
+    </gl-form>
+  </div>
+</template>
diff --git a/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/candidate_header.vue b/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/candidate_header.vue
index 3e57df46345f1ea9c94f16533b9a7dce0e95c895..48e0dc4424cb4de839c50ae3533b19315e31417b 100644
--- a/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/candidate_header.vue
+++ b/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/candidate_header.vue
@@ -1,5 +1,5 @@
 <script>
-import { GlBadge, GlIcon, GlLink } from '@gitlab/ui';
+import { GlBadge, GlButton, GlIcon, GlLink } from '@gitlab/ui';
 import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
 import timeagoMixin from '~/vue_shared/mixins/timeago';
 import PageHeading from '~/vue_shared/components/page_heading.vue';
@@ -11,6 +11,7 @@ export default {
   components: {
     DeleteButton,
     GlBadge,
+    GlButton,
     GlIcon,
     GlLink,
     PageHeading,
@@ -40,6 +41,7 @@ export default {
     ),
     deleteCandidatePrimaryActionLabel: s__('MlExperimentTracking|Delete run'),
     deleteCandidateModalTitle: s__('MlExperimentTracking|Delete run?'),
+    promoteText: s__('ExperimentTracking|Promote run'),
   },
   statusVariants: {
     running: 'success',
@@ -80,12 +82,16 @@ export default {
         </div>
       </template>
     </page-heading>
-
-    <delete-button
-      :delete-path="info.path"
-      :delete-confirmation-text="$options.i18n.deleteCandidateConfirmationMessage"
-      :action-primary-text="$options.i18n.deleteCandidatePrimaryActionLabel"
-      :modal-title="$options.i18n.deleteCandidateModalTitle"
-    />
+    <div class="gl-flex">
+      <gl-button v-if="info.canPromote" :href="info.promotePath" variant="confirm" class="gl-mr-3">
+        {{ $options.i18n.promoteText }}
+      </gl-button>
+      <delete-button
+        :delete-path="info.path"
+        :delete-confirmation-text="$options.i18n.deleteCandidateConfirmationMessage"
+        :action-primary-text="$options.i18n.deleteCandidatePrimaryActionLabel"
+        :modal-title="$options.i18n.deleteCandidateModalTitle"
+      />
+    </div>
   </div>
 </template>
diff --git a/app/assets/javascripts/pages/projects/ml/candidates/promote/index.js b/app/assets/javascripts/pages/projects/ml/candidates/promote/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..f1e861d936c4c51eacecf46131952bcd5636063f
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/ml/candidates/promote/index.js
@@ -0,0 +1,4 @@
+import { initSimpleApp } from '~/helpers/init_simple_app_helper';
+import PromoteRun from '~/ml/experiment_tracking/routes/candidates/promote/promote_run.vue';
+
+initSimpleApp('#js-promote-ml-candidate', PromoteRun, { withApolloProvider: true });
diff --git a/app/controllers/projects/ml/candidates_controller.rb b/app/controllers/projects/ml/candidates_controller.rb
index a6829ddf82cb431f01ae8c67d2f7271e4095b839..60b01b07a2278724e5076b324fbbc29c8f6c5270 100644
--- a/app/controllers/projects/ml/candidates_controller.rb
+++ b/app/controllers/projects/ml/candidates_controller.rb
@@ -5,12 +5,14 @@ module Ml
     class CandidatesController < ApplicationController
       before_action :set_candidate
       before_action :check_read, only: [:show]
-      before_action :check_write, only: [:destroy]
+      before_action :check_write, only: [:destroy, :promote]
 
       feature_category :mlops
 
       def show; end
 
+      def promote; end
+
       def destroy
         @experiment = @candidate.experiment
         @candidate.destroy!
diff --git a/app/graphql/mutations/ml/model_versions/create.rb b/app/graphql/mutations/ml/model_versions/create.rb
index 86066ad38deae414ca7e2f1a30084ada7b83c7cf..a5a0f6504bcf963c0688a3d2829b61e36b75700c 100644
--- a/app/graphql/mutations/ml/model_versions/create.rb
+++ b/app/graphql/mutations/ml/model_versions/create.rb
@@ -25,6 +25,10 @@ class Create < BaseMutation
           required: false,
           description: 'Description of the model version.'
 
+        argument :candidate_id, ::Types::GlobalIDType[::Ml::Candidate],
+          required: false,
+          description: 'Global ID of a candidate to promote optionally.'
+
         field :model_version,
           Types::Ml::ModelVersionType,
           null: true,
@@ -40,6 +44,7 @@ def resolve(**args)
             {
               version: args[:version],
               description: args[:description],
+              candidate_id: args[:candidate_id],
               user: current_user
             }
           ).execute
diff --git a/app/models/ml/candidate.rb b/app/models/ml/candidate.rb
index 99f7008c0c95924a254b7494ce041fc2503455ec..c2d4dfe5a482bc56d5db878381290c67454ca170 100644
--- a/app/models/ml/candidate.rb
+++ b/app/models/ml/candidate.rb
@@ -93,6 +93,12 @@ def with_project_id_and_iid(project_id, iid)
 
         find_by(project_id: project_id, internal_id: iid)
       end
+
+      def with_project_id_and_id(project_id, id)
+        return unless project_id.present? && id.present?
+
+        find_by(project_id: project_id, id: id)
+      end
     end
 
     private
diff --git a/app/presenters/ml/candidate_details_presenter.rb b/app/presenters/ml/candidate_details_presenter.rb
index d4f75cb447ff6b9064adb85775f8f61432f6798a..cb1da18f1ee9b7aa3c579c64b1ccdc2c61cddee4 100644
--- a/app/presenters/ml/candidate_details_presenter.rb
+++ b/app/presenters/ml/candidate_details_presenter.rb
@@ -9,12 +9,14 @@ def initialize(candidate, current_user)
       @current_user = current_user
     end
 
+    # rubocop:disable Metrics/AbcSize -- Monoton complexity
     def present
       {
         candidate: {
           info: {
             iid: candidate.iid,
             eid: candidate.eid,
+            gid: candidate.to_global_id.to_s,
             path_to_artifact: link_to_artifact,
             experiment_name: candidate.experiment.name,
             path_to_experiment: link_to_experiment,
@@ -22,15 +24,23 @@ def present
             status: candidate.status,
             ci_job: job_info,
             created_at: candidate.created_at,
-            authorWebUrl: candidate.user&.namespace&.web_url,
-            authorName: candidate.user&.name
+            author_web_url: candidate.user&.namespace&.web_url,
+            author_name: candidate.user&.name,
+            promote_path: promote_project_ml_candidate_path(candidate.project, candidate.iid),
+            can_promote: candidate.model_version_id.nil? && candidate.experiment.model_id.present?
           },
           params: candidate.params,
           metrics: candidate.metrics,
-          metadata: candidate.metadata
+          metadata: candidate.metadata,
+          projectPath: candidate.project.full_path,
+          can_write_model_registry: current_user&.can?(:write_model_registry, candidate.project),
+          markdown_preview_path: project_preview_markdown_path(candidate.project),
+          model_gid: candidate.experiment.model&.to_global_id.to_s,
+          latest_version: candidate.experiment.model&.latest_version&.version
         }
       }
     end
+    # rubocop:enable Metrics/AbcSize
 
     def present_as_json
       Gitlab::Json.generate(present.deep_transform_keys { |k| k.to_s.camelize(:lower) })
diff --git a/app/services/ml/create_model_version_service.rb b/app/services/ml/create_model_version_service.rb
index 882cd0b17485192cecb7e321a2b7a33984de1414..6189de07fc29f66acfc437c9da62950e4c2f2cf5 100644
--- a/app/services/ml/create_model_version_service.rb
+++ b/app/services/ml/create_model_version_service.rb
@@ -9,6 +9,7 @@ def initialize(model, params = {})
       @description = params[:description]
       @user = params[:user]
       @metadata = params[:metadata]
+      @candidate_id = params[:candidate_id]
     end
 
     def execute
@@ -17,23 +18,18 @@ def execute
 
         error(_("Version must be semantic version")) unless Packages::SemVer.match(@version)
 
-        package = @package || find_or_create_package(@model.name, @version)
-
-        error(_("Can't create model version package")) unless package
-
         @model_version = Ml::ModelVersion.new(model: @model, project: @model.project, version: @version,
-          package: package, description: @description)
+          description: @description)
 
         @model_version.save
 
         error(@model_version.errors.full_messages) unless @model_version.persisted?
 
-        @model_version.candidate = ::Ml::CreateCandidateService.new(
-          @model.default_experiment,
-          { model_version: @model_version }
-        ).execute
+        package = find_or_create_candidate
+        package ||= find_or_create_package(@model.name, @version)
+        error(_("Can't create model version package")) unless package
 
-        error(_("Version must be semantic version")) unless @model_version.candidate
+        @model_version.update! package: package
 
         @model_version.add_metadata(@metadata)
 
@@ -55,6 +51,30 @@ def execute
 
     private
 
+    def find_or_create_candidate
+      if @candidate_id
+        candidate = ::Ml::Candidate.with_project_id_and_id(@model.project_id, @candidate_id.model_id)
+        error(_("Run not found")) unless candidate
+        error(_("Run has already a model version")) if candidate.model_version_id
+        error(_("Run's experiment does not belong to this model")) unless candidate.experiment.model_id == @model.id
+
+        candidate.update! model_version: @model_version
+
+        package = candidate.package
+        package.update!(name: @model_version.name, version: @model_version.version) if package
+      else
+        candidate = ::Ml::CreateCandidateService.new(
+          @model.default_experiment,
+          { model_version: @model_version }
+        ).execute
+        error(_("Version must be semantic version")) unless candidate
+
+        package = @package
+      end
+
+      package
+    end
+
     def find_or_create_package(model_name, model_version)
       package_params = {
         name: model_name,
diff --git a/app/views/projects/ml/candidates/promote.html.haml b/app/views/projects/ml/candidates/promote.html.haml
new file mode 100644
index 0000000000000000000000000000000000000000..10d4642852155e3d77cac0bb2378b7bcc74fd7b9
--- /dev/null
+++ b/app/views/projects/ml/candidates/promote.html.haml
@@ -0,0 +1,8 @@
+- experiment = @candidate.experiment
+- add_to_breadcrumbs _("Experiments"), project_ml_experiments_path(@project)
+- add_to_breadcrumbs experiment.name, project_ml_experiment_path(@project, experiment.iid)
+- add_to_breadcrumbs "Run #{@candidate.iid}", project_ml_candidate_path(@project, @candidate.iid)
+- breadcrumb_title "Promote to model version"
+- presenter = ::Ml::CandidateDetailsPresenter.new(@candidate, current_user)
+
+#js-promote-ml-candidate{ data: { view_model: presenter.present_as_json } }
diff --git a/config/routes/project.rb b/config/routes/project.rb
index 65f1208e29f6db46742584c12892a68458d28092..d3c3929b7fc1ebecef7f795215263e02c9bc8940 100644
--- a/config/routes/project.rb
+++ b/config/routes/project.rb
@@ -483,7 +483,11 @@
 
         namespace :ml do
           resources :experiments, only: [:index, :show, :destroy], controller: 'experiments', param: :iid
-          resources :candidates, only: [:show, :destroy], controller: 'candidates', param: :iid
+          resources :candidates, only: [:show, :destroy], controller: 'candidates', param: :iid do
+            member do
+              get :promote
+            end
+          end
           resources :models, only: [:index, :show, :edit, :destroy, :new], controller: 'models', param: :model_id do
             resources :versions, only: [:new], controller: 'model_versions'
             resources :versions, only: [:show, :edit], controller: 'model_versions', param: :model_version_id
diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index 161c7c1d07a5844a9bbb3ec6496232d651ed3703..5a098830aacfebc2740db315c453e86081c9a82d 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -7723,6 +7723,7 @@ Input type: `MlModelVersionCreateInput`
 
 | Name | Type | Description |
 | ---- | ---- | ----------- |
+| <a id="mutationmlmodelversioncreatecandidateid"></a>`candidateId` | [`MlCandidateID`](#mlcandidateid) | Global ID of a candidate to promote optionally. |
 | <a id="mutationmlmodelversioncreateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
 | <a id="mutationmlmodelversioncreatedescription"></a>`description` | [`String`](#string) | Description of the model version. |
 | <a id="mutationmlmodelversioncreatemodelid"></a>`modelId` | [`MlModelID!`](#mlmodelid) | Global ID of the model the version belongs to. |
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 0016812822b99a0f409fba7bba57527a38291984..8eb3a815bb70922c38e02a9508b08adf1b72737e 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -23459,6 +23459,9 @@ msgstr ""
 msgid "ExperimentTracking|Performance graph will be shown when runs with logged metrics are available"
 msgstr ""
 
+msgid "ExperimentTracking|Promote run"
+msgstr ""
+
 msgid "ExperimentTracking|Run"
 msgstr ""
 
@@ -35926,6 +35929,9 @@ msgstr ""
 msgid "MlModelRegistry|CI Info"
 msgstr ""
 
+msgid "MlModelRegistry|Complete the form below to promote run to a model version."
+msgstr ""
+
 msgid "MlModelRegistry|Create"
 msgstr ""
 
@@ -36157,6 +36163,12 @@ msgstr ""
 msgid "MlModelRegistry|Performance"
 msgstr ""
 
+msgid "MlModelRegistry|Promote"
+msgstr ""
+
+msgid "MlModelRegistry|Promote run"
+msgstr ""
+
 msgid "MlModelRegistry|Provide a subfolder name to organize your artifacts. Entering an existing subfolder's name will place artifacts in the existing folder"
 msgstr ""
 
@@ -48037,6 +48049,9 @@ msgstr ""
 msgid "Run container scanning job whenever a container image with the latest tag is pushed."
 msgstr ""
 
+msgid "Run has already a model version"
+msgstr ""
+
 msgid "Run housekeeping"
 msgstr ""
 
@@ -48049,6 +48064,9 @@ msgstr ""
 msgid "Run manual or delayed jobs"
 msgstr ""
 
+msgid "Run not found"
+msgstr ""
+
 msgid "Run pipeline"
 msgstr ""
 
@@ -48064,6 +48082,9 @@ msgstr ""
 msgid "Run untagged jobs"
 msgstr ""
 
+msgid "Run's experiment does not belong to this model"
+msgstr ""
+
 msgid "Runner"
 msgstr ""
 
diff --git a/spec/frontend/ml/experiment_tracking/routes/candidates/promote/promote_run_spec.js b/spec/frontend/ml/experiment_tracking/routes/candidates/promote/promote_run_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..53a312a754fd2359e14f1775b3270688e6cf9e27
--- /dev/null
+++ b/spec/frontend/ml/experiment_tracking/routes/candidates/promote/promote_run_spec.js
@@ -0,0 +1,240 @@
+import Vue, { nextTick } from 'vue';
+import VueApollo from 'vue-apollo';
+import { GlAlert } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import * as Sentry from '~/sentry/sentry_browser_wrapper';
+import { visitUrl } from '~/lib/utils/url_utility';
+import PromoteRun from '~/ml/experiment_tracking/routes/candidates/promote/promote_run.vue';
+import PageHeading from '~/vue_shared/components/page_heading.vue';
+import createModelVersionMutation from '~/ml/experiment_tracking/graphql/mutations/promote_model_version.mutation.graphql';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue';
+import { createModelVersionResponses } from 'jest/ml/model_registry/graphql_mock_data';
+import { newCandidate } from 'jest/ml/model_registry/mock_data';
+
+Vue.use(VueApollo);
+
+jest.mock('~/lib/utils/url_utility', () => ({
+  ...jest.requireActual('~/lib/utils/url_utility'),
+  visitUrl: jest.fn(),
+}));
+
+describe('PromoteRun', () => {
+  let wrapper;
+  let apolloProvider;
+
+  beforeEach(() => {
+    jest.spyOn(Sentry, 'captureException').mockImplementation();
+  });
+
+  afterEach(() => {
+    apolloProvider = null;
+  });
+
+  const createWrapper = (
+    createResolver = jest.fn().mockResolvedValue(createModelVersionResponses.success),
+  ) => {
+    const requestHandlers = [[createModelVersionMutation, createResolver]];
+    apolloProvider = createMockApollo(requestHandlers);
+
+    wrapper = shallowMountExtended(PromoteRun, {
+      propsData: {
+        candidate: newCandidate(),
+      },
+      apolloProvider,
+      stubs: {
+        PageHeading,
+      },
+    });
+  };
+
+  const findDescription = () => wrapper.findByTestId('page-heading-description');
+  const findPrimaryButton = () => wrapper.findByTestId('primary-button');
+  const findSecondaryButton = () => wrapper.findByTestId('secondary-button');
+  const findVersionInput = () => wrapper.findByTestId('versionId');
+  const findDescriptionInput = () => wrapper.findByTestId('descriptionId');
+  const findGlAlert = () => wrapper.findComponent(GlAlert);
+  const submitForm = async () => {
+    findPrimaryButton().vm.$emit('click');
+    await waitForPromises();
+  };
+  const findMarkdownEditor = () => wrapper.findComponent(MarkdownEditor);
+
+  describe('Initial state', () => {
+    beforeEach(() => {
+      createWrapper();
+    });
+
+    describe('Form', () => {
+      it('renders the title', () => {
+        expect(wrapper.findByRole('heading').text()).toBe('Promote run');
+      });
+
+      it('renders the description', () => {
+        expect(findDescription().text()).toBe(
+          'Complete the form below to promote run to a model version.',
+        );
+      });
+
+      it('renders the version input', () => {
+        expect(findVersionInput().exists()).toBe(true);
+      });
+
+      it('renders the version input label for initial state', () => {
+        expect(wrapper.findByTestId('versionDescriptionId').attributes().description).toBe(
+          'Must be a semantic version. Latest version is 1.0.2',
+        );
+        expect(wrapper.findByTestId('versionDescriptionId').attributes('invalid-feedback')).toBe(
+          '',
+        );
+        expect(wrapper.findByTestId('versionDescriptionId').attributes('valid-feedback')).toBe('');
+      });
+
+      it('renders the description input', () => {
+        expect(findDescriptionInput().exists()).toBe(true);
+      });
+
+      it('renders the create button', () => {
+        expect(findPrimaryButton().props()).toMatchObject({
+          variant: 'confirm',
+          disabled: true,
+        });
+      });
+
+      it('renders the cancel button', () => {
+        expect(findSecondaryButton().props()).toMatchObject({
+          variant: 'default',
+          disabled: false,
+        });
+      });
+
+      it('disables the create button in the modal when semver is incorrect', () => {
+        expect(findPrimaryButton().props()).toMatchObject({
+          variant: 'confirm',
+          disabled: true,
+        });
+      });
+
+      it('does not render the alert by default', () => {
+        expect(findGlAlert().exists()).toBe(false);
+      });
+    });
+  });
+
+  describe('Markdown editor', () => {
+    it('should show markdown editor', () => {
+      createWrapper();
+
+      expect(findMarkdownEditor().exists()).toBe(true);
+
+      expect(findMarkdownEditor().props()).toMatchObject({
+        enableContentEditor: true,
+        formFieldProps: {
+          id: 'model-version-description',
+          name: 'model-version-description',
+          placeholder: 'Enter a model version description',
+        },
+        markdownDocsPath: '/help/user/markdown',
+        renderMarkdownPath: '/markdown-preview',
+        uploadsPath: '',
+      });
+    });
+  });
+
+  describe('It reacts to semantic version input', () => {
+    beforeEach(() => {
+      createWrapper();
+    });
+    it('renders the version input label for initial state', () => {
+      expect(wrapper.findByTestId('versionDescriptionId').attributes('invalid-feedback')).toBe('');
+      expect(findPrimaryButton().props()).toMatchObject({
+        variant: 'confirm',
+        disabled: true,
+      });
+    });
+    it.each(['1.0', '1', 'abc', '1.abc', '1.0.0.0'])(
+      'renders the version input label for invalid state',
+      async (version) => {
+        findVersionInput().vm.$emit('input', version);
+        await nextTick();
+        expect(wrapper.findByTestId('versionDescriptionId').attributes('invalid-feedback')).toBe(
+          'Version is not a valid semantic version.',
+        );
+        expect(findPrimaryButton().props()).toMatchObject({
+          variant: 'confirm',
+          disabled: true,
+        });
+      },
+    );
+    it.each(['1.0.0', '0.0.0-b', '24.99.99-b99'])(
+      'renders the version input label for valid state',
+      async (version) => {
+        findVersionInput().vm.$emit('input', version);
+        await nextTick();
+        expect(wrapper.findByTestId('versionDescriptionId').attributes('valid-feedback')).toBe(
+          'Version is valid semantic version.',
+        );
+        expect(findPrimaryButton().props()).toMatchObject({
+          variant: 'confirm',
+          disabled: false,
+        });
+      },
+    );
+  });
+
+  describe('Successful flow', () => {
+    beforeEach(async () => {
+      createWrapper();
+      findVersionInput().vm.$emit('input', '1.0.0');
+      findDescriptionInput().vm.$emit('input', 'My model version description');
+      jest.spyOn(apolloProvider.defaultClient, 'mutate');
+
+      await submitForm();
+    });
+
+    it('Makes a create mutation upon confirm', () => {
+      expect(apolloProvider.defaultClient.mutate).toHaveBeenCalledWith(
+        expect.objectContaining({
+          mutation: createModelVersionMutation,
+          variables: {
+            modelId: 'gid://gitlab/Ml::Model/1',
+            projectPath: 'some/project',
+            version: '1.0.0',
+            description: 'My model version description',
+            candidateId: 'gid://gitlab/Ml::Candidate/1',
+          },
+        }),
+      );
+    });
+
+    it('Visits the model versions page upon successful create mutation', async () => {
+      createWrapper();
+
+      await submitForm();
+
+      expect(visitUrl).toHaveBeenCalledWith('/some/project/-/ml/models/1/versions/1');
+    });
+
+    it('clicking on secondary button clears the form', async () => {
+      createWrapper();
+
+      await findSecondaryButton().vm.$emit('click');
+
+      expect(visitUrl).toHaveBeenCalledWith('/some/project/-/ml/models/1/versions/1');
+    });
+  });
+
+  describe('Failed flow', () => {
+    it('Displays an alert upon failed create mutation', async () => {
+      const failedCreateResolver = jest.fn().mockResolvedValue(createModelVersionResponses.failure);
+      createWrapper(failedCreateResolver);
+
+      findVersionInput().vm.$emit('input', '1.0.0');
+
+      await submitForm();
+
+      expect(findGlAlert().text()).toBe('Version is invalid');
+    });
+  });
+});
diff --git a/spec/frontend/ml/experiment_tracking/routes/candidates/show/candidate_header_spec.js b/spec/frontend/ml/experiment_tracking/routes/candidates/show/candidate_header_spec.js
index a9032d64c9439aa343a6cb9dea6417846b48e81d..1b5dd9fa8c7a334e1866bb6f5f003eeec1ce6da3 100644
--- a/spec/frontend/ml/experiment_tracking/routes/candidates/show/candidate_header_spec.js
+++ b/spec/frontend/ml/experiment_tracking/routes/candidates/show/candidate_header_spec.js
@@ -1,4 +1,4 @@
-import { GlBadge, GlIcon } from '@gitlab/ui';
+import { GlBadge, GlButton, GlIcon } from '@gitlab/ui';
 import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
 import CandidateHeader from '~/ml/experiment_tracking/routes/candidates/show/candidate_header.vue';
 import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
@@ -24,6 +24,7 @@ describe('ml/experiment_tracking/routes/candidates/show/candidate_header.vue', (
 
   const findPageHeading = () => wrapper.findComponent(PageHeading);
   const findDeleteButton = () => wrapper.findComponent(DeleteButton);
+  const findPromoteButton = () => wrapper.findComponent(GlButton);
   const findTimeAgo = () => wrapper.findComponent(TimeAgoTooltip);
   const findBadge = () => wrapper.findComponent(GlBadge);
   const findAuthorLink = () => wrapper.findByTestId('author-link');
@@ -110,7 +111,7 @@ describe('ml/experiment_tracking/routes/candidates/show/candidate_header.vue', (
     });
   });
 
-  describe('delete button configuration', () => {
+  describe('Delete button configuration', () => {
     beforeEach(() => {
       wrapper = createWrapper();
     });
@@ -126,4 +127,17 @@ describe('ml/experiment_tracking/routes/candidates/show/candidate_header.vue', (
       });
     });
   });
+
+  describe('Promote button', () => {
+    beforeEach(() => {
+      wrapper = createWrapper();
+    });
+
+    it('passes correct props to promote button', () => {
+      expect(findPromoteButton().text()).toBe('Promote run');
+      expect(findPromoteButton().attributes()).toMatchObject({
+        href: 'promote/path',
+      });
+    });
+  });
 });
diff --git a/spec/frontend/ml/model_registry/mock_data.js b/spec/frontend/ml/model_registry/mock_data.js
index e30208751b798da40d0b780a9f867501b60a5c89..c6863ec68a8f38632a7b3273f727c8b185e67fef 100644
--- a/spec/frontend/ml/model_registry/mock_data.js
+++ b/spec/frontend/ml/model_registry/mock_data.js
@@ -17,6 +17,7 @@ export const newCandidate = () => ({
   info: {
     iid: 'candidate_iid',
     eid: 'abcdefg',
+    gid: 'gid://gitlab/Ml::Candidate/1',
     pathToArtifact: 'path_to_artifact',
     experimentName: 'The Experiment',
     pathToExperiment: 'path/to/experiment',
@@ -40,7 +41,14 @@ export const newCandidate = () => ({
     createdAt: '2024-01-01T00:00:00Z',
     authorName: 'Test User',
     authorWebUrl: '/test-user',
+    canPromote: true,
+    promotePath: 'promote/path',
   },
+  projectPath: 'some/project',
+  canWriteModelRegistry: true,
+  markdownPreviewPath: '/markdown-preview',
+  modelGid: 'gid://gitlab/Ml::Model/1',
+  latestVersion: '1.0.2',
 });
 
 const LATEST_VERSION = {
diff --git a/spec/models/ml/candidate_spec.rb b/spec/models/ml/candidate_spec.rb
index f7ea44e1e97f6da6f913a87b67501a60fcbea45e..1802c4331b7684872d36942b9f59f90555a69b5c 100644
--- a/spec/models/ml/candidate_spec.rb
+++ b/spec/models/ml/candidate_spec.rb
@@ -256,6 +256,29 @@
     end
   end
 
+  describe '#with_project_id_and_id' do
+    let(:project_id) { candidate.experiment.project_id }
+    let(:id) { candidate.id }
+
+    subject { described_class.with_project_id_and_id(project_id, id) }
+
+    context 'when internal_id exists', 'and belongs to project' do
+      it { is_expected.to eq(candidate) }
+    end
+
+    context 'when id exists and does not belong to project' do
+      let(:project_id) { non_existing_record_id }
+
+      it { is_expected.to be_nil }
+    end
+
+    context 'when id does not exist' do
+      let(:id) { non_existing_record_id }
+
+      it { is_expected.to be_nil }
+    end
+  end
+
   describe "#latest_metrics" do
     let_it_be(:candidate3) { create(:ml_candidates, experiment: candidate.experiment) }
     let_it_be(:metric1) { create(:ml_candidate_metrics, candidate: candidate3) }
diff --git a/spec/presenters/ml/candidate_details_presenter_spec.rb b/spec/presenters/ml/candidate_details_presenter_spec.rb
index 05b09d73221efe78ddd70ccf4ac25e551b5171e2..075d5ba5e3a94fd1fa127a6cd024f9cfe3e22187 100644
--- a/spec/presenters/ml/candidate_details_presenter_spec.rb
+++ b/spec/presenters/ml/candidate_details_presenter_spec.rb
@@ -54,6 +54,7 @@
             info: {
               iid: candidate.iid,
               eid: candidate.eid,
+              gid: candidate.to_global_id.to_s,
               path_to_artifact: "/#{project.full_path}/-/packages/#{candidate.artifact.id}",
               experiment_name: candidate.experiment.name,
               path_to_experiment: "/#{project.full_path}/-/ml/experiments/#{experiment.iid}",
@@ -75,12 +76,19 @@
                 }
               },
               created_at: candidate.created_at,
-              authorWebUrl: nil,
-              authorName: candidate.user.name
+              author_web_url: nil,
+              author_name: candidate.user.name,
+              promote_path: "/#{project.full_path}/-/ml/candidates/#{candidate.iid}/promote",
+              can_promote: false
             },
             params: params,
             metrics: metrics,
-            metadata: []
+            metadata: [],
+            projectPath: project.full_path,
+            can_write_model_registry: false,
+            markdown_preview_path: "/#{project.full_path}/-/preview_markdown",
+            model_gid: '',
+            latest_version: nil
           }
         }
       )
diff --git a/spec/requests/api/graphql/mutations/ml/model_versions/create_spec.rb b/spec/requests/api/graphql/mutations/ml/model_versions/create_spec.rb
index c2a1817f3a8087232821912e6013219db570d568..ff6a60ec36230d2cb09d0f93f073fe2458de61c7 100644
--- a/spec/requests/api/graphql/mutations/ml/model_versions/create_spec.rb
+++ b/spec/requests/api/graphql/mutations/ml/model_versions/create_spec.rb
@@ -8,6 +8,9 @@
   let_it_be(:model) { create(:ml_models) }
   let_it_be(:project) { model.project }
   let_it_be(:current_user) { project.owner }
+  let_it_be(:candidate) do
+    create(:ml_candidates, experiment: model.default_experiment, project: model.project)
+  end
 
   let(:version) { '1.0.0' }
   let(:description) { 'A description' }
@@ -54,4 +57,30 @@
       it_behaves_like 'a mutation that returns errors in the response', errors: ["Version must be semantic version"]
     end
   end
+
+  context 'when a candidate_id is present' do
+    let(:input) do
+      {
+        project_path: project.full_path,
+        modelId: model.to_gid,
+        version: version,
+        candidate_id: candidate.to_global_id.to_s
+      }
+    end
+
+    it 'creates a model' do
+      post_graphql_mutation(mutation, current_user: current_user)
+
+      expect(response).to have_gitlab_http_status(:success)
+      expect(mutation_response['modelVersion']).to include(
+        'version' => version
+      )
+    end
+
+    context 'when run is not found in the same project' do
+      let_it_be(:candidate) { create(:ml_candidates) }
+
+      it_behaves_like 'a mutation that returns errors in the response', errors: ["Run not found"]
+    end
+  end
 end
diff --git a/spec/requests/projects/ml/candidates_controller_spec.rb b/spec/requests/projects/ml/candidates_controller_spec.rb
index 357fe362b65ea34ab62d38a219d39e2d15776a94..14e42d0208b16def51cfc910932382910a2ee392 100644
--- a/spec/requests/projects/ml/candidates_controller_spec.rb
+++ b/spec/requests/projects/ml/candidates_controller_spec.rb
@@ -72,6 +72,25 @@
     it_behaves_like 'requires read_model_experiments'
   end
 
+  describe 'GET promote' do
+    before do
+      promote_candidate
+    end
+
+    it 'renders the template' do
+      expect(response).to render_template('projects/ml/candidates/promote')
+    end
+
+    it_behaves_like '404 if candidate does not exist'
+    describe 'requires write_model_experiments' do
+      let(:write_model_experiments) { false }
+
+      it 'is 404' do
+        expect(response).to have_gitlab_http_status(:not_found)
+      end
+    end
+  end
+
   describe 'DELETE #destroy' do
     let_it_be(:candidate_for_deletion) do
       create(:ml_candidates, project: project, experiment: experiment, user: user)
@@ -107,6 +126,10 @@ def show_candidate
     get project_ml_candidate_path(project, iid: candidate_iid)
   end
 
+  def promote_candidate
+    get promote_project_ml_candidate_path(project, iid: candidate_iid)
+  end
+
   def destroy_candidate
     delete project_ml_candidate_path(project, candidate_iid)
   end
diff --git a/spec/services/ml/create_model_version_service_spec.rb b/spec/services/ml/create_model_version_service_spec.rb
index 932069142c62814f08ecba09819f0f3dd37d272e..a8ec4a7ddad1782e6de49c5a6822b7da8bd89505 100644
--- a/spec/services/ml/create_model_version_service_spec.rb
+++ b/spec/services/ml/create_model_version_service_spec.rb
@@ -51,6 +51,67 @@
       end
     end
 
+    context 'when a proper candidate_id without a package is given for promotion' do
+      let_it_be(:candidate) do
+        create(:ml_candidates, experiment: model.default_experiment, project: model.project)
+      end
+
+      let(:params) { { user: user, candidate_id: candidate.to_global_id } }
+
+      it 'creates a model version' do
+        expect { service }.to change { Ml::ModelVersion.count }.by(1)
+                                                               .and not_change { Ml::Candidate.count }
+                                                               .and change { Packages::MlModel::Package.count }.by(1)
+
+        expect(model.reload.latest_version.version).to eq('1.0.1')
+        expect(service).to be_success
+
+        expect(model.latest_version.package.version).to eq('1.0.1')
+        expect(model.latest_version.package.name).to eq(model.name)
+
+        expect(Gitlab::InternalEvents).to have_received(:track_event).with(
+          'model_registry_ml_model_version_created',
+          { project: model.project, user: user }
+        )
+        expect(Gitlab::Audit::Auditor).to have_received(:audit).with(audit_context)
+      end
+    end
+
+    context 'when a proper candidate_id with a package is given for promotion' do
+      let_it_be(:candidate) do
+        create(:ml_candidates, experiment: model.default_experiment, project: model.project, package: package)
+      end
+
+      let_it_be(:package) do
+        create(:ml_model_package, name: candidate.package_name, version: candidate.package_version,
+          project: model.project)
+      end
+
+      let(:params) { { user: user, candidate_id: candidate.to_global_id } }
+
+      before do
+        candidate.update!(package: package)
+      end
+
+      it 'creates a model version' do
+        expect { service }.to change { Ml::ModelVersion.count }.by(1)
+                                                               .and not_change { Ml::Candidate.count }
+                                                               .and not_change { Packages::MlModel::Package.count }
+
+        expect(model.reload.latest_version.version).to eq('1.0.2')
+        expect(service).to be_success
+
+        expect(model.latest_version.package.version).to eq('1.0.2')
+        expect(model.latest_version.package.name).to eq(model.name)
+
+        expect(Gitlab::InternalEvents).to have_received(:track_event).with(
+          'model_registry_ml_model_version_created',
+          { project: model.project, user: user }
+        )
+        expect(Gitlab::Audit::Auditor).to have_received(:audit).with(audit_context)
+      end
+    end
+
     context 'when a version exist and no value is passed for version' do
       before do
         create(:ml_model_versions, model: model, version: '1.2.3')