Skip to content
Merged
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
9 changes: 8 additions & 1 deletion internal/contributor-info/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down
110 changes: 109 additions & 1 deletion rakelib/release.rake
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# frozen_string_literal: true

require "bundler"
require "cgi"
require "digest"
require "English"
require "json"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:)
Expand All @@ -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)
Comment thread
justin808 marked this conversation as resolved.

return report_release_dry_run_changelog(version:, has_changelog:) if dry_run

puts ""
Expand Down Expand Up @@ -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
Comment thread
justin808 marked this conversation as resolved.
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<pre>#{escaped_notes}</pre>"
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:,
Expand All @@ -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])
Expand Down Expand Up @@ -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
Expand Down
161 changes: 161 additions & 0 deletions react_on_rails/spec/react_on_rails/release_rake_helpers_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<release>"
#{'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("&lt;release&gt;")
expect(before_omission.scan("<pre>").length).to eq(before_omission.scan("</pre>").length)
expect(context[:notes].scan("<pre>").length).to eq(context[:notes].scan("</pre>").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)
Expand Down Expand Up @@ -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
Expand Down
Loading