diff --git a/.gitlab/ci/release-coordinator.canary.gitlab-ci.yml b/.gitlab/ci/release-coordinator.canary.gitlab-ci.yml index 05db2b1..ff4e17f 100644 --- a/.gitlab/ci/release-coordinator.canary.gitlab-ci.yml +++ b/.gitlab/ci/release-coordinator.canary.gitlab-ci.yml @@ -1,5 +1,5 @@ .release-coordinator:canary:stages: - - release-coordinator:canary:notify-start + - release-coordinator:canary:prepare - release-coordinator:canary:build - release-coordinator:canary:publish - release-coordinator:canary:notify-finish @@ -11,12 +11,23 @@ release-coordinator:canary:notify-start: extends: - .release-coordinator:canary - stage: release-coordinator:canary:notify-start + stage: release-coordinator:canary:prepare script: - bin/pyxis internal notify_new_coordinator --coordinator-pipeline-id $CI_PIPELINE_ID variables: DRY_RUN: "false" +release-coordinator:canary:check: + extends: + - .release-coordinator:canary + stage: release-coordinator:canary:prepare + needs: + - release-coordinator:canary:notify-start + script: + - bin/pyxis internal check_canary_release --build-id-to-promote $BUILD_ID_TO_PROMOTE + variables: + DRY_RUN: "false" + release-coordinator:canary:tmp-branch: extends: - .release-coordinator:canary diff --git a/.rubocop.yml b/.rubocop.yml index 67578b4..13fb3f0 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,13 +1,19 @@ AllCops: - TargetRubyVersion: 3.2.0 + TargetRubyVersion: 3.2 NewCops: enable Metrics: Enabled: false +Naming/PredicateMethod: + AllowBangMethods: true + Style/Documentation: Enabled: false +Style/EmptyClassDefinition: + Enabled: false + Style/NumericLiterals: Enabled: false diff --git a/Gemfile b/Gemfile index fb63e83..2f2e2cc 100644 --- a/Gemfile +++ b/Gemfile @@ -18,3 +18,5 @@ gem 'semantic_logger', '~> 4.16', require: 'semantic_logger/sync' gem 'json', '~> 2.12' gem 'discordrb', '~> 3.7' + +gem 'toml-rb', '~> 4.1' diff --git a/Gemfile.lock b/Gemfile.lock index df9f895..6dcfaec 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -5,6 +5,8 @@ GEM public_suffix (>= 2.0.2, < 7.0) ast (2.4.3) base64 (0.3.0) + bigdecimal (4.0.1) + citrus (3.0.2) concurrent-ruby (1.3.5) discordrb (3.7.2) base64 (~> 0.2) @@ -29,12 +31,17 @@ GEM http-accept (1.7.0) http-cookie (1.1.0) domain_name (~> 0.5) - json (2.12.2) + json (2.18.1) + json-schema (6.1.0) + addressable (~> 2.8) + bigdecimal (>= 3.1, < 5) jwt (2.10.1) base64 language_server-protocol (3.17.0.5) lint_roller (1.1.0) logger (1.7.0) + mcp (0.8.0) + json-schema (>= 4.1) mime-types (3.7.0) logger mime-types-data (~> 3.2025, >= 3.2025.0507) @@ -50,33 +57,34 @@ GEM opus-ruby (1.0.1) ffi parallel (1.27.0) - parser (3.3.8.0) + parser (3.3.10.2) ast (~> 2.4.1) racc - prism (1.4.0) + prism (1.9.0) public_suffix (6.0.2) racc (1.8.1) rainbow (3.1.1) - regexp_parser (2.10.0) + regexp_parser (2.11.3) rest-client (2.1.0) http-accept (>= 1.7.0, < 2.0) http-cookie (>= 1.0.2, < 2.0) mime-types (>= 1.16, < 4.0) netrc (~> 0.8) - rubocop (1.76.0) + rubocop (1.85.1) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) + mcp (~> 0.6) parallel (~> 1.10) parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 2.9.3, < 3.0) - rubocop-ast (>= 1.45.0, < 2.0) + rubocop-ast (>= 1.49.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 4.0) - rubocop-ast (1.45.0) + rubocop-ast (1.49.0) parser (>= 3.3.7.2) - prism (~> 1.4) + prism (~> 1.7) ruby-progressbar (1.13.0) sawyer (0.9.2) addressable (>= 2.3.5) @@ -84,9 +92,12 @@ GEM semantic_logger (4.16.1) concurrent-ruby (~> 1.0) thor (1.3.2) - unicode-display_width (3.1.4) - unicode-emoji (~> 4.0, >= 4.0.4) - unicode-emoji (4.0.4) + toml-rb (4.1.0) + citrus (~> 3.0, > 3.0) + racc (~> 1.7) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.2.0) uri (1.0.3) websocket (1.2.11) websocket-client-simple (0.9.0) @@ -109,6 +120,7 @@ DEPENDENCIES rubocop (~> 1.76) semantic_logger (~> 4.16) thor (~> 1.3) + toml-rb (~> 4.1) zeitwerk (~> 2.7) BUNDLED WITH diff --git a/lib/pyxis/checks/aggregated_check.rb b/lib/pyxis/checks/aggregated_check.rb new file mode 100644 index 0000000..53cd0d9 --- /dev/null +++ b/lib/pyxis/checks/aggregated_check.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Pyxis + module Checks + class AggregatedCheck + include Check + + attr_reader :check_context, :checks + + def initialize(check_context, checks) + @check_context = check_context + @checks = checks + end + + def perform_check! + checks.all?(&:pass?) + end + + def status_message + message = [] + message << if pass? + "#{icon} #{check_context} checks pass" + else + "#{icon} #{check_context} checks fail" + end + checks.each do |check| + message << '' + message << check.status_message + end + + message.join("\n") + end + end + end +end diff --git a/lib/pyxis/checks/canary_release.rb b/lib/pyxis/checks/canary_release.rb new file mode 100644 index 0000000..f5fa6ec --- /dev/null +++ b/lib/pyxis/checks/canary_release.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Pyxis + module Checks + class CanaryRelease < AggregatedCheck + def initialize(build_id_to_promote) + info = Pyxis::ManagedVersioning::ComponentInfo.new(build_id: build_id_to_promote) + super( + 'Canary release', + [ + TucanaVersionMatch.new(info), + OpenIssues.new('release blocking', ['blocks-releases']) + ] + ) + end + end + end +end diff --git a/lib/pyxis/checks/check.rb b/lib/pyxis/checks/check.rb new file mode 100644 index 0000000..42efe5e --- /dev/null +++ b/lib/pyxis/checks/check.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Pyxis + module Checks + module Check + def perform_check! + raise NotImplementedError + end + + def status_message + raise NotImplementedError + end + + def pass? + return @pass if defined?(@pass) + + @pass = perform_check! + end + + def icon + if pass? + pass_icon + else + fail_icon + end + end + + def pass_icon + '✅' # :white_check_mark: + end + + def fail_icon + '❌' # :x: + end + end + end +end diff --git a/lib/pyxis/checks/open_issues.rb b/lib/pyxis/checks/open_issues.rb new file mode 100644 index 0000000..77b9602 --- /dev/null +++ b/lib/pyxis/checks/open_issues.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Pyxis + module Checks + class OpenIssues + include Check + + attr_reader :check_context, :labels + + def initialize(check_context, labels) + @check_context = check_context + @labels = labels + end + + def perform_check! + issues.empty? + end + + def status_message + return "#{icon} No open #{check_context} issues" if pass? + + message = [] + message << "#{icon} Open #{check_context} issues" + issues.each do |issue| + message << "- [#{issue.title}](#{issue.html_url})" + end + + message.join("\n") + end + + private + + def issues + @issues ||= GithubClient.octokit.search_issues( + "org:#{GithubClient::ORGANIZATION_NAME} is:open is:issue #{labels.map { |l| "label:#{l}" }.join(' ')}" + ).items + end + end + end +end diff --git a/lib/pyxis/checks/tucana_version_match.rb b/lib/pyxis/checks/tucana_version_match.rb new file mode 100644 index 0000000..21a4075 --- /dev/null +++ b/lib/pyxis/checks/tucana_version_match.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +module Pyxis + module Checks + class TucanaVersionMatch + include Check + + attr_reader :component_info + + def initialize(component_info) + @component_info = component_info + end + + def perform_check! + [sagittarius_version, aquila_version, draco_version, taurus_version].uniq.size == 1 + end + + def status_message + return "#{icon} Components use the same tucana version" if pass? + + message = [] + message << "#{icon} Components use different tucana versions" + message << '' + message << "sagittarius: #{sagittarius_version}" + message << "aquila: #{aquila_version}" + message << "draco: #{draco_version}" + message << "taurus: #{taurus_version}" + + message.join("\n") + end + + private + + def sagittarius_version + return @sagittarius_version if defined?(@sagittarius_version) + + gemfile_content = Base64.decode64 GithubClient.octokit.contents( + Project::Sagittarius.github_path, + path: 'Gemfile.lock', + ref: executed_component_info[:sagittarius] + ).content + + @sagittarius_version = Bundler::LockfileParser.new(gemfile_content) + .specs + .find { |spec| spec.name == 'tucana' } + .version + .to_s + end + + def aquila_version + @aquila_version ||= from_cargo_lockfile( + Base64.decode64( + GithubClient.octokit.contents( + Project::Aquila.github_path, + path: 'Cargo.lock', + ref: executed_component_info[:aquila] + ).content + ) + ) + end + + def draco_version + @draco_version ||= from_cargo_lockfile( + Base64.decode64( + GithubClient.octokit.contents( + Project::Draco.github_path, + path: 'Cargo.lock', + ref: executed_component_info[:draco] + ).content + ) + ) + end + + def taurus_version + @taurus_version ||= from_cargo_lockfile( + Base64.decode64( + GithubClient.octokit.contents( + Project::Taurus.github_path, + path: 'Cargo.lock', + ref: executed_component_info[:taurus] + ).content + ) + ) + end + + def from_cargo_lockfile(lockfile) + toml = TomlRB.parse(lockfile, symbolize_keys: true) + toml[:package].find { |package| package[:name] == 'tucana' }[:version] + end + + def executed_component_info + @executed_component_info ||= component_info.execute + end + end + end +end diff --git a/lib/pyxis/commands/internal.rb b/lib/pyxis/commands/internal.rb index 52424cb..cea104d 100644 --- a/lib/pyxis/commands/internal.rb +++ b/lib/pyxis/commands/internal.rb @@ -76,6 +76,20 @@ def notify_finish_coordinator #{pipeline.web_url} DESC end + + desc 'check_canary_release', '' + method_option :build_id_to_promote, required: true, type: :numeric + def check_canary_release + check = Pyxis::Checks::CanaryRelease.new(options[:build_id_to_promote]) + + severity = check.pass? ? :info : :error + + Pyxis::DiscordClient.new.send_notification(check.status_message, severity) + + puts check.status_message + + exit(false) unless check.pass? + end end end end