diff --git a/lib/tasks/version_bump.rake b/lib/tasks/version_bump.rake new file mode 100644 index 00000000000..76cc7d1db8c --- /dev/null +++ b/lib/tasks/version_bump.rake @@ -0,0 +1,425 @@ +# frozen_string_literal: true + +def dry_run? + !!ENV["DRY_RUN"] +end + +def test_mode? + ENV["RUNNING_VERSION_BUMP_IN_RSPEC_TESTS"] == "1" +end + +class PlannedTag + attr_reader :name, :message + + def initialize(name:, message:) + @name = name + @message = message + end +end + +class PlannedCommit + attr_reader :version, :tags, :ref + + def initialize(version:, tags: []) + @version = version + @tags = tags + end + + def perform! + write_version(@version) + git "add", "lib/version.rb" + git "commit", "-m", "Bump version to v#{@version}" + @ref = git("rev-parse", "HEAD").strip + end +end + +def read_version_rb + File.read("lib/version.rb") +end + +def parse_current_version + version = read_version_rb[/STRING = "(.*)"/, 1] + raise "Unable to parse current version" if version.nil? + puts "Parsed current version: #{version.inspect}" + version +end + +def write_version(version) + File.write("lib/version.rb", read_version_rb.sub(/STRING = ".*"/, "STRING = \"#{version}\"")) +end + +def git(*args, allow_failure: false, silent: false) + puts "> git #{args.inspect}" unless silent + stdout, stderr, status = Open3.capture3({ "LEFTHOOK" => "0" }, "git", *args) + if !status.success? && !allow_failure + raise "Command failed: git #{args.inspect}\n#{stdout.indent(2)}\n#{stderr.indent(2)}" + end + stdout +end + +def ref_exists?(ref) + git "rev-parse", "--verify", ref + true +rescue StandardError + false +end + +def confirm(msg) + loop do + print "#{msg} (yes/no)..." + break if test_mode? + + response = $stdin.gets.strip + + case response.downcase + when "no" + raise "Aborted" + when "yes" + break + else + puts "unknown response: #{response}" + end + end +end + +def make_commits(commits:, branch:, base:) + raise "You have other staged changes. Aborting." if !git("diff", "--cached").empty? + + git "branch", "-D", branch if ref_exists?(branch) + git "checkout", "-b", branch + + commits.each(&:perform!) + + git("push", "-f", "--set-upstream", "origin", branch) + + make_pr( + base: base, + branch: branch, + title: + "Version bump#{"s" if commits.length > 1} for #{base}: #{commits.map { |c| "v#{c.version}" }.join(", ")}", + ) +end + +def make_pr(base:, branch:, title:) + params = { expand: 1, title: title, body: <<~MD } + > :warning: This PR should not be merged via the GitHub web interface + > + > It should only be merged (via fast-forward) using the associated `bin/rake version_bump:*` task. + MD + + if !test_mode? + system( + "open", + "https://github.com/discourse/discourse/compare/#{base}...#{branch}?#{params.to_query}", + exception: true, + ) + end + + puts "Do not merge the PR via the GitHub web interface. Get it approved, then come back here to continue." +end + +def fastforward(base:, branch:) + if dry_run? + puts "[DRY RUN] Skipping fastforward of #{base}" + return + end + + confirm "Ready to merge? This will fast-forward #{base} to #{branch}" + begin + git "push", "origin", "#{branch}:#{base}" + rescue => e + raise <<~MSG + #{e.message} + Error occured during fastforward. Maybe another commit was added to `#{base}` since the PR was created, or maybe the PR wasn't approved. + Don't worry, this is not unusual. To update the branch and try again, rerun this script again. The existing PR and approval will be reused. + MSG + end + puts "Merge successful" +end + +def stage_tags(commits) + puts "Staging tags locally..." + commits.each do |commit| + commit.tags.each { |tag| git "tag", "-f", "-a", tag.name, "-m", tag.message, commit.ref } + end +end + +def push_tags(commits) + tag_names = commits.flat_map { |commit| commit.tags.map(&:name) } + + if dry_run? + puts "[DRY RUN] Skipping pushing tags to origin (#{tag_names.join(", ")})" + return + end + + confirm "Ready to push tags #{tag_names.join(", ")} to origin?" + tag_names.each { |tag_name| git "push", "-f", "origin", "refs/tags/#{tag_name}" } +end + +def with_clean_worktree(origin_branch) + origin_url = git("remote", "get-url", "origin").strip + + if !test_mode? && !origin_url.include?("discourse/discourse") + raise "Expected 'origin' remote to point to discourse/discourse (got #{origin_url})" + end + + git "fetch", "origin", origin_branch + path = "#{Rails.root}/tmp/version-bump-worktree-#{SecureRandom.hex}" + begin + FileUtils.mkdir_p(path) + git "worktree", "add", path, "origin/#{origin_branch}" + Dir.chdir(path) { yield } # rubocop:disable Discourse/NoChdir + ensure + puts "Cleaning up temporary worktree..." + git "worktree", "remove", "--force", path, silent: true, allow_failure: true + FileUtils.rm_rf(path) + end +end + +desc "Stage commits for a beta version bump (e.g. beta1.dev -> beta1 -> beta2.dev). A PR will be created for approval, then the script will prompt to perform the release" +task "version_bump:beta" do + branch = "version_bump/beta" + base = "main" + + with_clean_worktree(base) do + current_version = parse_current_version + + commits = + if current_version.start_with?("3.1") + # Legacy strategy - no `-dev` suffix + next_version = current_version.sub(/beta(\d+)/) { "beta#{$1.to_i + 1}" } + + [ + PlannedCommit.new( + version: next_version, + tags: [ + PlannedTag.new(name: "beta", message: "latest beta release"), + PlannedTag.new(name: "latest-release", message: "latest release"), + PlannedTag.new(name: "v#{next_version}", message: "version #{next_version}"), + ], + ), + ] + else + raise "Expected current version to end in -dev" if !current_version.end_with?("-dev") + + beta_release_version = current_version.sub("-dev", "") + next_dev_version = current_version.sub(/beta(\d+)/) { "beta#{$1.to_i + 1}" } + + [ + PlannedCommit.new( + version: beta_release_version, + tags: [ + PlannedTag.new(name: "beta", message: "latest beta release"), + PlannedTag.new(name: "latest-release", message: "latest release"), + PlannedTag.new( + name: "v#{beta_release_version}", + message: "version #{beta_release_version}", + ), + ], + ), + PlannedCommit.new(version: next_dev_version), + ] + end + + make_commits(commits: commits, branch: branch, base: base) + fastforward(base: base, branch: branch) + stage_tags(commits) + push_tags(commits) + end + + puts "Done!" +end + +desc "Stage commits for minor stable version bump (e.g. 3.1.1 -> 3.1.2). A PR will be created for approval, then the script will prompt to perform the release" +task "version_bump:minor_stable" do + base = "stable" + branch = "version_bump/stable" + + with_clean_worktree(base) do + current_version = parse_current_version + if current_version !~ /^(\d+)\.(\d+)\.(\d+)$/ + raise "Expected current stable version to be in the form X.Y.Z. It was #{current_version}" + end + + new_version = current_version.sub(/\.(\d+)\z/) { ".#{$1.to_i + 1}" } + + commits = [ + PlannedCommit.new( + version: new_version, + tags: [PlannedTag.new(name: "v#{new_version}", message: "version #{new_version}")], + ), + ] + + make_commits(commits: commits, branch: branch, base: base) + fastforward(base: base, branch: branch) + stage_tags(commits) + push_tags(commits) + end + + puts "Done!" +end + +desc "Stage commits for a major version bump (e.g. 3.1.0.beta6-dev -> 3.1.0.beta6 -> 3.1.0 -> 3.2.0.beta1-dev). A PR will be created for approval, then the script will merge to `main`. Should be passed a version number for the next stable version (e.g. 3.2.0)" +task "version_bump:major_stable_prepare", [:next_major_version_number] do |t, args| + unless args[:next_major_version_number] =~ /\A\d+\.\d+\.\d+\z/ + raise "Expected next_major_version number to be in the form X.Y.Z" + end + + base = "main" + branch = "version_bump/beta" + + with_clean_worktree(base) do + current_version = parse_current_version + + # special case for moving away from the 'legacy' release system where we don't use the `-dev` suffix + is_31_release = args[:next_major_version_number] == "3.2.0" + + if !current_version.end_with?("-dev") && !is_31_release + raise "Expected current version to end in -dev" + end + + beta_release_version = + if is_31_release + # The 3.1.0 beta series didn't use the -dev suffix + current_version.sub(/beta(\d+)/) { "beta#{$1.to_i + 1}" } + else + current_version.sub("-dev", "") + end + + next_dev_version = args[:next_major_version_number] + ".beta1-dev" + + final_beta_release = + PlannedCommit.new( + version: beta_release_version, + tags: [ + PlannedTag.new(name: "beta", message: "latest beta release"), + PlannedTag.new(name: "latest-release", message: "latest release"), + PlannedTag.new( + name: "v#{beta_release_version}", + message: "version #{beta_release_version}", + ), + ], + ) + + commits = [final_beta_release, PlannedCommit.new(version: next_dev_version)] + + make_commits(commits: commits, branch: branch, base: base) + fastforward(base: base, branch: branch) + stage_tags(commits) + push_tags(commits) + + puts <<~MSG + The #{base} branch is now ready for a stable release. + Now run this command to merge the release into the stable branch: + bin/rake "version_bump:major_stable_merge[v#{beta_release_version}]" + MSG + end +end + +desc <<~DESC + Stage the merge of a stable version bump into the stable branch. + A PR will be created for approval, then the script will merge to `stable`. + Should be passed the ref of the beta release which should be promoted to stable + (output from the version_bump:major_stable_prepare rake task) + e.g.: + bin/rake "version_bump:major_stable_merge[v3.1.0.beta12]" +DESC +task "version_bump:major_stable_merge", [:version_bump_ref] do |t, args| + merge_ref = args[:version_bump_ref] + raise "Must pass version_bump_ref" unless merge_ref.present? + + git "fetch", "origin", merge_ref + raise "Unknown version_bump_ref: #{merge_ref.inspect}" unless ref_exists?(merge_ref) + + base = "stable" + branch = "version_bump/stable" + + with_clean_worktree(base) do + git "branch", "-D", branch if ref_exists?(branch) + git "checkout", "-b", branch + + git "merge", "--no-commit", merge_ref, allow_failure: true + + out, status = Open3.capture2e "git diff --binary #{merge_ref} | patch -p1 -R" + raise "Error applying diff\n#{out}}" unless status.success? + + git "add", "." + + merged_version = parse_current_version + git "commit", "-m", "Merge v#{merged_version} into #{base}" + + diff_to_base = git("diff", merge_ref).strip + raise "There are diffs remaining to #{merge_ref}" unless diff_to_base.empty? + + stable_release_version = merged_version.sub(/\.beta\d+\z/, "") + stable_release_commit = + PlannedCommit.new( + version: stable_release_version, + tags: [ + PlannedTag.new( + name: "v#{stable_release_version}", + message: "version #{stable_release_version}", + ), + ], + ) + stable_release_commit.perform! + + git("push", "-f", "--set-upstream", "origin", branch) + + make_pr(base: base, branch: branch, title: "Merge v#{merged_version} into #{base}") + fastforward(base: base, branch: branch) + stage_tags([stable_release_commit]) + push_tags([stable_release_commit]) + end +end + +desc <<~DESC + squash-merge many security fixes into a single branch for review/merge. + Pass a list of comma-separated branches in the SECURITY_FIX_REFS env var, including the remote name. + Pass the name of the destination branch as the argument of the rake task. + e.g. + SECURITY_FIX_REFS='privatemirror/mybranch1,privatemirror/mybranch2' bin/rake "version_bump:stage_security_fixes[main]" +DESC +task "version_bump:stage_security_fixes", [:base] do |t, args| + base = args[:base] + raise "Unknown base: #{base.inspect}" unless %w[stable main].include?(base) + + fix_refs = ENV["SECURITY_FIX_REFS"]&.split(",").map(&:strip) + raise "No branches specified in SECURITY_FIX_REFS env" if fix_refs.nil? || fix_refs.empty? + + fix_refs.each do |ref| + if !ref.include?("/") + raise "Ref #{ref} did not specify an origin. Please specify the origin, e.g. privatemirror/mybranch" + end + end + + puts "Staging security fixes for #{base} branch: #{fix_refs.inspect}" + + branch = "security/#{base}-security-fixes" + + with_clean_worktree(base) do + git "branch", "-D", branch if ref_exists?(branch) + git "checkout", "-b", branch + + fix_refs.each do |ref| + origin, origin_branch = ref.split("/", 2) + git "fetch", origin, origin_branch + + first_commit_on_branch = git("log", "--format=%H", "origin/#{base}..#{ref}").lines.last.strip + author = git("log", "-n", "1", "--format=%an <%ae>", first_commit_on_branch).strip + message = git("log", "-n", "1", "--format=%B", first_commit_on_branch).strip + + git "merge", "--squash", ref + git "commit", "--author", author, "-m", message + end + + puts "Finished merging commits into a locally-staged #{branch} branch. Git log is:" + puts git("log", "origin/#{base}..#{branch}") + + confirm "Check the log above. Ready to push this branch to the origin and create a PR?" + git("push", "-f", "--set-upstream", "origin", branch) + + make_pr(base: base, branch: branch, title: "Security fixes for #{base}") + fastforward(base: base, branch: branch) + end +end diff --git a/spec/tasks/version_bump_spec.rb b/spec/tasks/version_bump_spec.rb new file mode 100644 index 00000000000..b40cc63fccb --- /dev/null +++ b/spec/tasks/version_bump_spec.rb @@ -0,0 +1,225 @@ +# frozen_string_literal: true + +def run(*args) + out, err, status = Open3.capture3(*args) + raise "Command failed: #{args.inspect}\n#{out}\n#{err}" unless status.success? + out +end + +def fake_version_rb(version) + File.read("#{Rails.root}/lib/version.rb").sub(/STRING = ".*"/, "STRING = \"#{version}\"") +end + +RSpec.describe "tasks/version_bump" do + let(:tmpdir) { Dir.mktmpdir } + let(:origin_path) { "#{tmpdir}/origin-repo" } + let(:local_path) { "#{tmpdir}/local-repo" } + + before do + ENV["RUNNING_VERSION_BUMP_IN_RSPEC_TESTS"] = "1" + + Rake::Task.clear + Discourse::Application.load_tasks + + FileUtils.mkdir_p origin_path + + Dir.chdir(origin_path) do + FileUtils.mkdir_p "lib" + FileUtils.mkdir_p "tmp" + + File.write(".gitignore", "tmp\n") + File.write("lib/version.rb", fake_version_rb("3.2.0.beta1-dev")) + + run "git", "init" + run "git", "checkout", "-b", "main" + run "git", "add", "." + run "git", "commit", "-m", "Initial commit" + + run "git", "checkout", "-b", "stable" + File.write("#{origin_path}/lib/version.rb", fake_version_rb("3.1.2")) + run "git", "add", "." + run "git", "commit", "-m", "Previous stable version bump" + + run "git", "checkout", "main" + run "git", "config", "receive.denyCurrentBranch", "ignore" + end + + run "git", "clone", "-b", "main", origin_path, local_path + end + + after do + FileUtils.remove_entry(tmpdir) + ENV.delete("RUNNING_VERSION_BUMP_IN_RSPEC_TESTS") + end + + it "can bump the beta version with version_bump:beta" do + Dir.chdir(local_path) { capture_stdout { Rake::Task["version_bump:beta"].invoke } } + + Dir.chdir(origin_path) do + # Commits are present with correct messages + expect(run("git", "log", "--pretty=%s").lines.map(&:strip)).to eq( + ["Bump version to v3.2.0.beta2-dev", "Bump version to v3.2.0.beta1", "Initial commit"], + ) + + # Expected tags present + expect(run("git", "tag").lines.map(&:strip)).to contain_exactly( + "latest-release", + "beta", + "v3.2.0.beta1", + ) + + # Tags are all present and attached to the correct commit + %w[latest-release beta v3.2.0.beta1].each do |tag_name| + expect(run("git", "log", "--pretty=%s", "-1", tag_name).strip).to eq( + "Bump version to v3.2.0.beta1", + ) + end + + # Version numbers in version.rb are correct at all commits + expect(run "git", "show", "HEAD", "lib/version.rb").to include('STRING = "3.2.0.beta2-dev"') + expect(run "git", "show", "HEAD~1", "lib/version.rb").to include('STRING = "3.2.0.beta1"') + expect(run "git", "show", "HEAD~2", "lib/version.rb").to include('STRING = "3.2.0.beta1-dev"') + end + end + + it "can perform a minor stable bump with version_bump:minor_stable" do + Dir.chdir(local_path) { capture_stdout { Rake::Task["version_bump:minor_stable"].invoke } } + + Dir.chdir(origin_path) do + # No commits on main branch + expect(run("git", "log", "--pretty=%s").lines.map(&:strip)).to eq(["Initial commit"]) + + # Expected tags present + expect(run("git", "tag").lines.map(&:strip)).to eq(["v3.1.3"]) + + run "git", "checkout", "stable" + + # Correct commits on stable branch + expect(run("git", "log", "--pretty=%s").lines.map(&:strip)).to eq( + ["Bump version to v3.1.3", "Previous stable version bump", "Initial commit"], + ) + + # Tag points to correct commit + expect(run("git", "log", "--pretty=%s", "-1", "v3.1.3").strip).to eq("Bump version to v3.1.3") + + # Version numbers in version.rb are correct at all commits + expect(run "git", "show", "HEAD", "lib/version.rb").to include('STRING = "3.1.3"') + expect(run "git", "show", "HEAD~1", "lib/version.rb").to include('STRING = "3.1.2"') + end + end + + it "can prepare a major stable bump with version_bump:major_stable_prepare" do + Dir.chdir(local_path) do + capture_stdout { Rake::Task["version_bump:major_stable_prepare"].invoke("3.3.0") } + end + + Dir.chdir(origin_path) do + # Commits are present with correct messages + expect(run("git", "log", "--pretty=%s").lines.map(&:strip)).to eq( + ["Bump version to v3.3.0.beta1-dev", "Bump version to v3.2.0.beta1", "Initial commit"], + ) + + # Expected tags present + expect(run("git", "tag").lines.map(&:strip)).to contain_exactly( + "latest-release", + "beta", + "v3.2.0.beta1", + ) + + # Tags are all present and attached to the correct commit + %w[latest-release beta v3.2.0.beta1].each do |tag_name| + expect(run("git", "log", "--pretty=%s", "-1", tag_name).strip).to eq( + "Bump version to v3.2.0.beta1", + ) + end + + # Version numbers in version.rb are correct at all commits + expect(run "git", "show", "HEAD:lib/version.rb").to include('STRING = "3.3.0.beta1-dev"') + expect(run "git", "show", "HEAD~1:lib/version.rb").to include('STRING = "3.2.0.beta1"') + expect(run "git", "show", "HEAD~2:lib/version.rb").to include('STRING = "3.2.0.beta1-dev"') + + # No changes to stable branch + expect(run("git", "log", "--pretty=%s", "stable").lines.map(&:strip)).to eq( + ["Previous stable version bump", "Initial commit"], + ) + end + end + + it "can merge a stable release commit into the stable branch with version_bump:major_stable_merge" do + Dir.chdir(local_path) do + # Prepare first, and find sha1 in output + output = capture_stdout { Rake::Task["version_bump:major_stable_prepare"].invoke("3.3.0") } + stable_bump_commit = output[/major_stable_merge\[(.*)\]/, 1] + capture_stdout { Rake::Task["version_bump:major_stable_merge"].invoke(stable_bump_commit) } + end + + Dir.chdir(origin_path) do + # Commits on stable branch are present with correct messages + expect(run("git", "log", "--pretty=%s", "stable").lines.map(&:strip)).to contain_exactly( + "Merge v3.2.0.beta1 into stable", + "Bump version to v3.2.0", + "Previous stable version bump", + "Bump version to v3.2.0.beta1", + "Initial commit", + ) + + # Most recent commit is the version bump + expect(run("git", "log", "--pretty=%s", "-1", "stable").strip).to eq("Bump version to v3.2.0") + + # Second most recent commit is a merge commit + parents = run("git", "log", "--pretty=%P", "-n", "1", "stable~1").split(/\s+/).map(&:strip) + expect(parents.length).to eq(2) + + # With correct parents + parent_commit_messages = parents.map { |p| run("git", "log", "--pretty=%s", "-1", p).strip } + expect(parent_commit_messages).to contain_exactly( + "Bump version to v3.2.0.beta1", + "Previous stable version bump", + ) + + # Tag is applied to stable version bump commit + expect(run("git", "log", "--pretty=%s", "-1", "v3.2.0").strip).to eq("Bump version to v3.2.0") + end + end + + it "can stage a PR of multiple security fixes using version_bump:stage_security_fixes" do + Dir.chdir(origin_path) do + run "git", "checkout", "-b", "security-fix-one" + + File.write("firstfile.txt", "contents") + run "git", "add", "firstfile.txt" + run "git", "commit", "-m", "security fix one, commit one" + File.write("secondfile.txt", "contents") + run "git", "add", "secondfile.txt" + run "git", "commit", "-m", "security fix one, commit two" + + run "git", "checkout", "main" + run "git", "checkout", "-b", "security-fix-two" + File.write("somefile.txt", "contents") + run "git", "add", "somefile.txt" + run "git", "commit", "-m", "security fix two" + end + + Dir.chdir(local_path) do + output = + capture_stdout do + ENV["SECURITY_FIX_REFS"] = "origin/security-fix-one,origin/security-fix-two" + Rake::Task["version_bump:stage_security_fixes"].invoke("main") + ensure + ENV.delete("SECURITY_FIX_REFS") + end + end + + Dir.chdir(origin_path) do + # Check each fix has been added as a single commit, with the message matching the first commit on the branch + expect(run("git", "log", "--pretty=%s", "main").lines.map(&:strip)).to eq( + ["security fix two", "security fix one, commit one", "Initial commit"], + ) + + # Check all the files from both fixes are present + expect(run("git", "show", "main:somefile.txt")).to eq("contents") + expect(run("git", "show", "main:firstfile.txt")).to eq("contents") + expect(run("git", "show", "main:secondfile.txt")).to eq("contents") + end + end +end