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 "" + #{'x' * 130_000} + ``` + + #### Security + + - Final security note + MARKDOWN + 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") + omission_index = context[:notes].index("Some changelog entries were omitted") + before_omission = context[:notes][0...omission_index] + + expect(context[:notes]).to include("Beginning of release notes (excerpt)") + expect(context[:notes]).to include("End of release notes (excerpt)") + expect(context[:notes]).to include("<release>") + expect(before_omission.scan("
").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