diff --git a/internal/contributor-info/releasing.md b/internal/contributor-info/releasing.md index 74841a9e59..10d0272858 100644 --- a/internal/contributor-info/releasing.md +++ b/internal/contributor-info/releasing.md @@ -61,6 +61,10 @@ For a prerelease, the task warns and skips the GitHub release; after adding the #### Why changelog comes BEFORE the release - `rake release` automatically creates a GitHub release if a changelog section exists -- no separate `sync_github_release` step needed +- Before confirmation, the release task prepares the GitHub body and verifies it fits GitHub's limit. For unusually + large sections, it first replaces repeated same-repository PR/issue URLs and contributor profile URLs with compact + GitHub-native references without dropping entries. If the result is still too large, it keeps Markdown-safe beginning + and ending excerpts and links to the complete tag-pinned changelog. - The release task aborts a stable target if no matching non-empty section exists; prereleases warn and skip GitHub release creation - A premature version header (if release fails) is harmless -- you'll release eventually @@ -634,7 +638,9 @@ bundle exec rake "sync_github_release[16.5.0.rc.1]" bundle exec rake "sync_github_release[16.5.0,true]" ``` -`sync_github_release` reads release notes from the matching `CHANGELOG.md` section and creates/updates the GitHub release for the corresponding tag. +`sync_github_release` reads release notes from the matching `CHANGELOG.md` section, applies the same size preparation +as the main release task, and creates or updates the GitHub release for the corresponding tag. It is the idempotent +recovery path when package publication succeeded but the final GitHub step failed. ### Pre-Release Checklist @@ -734,6 +740,7 @@ If the release fails partway through (e.g., during NPM publish): 3. If GitHub release creation fails after successful publishing: - Fix GitHub auth (`gh auth login`) or permissions - Ensure `CHANGELOG.md` has matching header `### [X.Y.Z]` + - Do not rerun registry publication; the failure message prints the GitHub-only recovery command - Rerun only: `bundle exec rake "sync_github_release[X.Y.Z]"` 4. If some packages were published but not others: diff --git a/rakelib/release.rake b/rakelib/release.rake index c66b7245f1..c0a9fb3f33 100644 --- a/rakelib/release.rake +++ b/rakelib/release.rake @@ -1,6 +1,7 @@ # frozen_string_literal: true require "bundler" +require "cgi" require "digest" require "English" require "json" @@ -28,6 +29,7 @@ NPM_REGISTRY_URL = "https://registry.npmjs.org/" RUBYGEMS_VERSIONS_API_URL = "https://rubygems.org/api/v1/versions" RUBYGEMS_VERSIONS_OPEN_TIMEOUT_SECONDS = 10 RUBYGEMS_VERSIONS_READ_TIMEOUT_SECONDS = 15 +GITHUB_RELEASE_BODY_MAX_LENGTH = 125_000 NPM_PUBLISH_VERIFY_ATTEMPTS = 6 NPM_PUBLISH_VERIFY_RETRY_DELAY_SECONDS = 5 NPM_INSTALL_DEPENDENCY_FIELDS = %w[dependencies optionalDependencies peerDependencies].freeze @@ -6661,6 +6663,14 @@ def report_release_dry_run_changelog(version:, has_changelog:) "no GitHub release would be created." end +def report_github_release_notes_preflight!(version:, changelog_notes:) + return unless changelog_notes + + github_notes = github_release_notes(notes: changelog_notes, tag: "v#{version}") + puts " GitHub: ✓ release body prepared before publishing " \ + "(#{changelog_notes.length} → #{github_notes.length} characters)" +end + def confirm_release!(version:, monorepo_root:, dry_run: false) changelog_path = File.join(monorepo_root, "CHANGELOG.md") has_changelog = extract_changelog_section(changelog_path:, version:) @@ -6675,6 +6685,8 @@ def confirm_release!(version:, monorepo_root:, dry_run: false) ERROR end + report_github_release_notes_preflight!(version:, changelog_notes: has_changelog) + return report_release_dry_run_changelog(version:, has_changelog:) if dry_run puts "" @@ -6723,12 +6735,98 @@ def ensure_git_tag_exists!(monorepo_root:, tag:) abort "❌ Git tag #{tag.inspect} was not found locally or remotely." end +def compact_github_release_notes(notes) + compacted_notes = notes.gsub( + %r{\[PR (\d+)\]\(https://github\.com/shakacode/react_on_rails/pull/\1\)} + ) { "##{Regexp.last_match(1)}" } + compacted_notes = compacted_notes.gsub( + %r{\[(?:Issue|issue) (\d+)\]\(https://github\.com/shakacode/react_on_rails/issues/\1\)} + ) { "##{Regexp.last_match(1)}" } + compacted_notes.gsub(%r{\[([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))\]\(https://github\.com/\1\)}) do + username = Regexp.last_match(1) + "[#{username}](/#{username})" + end +end + +def github_release_body_within_limit?(notes) + notes.length <= GITHUB_RELEASE_BODY_MAX_LENGTH +end + +def ensure_github_release_body_within_limit!(notes) + return notes if github_release_body_within_limit?(notes) + + abort "❌ Prepared GitHub release notes still exceed GitHub's #{GITHUB_RELEASE_BODY_MAX_LENGTH}-character limit." +end + +def github_release_omission(tag) + <<~MARKDOWN + + --- + + > Some changelog entries were omitted from this page to fit GitHub's release-description limit. + > Read the [complete #{tag} changelog](https://github.com/shakacode/react_on_rails/blob/#{tag}/CHANGELOG.md). + + --- + + MARKDOWN +end + +def github_release_escaped_excerpt(notes, character_budget:, from:) + low = 0 + high = [notes.length, character_budget].min + + while low < high + count = (low + high + 1) / 2 + candidate = from == :beginning ? notes[0, count] : notes[-count, count] + if CGI.escapeHTML(candidate).length <= character_budget + low = count + else + high = count - 1 + end + end + + excerpt = from == :beginning ? notes[0, low] : notes[-low, low] + CGI.escapeHTML(excerpt) +end + +def github_release_excerpt(title:, escaped_notes:) + "#### #{title} (excerpt)\n\n
#{escaped_notes}"
+end
+
+def truncate_github_release_notes(notes:, tag:)
+ omission = github_release_omission(tag).strip
+ beginning_template = github_release_excerpt(title: "Beginning of release notes", escaped_notes: "")
+ ending_template = github_release_excerpt(title: "End of release notes", escaped_notes: "")
+ fixed_content = [beginning_template, omission, ending_template].join("\n\n")
+ available_characters = GITHUB_RELEASE_BODY_MAX_LENGTH - fixed_content.length
+ beginning_budget = available_characters * 3 / 4
+ ending_budget = available_characters - beginning_budget
+ beginning = github_release_escaped_excerpt(notes, character_budget: beginning_budget, from: :beginning)
+ ending = github_release_escaped_excerpt(notes, character_budget: ending_budget, from: :ending)
+
+ [
+ github_release_excerpt(title: "Beginning of release notes", escaped_notes: beginning),
+ omission,
+ github_release_excerpt(title: "End of release notes", escaped_notes: ending)
+ ].join("\n\n")
+end
+
+def github_release_notes(notes:, tag:)
+ compacted_notes = compact_github_release_notes(notes)
+ return compacted_notes if github_release_body_within_limit?(compacted_notes)
+
+ truncated_notes = truncate_github_release_notes(notes: compacted_notes, tag:)
+ ensure_github_release_body_within_limit!(truncated_notes)
+end
+
def prepare_github_release_context(monorepo_root:, gem_version:)
prerelease = release_prerelease_version?(gem_version)
changelog_path = File.join(monorepo_root, "CHANGELOG.md")
notes = extract_changelog_section(changelog_path:, version: gem_version)
abort "❌ Could not find `### [#{gem_version}]` in CHANGELOG.md. Add that section and retry." unless notes
+ notes = github_release_notes(notes:, tag: "v#{gem_version}")
+
{
notes:,
prerelease:,
@@ -6737,6 +6835,16 @@ def prepare_github_release_context(monorepo_root:, gem_version:)
}
end
+def abort_github_release_publish_failure!(tag)
+ version = tag.delete_prefix("v")
+ abort <<~ERROR
+ ❌ Failed to publish GitHub release #{tag}.
+
+ The git tag and registry packages may already be published. Retry only the idempotent GitHub step:
+ bundle exec rake "sync_github_release[#{version}]"
+ ERROR
+end
+
# rubocop:disable Metrics/AbcSize
def publish_or_update_github_release(monorepo_root:, release_context:, dry_run:)
ensure_git_tag_exists!(monorepo_root:, tag: release_context[:tag])
@@ -6769,7 +6877,7 @@ def publish_or_update_github_release(monorepo_root:, release_context:, dry_run:)
puts "Publishing GitHub release #{release_context[:tag]}#{release_context[:prerelease] ? ' (prerelease)' : ''}"
success = system(*release_command, chdir: monorepo_root)
- abort "❌ Failed to publish GitHub release #{release_context[:tag]}." unless success
+ abort_github_release_publish_failure!(release_context[:tag]) unless success
end
end
# rubocop:enable Metrics/AbcSize
diff --git a/react_on_rails/spec/react_on_rails/release_rake_helpers_spec.rb b/react_on_rails/spec/react_on_rails/release_rake_helpers_spec.rb
index b638bce1f9..ef62fb23f5 100644
--- a/react_on_rails/spec/react_on_rails/release_rake_helpers_spec.rb
+++ b/react_on_rails/spec/react_on_rails/release_rake_helpers_spec.rb
@@ -538,6 +538,152 @@ def accelerated_rc_test_issue_comment(id:, body:, tracker: 3823, created_at: "20
end
end
+ describe "#prepare_github_release_context" do
+ it "compacts GitHub references so oversized release notes fit without dropping entries" do
+ entries = Array.new(800) do |index|
+ number = index + 1
+ "- Change #{number}. [PR #{number}](https://github.com/shakacode/react_on_rails/pull/#{number}) " \
+ "by [alice](https://github.com/alice). " \
+ "Fixes [Issue #{number}](https://github.com/shakacode/react_on_rails/issues/#{number})."
+ end
+ changelog_notes = entries.join("\n")
+ expect(changelog_notes.length).to be > GITHUB_RELEASE_BODY_MAX_LENGTH
+ allow(self).to receive(:extract_changelog_section)
+ .with(changelog_path: "/tmp/repo/CHANGELOG.md", version: "17.0.0")
+ .and_return(changelog_notes)
+
+ context = prepare_github_release_context(monorepo_root: "/tmp/repo", gem_version: "17.0.0")
+
+ expect(context[:notes].length).to be <= GITHUB_RELEASE_BODY_MAX_LENGTH
+ expect(context[:notes]).to include("- Change 1. #1 by [alice](/alice). Fixes #1.")
+ expect(context[:notes]).to include("- Change 800. #800 by [alice](/alice). Fixes #800.")
+ end
+
+ it "compacts profile links for maximum-length GitHub usernames" do
+ username = "a#{'b' * 38}"
+ changelog_notes = "- Change by [#{username}](https://github.com/#{username})."
+ allow(self).to receive(:extract_changelog_section)
+ .with(changelog_path: "/tmp/repo/CHANGELOG.md", version: "17.0.0")
+ .and_return(changelog_notes)
+
+ context = prepare_github_release_context(monorepo_root: "/tmp/repo", gem_version: "17.0.0")
+
+ expect(context[:notes]).to eq("- Change by [#{username}](/#{username}).")
+ end
+
+ it "keeps multibyte notes within GitHub's character limit unchanged" do
+ changelog_notes = "#### Added\n\n- #{'🧪' * 100_000}"
+ expect(changelog_notes.length).to be < GITHUB_RELEASE_BODY_MAX_LENGTH
+ expect(changelog_notes.bytesize).to be > GITHUB_RELEASE_BODY_MAX_LENGTH
+ allow(self).to receive(:extract_changelog_section)
+ .with(changelog_path: "/tmp/repo/CHANGELOG.md", version: "17.0.0")
+ .and_return(changelog_notes)
+
+ context = prepare_github_release_context(monorepo_root: "/tmp/repo", gem_version: "17.0.0")
+
+ expect(context[:notes]).to eq(changelog_notes)
+ end
+
+ it "renders retained excerpts without letting Markdown delimiters span the omission" do
+ changelog_notes = <<~MARKDOWN
+ #### Added
+
+ - Before code
+
+ ```ruby
+ puts "").length).to eq(before_omission.scan("").length)
+ expect(context[:notes].scan("").length).to eq(context[:notes].scan("").length)
+ end
+
+ it "keeps the beginning and end when notes remain oversized after compaction" do
+ entries = Array.new(1_000) do |index|
+ "- Change #{index + 1}: #{'release detail ' * 12}"
+ end
+ changelog_notes = "#### Breaking Changes\n\n#{entries.join("\n")}\n\n#### Security\n\n- Final security note"
+ allow(self).to receive(:extract_changelog_section)
+ .with(changelog_path: "/tmp/repo/CHANGELOG.md", version: "17.0.0")
+ .and_return(changelog_notes)
+
+ context = prepare_github_release_context(monorepo_root: "/tmp/repo", gem_version: "17.0.0")
+
+ expect(context[:notes].length).to be <= GITHUB_RELEASE_BODY_MAX_LENGTH
+ expect(context[:notes]).to include("#### Breaking Changes")
+ expect(context[:notes]).to include("#### Security")
+ expect(context[:notes]).to include("Some changelog entries were omitted")
+ expect(context[:notes]).to include("blob/v17.0.0/CHANGELOG.md")
+ end
+
+ it "keeps the beginning and end of a single oversized changelog entry" do
+ changelog_notes = "- First release detail #{'🧪' * 150_000} final release detail"
+ allow(self).to receive(:extract_changelog_section)
+ .with(changelog_path: "/tmp/repo/CHANGELOG.md", version: "17.0.0")
+ .and_return(changelog_notes)
+
+ context = prepare_github_release_context(monorepo_root: "/tmp/repo", gem_version: "17.0.0")
+
+ expect(context[:notes].length).to be <= GITHUB_RELEASE_BODY_MAX_LENGTH
+ expect(context[:notes]).to be_valid_encoding
+ expect(context[:notes]).to start_with("#### Beginning of release notes (excerpt)")
+ expect(context[:notes]).to include("- First release detail")
+ expect(context[:notes]).to include("Some changelog entries were omitted")
+ expect(context[:notes]).to include("final release detail")
+ expect(context[:notes]).to include("#### End of release notes (excerpt)")
+ end
+
+ it "reserves fallback wrappers and separators within the character limit" do
+ unreserved_characters = GITHUB_RELEASE_BODY_MAX_LENGTH - github_release_omission("v17.0.0").length
+ suffix_budget = unreserved_characters - (unreserved_characters * 3 / 4)
+ changelog_notes = "- #{'x' * ((suffix_budget * 5) - 2)}"
+ allow(self).to receive(:extract_changelog_section)
+ .with(changelog_path: "/tmp/repo/CHANGELOG.md", version: "17.0.0")
+ .and_return(changelog_notes)
+
+ context = prepare_github_release_context(monorepo_root: "/tmp/repo", gem_version: "17.0.0")
+
+ expect(context[:notes].length).to be <= GITHUB_RELEASE_BODY_MAX_LENGTH
+ end
+ end
+
+ describe "#publish_or_update_github_release" do
+ it "reports the GitHub-only recovery command when publication fails" do
+ release_context = {
+ notes: "#### Fixed\n\n- Release fix",
+ prerelease: false,
+ tag: "v17.0.0",
+ title: "v17.0.0"
+ }
+ allow(self).to receive(:ensure_git_tag_exists!)
+ allow(self).to receive(:system).and_return(false, false)
+
+ expect do
+ publish_or_update_github_release(
+ monorepo_root: "/tmp/repo",
+ release_context:,
+ dry_run: false
+ )
+ end.to raise_error(SystemExit, /bundle exec rake "sync_github_release\[17\.0\.0\]"/)
+ end
+ end
+
describe "#confirm_release!" do
it "refuses a stable release with no changelog content before prompting even when input would confirm" do
allow(self).to receive(:extract_changelog_section)
@@ -582,6 +728,21 @@ def accelerated_rc_test_issue_comment(id:, body:, tracker: 3823, created_at: "20
confirm_release!(version: "17.0.0", monorepo_root: "/tmp/repo")
end.to output(/Changelog: ✓ section found.*Proceed with release\?/m).to_stdout
end
+
+ it "prepares oversized GitHub notes before confirming irreversible publication" do
+ entry = "- Change. [PR 123](https://github.com/shakacode/react_on_rails/pull/123) " \
+ "by [alice](https://github.com/alice). " \
+ "Fixes [Issue 456](https://github.com/shakacode/react_on_rails/issues/456).\n"
+ changelog_notes = entry * 1_000
+ allow(self).to receive(:extract_changelog_section)
+ .with(changelog_path: "/tmp/repo/CHANGELOG.md", version: "17.0.0")
+ .and_return(changelog_notes)
+ allow($stdin).to receive(:gets).and_return("y\n")
+
+ expect do
+ confirm_release!(version: "17.0.0", monorepo_root: "/tmp/repo")
+ end.to output(/GitHub: ✓ release body prepared before publishing \(\d+ → \d+ characters\)/).to_stdout
+ end
end
describe "#run_release_preflight_checks!" do