diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b5ddc3ddd9eb5..10177138aa730 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -143,10 +143,6 @@ jobs: gem-trusted-publishing: ${{ matrix.language == 'ruby' }} node-version: ${{ matrix.language == 'javascript' && '24.14.1' || '' }} run: | - if [ "${{ matrix.language == 'java' && (needs.parse-tag.outputs.language == 'all' || needs.parse-tag.outputs.language == 'java') && github.run_attempt > 1 }}" = "true" ]; then - echo "::error::Java release is not yet rerun-safe — check/drop the staging repo at https://central.sonatype.com/publishing/deployments and publish manually" - exit 1 - fi if [ "${{ needs.parse-tag.outputs.language == 'all' || needs.parse-tag.outputs.language == matrix.language }}" = "true" ]; then ./go ${{ matrix.language }}:release else diff --git a/rake_tasks/common.rb b/rake_tasks/common.rb index d14553f9492b8..d7a53b3e7d43a 100644 --- a/rake_tasks/common.rb +++ b/rake_tasks/common.rb @@ -121,12 +121,20 @@ def self.aggregate_errors(**steps) raise failures.join("\n\n") end + # Takes a url or a prepared request, so callers needing headers can build their own. + def self.get_request(target) + request = target.is_a?(Net::HTTPRequest) ? target : Net::HTTP::Get.new(URI(target)) + uri = request.uri + + Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https', + open_timeout: 10, read_timeout: 60) do |http| + http.request(request) + end + end + def self.verify_package_published(url) puts "Verifying #{url}..." - uri = URI(url) - res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http| - http.request(Net::HTTP::Get.new(uri)) - end + res = get_request(url) raise "Package not published: #{url}" unless res.is_a?(Net::HTTPSuccess) puts 'Verified!' diff --git a/rake_tasks/dotnet.rake b/rake_tasks/dotnet.rake index 8f4856ee9510a..8e0e36ee81120 100644 --- a/rake_tasks/dotnet.rake +++ b/rake_tasks/dotnet.rake @@ -36,22 +36,6 @@ desc 'Build, package, and push nupkg files to NuGet' task :release do |_task, arguments| nightly = arguments.to_a.include?('nightly') - unless nightly - already_published = begin - Rake::Task['dotnet:verify'].invoke - true - rescue StandardError - false - ensure - Rake::Task['dotnet:verify'].reenable - end - - if already_published - puts '.NET packages already published — skipping release.' - next - end - end - Rake::Task['dotnet:check_credentials'].invoke(*arguments.to_a) if nightly diff --git a/rake_tasks/java.rake b/rake_tasks/java.rake index 776bb71083f98..b4de6f2fd8ab2 100644 --- a/rake_tasks/java.rake +++ b/rake_tasks/java.rake @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'base64' +require 'json' require 'net/http' # use #java_release_targets to access this list @@ -72,64 +73,111 @@ def verify_java_release_targets raise error_message end -def read_m2_user_pass - settings_path = File.join(Dir.home, '.m2', 'settings.xml') - unless File.exist?(settings_path) - warn "Maven settings file not found at #{settings_path}" - return - end +module Sonatype + module_function + + def load_credentials + ENV['MAVEN_USER'] ||= ENV.fetch('SEL_M2_USER', nil) + ENV['MAVEN_PASSWORD'] ||= ENV.fetch('SEL_M2_PASS', nil) + return if ENV['MAVEN_PASSWORD'] && ENV['MAVEN_USER'] - puts 'Maven environment variables not set, inspecting ~/.m2/settings.xml.' - settings = File.read(settings_path) - found_section = false - settings.each_line do |line| - if !found_section - found_section = line.include? 'central' - elsif line.include?('') - ENV['MAVEN_USER'] = line[%r{(.*?)}, 1] - elsif line.include?('') - ENV['MAVEN_PASSWORD'] = line[%r{(.*?)}, 1] + settings_path = File.join(Dir.home, '.m2', 'settings.xml') + unless File.exist?(settings_path) + warn "Maven settings file not found at #{settings_path}" + return end - break if ENV['MAVEN_PASSWORD'] && ENV['MAVEN_USER'] - end -end -def sonatype_auth_token - read_m2_user_pass unless ENV['MAVEN_PASSWORD'] && ENV['MAVEN_USER'] - Base64.strict_encode64("#{ENV.fetch('MAVEN_USER')}:#{ENV.fetch('MAVEN_PASSWORD')}") -end + puts 'Maven environment variables not set, inspecting ~/.m2/settings.xml.' + settings = File.read(settings_path) + found_section = false + settings.each_line do |line| + if !found_section + found_section = line.include? 'central' + elsif line.include?('') + ENV['MAVEN_USER'] = line[%r{(.*?)}, 1] + elsif line.include?('') + ENV['MAVEN_PASSWORD'] = line[%r{(.*?)}, 1] + end + break if ENV['MAVEN_PASSWORD'] && ENV['MAVEN_USER'] + end + end -def trigger_sonatype_publish(token) - puts 'Triggering Sonatype upload with automatic publishing...' - url = 'https://ossrh-staging-api.central.sonatype.com/manual/upload/defaultRepository/' \ - 'org.seleniumhq?publishing_type=automatic' - uri = URI(url) + def auth_token + load_credentials + Base64.strict_encode64("#{ENV.fetch('MAVEN_USER')}:#{ENV.fetch('MAVEN_PASSWORD')}") + end - req = Net::HTTP::Post.new(uri) - req['Authorization'] = "Basic #{token}" - req['Accept'] = '*/*' - req['Content-Length'] = '0' + def trigger_publish + puts 'Triggering Sonatype upload with automatic publishing...' + url = 'https://ossrh-staging-api.central.sonatype.com/manual/upload/defaultRepository/' \ + 'org.seleniumhq?publishing_type=automatic' + uri = URI(url) + + req = Net::HTTP::Post.new(uri) + req['Authorization'] = "Basic #{auth_token}" + req['Accept'] = '*/*' + req['Content-Length'] = '0' + + begin + res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, + open_timeout: 10, read_timeout: 180) do |http| + http.request(req) + end + rescue Net::ReadTimeout, Net::OpenTimeout => e + warn <<~MSG + Request timed out. + The deployment may still have been created on the server. + Check https://central.sonatype.com/publishing/deployments for status. + MSG + raise e + end - begin - res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, - open_timeout: 10, read_timeout: 180) do |http| - http.request(req) + unless res.is_a?(Net::HTTPSuccess) + warn "Failed to trigger upload (HTTP #{res.code}): #{res.body}" + exit(1) end - rescue Net::ReadTimeout, Net::OpenTimeout => e - warn <<~MSG - Request timed out. - The deployment may still have been created on the server. - Check https://central.sonatype.com/publishing/deployments for status. - MSG - raise e + + puts 'Upload triggered — Sonatype will automatically validate and publish.' + end + + def request_json(request) + request['Accept'] = 'application/json' + res = SeleniumRake.get_request(request) + raise "#{request.uri.path} returned HTTP #{res.code}: #{res.body}" unless res.is_a?(Net::HTTPSuccess) + + JSON.parse(res.body) end - unless res.is_a?(Net::HTTPSuccess) - warn "Failed to trigger upload (HTTP #{res.code}): #{res.body}" - exit(1) + # Staging repositories are keyed by (user, IP, user agent) and the search defaults to the caller's + # IP, so ip=any is required to see one opened by a previous attempt on a different runner. + def staging_repositories + req = Net::HTTP::Get.new(URI('https://ossrh-staging-api.central.sonatype.com/manual/search/repositories?ip=any')) + req['Authorization'] = "Basic #{auth_token}" + request_json(req).fetch('repositories') end - puts 'Upload triggered — Sonatype will automatically validate and publish.' + def published?(version) + query = "namespace=org.seleniumhq.selenium&name=selenium-java&version=#{version}" + req = Net::HTTP::Get.new(URI("https://central.sonatype.com/api/v1/publisher/published?#{query}")) + # The Portal documents Bearer, where the staging API shim above takes Basic. Same credential. + req['Authorization'] = "Bearer #{auth_token}" + request_json(req).fetch('published') + end + + # A repository still open or closed is a previous attempt that has not landed — mid-publish, or + # rejected by the Portal. Both want a look at the deployments page before another deploy piles on. + def already_deployed?(version) + unfinished = %w[open closed] + if staging_repositories.any? { |repo| unfinished.include?(repo['state']) } + raise 'A previous attempt left a staging repository behind; check it at ' \ + 'https://central.sonatype.com/publishing/deployments before releasing again.' + end + + return false unless published?(version) + + puts "#{version} is already deployed — skipping the deploy." + true + end end desc 'Build Java Client Jars' @@ -260,11 +308,8 @@ desc 'Validate Java release credentials' task :check_credentials do |_task, arguments| nightly = arguments.to_a.include?('nightly') - has_env = (ENV['MAVEN_USER'] || ENV.fetch('SEL_M2_USER', - nil)) && (ENV['MAVEN_PASSWORD'] || ENV.fetch('SEL_M2_PASS', nil)) - settings = File.join(Dir.home, '.m2', 'settings.xml') - has_file = File.exist?(settings) && File.read(settings).include?('central') - unless has_env || has_file + Sonatype.load_credentials + unless ENV['MAVEN_USER'] && ENV['MAVEN_PASSWORD'] raise 'Missing Maven credentials: set MAVEN_USER/MAVEN_PASSWORD or configure ~/.m2/settings.xml' end @@ -279,26 +324,8 @@ task :release do |_task, arguments| args = arguments.to_a nightly = args.delete('nightly') - unless nightly - already_published = begin - SeleniumRake.verify_package_published(maven_central_pom_url) - true - rescue StandardError - false - end - - if already_published - puts 'Java packages already published — skipping release.' - next - end - end - Rake::Task['java:check_credentials'].invoke(*(nightly ? ['nightly'] : [])) - ENV['MAVEN_USER'] ||= ENV.fetch('SEL_M2_USER', nil) - ENV['MAVEN_PASSWORD'] ||= ENV.fetch('SEL_M2_PASS', nil) - token = sonatype_auth_token - repo_domain = 'central.sonatype.com' repo = if nightly "#{repo_domain}/repository/maven-snapshots" @@ -317,12 +344,14 @@ task :release do |_task, arguments| Rake::Task['java:package'].invoke('--config=release') Rake::Task['java:build'].invoke('--config=release') - puts "Releasing Java artifacts to Maven repository at '#{ENV.fetch('MAVEN_REPO', nil)}'" + next if !nightly && Sonatype.already_deployed?(java_version) + + puts "Deploying Java artifacts to '#{ENV.fetch('MAVEN_REPO', nil)}'" java_release_targets.each { |target| Bazel.execute('run', ['--config=release'], target) } next if nightly - trigger_sonatype_publish(token) + Sonatype.trigger_publish end def maven_central_pom_url