diff --git a/app/models/ci/catalog/resource.rb b/app/models/ci/catalog/resource.rb
index 38603ddfe59a4d5e0effce10d0d54c6177f3a8be..442851f577780a1c7909d2c7bfb78baa49672797 100644
--- a/app/models/ci/catalog/resource.rb
+++ b/app/models/ci/catalog/resource.rb
@@ -11,6 +11,7 @@ class Resource < ::ApplicationRecord
       self.table_name = 'catalog_resources'
 
       belongs_to :project
+      has_many :versions, class_name: 'Ci::Catalog::Resources::Version', inverse_of: :catalog_resource
 
       scope :for_projects, ->(project_ids) { where(project_id: project_ids) }
       scope :order_by_created_at_desc, -> { reorder(created_at: :desc) }
diff --git a/app/models/ci/catalog/resources/version.rb b/app/models/ci/catalog/resources/version.rb
new file mode 100644
index 0000000000000000000000000000000000000000..bef1b6e64cb499a40a3cf08e5570a0c4fb94db51
--- /dev/null
+++ b/app/models/ci/catalog/resources/version.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Ci
+  module Catalog
+    module Resources
+      # This class represents a CI/CD Catalog resource version.
+      # Only versions which contain valid CI components are included in this table.
+      class Version < ::ApplicationRecord
+        self.table_name = 'catalog_resource_versions'
+
+        belongs_to :release, inverse_of: :catalog_resource_version
+        belongs_to :catalog_resource, class_name: 'Ci::Catalog::Resource', inverse_of: :versions
+        belongs_to :project, inverse_of: :catalog_resource_versions
+
+        validates :release, :catalog_resource, :project, presence: true
+      end
+    end
+  end
+end
+
+Ci::Catalog::Resources::Version.prepend_mod_with('Ci::Catalog::Resources::Version')
diff --git a/app/models/project.rb b/app/models/project.rb
index cd107fb8c35ff2a4a755dc8c26ba027623d482c0..f8c38c1587d062c81785b51488dce8b556712b6a 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -170,6 +170,8 @@ class Project < ApplicationRecord
   alias_attribute :parent_id, :namespace_id
 
   has_one :catalog_resource, class_name: 'Ci::Catalog::Resource', inverse_of: :project
+  has_many :catalog_resource_versions, class_name: 'Ci::Catalog::Resources::Version', inverse_of: :project
+
   has_one :last_event, -> { order 'events.created_at DESC' }, class_name: 'Event'
   has_many :boards
 
diff --git a/app/models/release.rb b/app/models/release.rb
index f0ba56390ab7550b6ae517cad934531303c4eae9..6830f6e8480407fe3e8ae4064e003511ac7ebbcd 100644
--- a/app/models/release.rb
+++ b/app/models/release.rb
@@ -20,6 +20,8 @@ class Release < ApplicationRecord
   has_many :milestones, through: :milestone_releases
   has_many :evidences, inverse_of: :release, class_name: 'Releases::Evidence'
 
+  has_one :catalog_resource_version, class_name: 'Ci::Catalog::Resources::Version', inverse_of: :release
+
   accepts_nested_attributes_for :links, allow_destroy: true
 
   before_create :set_released_at
diff --git a/db/docs/catalog_resource_versions.yml b/db/docs/catalog_resource_versions.yml
new file mode 100644
index 0000000000000000000000000000000000000000..f01dcd8a2d66372e1dd57ae99200232e45ab054b
--- /dev/null
+++ b/db/docs/catalog_resource_versions.yml
@@ -0,0 +1,8 @@
+---
+table_name: catalog_resource_versions
+feature_categories:
+- pipeline_composition
+description: Catalog resource versions that contain valid CI components.
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/124668
+milestone: '16.2'
+gitlab_schema: gitlab_main
diff --git a/db/migrate/20230626211305_create_catalog_resource_versions.rb b/db/migrate/20230626211305_create_catalog_resource_versions.rb
new file mode 100644
index 0000000000000000000000000000000000000000..9a9aa615ea54a5046a2769eab2ad3aef91754916
--- /dev/null
+++ b/db/migrate/20230626211305_create_catalog_resource_versions.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class CreateCatalogResourceVersions < Gitlab::Database::Migration[2.1]
+  def change
+    create_table :catalog_resource_versions do |t|
+      t.bigint :release_id, null: false, index: { unique: true }
+      t.bigint :catalog_resource_id, null: false, index: true
+      t.bigint :project_id, null: false, index: true
+
+      t.datetime_with_timezone :created_at, null: false
+    end
+  end
+end
diff --git a/db/migrate/20230626215602_add_release_fk_to_catalog_resource_versions.rb b/db/migrate/20230626215602_add_release_fk_to_catalog_resource_versions.rb
new file mode 100644
index 0000000000000000000000000000000000000000..43dda42f09ef91264f6f1853f4c35c7ab1fe4d04
--- /dev/null
+++ b/db/migrate/20230626215602_add_release_fk_to_catalog_resource_versions.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class AddReleaseFkToCatalogResourceVersions < Gitlab::Database::Migration[2.1]
+  disable_ddl_transaction!
+
+  def up
+    add_concurrent_foreign_key :catalog_resource_versions, :releases, column: :release_id, on_delete: :cascade
+  end
+
+  def down
+    with_lock_retries do
+      remove_foreign_key :catalog_resource_versions, column: :release_id
+    end
+  end
+end
diff --git a/db/migrate/20230626215614_add_project_fk_to_catalog_resource_versions.rb b/db/migrate/20230626215614_add_project_fk_to_catalog_resource_versions.rb
new file mode 100644
index 0000000000000000000000000000000000000000..115b13700ad5ebe047e409cb2b777c4536451da5
--- /dev/null
+++ b/db/migrate/20230626215614_add_project_fk_to_catalog_resource_versions.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class AddProjectFkToCatalogResourceVersions < Gitlab::Database::Migration[2.1]
+  disable_ddl_transaction!
+
+  def up
+    add_concurrent_foreign_key :catalog_resource_versions, :projects, column: :project_id, on_delete: :cascade
+  end
+
+  def down
+    with_lock_retries do
+      remove_foreign_key :catalog_resource_versions, column: :project_id
+    end
+  end
+end
diff --git a/db/migrate/20230626215638_add_catalog_resource_fk_to_catalog_resource_versions.rb b/db/migrate/20230626215638_add_catalog_resource_fk_to_catalog_resource_versions.rb
new file mode 100644
index 0000000000000000000000000000000000000000..844fb96cf870b70f37d9400172a77a0948d8f67c
--- /dev/null
+++ b/db/migrate/20230626215638_add_catalog_resource_fk_to_catalog_resource_versions.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+class AddCatalogResourceFkToCatalogResourceVersions < Gitlab::Database::Migration[2.1]
+  disable_ddl_transaction!
+
+  def up
+    add_concurrent_foreign_key :catalog_resource_versions, :catalog_resources,
+      column: :catalog_resource_id, on_delete: :cascade
+  end
+
+  def down
+    with_lock_retries do
+      remove_foreign_key :catalog_resource_versions, column: :catalog_resource_id
+    end
+  end
+end
diff --git a/db/schema_migrations/20230626211305 b/db/schema_migrations/20230626211305
new file mode 100644
index 0000000000000000000000000000000000000000..bb9cf36e48dffd8d9e0e0974d20135c94ef0067b
--- /dev/null
+++ b/db/schema_migrations/20230626211305
@@ -0,0 +1 @@
+f4e628f28e4d2ad11bcbee48aed5146a0dfdf2745911db3f63de2dca455e69a5
\ No newline at end of file
diff --git a/db/schema_migrations/20230626215602 b/db/schema_migrations/20230626215602
new file mode 100644
index 0000000000000000000000000000000000000000..fb64b8c7f66ff3630fe9aef71a83073e43792646
--- /dev/null
+++ b/db/schema_migrations/20230626215602
@@ -0,0 +1 @@
+6a4ecb8e9f8855cb44b425e16cc89c9146fd4709b5daf322747c3034f11c4cf2
\ No newline at end of file
diff --git a/db/schema_migrations/20230626215614 b/db/schema_migrations/20230626215614
new file mode 100644
index 0000000000000000000000000000000000000000..479153c6451ed409450bdc3e01fd3aed11716907
--- /dev/null
+++ b/db/schema_migrations/20230626215614
@@ -0,0 +1 @@
+5c8dbf1b5ad9d41014330eeb07a7daed135a0e9a579d2c15c1f9f0cba83f0bcd
\ No newline at end of file
diff --git a/db/schema_migrations/20230626215638 b/db/schema_migrations/20230626215638
new file mode 100644
index 0000000000000000000000000000000000000000..b93870e7d7e7b54454185d6c677158e1c31c0b52
--- /dev/null
+++ b/db/schema_migrations/20230626215638
@@ -0,0 +1 @@
+e72aae56b010d9c04fb6a8cc799ae39a817eb4a755f162d2a1157d6d5a8a3131
\ No newline at end of file
diff --git a/db/structure.sql b/db/structure.sql
index b69f0da4a549c61dd4ea57f7a362ada21c9383d7..7da785a45d772429ff20949ef68e25d4fff19e3a 100644
--- a/db/structure.sql
+++ b/db/structure.sql
@@ -12934,6 +12934,23 @@ CREATE SEQUENCE bulk_imports_id_seq
 
 ALTER SEQUENCE bulk_imports_id_seq OWNED BY bulk_imports.id;
 
+CREATE TABLE catalog_resource_versions (
+    id bigint NOT NULL,
+    release_id bigint NOT NULL,
+    catalog_resource_id bigint NOT NULL,
+    project_id bigint NOT NULL,
+    created_at timestamp with time zone NOT NULL
+);
+
+CREATE SEQUENCE catalog_resource_versions_id_seq
+    START WITH 1
+    INCREMENT BY 1
+    NO MINVALUE
+    NO MAXVALUE
+    CACHE 1;
+
+ALTER SEQUENCE catalog_resource_versions_id_seq OWNED BY catalog_resource_versions.id;
+
 CREATE TABLE catalog_resources (
     id bigint NOT NULL,
     project_id bigint NOT NULL,
@@ -25137,6 +25154,8 @@ ALTER TABLE ONLY bulk_import_trackers ALTER COLUMN id SET DEFAULT nextval('bulk_
 
 ALTER TABLE ONLY bulk_imports ALTER COLUMN id SET DEFAULT nextval('bulk_imports_id_seq'::regclass);
 
+ALTER TABLE ONLY catalog_resource_versions ALTER COLUMN id SET DEFAULT nextval('catalog_resource_versions_id_seq'::regclass);
+
 ALTER TABLE ONLY catalog_resources ALTER COLUMN id SET DEFAULT nextval('catalog_resources_id_seq'::regclass);
 
 ALTER TABLE ONLY chat_names ALTER COLUMN id SET DEFAULT nextval('chat_names_id_seq'::regclass);
@@ -26963,6 +26982,9 @@ ALTER TABLE ONLY bulk_import_trackers
 ALTER TABLE ONLY bulk_imports
     ADD CONSTRAINT bulk_imports_pkey PRIMARY KEY (id);
 
+ALTER TABLE ONLY catalog_resource_versions
+    ADD CONSTRAINT catalog_resource_versions_pkey PRIMARY KEY (id);
+
 ALTER TABLE ONLY catalog_resources
     ADD CONSTRAINT catalog_resources_pkey PRIMARY KEY (id);
 
@@ -30392,6 +30414,12 @@ CREATE INDEX index_bulk_import_failures_on_correlation_id_value ON bulk_import_f
 
 CREATE INDEX index_bulk_imports_on_user_id ON bulk_imports USING btree (user_id);
 
+CREATE INDEX index_catalog_resource_versions_on_catalog_resource_id ON catalog_resource_versions USING btree (catalog_resource_id);
+
+CREATE INDEX index_catalog_resource_versions_on_project_id ON catalog_resource_versions USING btree (project_id);
+
+CREATE UNIQUE INDEX index_catalog_resource_versions_on_release_id ON catalog_resource_versions USING btree (release_id);
+
 CREATE UNIQUE INDEX index_catalog_resources_on_project_id ON catalog_resources USING btree (project_id);
 
 CREATE INDEX index_chat_names_on_team_id_and_chat_id ON chat_names USING btree (team_id, chat_id);
@@ -35410,6 +35438,9 @@ ALTER TABLE ONLY vulnerabilities
 ALTER TABLE ONLY vulnerabilities
     ADD CONSTRAINT fk_131d289c65 FOREIGN KEY (milestone_id) REFERENCES milestones(id) ON DELETE SET NULL;
 
+ALTER TABLE ONLY catalog_resource_versions
+    ADD CONSTRAINT fk_15376d917e FOREIGN KEY (release_id) REFERENCES releases(id) ON DELETE CASCADE;
+
 ALTER TABLE ONLY sbom_occurrences
     ADD CONSTRAINT fk_157506c0e2 FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE;
 
@@ -35815,6 +35846,9 @@ ALTER TABLE ONLY scan_result_policies
 ALTER TABLE ONLY vulnerabilities
     ADD CONSTRAINT fk_7ac31eacb9 FOREIGN KEY (updated_by_id) REFERENCES users(id) ON DELETE SET NULL;
 
+ALTER TABLE ONLY catalog_resource_versions
+    ADD CONSTRAINT fk_7ad8849db4 FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE;
+
 ALTER TABLE ONLY issue_customer_relations_contacts
     ADD CONSTRAINT fk_7b92f835bb FOREIGN KEY (contact_id) REFERENCES customer_relations_contacts(id) ON DELETE CASCADE;
 
@@ -36052,6 +36086,9 @@ ALTER TABLE ONLY issues
 ALTER TABLE ONLY protected_tag_create_access_levels
     ADD CONSTRAINT fk_b4eb82fe3c FOREIGN KEY (group_id) REFERENCES namespaces(id) ON DELETE CASCADE;
 
+ALTER TABLE ONLY catalog_resource_versions
+    ADD CONSTRAINT fk_b670eae96b FOREIGN KEY (catalog_resource_id) REFERENCES catalog_resources(id) ON DELETE CASCADE;
+
 ALTER TABLE ONLY bulk_import_entities
     ADD CONSTRAINT fk_b69fa2b2df FOREIGN KEY (bulk_import_id) REFERENCES bulk_imports(id) ON DELETE CASCADE;
 
diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml
index 981802ad09d2fcbf05d53d5da9d3fb77f5d56b6a..7dc969f274a9111abe4fd8e86f62aa4e2028993b 100644
--- a/spec/lib/gitlab/import_export/all_models.yml
+++ b/spec/lib/gitlab/import_export/all_models.yml
@@ -162,6 +162,7 @@ releases:
 - milestone_releases
 - milestones
 - evidences
+- catalog_resource_version
 links:
 - release
 project_members:
@@ -516,6 +517,7 @@ container_repositories:
 - name
 project:
 - catalog_resource
+- catalog_resource_versions
 - external_status_checks
 - base_tags
 - project_topics
@@ -1039,6 +1041,11 @@ iterations_cadence:
   - iterations
 catalog_resource:
   - project
+  - catalog_resource_versions
+catalog_resource_versions:
+  - project
+  - release
+  - catalog_resource
 approval_rules:
   - users
   - groups
diff --git a/spec/models/ci/catalog/resource_spec.rb b/spec/models/ci/catalog/resource_spec.rb
index 45d49d65b024268e6900f4c5838290808c24f91d..7af3369ae7b61f0fd3b2240b1b5b0dd8f1b43ce3 100644
--- a/spec/models/ci/catalog/resource_spec.rb
+++ b/spec/models/ci/catalog/resource_spec.rb
@@ -15,6 +15,7 @@
   let_it_be(:release3) { create(:release, project: project, released_at: Time.zone.now) }
 
   it { is_expected.to belong_to(:project) }
+  it { is_expected.to have_many(:versions).class_name('Ci::Catalog::Resources::Version') }
 
   it { is_expected.to delegate_method(:avatar_path).to(:project) }
   it { is_expected.to delegate_method(:description).to(:project) }
diff --git a/spec/models/ci/catalog/resources/version_spec.rb b/spec/models/ci/catalog/resources/version_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..f6abb112820d8113ee24e8a71a0d4176ac279762
--- /dev/null
+++ b/spec/models/ci/catalog/resources/version_spec.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Ci::Catalog::Resources::Version, type: :model, feature_category: :pipeline_composition do
+  it { is_expected.to belong_to(:release) }
+  it { is_expected.to belong_to(:catalog_resource).class_name('Ci::Catalog::Resource') }
+  it { is_expected.to belong_to(:project) }
+
+  describe 'validations' do
+    it { is_expected.to validate_presence_of(:release) }
+    it { is_expected.to validate_presence_of(:catalog_resource) }
+    it { is_expected.to validate_presence_of(:project) }
+  end
+end
diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb
index 1a7a0688abe98a8a23acd6454c7cf68ae5f4d938..302fe4098b27b76c2f9bcd8594da037a9e82a133 100644
--- a/spec/models/project_spec.rb
+++ b/spec/models/project_spec.rb
@@ -46,6 +46,7 @@
     it { is_expected.to have_one(:design_management_repository).class_name('DesignManagement::Repository').inverse_of(:project) }
     it { is_expected.to have_one(:slack_integration) }
     it { is_expected.to have_one(:catalog_resource) }
+    it { is_expected.to have_many(:catalog_resource_versions).class_name('Ci::Catalog::Resources::Version') }
     it { is_expected.to have_one(:microsoft_teams_integration) }
     it { is_expected.to have_one(:mattermost_integration) }
     it { is_expected.to have_one(:hangouts_chat_integration) }
diff --git a/spec/models/release_spec.rb b/spec/models/release_spec.rb
index 446ef4180d25e03cd5f1ad7a302b4aae33b10105..164cef95cb6cdc2fd3ea6d9c2f5ef33db26ecf9c 100644
--- a/spec/models/release_spec.rb
+++ b/spec/models/release_spec.rb
@@ -17,6 +17,7 @@
     it { is_expected.to have_many(:milestones) }
     it { is_expected.to have_many(:milestone_releases) }
     it { is_expected.to have_many(:evidences).class_name('Releases::Evidence') }
+    it { is_expected.to have_one(:catalog_resource_version).class_name('Ci::Catalog::Resources::Version') }
   end
 
   describe 'validation' do