mirror of
https://gh.wpcy.net/https://github.com/discourse/discourse.git
synced 2026-05-01 14:54:42 +08:00
420 lines
14 KiB
Ruby
420 lines
14 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require "tty-prompt"
|
|
require_relative "../release_utils"
|
|
|
|
namespace :release do
|
|
desc "Check a commit hash and create a release branch if it's a trigger"
|
|
task "maybe_cut_branch", [:check_ref] do |t, args|
|
|
check_ref = args[:check_ref]
|
|
|
|
new_version = nil
|
|
previous_version = nil
|
|
|
|
ReleaseUtils.with_clean_worktree("main") do
|
|
ReleaseUtils.git("checkout", check_ref.to_s)
|
|
new_version = ReleaseUtils::Version.current
|
|
|
|
ReleaseUtils.git("checkout", "#{check_ref}^1")
|
|
previous_version = ReleaseUtils::Version.current
|
|
end
|
|
|
|
raise "Unexpected previous version" if !previous_version.development?
|
|
raise "Unexpected new version" if !new_version.development?
|
|
|
|
next "version has not changed" if new_version.same_development_cycle?(previous_version)
|
|
|
|
raise "New version is smaller than old version" if new_version < previous_version
|
|
|
|
branch_name = previous_version.branch_name
|
|
release_version = previous_version.release_version
|
|
|
|
ReleaseUtils.with_clean_worktree("main") do
|
|
ReleaseUtils.git("checkout", "#{check_ref}^1")
|
|
ReleaseUtils.git("checkout", "-b", branch_name)
|
|
|
|
ReleaseUtils.commit_version_bump(release_version, "DEV: Bump version to v#{release_version}")
|
|
|
|
puts "Created new branch #{branch_name} with version #{release_version}"
|
|
|
|
File.write(ENV["GITHUB_OUTPUT"] || "/dev/null", "new_branch_name=#{branch_name}\n", mode: "a")
|
|
|
|
if ReleaseUtils.dry_run?
|
|
puts "[DRY RUN] Skipping pushing branch #{branch_name} to origin"
|
|
else
|
|
ReleaseUtils.git("push", "--set-upstream", "origin", branch_name)
|
|
puts "Pushed branch #{branch_name} to origin"
|
|
end
|
|
end
|
|
|
|
puts "Done!"
|
|
end
|
|
|
|
desc "Maybe tag release"
|
|
task "maybe_tag_release", [:check_ref] do |t, args|
|
|
check_ref = args[:check_ref]
|
|
|
|
current_version =
|
|
ReleaseUtils.with_clean_worktree("main") do
|
|
ReleaseUtils.git "checkout", check_ref.to_s
|
|
release_branches =
|
|
ReleaseUtils
|
|
.git("branch", "-a", "--contains", check_ref, "release/*", "main")
|
|
.lines
|
|
.map(&:strip)
|
|
if release_branches.empty?
|
|
puts "Commit #{check_ref} is not on a release branch. Skipping"
|
|
next
|
|
end
|
|
|
|
ReleaseUtils::Version.current
|
|
end
|
|
next unless current_version
|
|
|
|
if ReleaseUtils.ref_exists?(current_version.tag_name)
|
|
puts "Tag #{current_version.tag_name} already exists, skipping"
|
|
else
|
|
puts "Tagging release #{current_version.tag_name}"
|
|
ReleaseUtils.git "tag", "-a", current_version.tag_name, "-m", "version #{current_version}"
|
|
|
|
if ReleaseUtils.dry_run?
|
|
puts "[DRY RUN] Skipping pushing tag to origin"
|
|
else
|
|
ReleaseUtils.git "push", "origin", "refs/tags/#{current_version.tag_name}"
|
|
end
|
|
end
|
|
|
|
puts "Done!"
|
|
end
|
|
|
|
desc "Update release/beta/latest-release tags to track latest release"
|
|
task "update_release_tags", [:check_ref] do |t, args|
|
|
check_ref = args[:check_ref]
|
|
|
|
current_version =
|
|
ReleaseUtils.with_clean_worktree("main") do
|
|
ReleaseUtils.git "checkout", check_ref.to_s
|
|
ReleaseUtils::Version.current
|
|
end
|
|
|
|
released_versions = ReleaseUtils.released_versions.map(&ReleaseUtils::Version.method(:new))
|
|
|
|
if released_versions.empty? || current_version >= released_versions.last
|
|
ReleaseUtils::RELEASE_TAGS.each do |synonym_tag|
|
|
message =
|
|
if synonym_tag == ReleaseUtils::PRIMARY_RELEASE_TAG
|
|
"latest release"
|
|
else
|
|
"backwards-compatibility alias for `#{ReleaseUtils::PRIMARY_RELEASE_TAG}` tag"
|
|
end
|
|
ReleaseUtils.git "tag", "-a", synonym_tag, "-m", message, "-f"
|
|
end
|
|
if ReleaseUtils.dry_run?
|
|
puts "[DRY RUN] Skipping pushing #{ReleaseUtils::RELEASE_TAGS.inspect} tags to origin"
|
|
else
|
|
ReleaseUtils.git "push",
|
|
"origin",
|
|
"-f",
|
|
*ReleaseUtils::RELEASE_TAGS.map { |tag| "refs/tags/#{tag}" }
|
|
end
|
|
else
|
|
puts "Current version #{current_version} is older than latest release #{released_versions.last}. Skipping."
|
|
end
|
|
|
|
# Update ESR tags if this version is in the latest released ESR series
|
|
released_esrs = ReleaseUtils.released_esrs.map(&ReleaseUtils::Version.method(:new))
|
|
if released_esrs.any? && current_version.same_series?(released_esrs.last)
|
|
ReleaseUtils::ESR_TAGS.each do |synonym_tag|
|
|
message =
|
|
if synonym_tag == ReleaseUtils::PRIMARY_ESR_TAG
|
|
"latest ESR release"
|
|
else
|
|
"backwards-compatibility alias for `#{ReleaseUtils::PRIMARY_ESR_TAG}` tag"
|
|
end
|
|
ReleaseUtils.git "tag", "-a", synonym_tag, "-m", message, "-f"
|
|
end
|
|
if ReleaseUtils.dry_run?
|
|
puts "[DRY RUN] Skipping pushing #{ReleaseUtils::ESR_TAGS.inspect} tags to origin"
|
|
else
|
|
ReleaseUtils.git "push",
|
|
"origin",
|
|
"-f",
|
|
*ReleaseUtils::ESR_TAGS.map { |tag| "refs/tags/#{tag}" }
|
|
end
|
|
end
|
|
|
|
puts "Done!"
|
|
end
|
|
|
|
desc "Prepare a version bump PR for `main`"
|
|
task "prepare_next_version" do |t, args|
|
|
pr_branch_name = "version-bump/main"
|
|
branch = "main"
|
|
|
|
ReleaseUtils.with_clean_worktree(branch) do
|
|
ReleaseUtils.git "branch", "-D", pr_branch_name if ReleaseUtils.ref_exists?(pr_branch_name)
|
|
ReleaseUtils.git "checkout", "-b", pr_branch_name
|
|
|
|
current_version = ReleaseUtils::Version.current
|
|
target_version = ReleaseUtils::Version.next
|
|
|
|
ReleaseUtils.write_version(target_version)
|
|
ReleaseUtils.update_versions_json(target_version.series)
|
|
ReleaseUtils.git "add", "lib/version.rb", "versions.json"
|
|
ReleaseUtils.git "commit",
|
|
"-m",
|
|
"DEV: Begin development of v#{target_version}\n\nMerging this will trigger the creation of a `#{current_version.branch_name}` branch on the preceding commit."
|
|
end
|
|
|
|
if ReleaseUtils.dry_run?
|
|
puts "[DRY RUN] Skipping pushing & PR for branch #{pr_branch_name}"
|
|
else
|
|
ReleaseUtils.git "push", "-f", "--set-upstream", "origin", pr_branch_name
|
|
ReleaseUtils.make_pr(base: branch, branch: pr_branch_name)
|
|
puts "Done! Branch #{pr_branch_name} has been pushed to origin and a pull request has been created."
|
|
end
|
|
end
|
|
|
|
desc <<~DESC
|
|
Stage security fixes from private-mirror PRs into a single branch for review/merge.
|
|
Fetches open PRs from private-mirror and presents an interactive selection.
|
|
e.g.
|
|
bin/rake "release:stage_security_fixes[main]"
|
|
bin/rake "release:stage_security_fixes[release/2025.11]"
|
|
DESC
|
|
task "stage_security_fixes", [:base] do |t, args|
|
|
base = args[:base]
|
|
raise "Unknown base: #{base.inspect}" unless base.start_with?("release/") || base == "main"
|
|
|
|
json_output =
|
|
ReleaseUtils.gh(
|
|
"pr",
|
|
"list",
|
|
"--repo",
|
|
"discourse/discourse-private-mirror",
|
|
"--base",
|
|
base,
|
|
"--state",
|
|
"open",
|
|
"--json",
|
|
"number,title,body,headRefName",
|
|
"--limit",
|
|
"100",
|
|
capture: true,
|
|
)
|
|
|
|
prs = JSON.parse(json_output)
|
|
raise "No open PRs found targeting #{base} on private-mirror" if prs.empty?
|
|
|
|
extract_ghsa_id = ->(body) do
|
|
return nil if body.nil?
|
|
match = body.match(%r{github\.com/discourse/discourse/security/advisories/(GHSA-[\w-]+)})
|
|
match&.[](1)
|
|
end
|
|
|
|
choices =
|
|
prs.map do |pr|
|
|
pr.slice("number", "title", "body", "headRefName").merge(
|
|
"ghsa_id" => extract_ghsa_id.call(pr["body"]),
|
|
)
|
|
end
|
|
|
|
selected =
|
|
if (ghsa_ids = ENV["SECURITY_FIX_GHSA_IDS"])
|
|
requested = ghsa_ids.split(",").map(&:strip)
|
|
choices.select { |pr| pr["ghsa_id"] && requested.include?(pr["ghsa_id"]) }
|
|
else
|
|
prompt = TTY::Prompt.new
|
|
prompt_choices =
|
|
choices.map do |pr|
|
|
ghsa_label = pr["ghsa_id"] || "GHSA MISSING"
|
|
{ name: "##{pr["number"]} [#{ghsa_label}]: #{pr["title"]}", value: pr }
|
|
end
|
|
prompt.multi_select(
|
|
"Select security fix PRs to include (space to toggle, enter to finish):",
|
|
prompt_choices,
|
|
default: [],
|
|
per_page: prompt_choices.size,
|
|
)
|
|
end
|
|
raise "No PRs selected" if selected.empty?
|
|
|
|
puts "Staging security fixes for #{base} branch: #{selected.map { |pr| pr["headRefName"] }.inspect}"
|
|
|
|
branch = "security/#{base}-security-fixes"
|
|
|
|
ReleaseUtils.with_clean_worktree(base) do
|
|
selected.each do |pr|
|
|
ReleaseUtils.git "fetch", "privatemirror", pr["headRefName"]
|
|
|
|
ReleaseUtils.git "merge", "--squash", "privatemirror/#{pr["headRefName"]}"
|
|
|
|
commit_message = "#{pr["title"]}\n\n#{pr["body"]}".strip
|
|
author =
|
|
ReleaseUtils.git(
|
|
"log",
|
|
"-1",
|
|
"--format=%an <%ae>",
|
|
"privatemirror/#{pr["headRefName"]}",
|
|
).strip
|
|
|
|
ReleaseUtils.git "commit", "-m", commit_message, "--author", author
|
|
end
|
|
|
|
if base == "main" &&
|
|
ReleaseUtils.confirm(
|
|
"Bump the `latest` branch revision to #{ReleaseUtils::Version.current.next_revision}? This should only be done for security-fix merges which are not part of a regular monthly release.",
|
|
)
|
|
new_version = ReleaseUtils::Version.current.next_revision
|
|
ReleaseUtils.commit_version_bump(
|
|
new_version,
|
|
"DEV: Bump development branch to v#{new_version}",
|
|
)
|
|
elsif base.start_with?("release/")
|
|
new_version = ReleaseUtils::Version.current.next_patch
|
|
ReleaseUtils.commit_version_bump(new_version, "DEV: Bump release branch to v#{new_version}")
|
|
end
|
|
|
|
puts "Finished merging commits into a locally-staged branch. Git log is:"
|
|
puts ReleaseUtils.git("log", "origin/#{base}..HEAD")
|
|
|
|
ReleaseUtils.confirm_or_abort "Check the log above. Ready to push this branch to the origin and create a PR?"
|
|
ReleaseUtils.git("push", "-f", "origin", "HEAD:refs/heads/#{branch}")
|
|
|
|
ReleaseUtils.make_pr(
|
|
base: base,
|
|
branch: branch,
|
|
title: "Security fixes for #{base}",
|
|
body: <<~MD,
|
|
> :warning: This PR should not be merged via the GitHub web interface
|
|
>
|
|
> It should only be merged using the associated `bin/rake release:stage_security_fixes` task.
|
|
MD
|
|
draft: true,
|
|
)
|
|
puts "Do not merge the PR via the GitHub web interface. Get it approved, then come back here to continue."
|
|
ReleaseUtils.merge_pr(base: base, branch: branch)
|
|
end
|
|
|
|
if selected.any?
|
|
puts "Closing associated PRs in private-mirror..."
|
|
selected.each do |pr|
|
|
ReleaseUtils.gh(
|
|
"pr",
|
|
"close",
|
|
pr["number"].to_s,
|
|
"--repo",
|
|
"discourse/discourse-private-mirror",
|
|
"--delete-branch",
|
|
)
|
|
end
|
|
end
|
|
end
|
|
|
|
desc "Update advisory affected versions on GitHub for all supported Discourse versions"
|
|
task "update_security_advisories" do
|
|
advisories_base = "repos/discourse/discourse/security-advisories"
|
|
|
|
puts "Fetching draft security advisories..."
|
|
pages =
|
|
JSON.parse(
|
|
ReleaseUtils.gh(
|
|
"api",
|
|
"#{advisories_base}?state=draft",
|
|
"--paginate",
|
|
"--slurp",
|
|
capture: true,
|
|
),
|
|
)
|
|
advisories = pages.flat_map { |page| page.is_a?(Array) ? page : [page] }
|
|
|
|
draft_advisories =
|
|
advisories.reject { |advisory| advisory.fetch("summary").start_with?("DRAFT") }
|
|
|
|
if draft_advisories.empty?
|
|
puts "No draft advisories to update."
|
|
next
|
|
end
|
|
|
|
puts "Found #{draft_advisories.size} draft advisory(ies) to update."
|
|
|
|
# Calculate patched versions for all supported versions
|
|
version_patches = []
|
|
version_info = ReleaseUtils.supported_version_info
|
|
|
|
version_info.each do |series, info|
|
|
branch = info["released"] ? "release/#{series}" : "main"
|
|
|
|
current_version = ReleaseUtils.with_clean_worktree(branch) { ReleaseUtils::Version.current }
|
|
|
|
if info["released"]
|
|
# For released versions, next patch is current patch + 1
|
|
next_version =
|
|
"#{current_version.major}.#{current_version.minor}.#{current_version.patch + 1}"
|
|
version_patches << { series:, patched_version: next_version }
|
|
puts " #{series}: #{current_version} -> #{next_version}"
|
|
else
|
|
# For unreleased (latest/development) version, ask about release type
|
|
prompt = TTY::Prompt.new
|
|
release_type =
|
|
prompt.select(
|
|
"What type of release for #{series} (currently #{current_version})?",
|
|
{
|
|
"Intermediate release (security patch)" => :intermediate,
|
|
"Monthly release" => :monthly,
|
|
},
|
|
)
|
|
|
|
next_version =
|
|
if release_type == :intermediate
|
|
current_version.next_revision.to_s
|
|
else
|
|
"#{current_version.major}.#{current_version.minor}.#{current_version.patch}"
|
|
end
|
|
|
|
version_patches << { series:, patched_version: next_version }
|
|
puts " #{series}: #{current_version} -> #{next_version} (#{release_type})"
|
|
end
|
|
end
|
|
|
|
vulnerabilities =
|
|
version_patches.map.with_index do |entry, index|
|
|
vulnerable_range = index == 0 ? ">= 0" : ">= #{entry[:series]}.0-latest"
|
|
{
|
|
package: {
|
|
ecosystem: "other",
|
|
name: "Discourse",
|
|
},
|
|
vulnerable_version_range: vulnerable_range,
|
|
patched_versions: entry[:patched_version],
|
|
}
|
|
end
|
|
|
|
patched_versions = version_patches.map { |e| e[:patched_version] }
|
|
puts "\nAdvisories to update:"
|
|
draft_advisories.each { |a| puts " - #{a.fetch("ghsa_id")}: #{a.fetch("summary")}" }
|
|
puts "\nPatched versions: #{patched_versions.join(", ")}"
|
|
|
|
ReleaseUtils.confirm_or_abort("Proceed with updating #{draft_advisories.size} advisory(ies)?")
|
|
|
|
draft_advisories.each do |advisory|
|
|
ghsa_id = advisory.fetch("ghsa_id")
|
|
payload = { vulnerabilities: vulnerabilities }
|
|
|
|
ReleaseUtils.gh(
|
|
"api",
|
|
"#{advisories_base}/#{ghsa_id}",
|
|
"--method",
|
|
"PATCH",
|
|
"--input",
|
|
"-",
|
|
capture: true,
|
|
input: JSON.pretty_generate(payload),
|
|
)
|
|
|
|
puts "Updated #{ghsa_id}"
|
|
end
|
|
|
|
puts "\nDone! Updated #{draft_advisories.size} advisory(ies)."
|
|
end
|
|
end
|