equilibrium 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "../mixins/error_handling"
6
+ require_relative "../mixins/input_output"
7
+ require_relative "../schema_validator"
8
+ require_relative "../schemas/expected_actual"
9
+ require_relative "../schemas/analyzer_output"
10
+ require_relative "../analyzer"
11
+
12
+ module Equilibrium
13
+ module Commands
14
+ # Command for analyzing expected vs actual tags and generating remediation plan
15
+ class AnalyzeCommand
16
+ include Mixins::ErrorHandling
17
+ include Mixins::InputOutput
18
+
19
+ # Execute the analyze command
20
+ # @param options [Hash] Command options (expected, actual, format, etc.)
21
+ def execute(options = {})
22
+ with_error_handling do
23
+ # Load and validate data files
24
+ expected_data = load_and_validate_json_file(options[:expected], error_prefix: "Expected data schema validation failed")
25
+ actual_data = load_and_validate_json_file(options[:actual], error_prefix: "Actual data schema validation failed")
26
+
27
+ # Perform analysis
28
+ analysis = Analyzer.analyze(expected_data, actual_data)
29
+
30
+ # Validate output schema
31
+ SchemaValidator.validate!(analysis, Equilibrium::Schemas::ANALYZER_OUTPUT, error_prefix: "Analyzer output schema validation failed")
32
+
33
+ # Format and display output
34
+ format_output(analysis, options[:format] || "summary", "analysis")
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ def load_and_validate_json_file(file_path, error_prefix:)
41
+ raise "File not found: #{file_path}" unless File.exist?(file_path)
42
+
43
+ JSON.parse(File.read(file_path)).tap do |json|
44
+ SchemaValidator.validate!(json, Equilibrium::Schemas::EXPECTED_ACTUAL, error_prefix: error_prefix)
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "../mixins/error_handling"
6
+ require_relative "../mixins/input_output"
7
+ require_relative "../schema_validator"
8
+ require_relative "../schemas/expected_actual"
9
+ require_relative "../catalog_builder"
10
+
11
+ module Equilibrium
12
+ module Commands
13
+ # Command for converting expected tags JSON to catalog format
14
+ class CatalogCommand
15
+ include Mixins::ErrorHandling
16
+ include Mixins::InputOutput
17
+
18
+ # Execute the catalog command
19
+ # @param file_path [String, nil] Optional file path, uses stdin if nil
20
+ def execute(file_path = nil)
21
+ with_error_handling do
22
+ # Read input from file or stdin
23
+ input = read_input_data(file_path, "No input provided. Use: equilibrium expected registry | equilibrium catalog")
24
+
25
+ # Parse and validate JSON
26
+ data = JSON.parse(input)
27
+ SchemaValidator.validate!(data, Equilibrium::Schemas::EXPECTED_ACTUAL, error_prefix: "Schema validation failed")
28
+
29
+ # Convert to catalog format
30
+ catalog = CatalogBuilder.build_catalog(data)
31
+
32
+ # Output as JSON
33
+ puts JSON.pretty_generate(catalog)
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../mixins/error_handling"
4
+ require_relative "../mixins/input_output"
5
+ require_relative "../repository_url_validator"
6
+ require_relative "../schema_validator"
7
+ require_relative "../schemas/expected_actual"
8
+ require_relative "../tags_operation_service"
9
+
10
+ module Equilibrium
11
+ module Commands
12
+ # Command for generating expected mutable tags based on semantic versions
13
+ class ExpectedCommand
14
+ include Mixins::ErrorHandling
15
+ include Mixins::InputOutput
16
+
17
+ # Execute the expected command
18
+ # @param registry [String] Repository URL
19
+ # @param options [Hash] Command options (format, etc.)
20
+ def execute(registry, options = {})
21
+ with_error_handling do
22
+ full_repository_url = RepositoryUrlValidator.validate(registry)
23
+
24
+ # Generate complete expected output using high-level service
25
+ output = TagsOperationService.generate_expected_output(full_repository_url)
26
+
27
+ # Validate output against schema before writing
28
+ SchemaValidator.validate!(output, Equilibrium::Schemas::EXPECTED_ACTUAL, error_prefix: "Schema validation failed")
29
+
30
+ # Format and display output
31
+ format_output(output, options[:format] || "summary", "expected")
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "../mixins/error_handling"
6
+ require_relative "../mixins/input_output"
7
+ require_relative "../schema_validator"
8
+ require_relative "../schemas/catalog"
9
+ require_relative "../schemas/expected_actual"
10
+ require_relative "../catalog_builder"
11
+
12
+ module Equilibrium
13
+ module Commands
14
+ # Command for converting catalog format back to expected/actual format
15
+ class UncatalogCommand
16
+ include Mixins::ErrorHandling
17
+ include Mixins::InputOutput
18
+
19
+ # Execute the uncatalog command
20
+ # @param file_path [String, nil] Optional file path, uses stdin if nil
21
+ def execute(file_path = nil)
22
+ with_error_handling do
23
+ # Read input from file or stdin
24
+ input = read_input_data(file_path, "No input provided. Use: equilibrium catalog registry | equilibrium uncatalog")
25
+
26
+ # Parse and validate JSON
27
+ data = JSON.parse(input)
28
+ SchemaValidator.validate!(data, Equilibrium::Schemas::CATALOG, error_prefix: "Catalog schema validation failed")
29
+
30
+ # Convert back to expected/actual format
31
+ result = CatalogBuilder.reverse_catalog(data)
32
+
33
+ # Validate output before printing
34
+ SchemaValidator.validate!(result, Equilibrium::Schemas::EXPECTED_ACTUAL, error_prefix: "Output validation failed")
35
+
36
+ # Output as JSON
37
+ puts JSON.pretty_generate(result)
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../version"
4
+
5
+ module Equilibrium
6
+ module Commands
7
+ # Command for displaying version information
8
+ class VersionCommand
9
+ # Execute the version command
10
+ def self.execute
11
+ puts "Equilibrium v#{Equilibrium::VERSION}"
12
+ puts "Container tag validation tool"
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "thor"
5
+ require_relative "../schema_validator"
6
+
7
+ module Equilibrium
8
+ module Mixins
9
+ # Module for consistent error handling across all commands
10
+ module ErrorHandling
11
+ def with_error_handling(&block)
12
+ block.call
13
+ rescue Thor::Error
14
+ raise # Let Thor::Error bubble up for validation errors
15
+ rescue RegistryClient::Error => e
16
+ raise StandardError, e.message # Convert for test compatibility
17
+ rescue SchemaValidator::ValidationError => e
18
+ error_and_exit(e.message)
19
+ rescue JSON::ParserError => e
20
+ error_and_exit("Invalid JSON input: #{e.message}")
21
+ rescue => e
22
+ error_and_exit(e.message)
23
+ end
24
+
25
+ private
26
+
27
+ # Exit with error message
28
+ def error_and_exit(message, usage = nil)
29
+ warn message
30
+ exit 1
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "../summary_formatter"
5
+
6
+ module Equilibrium
7
+ module Mixins
8
+ # Module for input/output operations - reading files, parsing JSON, formatting output
9
+ module InputOutput
10
+ def read_input_data(file_path = nil, usage_message = "No input provided")
11
+ input = file_path ? File.read(file_path) : $stdin.read
12
+ input = input.strip
13
+
14
+ raise usage_message if input.empty?
15
+ input
16
+ end
17
+
18
+ def format_output(data, format, summary_type = nil)
19
+ case format
20
+ when "json"
21
+ puts JSON.pretty_generate(data)
22
+ when "summary"
23
+ if summary_type
24
+ formatter = SummaryFormatter.new
25
+ case summary_type
26
+ when "expected", "actual"
27
+ formatter.print_tags_summary(data, summary_type)
28
+ when "analysis"
29
+ formatter.print_analysis_summary(data)
30
+ end
31
+ else
32
+ puts JSON.pretty_generate(data)
33
+ end
34
+ else
35
+ puts JSON.pretty_generate(data)
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -52,26 +52,14 @@ module Equilibrium
52
52
  class RegistryClient
53
53
  class Error < StandardError; end
54
54
 
55
- def list_tags(registry)
56
- url = build_api_url(registry)
57
- data = fetch_tags_data(url)
58
- parse_response(data)
59
- end
60
-
61
- private
62
-
63
- def build_api_url(registry)
64
- parts = registry.split("/")
65
- raise Error, "Invalid GCR registry format: #{registry}" if parts.length < 3
66
-
67
- host, project, *image = parts
55
+ attr_reader :uri
68
56
 
69
- "https://#{host}/v2/#{project}/#{image.join("/")}/tags/list"
57
+ def initialize(registry)
58
+ @registry = registry
59
+ @uri = URI(build_api_url(registry))
70
60
  end
71
61
 
72
- def fetch_tags_data(url)
73
- uri = URI(url)
74
-
62
+ def tagged_digests
75
63
  http = Net::HTTP.new(uri.host, uri.port)
76
64
  http.use_ssl = true
77
65
  http.read_timeout = 30
@@ -81,44 +69,45 @@ module Equilibrium
81
69
 
82
70
  raise Error, "API request failed: #{response.code} #{response.message}" unless response.is_a?(Net::HTTPSuccess)
83
71
 
84
- data = JSON.parse(response.body)
85
- validate_registry_response(data)
86
- data
87
- rescue JSON::ParserError => e
88
- raise Error, "Invalid JSON response: #{e.message}"
89
- rescue => e
90
- raise Error, "Request failed: #{e.message}"
91
- end
92
-
93
- def validate_registry_response(data)
94
- SchemaValidator.validate!(data, Equilibrium::Schemas::REGISTRY_API_RESPONSE, error_prefix: "Registry API response validation failed")
95
- rescue SchemaValidator::ValidationError => e
96
- raise Error, e.message
97
- end
72
+ data = JSON.parse(response.body).tap do |json|
73
+ SchemaValidator.validate!(json, Equilibrium::Schemas::REGISTRY_API_RESPONSE, error_prefix: "Registry API response validation failed")
74
+ end
98
75
 
99
- def parse_response(data)
100
76
  # Data is already validated by schema, safe to process
101
- tags = data["tags"]
77
+ all_tags = data["tags"]
102
78
 
103
79
  # The 'manifest' field is a non-standard extension provided by some registries (like GCR)
104
80
  # Most registries (GHCR, Docker Hub, ECR) only return 'name' and 'tags' per Docker Registry v2 spec
105
81
  # When manifest data is missing, digest information is not available from the tags endpoint
106
- tag_to_digest = build_digest_mapping(data["manifest"] || {})
82
+ manifests = data.fetch("manifest")
107
83
 
108
- tags.each_with_object({}) do |tag, result|
109
- # Map tags to their digests. For registries without manifest data, digest will be nil
110
- # This is expected behavior - digest info requires separate manifest API calls per tag
111
- result[tag] = tag_to_digest[tag]
112
- end
113
- end
114
-
115
- def build_digest_mapping(manifests)
116
84
  # Build mapping from tag names to their SHA256 digests
117
85
  # Only works when registry provides non-standard 'manifest' field in tags response
118
- manifests.each_with_object({}) do |(digest, manifest_info), mapping|
119
- tags = manifest_info["tag"]
120
- tags.each { |tag| mapping[tag] = digest }
86
+ tag_to_digest = manifests.each_with_object({}) do |(digest, manifest_info), mapping|
87
+ tags_for_digest = manifest_info["tag"]
88
+ tags_for_digest.each { |tag| mapping[tag] = digest }
89
+ end
90
+
91
+ # Validate that all_tags from API response matches tag_to_digest keys
92
+ all_tags_set = all_tags.to_set
93
+ manifest_tags_set = tag_to_digest.keys.to_set
94
+
95
+ unless all_tags_set == manifest_tags_set
96
+ raise Error, "Tag mismatch: API tags #{all_tags_set.to_a.sort} != manifest tags #{manifest_tags_set.to_a.sort}"
121
97
  end
98
+
99
+ tag_to_digest
100
+ end
101
+
102
+ private
103
+
104
+ def build_api_url(registry)
105
+ parts = registry.split("/")
106
+ raise Error, "Invalid GCR registry format: #{registry}" if parts.length < 3
107
+
108
+ host, project, *image = parts
109
+
110
+ "https://#{host}/v2/#{project}/#{image.join("/")}/tags/list"
122
111
  end
123
112
  end
124
113
  end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "registry_client"
4
+ require_relative "tag_processor"
5
+ require_relative "canonical_version_mapper"
6
+
7
+ module Equilibrium
8
+ class RepositoryTagsService
9
+ def self.generate_expected_tags(repository_url)
10
+ registry_client = RegistryClient.new(repository_url)
11
+
12
+ tagged_digests = registry_client.tagged_digests
13
+ semantic_tags = TagProcessor.filter_semantic_tags(tagged_digests)
14
+ TagProcessor.compute_virtual_tags(semantic_tags)
15
+ end
16
+
17
+ def self.generate_actual_tags(repository_url)
18
+ registry_client = RegistryClient.new(repository_url)
19
+
20
+ tagged_digests = registry_client.tagged_digests
21
+ semantic_tags = TagProcessor.filter_semantic_tags(tagged_digests)
22
+
23
+ mutable_tags = TagProcessor.filter_mutable_tags(tagged_digests)
24
+ canonical_versions = CanonicalVersionMapper.map_to_canonical_versions(mutable_tags, semantic_tags)
25
+
26
+ {
27
+ "digests" => mutable_tags,
28
+ "canonical_versions" => canonical_versions
29
+ }
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+
5
+ module Equilibrium
6
+ module RepositoryUrlValidator
7
+ def self.validate(repository_url)
8
+ unless repository_url.include?("/")
9
+ raise Thor::Error, "Repository URL must be full format (e.g., 'gcr.io/project-id/image-name'), not '#{repository_url}'"
10
+ end
11
+ repository_url
12
+ end
13
+ end
14
+ end
@@ -14,8 +14,7 @@ module Equilibrium
14
14
  # @param error_prefix [String] Prefix for error messages (optional)
15
15
  # @raise [ValidationError] If validation fails
16
16
  def self.validate!(data, schema, error_prefix: "Schema validation failed")
17
- schemer = JSONSchemer.schema(schema)
18
- errors = schemer.validate(data).to_a
17
+ errors = validate(data, schema)
19
18
 
20
19
  return if errors.empty?
21
20
 
@@ -25,5 +24,14 @@ module Equilibrium
25
24
 
26
25
  raise ValidationError, "#{error_prefix}:\n#{error_messages.join("\n")}"
27
26
  end
27
+
28
+ # Validates data against a JSON Schema and returns validation errors
29
+ # @param data [Hash] The data to validate
30
+ # @param schema [Hash] The JSON Schema to validate against
31
+ # @return [Array] Array of validation errors (empty if valid)
32
+ def self.validate(data, schema)
33
+ schemer = JSONSchemer.schema(schema)
34
+ schemer.validate(data).to_a
35
+ end
28
36
  end
29
37
  end
@@ -8,6 +8,7 @@ module Equilibrium
8
8
  # Example output (perfect equilibrium):
9
9
  # {
10
10
  # "repository_url": "gcr.io/datadoghq/apm-inject",
11
+ # "repository_name": "apm-inject",
11
12
  # "expected_count": 28,
12
13
  # "actual_count": 28,
13
14
  # "missing_tags": {},
@@ -20,6 +21,7 @@ module Equilibrium
20
21
  # Example output (requires remediation):
21
22
  # {
22
23
  # "repository_url": "gcr.io/datadoghq/example",
24
+ # "repository_name": "example",
23
25
  # "expected_count": 3,
24
26
  # "actual_count": 2,
25
27
  # "missing_tags": {
@@ -49,9 +51,13 @@ module Equilibrium
49
51
  "title" => "Equilibrium Analyzer Output Schema",
50
52
  "description" => "Schema for output from 'equilibrium analyze --format=json' command",
51
53
  "type" => "object",
52
- "required" => ["repository_url", "expected_count", "actual_count", "missing_tags", "unexpected_tags", "mismatched_tags", "status", "remediation_plan"],
54
+ "required" => [
55
+ "repository_url",
56
+ "repository_name", "expected_count", "actual_count", "missing_tags", "unexpected_tags", "mismatched_tags", "status", "remediation_plan"
57
+ ],
53
58
  "properties" => {
54
59
  "repository_url" => {"type" => "string"},
60
+ "repository_name" => {"type" => "string"},
55
61
  "expected_count" => {"type" => "integer", "minimum" => 0},
56
62
  "actual_count" => {"type" => "integer", "minimum" => 0},
57
63
  "status" => {"enum" => ["perfect", "missing_tags", "mismatched", "extra_tags"]},
@@ -84,7 +90,7 @@ module Equilibrium
84
90
  "type" => "array",
85
91
  "items" => {
86
92
  "type" => "object",
87
- "required" => ["action", "tag", "command"],
93
+ "required" => ["action", "tag"],
88
94
  "properties" => {
89
95
  "action" => {"enum" => ["create_tag", "update_tag", "remove_tag"]},
90
96
  "tag" => {"type" => "string"},
@@ -3,25 +3,24 @@
3
3
  module Equilibrium
4
4
  module Schemas
5
5
  # JSON Schema for catalog output format
6
- # Used by `equilibrium catalog` command
6
+ # Used by `catalog` command
7
7
  #
8
8
  # Example output:
9
9
  # {
10
+ # "repository_url": "gcr.io/datadoghq/apm-inject",
11
+ # "repository_name": "apm-inject",
10
12
  # "images": [
11
13
  # {
12
- # "name": "apm-inject",
13
14
  # "tag": "latest",
14
15
  # "digest": "sha256:5fcfe7ac14f6eeb0fe086ac7021d013d764af573b8c2d98113abf26b4d09b58c",
15
16
  # "canonical_version": "0.43.2"
16
17
  # },
17
18
  # {
18
- # "name": "apm-inject",
19
19
  # "tag": "0",
20
20
  # "digest": "sha256:5fcfe7ac14f6eeb0fe086ac7021d013d764af573b8c2d98113abf26b4d09b58c",
21
21
  # "canonical_version": "0.43.2"
22
22
  # },
23
23
  # {
24
- # "name": "apm-inject",
25
24
  # "tag": "0.43",
26
25
  # "digest": "sha256:5fcfe7ac14f6eeb0fe086ac7021d013d764af573b8c2d98113abf26b4d09b58c",
27
26
  # "canonical_version": "0.43.1"
@@ -33,22 +32,30 @@ module Equilibrium
33
32
  "title" => "Tag Resolver Schema",
34
33
  "description" => "Schema for Resolve tag",
35
34
  "type" => "object",
35
+ "required" => ["repository_url", "repository_name", "images"],
36
36
  "properties" => {
37
+ "repository_url" => {
38
+ "type" => "string",
39
+ "description" => "Full repository URL (e.g., 'gcr.io/project-id/image-name')",
40
+ "minLength" => 1,
41
+ "pattern" => "^[a-zA-Z0-9.-]+(/[a-zA-Z0-9._-]+)*$"
42
+ },
43
+ "repository_name" => {
44
+ "type" => "string",
45
+ "description" => "Repository name extracted from URL (e.g., 'apm-inject')",
46
+ "minLength" => 1,
47
+ "pattern" => "^[a-zA-Z0-9._-]+$"
48
+ },
37
49
  "images" => {
38
50
  "type" => "array",
39
51
  "items" => {
40
52
  "type" => "object",
41
53
  "required" => [
42
- "name",
43
54
  "tag",
44
55
  "digest",
45
56
  "canonical_version"
46
57
  ],
47
58
  "properties" => {
48
- "name" => {
49
- "type" => "string",
50
- "description" => "The name of the image"
51
- },
52
59
  "tag" => {
53
60
  "type" => "string",
54
61
  "description" => "The mutable tag of the image"
@@ -3,7 +3,7 @@
3
3
  module Equilibrium
4
4
  module Schemas
5
5
  # JSON Schema for expected/actual command output format
6
- # Used by `equilibrium expected` and `equilibrium actual` commands
6
+ # Used by `expected`, `actual` and `uncatalog` commands
7
7
  #
8
8
  # Example output:
9
9
  # {
@@ -24,7 +24,7 @@ module Equilibrium
24
24
  "title" => "Docker Registry v2 Tags List Response Schema",
25
25
  "description" => "Schema for response from Docker Registry v2 API /v2/{name}/tags/list endpoint",
26
26
  "type" => "object",
27
- "required" => ["name", "tags"],
27
+ "required" => ["name", "tags", "manifest"],
28
28
  "properties" => {
29
29
  "name" => {
30
30
  "type" => "string",
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "tag_sorter"
4
+
5
+ module Equilibrium
6
+ class TagDataBuilder
7
+ def self.build_output(repository_url, repository_name, digests, canonical_versions)
8
+ {
9
+ "repository_url" => repository_url,
10
+ "repository_name" => repository_name,
11
+ "digests" => TagSorter.sort_descending(digests),
12
+ "canonical_versions" => TagSorter.sort_descending(canonical_versions)
13
+ }
14
+ end
15
+ end
16
+ end
@@ -8,6 +8,14 @@ module Equilibrium
8
8
  new.compute_virtual_tags(semantic_tags)
9
9
  end
10
10
 
11
+ def self.filter_semantic_tags(tagged_digests)
12
+ new.filter_semantic_tags(tagged_digests)
13
+ end
14
+
15
+ def self.filter_mutable_tags(tagged_digests)
16
+ new.filter_mutable_tags(tagged_digests)
17
+ end
18
+
11
19
  def compute_virtual_tags(semantic_tags)
12
20
  return {"digests" => {}, "canonical_versions" => {}} if semantic_tags.empty?
13
21
 
@@ -55,26 +63,32 @@ module Equilibrium
55
63
  {"digests" => digests, "canonical_versions" => canonical_versions}
56
64
  end
57
65
 
58
- def filter_semantic_tags(all_tags)
59
- # Filter semantic tags (canonical_tags.json): exact major.minor.patch format
60
- all_tags.select { |tag, _| semantic_version?(tag) }
66
+ def filter_semantic_tags(tagged_digests)
67
+ tagged_digests.select { |tag, _| semantic_version?(tag) }
61
68
  end
62
69
 
63
- def filter_mutable_tags(all_tags)
64
- # Filter mutable tags (actual_tags.json): latest, digits, or major.minor format
65
- all_tags.select { |tag, _| mutable_tag?(tag) }
70
+ def filter_mutable_tags(tagged_digests)
71
+ tagged_digests.select { |tag, _| mutable_tag?(tag) }
66
72
  end
67
73
 
68
74
  private
69
75
 
70
76
  def semantic_version?(tag)
71
- SemanticVersion.valid?(tag)
77
+ # Strictly validate MAJOR.MINOR.PATCH format where:
78
+ # - MAJOR, MINOR, PATCH are non-negative integers
79
+ # - No leading zeros (except for '0' itself)
80
+ # - No prefixes (like 'v1.2.3', 'release-1.2.3')
81
+ # - No suffixes (like '1.2.3-alpha', '1.2.3+build')
82
+ # - No prereleases (like '1.2.3-rc.1', '1.2.3-beta.2')
83
+ tag.match?(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/)
72
84
  end
73
85
 
74
86
  def mutable_tag?(tag)
75
- tag.match?(/^latest$/) ||
76
- tag.match?(/^[0-9]+$/) ||
77
- tag.match?(/^[0-9]+\.[0-9]+$/)
87
+ # Validate mutable tags: latest, major versions (digits only), or major.minor format
88
+ # - 'latest' is the special case for the highest overall version
89
+ # - Major versions: non-negative integers without leading zeros (e.g., '1', '0', '42')
90
+ # - Minor versions: MAJOR.MINOR format with same zero-leading rules (e.g., '1.2', '0.9')
91
+ tag.match?(/^(latest|(0|[1-9]\d*)(\.(0|[1-9]\d*))?)$/)
78
92
  end
79
93
  end
80
94
  end