diff --git a/CHANGELOG.md b/CHANGELOG.md index aebbd4ece..8afe363d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,10 +10,12 @@ _None_ ### New Features +- 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 +- 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 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..e83816f5e --- /dev/null +++ b/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/generate_release_notes_file.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require 'fastlane/action' +require_relative 'get_prs_between_tags' +require_relative '../../helper/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? + 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 = "#{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) + else + File.write(path, updated) + end + section + end + + def self.description + '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. + 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 + 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/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/lib/fastlane/plugin/wpmreleasetoolkit/helper/release_notes_helper.rb b/lib/fastlane/plugin/wpmreleasetoolkit/helper/release_notes_helper.rb index dc6cf12a7..8f9441828 100644 --- a/lib/fastlane/plugin/wpmreleasetoolkit/helper/release_notes_helper.rb +++ b/lib/fastlane/plugin/wpmreleasetoolkit/helper/release_notes_helper.rb @@ -3,6 +3,31 @@ module Fastlane module Helper module ReleaseNotesHelper + VERSION_HEADING = /^\d+(?:\.\d+){0,2}\r?\n[-=]+\r?\n/ + + # 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 + + newline = contents.include?("\r\n") ? "\r\n" : "\n" + section = section.gsub(/\r?\n/, newline) + 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 new file mode 100644 index 000000000..e277a0381 --- /dev/null +++ b/spec/generate_release_notes_file_spec.rb @@ -0,0 +1,148 @@ +# 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 + ### 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 + + **Full Changelog**: https://github.com/woocommerce/woocommerce-android/compare/25.6...25.7 + MARKDOWN + end + let(:section) { "25.7\n-----\n#{markdown}\n" } + 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 '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 + end + with_tmp_file(content: original) { |path| generate(path) } + end + + 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(markdown.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 + + ["\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) + 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 + + 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#{notes}\n#{history}") + end + end + + 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| + generate(path) + expect(File.read(path)).to eq("#{header}25.7\n-----\n#{notes}\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