From 37c2ba141d34f58e31a00fd8612897f05903c090 Mon Sep 17 00:00:00 2001 From: Cody Oss Date: Tue, 4 Aug 2026 20:12:23 +0000 Subject: [PATCH] feat(release): add --docs-only flag to perform tool Add the `--docs-only` flag to `toys release perform` to support the Exit Gate two-stage release workflow, allowing documentation (YARD and Cloud RAD) to be built and published independently of releasing the gem. Key changes: - Add `--docs-only` flag to `toys/release/perform.rb`. - Bypass version checking, skip gem publishing, and force doc publishing when `--docs-only` is set. - Remove `include :gems` from `toys/release/perform.rb` to resolve method name collision with the `:gems` CLI flag. - Improve `transform_links` exception safety to ensure markdown link transformations are reverted even if an error occurs midway. - Add integration tests in `test/release_perform_test.rb`. - Add test dependencies (`toys`, `gems`, `jwt`) to `Gemfile`. - Add `/vendor/` to `.gitignore`. --- .gitignore | 1 + .rubocop.yml | 1 + Gemfile | 3 + test/release_perform_test.rb | 190 +++++++++++++++++++++++++++++++++++ toys/release/perform.rb | 32 +++--- 5 files changed, 212 insertions(+), 15 deletions(-) create mode 100644 test/release_perform_test.rb diff --git a/.gitignore b/.gitignore index bd2d1d7d..6fbca4dd 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ Gemfile.lock .vagrant/ .yardoc/ /tmp/ +/vendor/ diff --git a/.rubocop.yml b/.rubocop.yml index 50c423c5..f4690c6f 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -11,6 +11,7 @@ AllCops: Metrics/BlockLength: Exclude: - "owlbot-postprocessor/test/test_*.rb" + - "test/**/*" Metrics/ClassLength: Max: 300 Metrics/MethodLength: diff --git a/Gemfile b/Gemfile index cd8263d1..432ebd4a 100644 --- a/Gemfile +++ b/Gemfile @@ -1,5 +1,8 @@ source "https://rubygems.org" +gem "gems", "~> 1.3" gem "google-style", "~> 1.32.0" +gem "jwt", "~> 2.10" gem "minitest-mock", "~> 5.27" gem "minitest-reporters", "~> 1.8.0" +gem "toys", "~> 0.22" diff --git a/test/release_perform_test.rb b/test/release_perform_test.rb new file mode 100644 index 00000000..0e71a5c5 --- /dev/null +++ b/test/release_perform_test.rb @@ -0,0 +1,190 @@ +# frozen_string_literal: true + +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +require "minitest/autorun" +require "toys" +require "fileutils" +require "tmpdir" +require "gems" +require "json" + +module Gems + class Client + def info _gem_name = nil + { "version" => "0.1.0" } + end + + def push _file = nil + raise "Gems::Client#push should NOT be called" + end + end +end + +class ReleasePerformTest < Minitest::Test + def setup + @orig_cwd = Dir.pwd + @tmpdir = Dir.mktmpdir + @dummy_gem_dir = File.join @tmpdir, "dummy_gem" + FileUtils.mkdir_p @dummy_gem_dir + setup_dummy_gem_files + end + + def teardown + Dir.chdir @orig_cwd if @orig_cwd + FileUtils.rm_rf @tmpdir + end + + def test_docs_only_behavior + cli = Toys::StandardCLI.new + cli.loader.lookup ["release", "perform"] + performer_class = find_performer_class cli + + calls = {} + with_mocked_performer performer_class, calls do + args = ["dummy_gem", "--base-dir=#{@dummy_gem_dir}", "--docs-only", "--dry-run"] + exit_code = cli.run "release", "perform", *args + + assert_equal 0, exit_code + refute calls[:publish_gem], "publish_gem should NOT be called" + assert calls[:publish_docs], "publish_docs SHOULD be called" + assert calls[:publish_rad], "publish_rad SHOULD be called" + end + end + + def test_normal_release_publishes_gem_when_outdated + cli = Toys::StandardCLI.new + cli.loader.lookup ["release", "perform"] + performer_class = find_performer_class cli + + calls = {} + with_mocked_performer performer_class, calls, current_version: "0.0.0" do + args = [ + "dummy_gem", + "--base-dir=#{@dummy_gem_dir}", + "--enable-docs", + "--enable-rad", + "--dry-run" + ] + exit_code = cli.run "release", "perform", *args + + assert_equal 0, exit_code + assert calls[:publish_gem], "publish_gem SHOULD be called" + assert calls[:publish_docs], "publish_docs SHOULD be called" + assert calls[:publish_rad], "publish_rad SHOULD be called" + end + end + + def test_normal_release_skips_when_up_to_date + cli = Toys::StandardCLI.new + cli.loader.lookup ["release", "perform"] + performer_class = find_performer_class cli + + calls = {} + with_mocked_performer performer_class, calls, current_version: "0.1.0" do + args = [ + "dummy_gem", + "--base-dir=#{@dummy_gem_dir}", + "--enable-docs", + "--enable-rad", + "--dry-run" + ] + exit_code = cli.run "release", "perform", *args + + assert_equal 0, exit_code + refute calls[:publish_gem], "publish_gem should NOT be called" + refute calls[:publish_docs], "publish_docs should NOT be called (skipped early)" + refute calls[:publish_rad], "publish_rad should NOT be called (skipped early)" + end + end + + private + + def setup_dummy_gem_files + gemspec_content = <<~RUBY + Gem::Specification.new do |s| + s.name = "dummy_gem" + s.version = "0.1.0" + s.summary = "Dummy Gem for testing" + s.authors = ["Test"] + end + RUBY + metadata = { "name_pretty" => "Dummy Gem", "is_cloud" => true } + File.write File.join(@dummy_gem_dir, "dummy_gem.gemspec"), gemspec_content + File.write File.join(@dummy_gem_dir, ".repo-metadata.json"), JSON.dump(metadata) + File.write File.join(@dummy_gem_dir, ".yardopts"), "# Empty yardopts\n" + FileUtils.mkdir_p File.join(@dummy_gem_dir, "doc") + end + + def find_performer_class cli + tool, = cli.loader.lookup ["release", "perform"] + tool_class = tool.tool_class + Toys::InputFile.constants.each do |const_name| + mod = Toys::InputFile.const_get const_name + next unless mod.is_a?(Module) && mod.instance_variable_get(:@__tool_class) == tool_class + + return mod.const_get :Performer if mod.const_defined? :Performer, false + end + nil + end + + def with_mocked_performer performer_class, calls, current_version: nil + orig_methods = save_original_methods performer_class + apply_mock_methods performer_class, calls, current_version + yield + ensure + restore_original_methods performer_class, orig_methods if performer_class && orig_methods + end + + def save_original_methods performer_class + { + run_aux_task: performer_class.instance_method(:run_aux_task), + publish_gem: performer_class.instance_method(:publish_gem), + publish_docs: performer_class.instance_method(:publish_docs), + publish_rad: performer_class.instance_method(:publish_rad), + current_rubygems_version: performer_class.instance_method(:current_rubygems_version) + } + end + + def apply_mock_methods performer_class, calls, current_version + performer_class.class_eval do + define_method :run_aux_task do |*_args| + nil + end + define_method :publish_gem do |*_args| + calls[:publish_gem] = true + end + define_method :publish_docs do |*_args| + calls[:publish_docs] = true + end + define_method :publish_rad do |*_args| + calls[:publish_rad] = true + end + if current_version + define_method :current_rubygems_version do + Gem::Version.new current_version + end + end + end + end + + def restore_original_methods performer_class, orig_methods + performer_class.class_eval do + orig_methods.each do |name, method| + define_method name, method + end + end + end +end diff --git a/toys/release/perform.rb b/toys/release/perform.rb index 347b858c..ced0242f 100644 --- a/toys/release/perform.rb +++ b/toys/release/perform.rb @@ -36,9 +36,9 @@ flag :docs_staging_bucket, "--docs-staging-bucket=VALUE", default: ENV["STAGING_BUCKET"] flag :rad_staging_bucket, "--rad-staging-bucket=VALUE", default: ENV["V2_STAGING_BUCKET"] flag :docuploader_credentials, "--docuploader-credentials=VALUE", default: ENV["DOCUPLOADER_CREDENTIALS"] +flag :docs_only include :exec, e: true -include :gems def run Dir.chdir context_directory @@ -55,8 +55,8 @@ def run end def load_deps - gem "gems", "~> 1.3" - gem "jwt", "~> 2.10" + gem "gems", "~> 1.3" unless defined? Gems + gem "jwt", "~> 2.10" unless defined? JWT require "fileutils" require "gems" require "json" @@ -236,8 +236,10 @@ def perform_release_gem name:, last_version: docuploader_tries: 2 releaser.run force_republish: force_republish, + enable_docs: enable_docs, enable_rad: enable_rad, - dry_run: dry_run + dry_run: dry_run, + docs_only: docs_only end def determine_packages @@ -354,39 +356,39 @@ def needs_gem_publish? def run force_republish: false, enable_docs: false, enable_rad: false, - dry_run: false - if !force_republish && !needs_gem_publish? + dry_run: false, + docs_only: false + unless docs_only || force_republish || needs_gem_publish? logger.warn "**** Gem #{gem_name} is already up to date at version #{gem_version}. Skipping." return end - transformation_info = transform_links + transformation_info = {} begin - publish_gem dry_run: dry_run unless @gem_name == "help" - publish_docs dry_run: dry_run if enable_docs - publish_rad dry_run: dry_run if enable_rad + transform_links transformation_info + publish_gem dry_run: dry_run unless docs_only || @gem_name == "help" + publish_docs dry_run: dry_run if docs_only || enable_docs + publish_rad dry_run: dry_run if docs_only || enable_rad ensure detransform_links transformation_info end end - def transform_links + def transform_links transformation_info logger.info "**** Transforming links for #{gem_name}" - transformation_info = {} Dir.chdir gem_dir do Dir.glob "*.md" do |filename| content = File.read filename transformation_info[filename] = content transformed_content = content.gsub(/\[([^\]]*)\]\(([^):]*\.md)\)/, "{file:\\2 \\1}") - File.open(filename, "w") { |file| file << transformed_content } + File.write filename, transformed_content end end - transformation_info end def detransform_links transformation_info Dir.chdir gem_dir do transformation_info.each do |filename, content| - File.open(filename, "w") { |file| file << content } + File.write filename, content end end end