Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
148 changes: 148 additions & 0 deletions spec/generate_release_notes_file_spec.rb
Original file line number Diff line number Diff line change
@@ -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
<!-- Release notes generated using configuration in .github/developer-release-notes.yml at trunk -->

## 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
4 changes: 2 additions & 2 deletions spec/get_prs_between_tags_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading