diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue
index 5f35dbdc5e7317f583bb9416b217af19067a6fff..3c9c0b1ade1faf5cfe0a6e0fa090b6dbf28288ea 100644
--- a/app/assets/javascripts/ide/components/repo_editor.vue
+++ b/app/assets/javascripts/ide/components/repo_editor.vue
@@ -7,6 +7,7 @@ import {
   EDITOR_TYPE_CODE,
   EDITOR_CODE_INSTANCE_FN,
   EDITOR_DIFF_INSTANCE_FN,
+  EXTENSION_CI_SCHEMA_FILE_NAME_MATCH,
 } from '~/editor/constants';
 import { SourceEditorExtension } from '~/editor/extensions/source_editor_extension_base';
 import { EditorWebIdeExtension } from '~/editor/extensions/source_editor_webide_ext';
@@ -26,6 +27,7 @@ import { performanceMarkAndMeasure } from '~/performance/utils';
 import ContentViewer from '~/vue_shared/components/content_viewer/content_viewer.vue';
 import { viewerInformationForPath } from '~/vue_shared/components/content_viewer/lib/viewer_utils';
 import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
 import {
   leftSidebarViews,
   viewerTypes,
@@ -53,6 +55,7 @@ export default {
     DiffViewer,
     FileTemplatesBar,
   },
+  mixins: [glFeatureFlagMixin()],
   props: {
     file: {
       type: Object,
@@ -145,6 +148,12 @@ export default {
     showTabs() {
       return !this.shouldHideEditor && this.isEditModeActive && this.previewMode;
     },
+    isCiConfigFile() {
+      return (
+        this.file.path === EXTENSION_CI_SCHEMA_FILE_NAME_MATCH &&
+        this.editor?.getEditorType() === EDITOR_TYPE_CODE
+      );
+    },
   },
   watch: {
     'file.name': {
@@ -232,8 +241,6 @@ export default {
         return;
       }
 
-      this.registerSchemaForFile();
-
       Promise.all([this.fetchFileData(), this.fetchEditorconfigRules()])
         .then(() => {
           this.createEditorInstance();
@@ -357,6 +364,8 @@ export default {
 
       this.model.updateOptions(this.rules);
 
+      this.registerSchemaForFile();
+
       this.model.onChange((model) => {
         const { file } = model;
         if (!file.active) return;
@@ -446,8 +455,33 @@ export default {
       return Promise.resolve();
     },
     registerSchemaForFile() {
-      const schema = this.getJsonSchemaForPath(this.file.path);
-      registerSchema(schema);
+      const registerExternalSchema = () => {
+        const schema = this.getJsonSchemaForPath(this.file.path);
+        return registerSchema(schema);
+      };
+      const registerLocalSchema = async () => {
+        if (!this.CiSchemaExtension) {
+          const { CiSchemaExtension } = await import(
+            '~/editor/extensions/source_editor_ci_schema_ext'
+          ).catch((e) =>
+            createAlert({
+              message: e,
+            }),
+          );
+          this.CiSchemaExtension = CiSchemaExtension;
+        }
+        this.editor.use({ definition: this.CiSchemaExtension });
+        this.editor.registerCiSchema();
+      };
+
+      if (this.isCiConfigFile && this.glFeatures.schemaLinting) {
+        registerLocalSchema();
+      } else {
+        if (this.CiSchemaExtension) {
+          this.editor.unuse(this.CiSchemaExtension);
+        }
+        registerExternalSchema();
+      }
     },
     updateEditor(data) {
       // Looks like our model wrapper `.dispose` causes the monaco editor to emit some position changes after
diff --git a/spec/frontend/ide/components/repo_editor_spec.js b/spec/frontend/ide/components/repo_editor_spec.js
index 9921d8cba18730a8025cc5db4b5688ec60d5f6ae..2f9fd957c6b6fb51e689de5d64ff4d9494bb1dee 100644
--- a/spec/frontend/ide/components/repo_editor_spec.js
+++ b/spec/frontend/ide/components/repo_editor_spec.js
@@ -8,9 +8,14 @@ import '~/behaviors/markdown/render_gfm';
 import waitForPromises from 'helpers/wait_for_promises';
 import { stubPerformanceWebAPI } from 'helpers/performance';
 import { exampleConfigs, exampleFiles } from 'jest/ide/lib/editorconfig/mock_data';
-import { EDITOR_CODE_INSTANCE_FN, EDITOR_DIFF_INSTANCE_FN } from '~/editor/constants';
+import {
+  EDITOR_CODE_INSTANCE_FN,
+  EDITOR_DIFF_INSTANCE_FN,
+  EXTENSION_CI_SCHEMA_FILE_NAME_MATCH,
+} from '~/editor/constants';
 import { EditorMarkdownExtension } from '~/editor/extensions/source_editor_markdown_ext';
 import { EditorMarkdownPreviewExtension } from '~/editor/extensions/source_editor_markdown_livepreview_ext';
+import { CiSchemaExtension } from '~/editor/extensions/source_editor_ci_schema_ext';
 import SourceEditor from '~/editor/source_editor';
 import RepoEditor from '~/ide/components/repo_editor.vue';
 import { leftSidebarViews, FILE_VIEW_MODE_PREVIEW, viewerTypes } from '~/ide/constants';
@@ -22,6 +27,8 @@ import ContentViewer from '~/vue_shared/components/content_viewer/content_viewer
 import SourceEditorInstance from '~/editor/source_editor_instance';
 import { file } from '../helpers';
 
+jest.mock('~/editor/extensions/source_editor_ci_schema_ext');
+
 const PREVIEW_MARKDOWN_PATH = '/foo/bar/preview_markdown';
 const CURRENT_PROJECT_ID = 'gitlab-org/gitlab';
 
@@ -46,6 +53,12 @@ const dummyFile = {
     tempFile: true,
     active: true,
   },
+  ciConfig: {
+    ...file(EXTENSION_CI_SCHEMA_FILE_NAME_MATCH),
+    content: '',
+    tempFile: true,
+    active: true,
+  },
   empty: {
     ...file('empty'),
     tempFile: false,
@@ -101,6 +114,7 @@ describe('RepoEditor', () => {
   let createDiffInstanceSpy;
   let createModelSpy;
   let applyExtensionSpy;
+  let removeExtensionSpy;
   let extensionsStore;
 
   const waitForEditorSetup = () =>
@@ -108,7 +122,7 @@ describe('RepoEditor', () => {
       vm.$once('editorSetup', resolve);
     });
 
-  const createComponent = async ({ state = {}, activeFile = dummyFile.text } = {}) => {
+  const createComponent = async ({ state = {}, activeFile = dummyFile.text, flags = {} } = {}) => {
     const store = prepareStore(state, activeFile);
     wrapper = shallowMount(RepoEditor, {
       store,
@@ -118,6 +132,9 @@ describe('RepoEditor', () => {
       mocks: {
         ContentViewer,
       },
+      provide: {
+        glFeatures: flags,
+      },
     });
     await waitForPromises();
     vm = wrapper.vm;
@@ -137,6 +154,7 @@ describe('RepoEditor', () => {
     createDiffInstanceSpy = jest.spyOn(SourceEditor.prototype, EDITOR_DIFF_INSTANCE_FN);
     createModelSpy = jest.spyOn(monacoEditor, 'createModel');
     applyExtensionSpy = jest.spyOn(SourceEditorInstance.prototype, 'use');
+    removeExtensionSpy = jest.spyOn(SourceEditorInstance.prototype, 'unuse');
     jest.spyOn(service, 'getFileData').mockResolvedValue();
     jest.spyOn(service, 'getRawFileData').mockResolvedValue();
   });
@@ -177,6 +195,76 @@ describe('RepoEditor', () => {
     });
   });
 
+  describe('schema registration for .gitlab-ci.yml', () => {
+    const setup = async (activeFile, flagIsOn = true) => {
+      await createComponent({
+        flags: {
+          schemaLinting: flagIsOn,
+        },
+      });
+      vm.editor.registerCiSchema = jest.fn();
+      if (activeFile) {
+        wrapper.setProps({ file: activeFile });
+      }
+      await waitForPromises();
+      await nextTick();
+    };
+    it.each`
+      flagIsOn | activeFile            | shouldUseExtension | desc
+      ${false} | ${dummyFile.markdown} | ${false}           | ${`file is not CI config; should NOT`}
+      ${true}  | ${dummyFile.markdown} | ${false}           | ${`file is not CI config; should NOT`}
+      ${false} | ${dummyFile.ciConfig} | ${false}           | ${`file is CI config; should NOT`}
+      ${true}  | ${dummyFile.ciConfig} | ${true}            | ${`file is CI config; should`}
+    `(
+      'when the flag is "$flagIsOn", $desc use extension',
+      async ({ flagIsOn, activeFile, shouldUseExtension }) => {
+        await setup(activeFile, flagIsOn);
+
+        if (shouldUseExtension) {
+          expect(applyExtensionSpy).toHaveBeenCalledWith({
+            definition: CiSchemaExtension,
+          });
+        } else {
+          expect(applyExtensionSpy).not.toHaveBeenCalledWith({
+            definition: CiSchemaExtension,
+          });
+        }
+      },
+    );
+    it('stores the fetched extension and does not double-fetch the schema', async () => {
+      await setup();
+      expect(CiSchemaExtension).toHaveBeenCalledTimes(0);
+
+      wrapper.setProps({ file: dummyFile.ciConfig });
+      await waitForPromises();
+      await nextTick();
+      expect(CiSchemaExtension).toHaveBeenCalledTimes(1);
+      expect(vm.CiSchemaExtension).toEqual(CiSchemaExtension);
+      expect(vm.editor.registerCiSchema).toHaveBeenCalledTimes(1);
+
+      wrapper.setProps({ file: dummyFile.markdown });
+      await waitForPromises();
+      await nextTick();
+      expect(CiSchemaExtension).toHaveBeenCalledTimes(1);
+      expect(vm.editor.registerCiSchema).toHaveBeenCalledTimes(1);
+
+      wrapper.setProps({ file: dummyFile.ciConfig });
+      await waitForPromises();
+      await nextTick();
+      expect(CiSchemaExtension).toHaveBeenCalledTimes(1);
+      expect(vm.editor.registerCiSchema).toHaveBeenCalledTimes(2);
+    });
+    it('unuses the existing CI extension if the new model is not CI config', async () => {
+      await setup(dummyFile.ciConfig);
+
+      expect(removeExtensionSpy).not.toHaveBeenCalled();
+      wrapper.setProps({ file: dummyFile.markdown });
+      await waitForPromises();
+      await nextTick();
+      expect(removeExtensionSpy).toHaveBeenCalledWith(CiSchemaExtension);
+    });
+  });
+
   describe('when file is markdown', () => {
     let mock;
     let activeFile;