From 8af3ded67da2dade5e090bdf471ca55df59893a0 Mon Sep 17 00:00:00 2001
From: Laura Montemayor <lmontemayor@gitlab.com>
Date: Thu, 15 Feb 2024 17:13:21 +0000
Subject: [PATCH] Add semantic version validation for catalog versions

This MR sets up validation for semantic versioning when releasing a new
vesion of the catalog resource.

* Add migration for adding columns to support parsing of SemVer
* Updates the Versions::CreateService to use `release.tag` for
  versioning
* Updates specs that were not using semantic version, thus testing the
  semantic version validation further
* Quarantines an out of date feature spec

Changelog: added
---
 app/models/ci/catalog/resources/version.rb    |  6 ++--
 .../resources/versions/create_service.rb      |  3 +-
 ...dd_sem_ver_to_catalog_resources_version.rb | 16 ++++++++++
 ...log_resource_versions_semver_prerelease.rb | 15 +++++++++
 db/schema_migrations/20240207115842           |  1 +
 db/schema_migrations/20240213113719           |  1 +
 db/structure.sql                              |  7 ++++-
 doc/ci/components/index.md                    |  2 +-
 doc/development/semver.md                     |  3 +-
 .../ci/catalog/resources/versions.rb          |  2 ++
 .../explore/catalog/catalog_releases_spec.rb  |  2 +-
 .../catalog/resources/versions_finder_spec.rb |  4 +--
 .../resources/versions_resolver_spec.rb       |  4 +--
 .../ci/catalog/resources/version_spec.rb      | 31 ++++++++++++++++---
 .../catalog/resources/release_service_spec.rb |  2 +-
 .../resources/versions/create_service_spec.rb |  4 +--
 spec/services/releases/create_service_spec.rb |  2 +-
 .../resources/version_shared_context.rb       | 24 +++++++-------
 18 files changed, 98 insertions(+), 31 deletions(-)
 create mode 100644 db/migrate/20240207115842_add_sem_ver_to_catalog_resources_version.rb
 create mode 100644 db/migrate/20240213113719_add_text_limit_to_catalog_resource_versions_semver_prerelease.rb
 create mode 100644 db/schema_migrations/20240207115842
 create mode 100644 db/schema_migrations/20240213113719

diff --git a/app/models/ci/catalog/resources/version.rb b/app/models/ci/catalog/resources/version.rb
index 0d27bdecb63e2..c78adbafc95d5 100644
--- a/app/models/ci/catalog/resources/version.rb
+++ b/app/models/ci/catalog/resources/version.rb
@@ -7,6 +7,10 @@ module Resources
       # Only versions which contain valid CI components are included in this table.
       class Version < ::ApplicationRecord
         include BulkInsertableAssociations
+        include SemanticVersionable
+
+        semver_method :version
+        validate_semver
 
         self.table_name = 'catalog_resource_versions'
 
@@ -33,8 +37,6 @@ class Version < ::ApplicationRecord
         after_save :update_catalog_resource
 
         class << self
-          # In the future, we should support semantic versioning.
-          # See https://gitlab.com/gitlab-org/gitlab/-/issues/427286
           def latest
             order_by_released_at_desc.first
           end
diff --git a/app/services/ci/catalog/resources/versions/create_service.rb b/app/services/ci/catalog/resources/versions/create_service.rb
index 9547db7bcf169..7d05a3085c11d 100644
--- a/app/services/ci/catalog/resources/versions/create_service.rb
+++ b/app/services/ci/catalog/resources/versions/create_service.rb
@@ -35,7 +35,8 @@ def build_catalog_resource_version
             @version = Ci::Catalog::Resources::Version.new(
               release: release,
               catalog_resource: project.catalog_resource,
-              project: project
+              project: project,
+              version: release.tag
             )
           end
 
diff --git a/db/migrate/20240207115842_add_sem_ver_to_catalog_resources_version.rb b/db/migrate/20240207115842_add_sem_ver_to_catalog_resources_version.rb
new file mode 100644
index 0000000000000..b8a3aa5cb424d
--- /dev/null
+++ b/db/migrate/20240207115842_add_sem_ver_to_catalog_resources_version.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+class AddSemVerToCatalogResourcesVersion < Gitlab::Database::Migration[2.2]
+  enable_lock_retries!
+
+  milestone '16.10'
+  # rubocop:disable Migration/AddLimitToTextColumns -- limit is added in 20240213113719_add_text_limit_to_catalog_resource_versions_semver_prerelease
+
+  def change
+    add_column :catalog_resource_versions, :semver_major, :integer
+    add_column :catalog_resource_versions, :semver_minor, :integer
+    add_column :catalog_resource_versions, :semver_patch, :integer
+    add_column :catalog_resource_versions, :semver_prerelease, :text
+  end
+  # rubocop:enable Migration/AddLimitToTextColumns
+end
diff --git a/db/migrate/20240213113719_add_text_limit_to_catalog_resource_versions_semver_prerelease.rb b/db/migrate/20240213113719_add_text_limit_to_catalog_resource_versions_semver_prerelease.rb
new file mode 100644
index 0000000000000..452208c1b26a9
--- /dev/null
+++ b/db/migrate/20240213113719_add_text_limit_to_catalog_resource_versions_semver_prerelease.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class AddTextLimitToCatalogResourceVersionsSemverPrerelease < Gitlab::Database::Migration[2.2]
+  disable_ddl_transaction!
+
+  milestone '16.10'
+
+  def up
+    add_text_limit :catalog_resource_versions, :semver_prerelease, 255
+  end
+
+  def down
+    remove_text_limit :catalog_resource_versions, :semver_prerelease
+  end
+end
diff --git a/db/schema_migrations/20240207115842 b/db/schema_migrations/20240207115842
new file mode 100644
index 0000000000000..e1d8361c38d42
--- /dev/null
+++ b/db/schema_migrations/20240207115842
@@ -0,0 +1 @@
+70164c8c55ac94314a73074b04ec1fc1ad4aaed199347f22904e6691aee870d3
\ No newline at end of file
diff --git a/db/schema_migrations/20240213113719 b/db/schema_migrations/20240213113719
new file mode 100644
index 0000000000000..6779bd9a4277a
--- /dev/null
+++ b/db/schema_migrations/20240213113719
@@ -0,0 +1 @@
+07e1a3a02552425f4a5345d9ed3eb7da7f4f09b9f6c9071b1527a1a0e5e3fd10
\ No newline at end of file
diff --git a/db/structure.sql b/db/structure.sql
index fd9027155d6a8..71e9cfa36abc4 100644
--- a/db/structure.sql
+++ b/db/structure.sql
@@ -5512,7 +5512,12 @@ CREATE TABLE catalog_resource_versions (
     catalog_resource_id bigint NOT NULL,
     project_id bigint NOT NULL,
     created_at timestamp with time zone NOT NULL,
-    released_at timestamp with time zone DEFAULT '1970-01-01 00:00:00+00'::timestamp with time zone NOT NULL
+    released_at timestamp with time zone DEFAULT '1970-01-01 00:00:00+00'::timestamp with time zone NOT NULL,
+    semver_major integer,
+    semver_minor integer,
+    semver_patch integer,
+    semver_prerelease text,
+    CONSTRAINT check_701bdce47b CHECK ((char_length(semver_prerelease) <= 255))
 );
 
 CREATE SEQUENCE catalog_resource_versions_id_seq
diff --git a/doc/ci/components/index.md b/doc/ci/components/index.md
index 3980077cbc619..c791a4edb3508 100644
--- a/doc/ci/components/index.md
+++ b/doc/ci/components/index.md
@@ -260,7 +260,7 @@ To publish a new version of the component to the catalog:
    running the release job.
 
 After the release job completes successfully, the release is created and the new version
-is published to the CI/CD catalog.
+is published to the CI/CD catalog. Tags must use semantic versioning, for example `1.0.0`.
 
 ### Unpublish a component project
 
diff --git a/doc/development/semver.md b/doc/development/semver.md
index cd7aac5c5d7f1..f9a9c625efc1c 100644
--- a/doc/development/semver.md
+++ b/doc/development/semver.md
@@ -14,7 +14,8 @@ In order to use SemanticVersionable you must first create a database migration t
 
 ```ruby
 class AddVersionPartsToModelVersions < Gitlab::Database::Migration[2.2]
-  disable_ddl_transaction!
+  enable_lock_retries!
+
   milestone '16.9'
 
   def up
diff --git a/spec/factories/ci/catalog/resources/versions.rb b/spec/factories/ci/catalog/resources/versions.rb
index 520708d9d585d..34fc7fe38b4e2 100644
--- a/spec/factories/ci/catalog/resources/versions.rb
+++ b/spec/factories/ci/catalog/resources/versions.rb
@@ -2,6 +2,8 @@
 
 FactoryBot.define do
   factory :ci_catalog_resource_version, class: 'Ci::Catalog::Resources::Version' do
+    version { '1.0.0' }
+
     catalog_resource factory: :ci_catalog_resource
     project { catalog_resource.project }
     release { association :release, project: project }
diff --git a/spec/features/explore/catalog/catalog_releases_spec.rb b/spec/features/explore/catalog/catalog_releases_spec.rb
index 27b7aa17551e1..427de1d263327 100644
--- a/spec/features/explore/catalog/catalog_releases_spec.rb
+++ b/spec/features/explore/catalog/catalog_releases_spec.rb
@@ -2,7 +2,7 @@
 
 require 'spec_helper'
 
-RSpec.describe 'CI/CD Catalog releases', :js, feature_category: :pipeline_composition do
+RSpec.describe 'CI/CD Catalog releases', :js, feature_category: :pipeline_composition, quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/432824' do
   let_it_be(:tag_name) { 'catalog_release_tag' }
   let_it_be(:user) { create(:user) }
   let_it_be_with_reload(:namespace) { create(:group) }
diff --git a/spec/finders/ci/catalog/resources/versions_finder_spec.rb b/spec/finders/ci/catalog/resources/versions_finder_spec.rb
index dbde77101eecf..48df50caad425 100644
--- a/spec/finders/ci/catalog/resources/versions_finder_spec.rb
+++ b/spec/finders/ci/catalog/resources/versions_finder_spec.rb
@@ -39,11 +39,11 @@
     end
 
     context 'with name parameter' do
-      let(:name) { 'v1.0' }
+      let(:name) { '1.0.0' }
 
       it 'returns the version that matches the name' do
         expect(execute.count).to eq(1)
-        expect(execute.first.name).to eq('v1.0')
+        expect(execute.first.name).to eq('1.0.0')
       end
 
       context 'when no version matches the name' do
diff --git a/spec/graphql/resolvers/ci/catalog/resources/versions_resolver_spec.rb b/spec/graphql/resolvers/ci/catalog/resources/versions_resolver_spec.rb
index 4205259e5b9e5..3f15560553d89 100644
--- a/spec/graphql/resolvers/ci/catalog/resources/versions_resolver_spec.rb
+++ b/spec/graphql/resolvers/ci/catalog/resources/versions_resolver_spec.rb
@@ -21,11 +21,11 @@
       end
 
       context 'when name argument is provided' do
-        let(:name) { 'v1.0' }
+        let(:name) { '1.0.0' }
 
         it 'returns the version that matches the name' do
           expect(result.items.size).to eq(1)
-          expect(result.items.first.name).to eq('v1.0')
+          expect(result.items.first.name).to eq('1.0.0')
         end
 
         context 'when no version matches the name' do
diff --git a/spec/models/ci/catalog/resources/version_spec.rb b/spec/models/ci/catalog/resources/version_spec.rb
index 64957b2363876..fb2a10d86bc6e 100644
--- a/spec/models/ci/catalog/resources/version_spec.rb
+++ b/spec/models/ci/catalog/resources/version_spec.rb
@@ -3,6 +3,8 @@
 require 'spec_helper'
 
 RSpec.describe Ci::Catalog::Resources::Version, type: :model, feature_category: :pipeline_composition do
+  using RSpec::Parameterized::TableSyntax
+
   include_context 'when there are catalog resources with versions'
 
   it { is_expected.to belong_to(:release) }
@@ -17,6 +19,27 @@
     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) }
+
+    describe 'semver validation' do
+      where(:version, :valid, :semver_major, :semver_minor, :semver_patch, :semver_prerelease) do
+        '1'             | false | nil | nil | nil | nil
+        '1.2'           | false | nil | nil | nil | nil
+        '1.2.3'         | true  | 1   | 2   | 3   | nil
+        '1.2.3-beta'    | true  | 1   | 2   | 3   | 'beta'
+        '1.2.3.beta'    | false | nil | nil | nil | nil
+      end
+
+      with_them do
+        let(:catalog_version) { build(:ci_catalog_resource_version, version: version) }
+
+        it do
+          expect(catalog_version.semver_major).to be semver_major
+          expect(catalog_version.semver_minor).to be semver_minor
+          expect(catalog_version.semver_patch).to be semver_patch
+          expect(catalog_version.semver_prerelease).to eq semver_prerelease
+        end
+      end
+    end
   end
 
   describe '.for_catalog resources' do
@@ -29,10 +52,10 @@
 
   describe '.by_name' do
     it 'returns the version that matches the name' do
-      versions = described_class.by_name('v1.0')
+      versions = described_class.by_name('1.0.0')
 
       expect(versions.count).to eq(1)
-      expect(versions.first.name).to eq('v1.0')
+      expect(versions.first.name).to eq('1.0.0')
     end
 
     context 'when no version matches the name' do
@@ -144,8 +167,8 @@
 
   describe '#readme' do
     it 'returns the correct readme for the version' do
-      expect(v1_0.readme.data).to include('Readme v1.0')
-      expect(v1_1.readme.data).to include('Readme v1.1')
+      expect(v1_0.readme.data).to include('Readme 1.0.0')
+      expect(v1_1.readme.data).to include('Readme 1.1.0')
     end
   end
 
diff --git a/spec/services/ci/catalog/resources/release_service_spec.rb b/spec/services/ci/catalog/resources/release_service_spec.rb
index 60cd6cb5f96f3..790ec971e29c3 100644
--- a/spec/services/ci/catalog/resources/release_service_spec.rb
+++ b/spec/services/ci/catalog/resources/release_service_spec.rb
@@ -8,7 +8,7 @@
       it 'validates the catalog resource and creates a version' do
         project = create(:project, :catalog_resource_with_components)
         catalog_resource = create(:ci_catalog_resource, project: project)
-        release = create(:release, project: project, sha: project.repository.root_ref_sha)
+        release = create(:release, project: project, sha: project.repository.root_ref_sha, tag: '1.0.0')
 
         response = described_class.new(release).execute
 
diff --git a/spec/services/ci/catalog/resources/versions/create_service_spec.rb b/spec/services/ci/catalog/resources/versions/create_service_spec.rb
index c92b317885a22..2ad091942859e 100644
--- a/spec/services/ci/catalog/resources/versions/create_service_spec.rb
+++ b/spec/services/ci/catalog/resources/versions/create_service_spec.rb
@@ -24,13 +24,13 @@
       )
     end
 
-    let(:release) { create(:release, project: project, sha: project.repository.root_ref_sha) }
+    let(:release) { create(:release, tag: '1.2.0', project: project, sha: project.repository.root_ref_sha) }
     let!(:catalog_resource) { create(:ci_catalog_resource, project: project) }
 
     context 'when the project is not a catalog resource' do
       it 'does not create a version' do
         project = create(:project, :repository)
-        release =  create(:release, project: project, sha: project.repository.root_ref_sha)
+        release =  create(:release, tag: '1.2.1', project: project, sha: project.repository.root_ref_sha)
 
         response = described_class.new(release).execute
 
diff --git a/spec/services/releases/create_service_spec.rb b/spec/services/releases/create_service_spec.rb
index 3504f00412c78..77bd0a2138dbe 100644
--- a/spec/services/releases/create_service_spec.rb
+++ b/spec/services/releases/create_service_spec.rb
@@ -56,7 +56,7 @@
     end
 
     context 'when project is a catalog resource' do
-      let(:project) { create(:project, :catalog_resource_with_components, create_tag: 'final') }
+      let_it_be(:project) { create(:project, :catalog_resource_with_components, create_tag: '6.0.0') }
       let!(:ci_catalog_resource) { create(:ci_catalog_resource, project: project) }
       let(:ref) { 'master' }
 
diff --git a/spec/support/shared_contexts/ci/catalog/resources/version_shared_context.rb b/spec/support/shared_contexts/ci/catalog/resources/version_shared_context.rb
index a451608a5ccd1..b88675168ae57 100644
--- a/spec/support/shared_contexts/ci/catalog/resources/version_shared_context.rb
+++ b/spec/support/shared_contexts/ci/catalog/resources/version_shared_context.rb
@@ -6,33 +6,33 @@
 RSpec.shared_context 'when there are catalog resources with versions' do
   let_it_be(:current_user) { create(:user) }
 
-  let_it_be(:project1) { create(:project, :custom_repo, files: { 'README.md' => 'Readme v1.0' }) }
+  let_it_be(:project1) { create(:project, :custom_repo, files: { 'README.md' => 'Readme 1.0.0' }) }
   let_it_be(:project2) { create(:project, :repository) }
 
   let_it_be_with_reload(:resource1) { create(:ci_catalog_resource, project: project1) }
   let_it_be_with_reload(:resource2) { create(:ci_catalog_resource, project: project2) }
 
-  let(:v1_0) { resource1.versions.by_name('v1.0').first }
-  let(:v1_1) { resource1.versions.by_name('v1.1').first }
-  let(:v2_0) { resource2.versions.by_name('v2.0').first }
-  let(:v2_1) { resource2.versions.by_name('v2.1').first }
+  let(:v1_0) { resource1.versions.by_name('1.0.0').first }
+  let(:v1_1) { resource1.versions.by_name('1.1.0').first }
+  let(:v2_0) { resource2.versions.by_name('2.0.0').first }
+  let(:v2_1) { resource2.versions.by_name('2.1.0').first }
 
   before_all do
     project1.repository.create_branch('branch_v1.1', project1.default_branch)
 
     project1.repository.update_file(
-      current_user, 'README.md', 'Readme v1.1', message: 'Update readme', branch_name: 'branch_v1.1')
+      current_user, 'README.md', 'Readme 1.1.0', message: 'Update readme', branch_name: 'branch_v1.1')
 
-    tag_v1_0 = project1.repository.add_tag(current_user, 'v1.0', project1.default_branch)
-    tag_v1_1 = project1.repository.add_tag(current_user, 'v1.1', 'branch_v1.1')
+    tag_v1_0 = project1.repository.add_tag(current_user, '1.0.0', project1.default_branch)
+    tag_v1_1 = project1.repository.add_tag(current_user, '1.1.0', 'branch_v1.1')
 
-    release_v1_0 = create(:release, project: project1, tag: 'v1.0', released_at: 4.days.ago,
+    release_v1_0 = create(:release, project: project1, tag: '1.0.0', released_at: 4.days.ago,
       sha: tag_v1_0.dereferenced_target.sha)
-    release_v1_1 = create(:release, project: project1, tag: 'v1.1', released_at: 3.days.ago,
+    release_v1_1 = create(:release, project: project1, tag: '1.1.0', released_at: 3.days.ago,
       sha: tag_v1_1.dereferenced_target.sha)
 
-    release_v2_0 = create(:release, project: project2, tag: 'v2.0', released_at: 2.days.ago)
-    release_v2_1 = create(:release, project: project2, tag: 'v2.1', released_at: 1.day.ago)
+    release_v2_0 = create(:release, project: project2, tag: '2.0.0', released_at: 2.days.ago)
+    release_v2_1 = create(:release, project: project2, tag: '2.1.0', released_at: 1.day.ago)
 
     create(:ci_catalog_resource_version, catalog_resource: resource1, release: release_v1_0, created_at: 1.day.ago)
     create(:ci_catalog_resource_version, catalog_resource: resource1, release: release_v1_1, created_at: 2.days.ago)
-- 
GitLab