From 40fe3efcebbe80d2e1c5bf7578b8317c5516db91 Mon Sep 17 00:00:00 2001 From: Ian Maia Date: Tue, 29 Sep 2026 17:05:39 +0200 Subject: [PATCH 1/6] Add release notes file generation from PR titles --- CHANGELOG.md | 2 + .../common/generate_release_notes_file.rb | 65 ++++++++ .../helper/generated_release_notes_helper.rb | 51 +++++++ .../wpmreleasetoolkit/helper/github_helper.rb | 2 +- spec/generate_release_notes_file_spec.rb | 143 ++++++++++++++++++ spec/get_prs_between_tags_spec.rb | 4 +- 6 files changed, 264 insertions(+), 3 deletions(-) create mode 100644 lib/fastlane/plugin/wpmreleasetoolkit/actions/common/generate_release_notes_file.rb create mode 100644 lib/fastlane/plugin/wpmreleasetoolkit/helper/generated_release_notes_helper.rb create mode 100644 spec/generate_release_notes_file_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index aebbd4ece..3357a4621 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,10 +10,12 @@ _None_ ### New Features +- Add `generate_release_notes_file` to generate a version section from PR titles in existing release lanes. - Add an opt-in `fail_on_error` mode to translation download actions so GlotPress request and response errors fail CI jobs. [#771] ### Bug Fixes +- Pass the configured release notes path to GitHub using the expected `configuration_file_path` parameter. - Bump `fastlane` to `~> 2.240` to require `rubyzip >= 3.4.0`, fixing CVE-2026-85396 / GHSA-47m2-wp7j-p9vc. [#775] ### Internal Changes diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/generate_release_notes_file.rb b/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/generate_release_notes_file.rb new file mode 100644 index 000000000..21e7f717e --- /dev/null +++ b/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/generate_release_notes_file.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require 'fastlane/action' +require_relative 'get_prs_between_tags' +require_relative '../../helper/generated_release_notes_helper' + +module Fastlane + module Actions + class GenerateReleaseNotesFileAction < Action + def self.run(params) + UI.user_error!('An explicit previous_tag is required to generate release notes') if params[:previous_tag].to_s.empty? + + path = params[:release_notes_file_path] + contents = File.read(path) + options = GetPrsBetweenTagsAction.available_options.reject { |option| option.key == :fail_on_error }.to_h { |option| [option.key, params[option.key]] } + markdown = other_action.get_prs_between_tags(**options, fail_on_error: true) + helper = Helper::GeneratedReleaseNotesHelper + section = helper.section(version: params[:version], markdown: markdown) + updated = helper.update(contents: contents, version: params[:version], section: section) + + if params[:dry_run] + UI.message(section) + else + File.write(path, updated) + end + section + end + + def self.description + 'Generate a RELEASE-NOTES.txt version section from merged PR titles' + end + + def self.details + <<~DETAILS + Uses get_prs_between_tags and GitHub's release notes configuration to select PRs. + Replaces the requested version's section, or inserts it before existing versions, preserving history and header comments. + Entries use `- [*] Title [PR URL]`; explicit priority stars at the start of a title are preserved. + Requires an explicit previous_tag. Use a published target_commitish when tag_name does not exist yet. + API errors and unrecognized change entries fail without writing. Does not commit changes. + dry_run prints and returns the proposed section without changing the file. + DETAILS + end + + def self.available_options + GetPrsBetweenTagsAction.available_options.reject { |option| option.key == :fail_on_error } + [ + FastlaneCore::ConfigItem.new(key: :version, description: 'Numeric version heading in RELEASE-NOTES.txt', type: String), + FastlaneCore::ConfigItem.new(key: :release_notes_file_path, description: 'Existing release notes file', type: String, default_value: 'RELEASE-NOTES.txt'), + FastlaneCore::ConfigItem.new(key: :dry_run, description: 'Preview the section without writing the file', type: Boolean, default_value: false), + ] + end + + def self.return_value + 'The generated version section' + end + + def self.authors + ['Automattic'] + end + + def self.is_supported?(platform) + true + end + end + end +end diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/helper/generated_release_notes_helper.rb b/lib/fastlane/plugin/wpmreleasetoolkit/helper/generated_release_notes_helper.rb new file mode 100644 index 000000000..d37200cb6 --- /dev/null +++ b/lib/fastlane/plugin/wpmreleasetoolkit/helper/generated_release_notes_helper.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module Fastlane + module Helper + module GeneratedReleaseNotesHelper + VERSION_HEADING = /^(\d+(?:\.\d+){0,2})\r?\n[-=]+\r?\n/ + + # Convert GitHub's generated Markdown into the format consumed by release automation. + # @param [String] version The numeric release version. + # @param [String] markdown Output from get_prs_between_tags. + # @return [String] The complete version section. + def self.section(version:, markdown:) + UI.user_error!('Expected a numeric release version') unless version.match?(/\A\d+(?:\.\d+){0,2}\z/) + empty_changelog = markdown.strip.match?(%r{\A\*\*Full Changelog\*\*: https://github\.com/[^/\s]+/[^/\s]+/compare/\S+\z}) + UI.user_error!('Unrecognized generated release notes') unless empty_changelog || markdown.start_with?('## New PRs since ', "## What's Changed") + + # Contributors can also contain PR links; they are not release note entries. + changes = markdown.split(/^## (?:New Contributors|Contributors)\s*$/, 2).first + entries = changes.lines.filter_map do |line| + next unless line.match?(/^[-*] /) + + match = line.strip.match(%r{\A[-*] (.+) by @\S+ in (https://github\.com/[^/\s]+/[^/\s]+/pull/\d+)\z}) + UI.user_error!("Unrecognized generated PR entry: #{line.strip}") unless match + title, url = match.captures + priority = title.match?(/\A\[\*+\]/) ? '' : '[*] ' + "- #{priority}#{title} [#{url}]" + end + + "#{version}\n-----\n#{entries.join("\n")}\n\n" + end + + # Replace one version while preserving all other sections byte for byte. + # @param [String] contents Existing RELEASE-NOTES.txt contents. + # @param [String] version The section to replace or insert. + # @param [String] section The generated version section. + # @return [String] Updated file contents. + def self.update(contents:, version:, section:) + headings = contents.to_enum(:scan, VERSION_HEADING).map { Regexp.last_match } + UI.user_error!('No version headings found in the release notes file') if headings.empty? + matches = headings.select { |heading| heading[1] == version } + UI.user_error!("Duplicate release notes sections for #{version}") if matches.length > 1 + + heading = matches.first + start = heading ? heading.begin(0) : headings.first.begin(0) + finish = heading ? (headings[headings.index(heading) + 1]&.begin(0) || contents.length) : start + newline = contents.include?("\r\n") ? "\r\n" : "\n" + contents[0...start] + section.gsub("\n", newline) + contents[finish..] + end + end + end +end diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/helper/github_helper.rb b/lib/fastlane/plugin/wpmreleasetoolkit/helper/github_helper.rb index 9402794db..f2c6cf193 100644 --- a/lib/fastlane/plugin/wpmreleasetoolkit/helper/github_helper.rb +++ b/lib/fastlane/plugin/wpmreleasetoolkit/helper/github_helper.rb @@ -311,7 +311,7 @@ def generate_release_notes(repository:, tag_name:, previous_tag:, target_commiti tag_name: tag_name, target_commitish: target_commitish, # Only used if no git tag named `tag_name` exists yet previous_tag_name: previous_tag, - config_file_path: config_file_path + configuration_file_path: config_file_path ) res.body end diff --git a/spec/generate_release_notes_file_spec.rb b/spec/generate_release_notes_file_spec.rb new file mode 100644 index 000000000..9793347b3 --- /dev/null +++ b/spec/generate_release_notes_file_spec.rb @@ -0,0 +1,143 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Fastlane::Actions::GenerateReleaseNotesFileAction do + let(:header) { "*** Generated at code freeze.\n\n" } + let(:history) { "25.6\n-----\n- [Internal] Keep this historical entry\n" } + let(:original) { "#{header}25.7\n-----\n- Handwritten draft\n\n#{history}" } + let(:markdown) do + <<~MARKDOWN + ## New PRs since [25.6](https://github.com/woocommerce/woocommerce-android/releases/tag/25.6) + + ### Changes + * Fix checkout by @developer in https://github.com/woocommerce/woocommerce-android/pull/123 + * [*****] [WEAR] Update navigation by @bot[bot] in https://github.com/woocommerce/woocommerce-android/pull/124 + ## New Contributors + * @developer made their first contribution in https://github.com/woocommerce/woocommerce-android/pull/123 + + **Full Changelog**: https://github.com/woocommerce/woocommerce-android/compare/25.6...25.7 + MARKDOWN + end + let(:section) do + <<~NOTES + 25.7 + ----- + - [*] Fix checkout [https://github.com/woocommerce/woocommerce-android/pull/123] + - [*****] [WEAR] Update navigation [https://github.com/woocommerce/woocommerce-android/pull/124] + + NOTES + end + let(:options) do + { + repository: 'woocommerce/woocommerce-android', + tag_name: '25.7', + target_commitish: 'published-sha', + previous_tag: '25.6', + configuration_file_path: '.github/developer-release-notes.yml', + github_token: 'fake-token' + } + end + + before do + allow(Fastlane::Actions::GetPrsBetweenTagsAction).to receive(:`).with('git rev-parse HEAD').and_return('published-sha') + allow(Fastlane::Actions::GetPrsBetweenTagsAction).to receive(:run).and_return(markdown) + end + + def generate(path, **overrides) + run_described_fastlane_action(**options, version: '25.7', release_notes_file_path: path, **overrides) + end + + it 'uses an explicit comparison range and propagates API errors' do + expect(Fastlane::Actions::GetPrsBetweenTagsAction).to receive(:run) do |config| + expect(config.values).to include(**options, fail_on_error: true) + markdown + end + with_tmp_file(content: original) { |path| generate(path) } + end + + it 'replaces only the requested version, preserving history and header comments' do + with_tmp_file(content: original) do |path| + expect(generate(path)).to eq(section) + expect(File.read(path)).to eq(header + section + history) + expect(Fastlane::Actions::ExtractReleaseNotesForVersionAction.run(version: '25.7', release_notes_file_path: path)).to eq(section.lines.drop(2).join.chomp('')) + end + end + + it 'inserts a missing version and is idempotent on subsequent runs' do + with_tmp_file(content: header + history) do |path| + 2.times { generate(path) } + expect(File.read(path)).to eq(header + section + history) + end + end + + it 'preserves a newer empty section when regenerating an older version' do + with_tmp_file(content: "25.8\n-----\n\n#{original}") do |path| + generate(path) + expect(File.read(path)).to eq("25.8\n-----\n\n#{header}#{section}#{history}") + end + end + + it 'preserves CRLF line endings' do + with_tmp_file(content: original.gsub("\n", "\r\n")) do |path| + generate(path) + expect(File.read(path)).to eq((header + section + history).gsub("\n", "\r\n")) + end + end + + it 'previews the generated section without writing' do + with_tmp_file(content: original) do |path| + expect(generate(path, dry_run: true)).to eq(section) + expect(File.read(path)).to eq(original) + end + end + + it 'leaves the file untouched when GitHub fails' do + allow(Fastlane::Actions::GetPrsBetweenTagsAction).to receive(:run).and_raise(Octokit::NotFound) + with_tmp_file(content: original) do |path| + expect { generate(path) }.to raise_error(Octokit::NotFound) + expect(File.read(path)).to eq(original) + end + end + + it 'rejects an implicit comparison boundary' do + with_tmp_file(content: original) do |path| + expect { generate(path, previous_tag: nil) }.to raise_error(/explicit previous_tag/) + expect(File.read(path)).to eq(original) + end + end + + it 'rejects an invalid version' do + with_tmp_file(content: original) do |path| + expect { generate(path, version: "25.7\nInjected") }.to raise_error(/numeric release version/) + expect(File.read(path)).to eq(original) + end + end + + ['An API error', "## New PRs since 25.6\n* Unexpected change format"].each do |invalid_markdown| + it "leaves the file untouched for unrecognized output: #{invalid_markdown.inspect}" do + allow(Fastlane::Actions::GetPrsBetweenTagsAction).to receive(:run).and_return(invalid_markdown) + with_tmp_file(content: original) do |path| + expect { generate(path) }.to raise_error(/Unrecognized generated/) + expect(File.read(path)).to eq(original) + end + end + end + + it 'allows an empty release when all PRs have been excluded' do + allow(Fastlane::Actions::GetPrsBetweenTagsAction).to receive(:run).and_return("**Full Changelog**: https://github.com/woocommerce/woocommerce-android/compare/25.6...25.7\n") + with_tmp_file(content: original) do |path| + generate(path) + expect(File.read(path)).to eq("#{header}25.7\n-----\n\n\n#{history}") + end + end + + ["Unrecognized file format\n", "25.7\n-----\n\n25.7\n-----\n"].each do |invalid_contents| + it "leaves an invalid file untouched: #{invalid_contents.inspect}" do + with_tmp_file(content: invalid_contents) do |path| + expect { generate(path) }.to raise_error(/No version headings|Duplicate release notes/) + expect(File.read(path)).to eq(invalid_contents) + end + end + end +end diff --git a/spec/get_prs_between_tags_spec.rb b/spec/get_prs_between_tags_spec.rb index 0c63fdb2f..6ee5b3ef5 100644 --- a/spec/get_prs_between_tags_spec.rb +++ b/spec/get_prs_between_tags_spec.rb @@ -51,7 +51,7 @@ def test_with_params(target_commitish: nil, previous_tag: nil, configuration_fil allow(client).to receive(:post).with( "repos/#{test_repo}/releases/generate-notes", - config_file_path: configuration_file_path, + configuration_file_path: configuration_file_path, previous_tag_name: previous_tag, tag_name: test_tag_name, target_commitish: target_commitish || test_head_ref @@ -110,7 +110,7 @@ def test_with_params(target_commitish: nil, previous_tag: nil, configuration_fil def stub_failing_api_call(target_commitish: nil, previous_tag: nil, configuration_file_path: nil, error_msg: 'API Failure') allow(client).to receive(:post).with( "repos/#{test_repo}/releases/generate-notes", - config_file_path: configuration_file_path, + configuration_file_path: configuration_file_path, previous_tag_name: previous_tag, tag_name: test_tag_name, target_commitish: target_commitish || test_head_ref From c0f0520dc42dd0137a029259f6b9987401a08ff9 Mon Sep 17 00:00:00 2001 From: Ian Maia Date: Tue, 29 Sep 2026 17:06:33 +0200 Subject: [PATCH 2/6] Link release notes generation changelog to proposal PR --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3357a4621..597ec2ddf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,12 +10,12 @@ _None_ ### New Features -- Add `generate_release_notes_file` to generate a version section from PR titles in existing release lanes. +- Add `generate_release_notes_file` to generate a version section from PR titles in existing release lanes. [#776] - Add an opt-in `fail_on_error` mode to translation download actions so GlotPress request and response errors fail CI jobs. [#771] ### Bug Fixes -- Pass the configured release notes path to GitHub using the expected `configuration_file_path` parameter. +- Pass the configured release notes path to GitHub using the expected `configuration_file_path` parameter. [#776] - Bump `fastlane` to `~> 2.240` to require `rubyzip >= 3.4.0`, fixing CVE-2026-85396 / GHSA-47m2-wp7j-p9vc. [#775] ### Internal Changes From 6af6e05419c5a468d48c6772f58ad8d6e3430f8a Mon Sep 17 00:00:00 2001 From: Ian Maia Date: Tue, 29 Sep 2026 17:09:44 +0200 Subject: [PATCH 3/6] Handle GitHub configuration comments in generated notes --- .../helper/generated_release_notes_helper.rb | 1 + spec/generate_release_notes_file_spec.rb | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/helper/generated_release_notes_helper.rb b/lib/fastlane/plugin/wpmreleasetoolkit/helper/generated_release_notes_helper.rb index d37200cb6..aab0a3f57 100644 --- a/lib/fastlane/plugin/wpmreleasetoolkit/helper/generated_release_notes_helper.rb +++ b/lib/fastlane/plugin/wpmreleasetoolkit/helper/generated_release_notes_helper.rb @@ -11,6 +11,7 @@ module GeneratedReleaseNotesHelper # @return [String] The complete version section. def self.section(version:, markdown:) UI.user_error!('Expected a numeric release version') unless version.match?(/\A\d+(?:\.\d+){0,2}\z/) + markdown = markdown.sub(/\A\s*/, '') empty_changelog = markdown.strip.match?(%r{\A\*\*Full Changelog\*\*: https://github\.com/[^/\s]+/[^/\s]+/compare/\S+\z}) UI.user_error!('Unrecognized generated release notes') unless empty_changelog || markdown.start_with?('## New PRs since ', "## What's Changed") diff --git a/spec/generate_release_notes_file_spec.rb b/spec/generate_release_notes_file_spec.rb index 9793347b3..16d1b356e 100644 --- a/spec/generate_release_notes_file_spec.rb +++ b/spec/generate_release_notes_file_spec.rb @@ -8,6 +8,8 @@ let(:original) { "#{header}25.7\n-----\n- Handwritten draft\n\n#{history}" } let(:markdown) do <<~MARKDOWN + + ## New PRs since [25.6](https://github.com/woocommerce/woocommerce-android/releases/tag/25.6) ### Changes @@ -132,6 +134,13 @@ def generate(path, **overrides) end end + it 'also accepts the default GitHub format without a configuration comment' do + allow(Fastlane::Actions::GetPrsBetweenTagsAction).to receive(:run).and_return(markdown.lines.drop(2).join) + with_tmp_file(content: original) do |path| + expect(generate(path)).to eq(section) + end + end + ["Unrecognized file format\n", "25.7\n-----\n\n25.7\n-----\n"].each do |invalid_contents| it "leaves an invalid file untouched: #{invalid_contents.inspect}" do with_tmp_file(content: invalid_contents) do |path| From 791cea67cebcea46c36e38f12ac13597a7b85040 Mon Sep 17 00:00:00 2001 From: Ian Maia Date: Tue, 29 Sep 2026 18:38:18 +0200 Subject: [PATCH 4/6] Simplify release notes generation and reuse the existing helper --- .../common/generate_release_notes_file.rb | 18 +++---- .../helper/generated_release_notes_helper.rb | 52 ------------------- .../helper/release_notes_helper.rb | 49 +++++++++++++++++ spec/generate_release_notes_file_spec.rb | 2 +- 4 files changed, 57 insertions(+), 64 deletions(-) delete mode 100644 lib/fastlane/plugin/wpmreleasetoolkit/helper/generated_release_notes_helper.rb diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/generate_release_notes_file.rb b/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/generate_release_notes_file.rb index 21e7f717e..c114258ae 100644 --- a/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/generate_release_notes_file.rb +++ b/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/generate_release_notes_file.rb @@ -2,7 +2,7 @@ require 'fastlane/action' require_relative 'get_prs_between_tags' -require_relative '../../helper/generated_release_notes_helper' +require_relative '../../helper/release_notes_helper' module Fastlane module Actions @@ -12,11 +12,10 @@ def self.run(params) path = params[:release_notes_file_path] contents = File.read(path) - options = GetPrsBetweenTagsAction.available_options.reject { |option| option.key == :fail_on_error }.to_h { |option| [option.key, params[option.key]] } + options = params.values.except(:version, :release_notes_file_path, :dry_run) markdown = other_action.get_prs_between_tags(**options, fail_on_error: true) - helper = Helper::GeneratedReleaseNotesHelper - section = helper.section(version: params[:version], markdown: markdown) - updated = helper.update(contents: contents, version: params[:version], section: section) + section = Helper::ReleaseNotesHelper.section_from_prs(version: params[:version], markdown: markdown) + updated = Helper::ReleaseNotesHelper.update_section(contents: contents, version: params[:version], section: section) if params[:dry_run] UI.message(section) @@ -32,12 +31,9 @@ def self.description def self.details <<~DETAILS - Uses get_prs_between_tags and GitHub's release notes configuration to select PRs. - Replaces the requested version's section, or inserts it before existing versions, preserving history and header comments. - Entries use `- [*] Title [PR URL]`; explicit priority stars at the start of a title are preserved. - Requires an explicit previous_tag. Use a published target_commitish when tag_name does not exist yet. - API errors and unrecognized change entries fail without writing. Does not commit changes. - dry_run prints and returns the proposed section without changing the file. + Inserts or replaces one version section using get_prs_between_tags, preserving other versions and header comments. + Requires an explicit previous_tag and a published target_commitish if tag_name does not exist. + Use dry_run to preview without writing. Does not commit changes. DETAILS end diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/helper/generated_release_notes_helper.rb b/lib/fastlane/plugin/wpmreleasetoolkit/helper/generated_release_notes_helper.rb deleted file mode 100644 index aab0a3f57..000000000 --- a/lib/fastlane/plugin/wpmreleasetoolkit/helper/generated_release_notes_helper.rb +++ /dev/null @@ -1,52 +0,0 @@ -# frozen_string_literal: true - -module Fastlane - module Helper - module GeneratedReleaseNotesHelper - VERSION_HEADING = /^(\d+(?:\.\d+){0,2})\r?\n[-=]+\r?\n/ - - # Convert GitHub's generated Markdown into the format consumed by release automation. - # @param [String] version The numeric release version. - # @param [String] markdown Output from get_prs_between_tags. - # @return [String] The complete version section. - def self.section(version:, markdown:) - UI.user_error!('Expected a numeric release version') unless version.match?(/\A\d+(?:\.\d+){0,2}\z/) - markdown = markdown.sub(/\A\s*/, '') - empty_changelog = markdown.strip.match?(%r{\A\*\*Full Changelog\*\*: https://github\.com/[^/\s]+/[^/\s]+/compare/\S+\z}) - UI.user_error!('Unrecognized generated release notes') unless empty_changelog || markdown.start_with?('## New PRs since ', "## What's Changed") - - # Contributors can also contain PR links; they are not release note entries. - changes = markdown.split(/^## (?:New Contributors|Contributors)\s*$/, 2).first - entries = changes.lines.filter_map do |line| - next unless line.match?(/^[-*] /) - - match = line.strip.match(%r{\A[-*] (.+) by @\S+ in (https://github\.com/[^/\s]+/[^/\s]+/pull/\d+)\z}) - UI.user_error!("Unrecognized generated PR entry: #{line.strip}") unless match - title, url = match.captures - priority = title.match?(/\A\[\*+\]/) ? '' : '[*] ' - "- #{priority}#{title} [#{url}]" - end - - "#{version}\n-----\n#{entries.join("\n")}\n\n" - end - - # Replace one version while preserving all other sections byte for byte. - # @param [String] contents Existing RELEASE-NOTES.txt contents. - # @param [String] version The section to replace or insert. - # @param [String] section The generated version section. - # @return [String] Updated file contents. - def self.update(contents:, version:, section:) - headings = contents.to_enum(:scan, VERSION_HEADING).map { Regexp.last_match } - UI.user_error!('No version headings found in the release notes file') if headings.empty? - matches = headings.select { |heading| heading[1] == version } - UI.user_error!("Duplicate release notes sections for #{version}") if matches.length > 1 - - heading = matches.first - start = heading ? heading.begin(0) : headings.first.begin(0) - finish = heading ? (headings[headings.index(heading) + 1]&.begin(0) || contents.length) : start - newline = contents.include?("\r\n") ? "\r\n" : "\n" - contents[0...start] + section.gsub("\n", newline) + contents[finish..] - end - end - end -end diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/helper/release_notes_helper.rb b/lib/fastlane/plugin/wpmreleasetoolkit/helper/release_notes_helper.rb index dc6cf12a7..b74ca7408 100644 --- a/lib/fastlane/plugin/wpmreleasetoolkit/helper/release_notes_helper.rb +++ b/lib/fastlane/plugin/wpmreleasetoolkit/helper/release_notes_helper.rb @@ -3,6 +3,55 @@ module Fastlane module Helper module ReleaseNotesHelper + VERSION_HEADING = /^\d+(?:\.\d+){0,2}\r?\n[-=]+\r?\n/ + + # Format get_prs_between_tags output as a RELEASE-NOTES.txt version section. + # @param [String] version The numeric release version. + # @param [String] markdown Output from get_prs_between_tags. + # @return [String] The complete version section. + def self.section_from_prs(version:, markdown:) + UI.user_error!('Expected a numeric release version') unless version.match?(/\A\d+(?:\.\d+){0,2}\z/) + markdown = markdown.sub(/\A\s*/, '') + empty_changelog = markdown.strip.match?(%r{\A\*\*Full Changelog\*\*: https://github\.com/[^/\s]+/[^/\s]+/compare/\S+\z}) + UI.user_error!('Unrecognized generated release notes') unless empty_changelog || markdown.start_with?('## New PRs since ', "## What's Changed") + + # Contributors can also contain PR links; they are not release note entries. + changes = markdown.split(/^## (?:New Contributors|Contributors)\s*$/, 2).first + entries = changes.lines.filter_map do |line| + next unless line.match?(/^[-*] /) + + match = line.strip.match(%r{\A[-*] (.+) by @\S+ in (https://github\.com/[^/\s]+/[^/\s]+/pull/\d+)\z}) + UI.user_error!("Unrecognized generated PR entry: #{line.strip}") unless match + title, url = match.captures + priority = title.match?(/\A\[\*+\]/) ? '' : '[*] ' + "- #{priority}#{title} [#{url}]" + end + + "#{version}\n-----\n#{entries.join("\n")}\n\n" + end + + # Insert or replace one version, preserving other sections and line endings. + # @param [String] contents Existing RELEASE-NOTES.txt contents. + # @param [String] version The section to replace or insert. + # @param [String] section The generated version section. + # @return [String] Updated file contents. + def self.update_section(contents:, version:, section:) + sections = contents.split(/(?=#{VERSION_HEADING})/) + versions = sections.map { |part| part.lines.first.chomp if part.match?(VERSION_HEADING) } + first_version = versions.index { |value| value } + UI.user_error!('No version headings found in the release notes file') unless first_version + UI.user_error!("Duplicate release notes sections for #{version}") if versions.count(version) > 1 + + section = section.gsub("\n", "\r\n") if contents.include?("\r\n") + index = versions.index(version) + if index + sections[index] = section + else + sections.insert(first_version, section) + end + sections.join + end + # Update the release notes file (typycally RELEASE-NOTES.txt) to add a new entry. # # @param [String] path The path to the release notes text file. diff --git a/spec/generate_release_notes_file_spec.rb b/spec/generate_release_notes_file_spec.rb index 16d1b356e..fa6ce6786 100644 --- a/spec/generate_release_notes_file_spec.rb +++ b/spec/generate_release_notes_file_spec.rb @@ -50,7 +50,7 @@ def generate(path, **overrides) run_described_fastlane_action(**options, version: '25.7', release_notes_file_path: path, **overrides) end - it 'uses an explicit comparison range and propagates API errors' do + it 'forwards the comparison options and requires API errors to fail' do expect(Fastlane::Actions::GetPrsBetweenTagsAction).to receive(:run) do |config| expect(config.values).to include(**options, fail_on_error: true) markdown From ce3e8caecdb3ab44a75c08f29602853c7f53f2ad Mon Sep 17 00:00:00 2001 From: Ian Maia Date: Wed, 30 Sep 2026 12:14:42 +0200 Subject: [PATCH 5/6] Preserve generated Markdown without parsing release note entries --- CHANGELOG.md | 2 +- .../common/generate_release_notes_file.rb | 9 ++-- .../helper/release_notes_helper.rb | 28 +---------- spec/generate_release_notes_file_spec.rb | 49 +++++++++---------- 4 files changed, 31 insertions(+), 57 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 597ec2ddf..8afe363d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ _None_ ### New Features -- Add `generate_release_notes_file` to generate a version section from PR titles in existing release lanes. [#776] +- Add `generate_release_notes_file` to write generated GitHub Markdown into a version section in existing release lanes. [#776] - Add an opt-in `fail_on_error` mode to translation download actions so GlotPress request and response errors fail CI jobs. [#771] ### Bug Fixes diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/generate_release_notes_file.rb b/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/generate_release_notes_file.rb index c114258ae..f6b35486e 100644 --- a/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/generate_release_notes_file.rb +++ b/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/generate_release_notes_file.rb @@ -9,13 +9,15 @@ module Actions class GenerateReleaseNotesFileAction < Action def self.run(params) UI.user_error!('An explicit previous_tag is required to generate release notes') if params[:previous_tag].to_s.empty? + version = params[:version] + UI.user_error!('Expected a numeric release version') unless version.match?(/\A\d+(?:\.\d+){0,2}\z/) path = params[:release_notes_file_path] contents = File.read(path) options = params.values.except(:version, :release_notes_file_path, :dry_run) markdown = other_action.get_prs_between_tags(**options, fail_on_error: true) - section = Helper::ReleaseNotesHelper.section_from_prs(version: params[:version], markdown: markdown) - updated = Helper::ReleaseNotesHelper.update_section(contents: contents, version: params[:version], section: section) + section = "#{version}\n-----\n#{markdown.rstrip}\n\n" + updated = Helper::ReleaseNotesHelper.update_section(contents: contents, version: version, section: section) if params[:dry_run] UI.message(section) @@ -26,12 +28,13 @@ def self.run(params) end def self.description - 'Generate a RELEASE-NOTES.txt version section from merged PR titles' + 'Write generated GitHub Markdown to a RELEASE-NOTES.txt version section' end def self.details <<~DETAILS Inserts or replaces one version section using get_prs_between_tags, preserving other versions and header comments. + Keeps the returned Markdown, including headings, authors, contributors, and links. Requires an explicit previous_tag and a published target_commitish if tag_name does not exist. Use dry_run to preview without writing. Does not commit changes. DETAILS diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/helper/release_notes_helper.rb b/lib/fastlane/plugin/wpmreleasetoolkit/helper/release_notes_helper.rb index b74ca7408..8f9441828 100644 --- a/lib/fastlane/plugin/wpmreleasetoolkit/helper/release_notes_helper.rb +++ b/lib/fastlane/plugin/wpmreleasetoolkit/helper/release_notes_helper.rb @@ -5,31 +5,6 @@ module Helper module ReleaseNotesHelper VERSION_HEADING = /^\d+(?:\.\d+){0,2}\r?\n[-=]+\r?\n/ - # Format get_prs_between_tags output as a RELEASE-NOTES.txt version section. - # @param [String] version The numeric release version. - # @param [String] markdown Output from get_prs_between_tags. - # @return [String] The complete version section. - def self.section_from_prs(version:, markdown:) - UI.user_error!('Expected a numeric release version') unless version.match?(/\A\d+(?:\.\d+){0,2}\z/) - markdown = markdown.sub(/\A\s*/, '') - empty_changelog = markdown.strip.match?(%r{\A\*\*Full Changelog\*\*: https://github\.com/[^/\s]+/[^/\s]+/compare/\S+\z}) - UI.user_error!('Unrecognized generated release notes') unless empty_changelog || markdown.start_with?('## New PRs since ', "## What's Changed") - - # Contributors can also contain PR links; they are not release note entries. - changes = markdown.split(/^## (?:New Contributors|Contributors)\s*$/, 2).first - entries = changes.lines.filter_map do |line| - next unless line.match?(/^[-*] /) - - match = line.strip.match(%r{\A[-*] (.+) by @\S+ in (https://github\.com/[^/\s]+/[^/\s]+/pull/\d+)\z}) - UI.user_error!("Unrecognized generated PR entry: #{line.strip}") unless match - title, url = match.captures - priority = title.match?(/\A\[\*+\]/) ? '' : '[*] ' - "- #{priority}#{title} [#{url}]" - end - - "#{version}\n-----\n#{entries.join("\n")}\n\n" - end - # Insert or replace one version, preserving other sections and line endings. # @param [String] contents Existing RELEASE-NOTES.txt contents. # @param [String] version The section to replace or insert. @@ -42,7 +17,8 @@ def self.update_section(contents:, version:, section:) UI.user_error!('No version headings found in the release notes file') unless first_version UI.user_error!("Duplicate release notes sections for #{version}") if versions.count(version) > 1 - section = section.gsub("\n", "\r\n") if contents.include?("\r\n") + newline = contents.include?("\r\n") ? "\r\n" : "\n" + section = section.gsub(/\r?\n/, newline) index = versions.index(version) if index sections[index] = section diff --git a/spec/generate_release_notes_file_spec.rb b/spec/generate_release_notes_file_spec.rb index fa6ce6786..e8a67ccc9 100644 --- a/spec/generate_release_notes_file_spec.rb +++ b/spec/generate_release_notes_file_spec.rb @@ -21,15 +21,7 @@ **Full Changelog**: https://github.com/woocommerce/woocommerce-android/compare/25.6...25.7 MARKDOWN end - let(:section) do - <<~NOTES - 25.7 - ----- - - [*] Fix checkout [https://github.com/woocommerce/woocommerce-android/pull/123] - - [*****] [WEAR] Update navigation [https://github.com/woocommerce/woocommerce-android/pull/124] - - NOTES - end + let(:section) { "25.7\n-----\n#{markdown}\n" } let(:options) do { repository: 'woocommerce/woocommerce-android', @@ -58,11 +50,11 @@ def generate(path, **overrides) with_tmp_file(content: original) { |path| generate(path) } end - it 'replaces only the requested version, preserving history and header comments' do + it 'keeps the generated Markdown and replaces only the requested version' do with_tmp_file(content: original) do |path| expect(generate(path)).to eq(section) expect(File.read(path)).to eq(header + section + history) - expect(Fastlane::Actions::ExtractReleaseNotesForVersionAction.run(version: '25.7', release_notes_file_path: path)).to eq(section.lines.drop(2).join.chomp('')) + expect(Fastlane::Actions::ExtractReleaseNotesForVersionAction.run(version: '25.7', release_notes_file_path: path)).to eq(markdown.chomp('')) end end @@ -87,6 +79,16 @@ def generate(path, **overrides) end end + ["\n", "\r\n"].each do |newline| + it "normalizes CRLF Markdown to the file's #{newline.inspect} line endings" do + allow(Fastlane::Actions::GetPrsBetweenTagsAction).to receive(:run).and_return(markdown.gsub("\n", "\r\n")) + with_tmp_file(content: original.gsub("\n", newline)) do |path| + generate(path) + expect(File.read(path)).to eq((header + section + history).gsub("\n", newline)) + end + end + end + it 'previews the generated section without writing' do with_tmp_file(content: original) do |path| expect(generate(path, dry_run: true)).to eq(section) @@ -116,28 +118,21 @@ def generate(path, **overrides) end end - ['An API error', "## New PRs since 25.6\n* Unexpected change format"].each do |invalid_markdown| - it "leaves the file untouched for unrecognized output: #{invalid_markdown.inspect}" do - allow(Fastlane::Actions::GetPrsBetweenTagsAction).to receive(:run).and_return(invalid_markdown) - with_tmp_file(content: original) do |path| - expect { generate(path) }.to raise_error(/Unrecognized generated/) - expect(File.read(path)).to eq(original) - end - end - end - - it 'allows an empty release when all PRs have been excluded' do - allow(Fastlane::Actions::GetPrsBetweenTagsAction).to receive(:run).and_return("**Full Changelog**: https://github.com/woocommerce/woocommerce-android/compare/25.6...25.7\n") + it 'accepts changes to the generated Markdown format without parsing PR entries' do + notes = "## Changes\n- Fix checkout (#123) — developer\n\nThanks to our contributors!\n" + allow(Fastlane::Actions::GetPrsBetweenTagsAction).to receive(:run).and_return(notes) with_tmp_file(content: original) do |path| generate(path) - expect(File.read(path)).to eq("#{header}25.7\n-----\n\n\n#{history}") + expect(File.read(path)).to eq("#{header}25.7\n-----\n#{notes}\n#{history}") end end - it 'also accepts the default GitHub format without a configuration comment' do - allow(Fastlane::Actions::GetPrsBetweenTagsAction).to receive(:run).and_return(markdown.lines.drop(2).join) + it 'allows an empty release when all PRs have been excluded' do + notes = "**Full Changelog**: https://github.com/woocommerce/woocommerce-android/compare/25.6...25.7\n" + allow(Fastlane::Actions::GetPrsBetweenTagsAction).to receive(:run).and_return(notes) with_tmp_file(content: original) do |path| - expect(generate(path)).to eq(section) + generate(path) + expect(File.read(path)).to eq("#{header}25.7\n-----\n#{notes}\n#{history}") end end From 23b18dec4f8b6789a55c2092a117110259423102 Mon Sep 17 00:00:00 2001 From: Ian Maia Date: Wed, 30 Sep 2026 18:07:53 +0200 Subject: [PATCH 6/6] Document label-based release note categories and testing reports --- .../actions/common/generate_release_notes_file.rb | 2 ++ spec/generate_release_notes_file_spec.rb | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/generate_release_notes_file.rb b/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/generate_release_notes_file.rb index f6b35486e..e83816f5e 100644 --- a/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/generate_release_notes_file.rb +++ b/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/generate_release_notes_file.rb @@ -35,6 +35,8 @@ def self.details <<~DETAILS Inserts or replaces one version section using get_prs_between_tags, preserving other versions and header comments. Keeps the returned Markdown, including headings, authors, contributors, and links. + Use configuration_file_path to exclude or group PRs by GitHub labels; no title markers are needed. + For a separate report, such as release testing, call get_prs_between_tags with its own configuration_file_path. Requires an explicit previous_tag and a published target_commitish if tag_name does not exist. Use dry_run to preview without writing. Does not commit changes. DETAILS diff --git a/spec/generate_release_notes_file_spec.rb b/spec/generate_release_notes_file_spec.rb index e8a67ccc9..e277a0381 100644 --- a/spec/generate_release_notes_file_spec.rb +++ b/spec/generate_release_notes_file_spec.rb @@ -14,7 +14,8 @@ ### Changes * Fix checkout by @developer in https://github.com/woocommerce/woocommerce-android/pull/123 - * [*****] [WEAR] Update navigation by @bot[bot] in https://github.com/woocommerce/woocommerce-android/pull/124 + ### Android Wear + * Update navigation by @bot[bot] in https://github.com/woocommerce/woocommerce-android/pull/124 ## New Contributors * @developer made their first contribution in https://github.com/woocommerce/woocommerce-android/pull/123