discourse/spec/tasks/release_spec.rb

771 lines
23 KiB
Ruby

# frozen_string_literal: true
RSpec.describe "tasks/release" do
let(:tmpdir) { Dir.mktmpdir }
let(:origin_path) { "#{tmpdir}/origin-repo" }
let(:local_path) { "#{tmpdir}/local-repo" }
let(:git_tags) { git("tag").lines.map(&:strip) }
def git(*args)
out, err, status = Open3.capture3("git", *args)
raise "Command failed: git #{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
def commit_version(version)
File.write("lib/version.rb", fake_version_rb(version))
git "add", "."
git "commit", "-m", "version #{version}"
git("rev-parse", "HEAD").strip
end
def update_versions_json(overrides)
Dir.chdir(origin_path) do
git "checkout", "main"
current = JSON.parse(File.read("versions.json"))
File.write("versions.json", JSON.pretty_generate(current.merge(overrides)))
git "add", "versions.json"
git "commit", "-m", "Update versions.json"
end
end
before do
ENV["RUNNING_RELEASE_IN_RSPEC_TESTS"] = "1"
Rake::Task.tasks.each { |t| t.reenable }
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("2025.12.0-latest"))
versions =
(1..12).each_with_object({}) do |month, hash|
hash["2025.#{month}"] = { "released" => month <= 6, "esr" => [1, 7].include?(month) }
end
File.write("versions.json", JSON.pretty_generate(versions))
git "init"
git "config", "commit.gpgsign", "false"
git "checkout", "-b", "main"
git "add", "."
git "commit", "-m", "Initial commit"
git "checkout", "-b", "stable"
File.write("#{origin_path}/lib/version.rb", fake_version_rb("3.1.2"))
git "add", "."
git "commit", "-m", "Previous stable version bump"
git "checkout", "main"
git "config", "receive.denyCurrentBranch", "ignore"
end
git "clone", "-b", "main", origin_path, local_path
end
after do
FileUtils.remove_entry(tmpdir)
ENV.delete("RUNNING_RELEASE_IN_RSPEC_TESTS")
end
describe "release:maybe_tag_release" do
subject(:run_task) do
capture_stdout { invoke_rake_task("release:maybe_tag_release", commit_hash) }
end
let(:commit_hash) { commit_version(version) }
context "when commit is not on a release branch" do
let(:version) { "2025.6.0" }
it "does not create a tag" do
Dir.chdir(local_path) do
git "switch", "-c", "some-other-branch"
expect { run_task }.not_to change { git_tags }
end
end
end
context "when tag does not exist" do
let(:version) { "2025.3.0" }
let!(:commit_hash) { Dir.chdir(local_path) { commit_version(version) } }
it "creates the tag" do
Dir.chdir(local_path) do
git "branch", "release/2025.3"
expect(run_task).to include("Tagging release v2025.3.0")
expect(git_tags).to include("v2025.3.0")
end
end
end
context "when version is a pre-release" do
let(:version) { "2025.4.0-latest" }
it "tags the pre-release version" do
Dir.chdir(local_path) do
expect(run_task).to include("Tagging release v2025.4.0-latest")
expect(git_tags).to include("v2025.4.0-latest")
end
end
end
context "when version is a security patchlevel" do
let(:version) { "2025.4.0-latest.1" }
it "tags the patchlevel version" do
Dir.chdir(local_path) do
expect(run_task).to include("Tagging release v2025.4.0-latest.1")
expect(git_tags).to include("v2025.4.0-latest.1")
end
end
end
context "when tag already exists" do
let(:version) { "2025.5.0" }
let!(:commit_hash) { Dir.chdir(local_path) { commit_version(version) } }
it "skips tagging" do
Dir.chdir(local_path) do
git "branch", "release/2025.5"
git "tag", "-a", "v2025.5.0", "-m", "version 2025.5.0"
expect { run_task }.not_to change { git_tags }
end
end
end
end
describe "release:update_release_tags" do
subject(:run_task) do
capture_stdout { invoke_rake_task("release:update_release_tags", commit_hash) }
end
let(:commit_hash) { commit_version(version) }
context "when version is newer than latest release" do
let(:version) { "2025.6.0" }
it "creates release alias tags" do
Dir.chdir(local_path) do
run_task
expect(git_tags).to contain_exactly(*ReleaseUtils::RELEASE_TAGS)
end
end
end
context "when version is older than latest release" do
let(:version) { "2025.1.0" }
it "skips release tags" do
Dir.chdir(local_path) do
expect(run_task).to include("older than latest release")
expect(git_tags).not_to include(*ReleaseUtils::RELEASE_TAGS)
end
end
end
context "when version is a non-ESR release" do
let(:version) { "2025.6.0" }
it "does not create ESR tags" do
Dir.chdir(local_path) do
run_task
expect(git_tags).not_to include(*ReleaseUtils::ESR_TAGS)
end
end
end
context "when version is in the latest released ESR series" do
let(:version) { "2025.7.1" }
before { update_versions_json({ "2025.7" => { "released" => true, "esr" => true } }) }
it "creates both release and ESR alias tags" do
Dir.chdir(local_path) do
run_task
expect(git_tags).to include(*ReleaseUtils::RELEASE_TAGS, *ReleaseUtils::ESR_TAGS)
end
end
end
context "when version is newer than the latest ESR but not an ESR itself" do
let(:version) { "2025.8.0" }
before do
update_versions_json(
{
"2025.7" => {
"released" => true,
"esr" => true,
},
"2025.8" => {
"released" => true,
"esr" => false,
},
},
)
end
it "creates release tags but not ESR tags" do
Dir.chdir(local_path) do
run_task
expect(git_tags).to contain_exactly(*ReleaseUtils::RELEASE_TAGS)
end
end
end
end
describe "release:maybe_cut_branch" do
subject(:run_task) do
capture_stdout { invoke_rake_task("release:maybe_cut_branch", latest_hash) }
end
context "when development cycle changes" do
let!(:previous_hash) { Dir.chdir(local_path) { commit_version(previous_version) } }
let!(:latest_hash) { Dir.chdir(local_path) { commit_version(current_version) } }
let(:release_branch) { "release/#{previous_version.split(".").first(2).join(".")}" }
let(:parent_of_tip) do
Dir.chdir(origin_path) do
git "checkout", release_branch
git("rev-parse", "HEAD^1").strip
end
end
let(:version_on_branch) do
Dir.chdir(origin_path) do
git "checkout", release_branch
File.read("lib/version.rb")[/STRING = "(.*)"/, 1]
end
end
context "when going from one minor to another" do
let(:previous_version) { "2025.1.0-latest" }
let(:current_version) { "2025.2.0-latest" }
it "creates a release branch based on the previous commit" do
Dir.chdir(local_path) { run_task }
expect(parent_of_tip).to eq(previous_hash)
end
it "strips the -latest suffix" do
Dir.chdir(local_path) { run_task }
expect(version_on_branch).to eq("2025.1.0")
end
end
context "when going from a patchlevel to a new minor" do
let(:previous_version) { "2025.11.0-latest.2" }
let(:current_version) { "2025.12.0-latest" }
it "creates a release branch based on the previous commit" do
Dir.chdir(local_path) { run_task }
expect(parent_of_tip).to eq(previous_hash)
end
end
end
context "when development cycle stays the same" do
def origin_branches
Dir.chdir(origin_path) { git("branch").lines.map(&:strip) }
end
context "when bumping from latest to latest.1" do
let!(:latest_hash) { Dir.chdir(local_path) { commit_version("2025.12.0-latest.1") } }
it "does not create a branch" do
Dir.chdir(local_path) { expect { run_task }.not_to change { origin_branches } }
end
end
context "when bumping from latest.1 to latest.2" do
let!(:intermediate_hash) { Dir.chdir(local_path) { commit_version("2025.12.0-latest.1") } }
let!(:latest_hash) { Dir.chdir(local_path) { commit_version("2025.12.0-latest.2") } }
it "does not create a branch" do
Dir.chdir(local_path) { expect { run_task }.not_to change { origin_branches } }
end
end
end
end
describe "release:prepare_next_version" do
subject(:run_task) do
Dir.chdir(local_path) do
freeze_time(frozen_time) do
capture_stdout { invoke_rake_task("release:prepare_next_version") }
end
end
end
let(:bumped_version) do
on_version_bump_branch { File.read("lib/version.rb")[/STRING = "(.*)"/, 1] }
end
def on_version_bump_branch
Dir.chdir(origin_path) do
git "reset", "--hard"
git "checkout", "version-bump/main"
yield
end
end
context "with a custom version on main" do
before do
Dir.chdir(local_path) do
commit_version(initial_version)
git "push", "origin", "main"
end
end
context "when current version is older than target month" do
let(:frozen_time) { "2025-09-15" }
let(:initial_version) { "2024.1.0-latest" }
it "bumps to the current month" do
run_task
expect(bumped_version).to eq("2025.9.0-latest")
end
end
context "when current version matches target month" do
let(:frozen_time) { "2025-10-15" }
let(:initial_version) { "2025.10.0-latest" }
it "increments to next month" do
run_task
expect(bumped_version).to eq("2025.11.0-latest")
end
end
context "when current version has a security patchlevel" do
let(:frozen_time) { "2025-10-15" }
let(:initial_version) { "2025.10.0-latest.2" }
it "increments to next month and drops patchlevel" do
run_task
expect(bumped_version).to eq("2025.11.0-latest")
end
end
context "when PR creation succeeds" do
let(:frozen_time) { "2025-10-15" }
let(:initial_version) { "2025.5.0-latest" }
let(:commit_message) { on_version_bump_branch { git("log", "-1", "--pretty=%B").strip } }
before do
allow(ReleaseUtils).to receive(:gh).with("pr", "create", any_args).and_return(true)
run_task
end
it "includes the version bump description" do
expect(commit_message).to include("Begin development of v2025.10.0-latest")
expect(commit_message).to include(
"Merging this will trigger the creation of a `release/2025.5` branch on the preceding commit.",
)
end
it "creates a PR" do
commit_message
expect(ReleaseUtils).to have_received(:gh).with(
"pr",
"create",
"--base",
"main",
"--head",
"version-bump/main",
"--title",
"DEV: Begin development of v2025.10.0-latest",
"--body",
a_string_including(
"Merging this will trigger the creation of a `release/2025.5` branch",
),
"--label",
ReleaseUtils::PR_LABEL,
)
end
end
context "when PR creation fails" do
let(:frozen_time) { "2025-10-15" }
let(:initial_version) { "2025.5.0-latest" }
before do
allow(ReleaseUtils).to receive(:gh).with("pr", "create", any_args).and_return(false)
allow(ReleaseUtils).to receive(:gh).with("pr", "edit", any_args).and_return(true)
end
it "falls back to editing the PR" do
run_task
expect(ReleaseUtils).to have_received(:gh).with(
"pr",
"edit",
"version-bump/main",
"--title",
"DEV: Begin development of v2025.10.0-latest",
"--body",
a_string_including(
"Merging this will trigger the creation of a `release/2025.5` branch",
),
"--add-label",
ReleaseUtils::PR_LABEL,
)
end
end
end
context "when incrementing past December" do
let(:frozen_time) { "2025-12-15" }
it "rolls over to next year" do
run_task
expect(bumped_version).to eq("2026.1.0-latest")
end
end
context "when updating versions.json" do
let(:frozen_time) { "2025-12-28" }
let(:versions_json) { on_version_bump_branch { JSON.parse(File.read("versions.json")) } }
it "adds new version entry" do
run_task
expect(versions_json["2026.1"]).to eq(
{
"developmentStartDate" => "2025-12-28",
"releaseDate" => "2026-01",
"supportEndDate" => "2026-09",
"released" => false,
"esr" => true,
"supported" => true,
},
)
end
it "marks previous version as released" do
run_task
expect(versions_json["2025.12"]).to include(
"released" => true,
"releaseDate" => "2025-12-28",
)
end
end
end
describe "release:stage_security_fixes" do
subject(:run_task) do
Dir.chdir(local_path) do
capture_stdout { invoke_rake_task("release:stage_security_fixes", base_branch) }
end
end
let(:base_branch) { "main" }
let(:origin_main_commits) do
Dir.chdir(origin_path) { git("log", "--pretty=%s", base_branch).lines.map(&:strip) }
end
let(:pr_list_json) do
[
{
"number" => 1,
"title" => "Security fix one",
"body" =>
"Description for fix one\nhttps://github.com/discourse/discourse/security/advisories/GHSA-1111-2222-3333",
"headRefName" => "security-fix-one",
},
{
"number" => 2,
"title" => "Security fix two",
"body" =>
"https://github.com/discourse/discourse/security/advisories/GHSA-aaaa-bbbb-cccc",
"headRefName" => "security-fix-two",
},
].to_json
end
def origin_file(path)
Dir.chdir(origin_path) { git("show", "#{base_branch}:#{path}") }
end
before do
allow(ReleaseUtils).to receive(:gh).and_call_original
allow(ReleaseUtils).to receive(:gh).with(
"pr",
"list",
"--repo",
"discourse/discourse-private-mirror",
"--base",
base_branch,
"--state",
"open",
"--json",
"number,title,body,headRefName",
"--limit",
"100",
capture: true,
).and_return(pr_list_json)
ENV["SECURITY_FIX_GHSA_IDS"] = "GHSA-1111-2222-3333,GHSA-aaaa-bbbb-cccc"
Dir.chdir(origin_path) do
git "checkout", "-b", "security-fix-one"
File.write("firstfile.txt", "contents")
git "add", "firstfile.txt"
git "commit", "-m", "security fix one, commit one"
File.write("secondfile.txt", "contents")
git "add", "secondfile.txt"
git "commit", "-m", "security fix one, commit two"
git "checkout", "main"
git "checkout", "-b", "security-fix-two"
File.write("somefile.txt", "contents")
git "add", "somefile.txt"
git "commit", "-m", "security fix two"
end
# Add privatemirror as alias for origin so fetch works
Dir.chdir(local_path) { git "remote", "add", "privatemirror", origin_path }
end
after { ENV.delete("SECURITY_FIX_GHSA_IDS") }
context "when accepting the version bump" do
before { run_task }
it "squash-merges security fix PRs in order" do
expect(origin_main_commits).to eq(
[
"DEV: Bump development branch to v2025.12.0-latest.1",
"Security fix two",
"Security fix one",
"Initial commit",
],
)
end
it "includes files from all security fixes" do
expect(origin_file("firstfile.txt")).to eq("contents")
expect(origin_file("secondfile.txt")).to eq("contents")
expect(origin_file("somefile.txt")).to eq("contents")
end
it "bumps the development version" do
expect(origin_file("lib/version.rb")).to include('STRING = "2025.12.0-latest.1"')
end
end
context "when declining the version bump" do
before do
allow(ReleaseUtils).to receive(:confirm).and_return(false)
run_task
end
it "squash-merges security fix PRs without a version bump" do
expect(origin_main_commits).to eq(
["Security fix two", "Security fix one", "Initial commit"],
)
end
it "does not modify the version" do
expect(origin_file("lib/version.rb")).to include('STRING = "2025.12.0-latest"')
end
end
context "when targeting a release branch" do
let(:base_branch) { "release/2025.6" }
let(:pr_list_json) do
[
{
"number" => 1,
"title" => "Security fix for release branch",
"body" =>
"https://github.com/discourse/discourse/security/advisories/GHSA-release-test-1234",
"headRefName" => "security-fix-for-release-branch",
},
].to_json
end
before do
ENV["SECURITY_FIX_GHSA_IDS"] = "GHSA-release-test-1234"
Dir.chdir(origin_path) do
git "checkout", "-b", base_branch
File.write("lib/version.rb", fake_version_rb("2025.6.0"))
git "add", "lib/version.rb"
git "commit", "-m", "Initial release branch commit"
git "config", "receive.denyCurrentBranch", "ignore"
git "checkout", "main"
git "checkout", "-b", "security-fix-for-release-branch"
File.write("releasefile.txt", "contents")
git "add", "releasefile.txt"
git "commit", "-m", "security fix for release branch"
end
Dir.chdir(local_path) { git "fetch", "origin" }
end
it "bumps the patch version" do
run_task
expect(origin_file("lib/version.rb")).to include('STRING = "2025.6.1"')
end
it "squash-merges security fixes and adds version bump commit" do
run_task
expect(origin_main_commits.first(3)).to eq(
[
"DEV: Bump release branch to v2025.6.1",
"Security fix for release branch",
"Initial release branch commit",
],
)
end
end
end
describe "release:update_security_advisories" do
subject(:run_task) do
Dir.chdir(local_path) do
capture_stdout { invoke_rake_task("release:update_security_advisories") }
end
end
let(:draft_advisories) do
[
{ "ghsa_id" => "GHSA-1234-5678-9abc", "state" => "draft", "summary" => "Security issue 1" },
{ "ghsa_id" => "GHSA-abcd-efgh-ijkl", "state" => "draft", "summary" => "Security issue 2" },
]
end
let(:gh_api_response) { JSON.generate([draft_advisories]) }
let(:updated_advisories) { [] }
before do
allow(ReleaseUtils).to receive(:gh) do |*args, **kwargs|
if args[0..3] ==
%w[api repos/discourse/discourse/security-advisories?state=draft --paginate --slurp]
gh_api_response
elsif args[0] == "api" && args[1].match?(%r{security-advisories/GHSA-}) &&
args[2] == "--method"
updated_advisories << JSON.parse(kwargs[:input])
"{}"
else
true
end
end
Dir.chdir(origin_path) do
git "checkout", "-b", "release/2025.5"
File.write("lib/version.rb", fake_version_rb("2025.5.2"))
git "add", "lib/version.rb"
git "commit", "-m", "version 2025.5.2"
git "checkout", "-b", "release/2025.6"
File.write("lib/version.rb", fake_version_rb("2025.6.0"))
git "add", "lib/version.rb"
git "commit", "-m", "version 2025.6.0"
git "checkout", "main"
end
update_versions_json(
{
"2025.5" => {
"released" => true,
"supported" => true,
},
"2025.6" => {
"released" => true,
"supported" => true,
},
"2025.12" => {
"released" => false,
"supported" => true,
},
},
)
end
context "when choosing intermediate release for latest" do
before do
prompt = instance_double(TTY::Prompt)
allow(TTY::Prompt).to receive(:new).and_return(prompt)
allow(prompt).to receive(:select).and_return(:intermediate)
end
it "updates advisories with correct patched versions" do
run_task
expect(updated_advisories.size).to eq(2)
vulnerabilities = updated_advisories.first["vulnerabilities"]
expect(vulnerabilities.size).to eq(3)
expect(vulnerabilities[0]).to include(
"vulnerable_version_range" => ">= 0",
"patched_versions" => "2025.5.3",
)
expect(vulnerabilities[1]).to include(
"vulnerable_version_range" => ">= 2025.6.0-latest",
"patched_versions" => "2025.6.1",
)
expect(vulnerabilities[2]).to include(
"vulnerable_version_range" => ">= 2025.12.0-latest",
"patched_versions" => "2025.12.0-latest.1",
)
end
end
context "when choosing monthly release for latest" do
before do
prompt = instance_double(TTY::Prompt)
allow(TTY::Prompt).to receive(:new).and_return(prompt)
allow(prompt).to receive(:select).and_return(:monthly)
end
it "uses the current patch version for latest" do
run_task
vulnerabilities = updated_advisories.first["vulnerabilities"]
expect(vulnerabilities[2]).to include(
"vulnerable_version_range" => ">= 2025.12.0-latest",
"patched_versions" => "2025.12.0",
)
end
end
context "when there are no draft advisories" do
let(:gh_api_response) { JSON.generate([[]]) }
it "outputs a message and does nothing" do
output = run_task
expect(output).to include("No draft advisories to update")
expect(updated_advisories).to be_empty
end
end
context "when advisories have DRAFT summary prefix" do
let(:draft_advisories) do
[
{
"ghsa_id" => "GHSA-1234-5678-9abc",
"state" => "draft",
"summary" => "DRAFT - placeholder",
},
]
end
it "skips advisories with DRAFT summary prefix" do
output = run_task
expect(output).to include("No draft advisories to update")
expect(updated_advisories).to be_empty
end
end
end
end