From 10d9dd8de40ad73822b7a3ca3e8c3119405d2a6b Mon Sep 17 00:00:00 2001 From: Lucas Bedatty Date: Fri, 9 Oct 2026 17:15:43 -0300 Subject: [PATCH 1/3] fix(release): fail before tagging when release notes exceed the API limit --- .github/workflows/release.yml | 51 +++++++++++++++++++++++++++++++++-- docs/release.md | 24 +++++++++++++++++ 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index da614bb0..50e31472 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -147,6 +147,11 @@ on: required: false type: boolean default: false + release_notes_max_chars: + description: 'Fail the release before any tag is created when the generated release notes exceed this many characters. The GitHub Releases API rejects a body over 125000 characters, and semantic-release only hits that limit in its `publish` step — after the tag is already pushed, which leaves the repository with a tag, a deployed build and no Release, changelog or backmerge. The check runs on the dry-run notes, so an oversized body stops the run with nothing published. Set to 0 to disable.' + required: false + type: number + default: 125000 environment_name: description: 'Overrides the per-channel deployment environment for this run. Empty (default) keeps `stable` on main, `rc` on release-candidate and `beta` on anything else. Pass a value only when a ref needs its own environment, scoping it with a condition in the caller so the channel split still applies to every other ref (see docs/release.md, Deployment Environments). The environment lives in your repository; if it has a deployment branch policy that does not allow this ref, the job fails, which is the intended behaviour.' required: false @@ -410,8 +415,11 @@ jobs: - name: Determine next version (dry-run) id: semantic_dry + # Runs on every branch, not just the prerelease lines: the oversized + # release notes guard below needs the notes before the real run creates + # the tag. `pre_sync` only ever runs on a prerelease branch, so on a + # stable branch both conditions are vacuously true. if: | - steps.classify.outputs.is_prerelease == 'true' && steps.pre_sync.outputs.action != 'pr-opened' && steps.pre_sync.outputs.action != 'pr-existing' uses: cycjimmy/semantic-release-action@b12c8f6015dc215fe37bc154d4ad456dd3833c90 # v6 @@ -428,13 +436,52 @@ jobs: - name: Guard against stale prerelease id: guard - if: steps.semantic_dry.outputs.new_release_published == 'true' + # The dry-run above also runs on stable branches now; this guard only + # ever applied to the prerelease lines, so it keeps its own condition. + if: | + steps.classify.outputs.is_prerelease == 'true' && + steps.semantic_dry.outputs.new_release_published == 'true' uses: LerianStudio/github-actions-shared-workflows/src/config/prerelease-guard@v1 with: calculated-version: ${{ steps.semantic_dry.outputs.new_release_version }} source-branch: ${{ inputs.backmerge_source }} target-branch: ${{ github.ref_name }} + # The Releases API rejects a body over 125000 characters. semantic-release + # only finds out in `publish`, which runs *after* the tag is created and + # pushed: the tag triggers the caller's build and deploy, while the + # Release, the changelog, the announcement and the backmerge are all + # skipped. The target branch then never gets the stable tag, so the + # prerelease guard above fails every later push to it. + # + # The first stable release of a repository with a long prerelease history + # is the case that hits this: with no previous stable tag the notes cover + # the whole history. Checking the dry-run notes stops the run before any + # tag exists, so a failure here leaves nothing behind to clean up. + - name: Guard against oversized release notes + if: | + steps.semantic_dry.outputs.new_release_published == 'true' && + inputs.release_notes_max_chars > 0 + env: + NOTES: ${{ steps.semantic_dry.outputs.new_release_notes }} + MAX_CHARS: ${{ inputs.release_notes_max_chars }} + NEXT_VERSION: ${{ steps.semantic_dry.outputs.new_release_version }} + run: | + size="$(printf '%s' "$NOTES" | wc -m | tr -d '[:space:]')" + echo "Release notes for v${NEXT_VERSION}: ${size} characters (limit ${MAX_CHARS})" + + if [ "$size" -le "$MAX_CHARS" ]; then + exit 0 + fi + + printf '::error::%s%s%s%s%s\n' \ + "Release notes for v${NEXT_VERSION} are ${size} characters, over the ${MAX_CHARS} limit. " \ + "The GitHub Releases API would reject them in the publish step, after the tag had already been created and pushed. This run stopped before that: no tag, no release, nothing to clean up.%0A" \ + "This is usually the first stable release of a repository with a long prerelease history: with no previous stable tag, the notes cover every commit.%0A" \ + "To unblock it, publish this release by hand with the notes of the last prerelease on the promoted channel, or narrow what the notes cover in .releaserc.%0A" \ + "Set release_notes_max_chars to 0 to disable this check." + exit 1 + - name: Semantic Release uses: cycjimmy/semantic-release-action@b12c8f6015dc215fe37bc154d4ad456dd3833c90 # v6 id: semantic diff --git a/docs/release.md b/docs/release.md index d3dd3626..f2c181e0 100644 --- a/docs/release.md +++ b/docs/release.md @@ -133,8 +133,32 @@ is the correct setting. | `enable_release_announcement` | boolean | `true` | Announce the published release to the repository Slack channel after a successful release | | `announcement_product_name` | string | `''` | Product name displayed in the announcement. Defaults to the repository name | | `announcement_slack_channel` | string | `''` | Slack channel that receives the announcement. Defaults to the `RELEASE_SLACK_CHANNEL` repository variable; the announcement is skipped when both are empty | +| `release_notes_max_chars` | number | `125000` | Fail the release before any tag is created when the generated release notes are longer than this. Matches the GitHub Releases API limit — see [Oversized release notes](#oversized-release-notes). `0` disables the check | | `environment_name` | string | `''` | Overrides the per-channel deployment environment for this run. Empty keeps `stable`/`rc`/`beta` by ref — see [Deployment Environments](#deployment-environments) | +## Oversized Release Notes + +The GitHub Releases API rejects a release body longer than 125 000 characters. +semantic-release only discovers that in its `publish` step, which runs **after** the +tag has been created and pushed — so the failure leaves the repository half-released: +the tag fires the caller's tag-push build and reaches production, while the GitHub +Release, the changelog, the announcement and the backmerge are all skipped. With the +stable tag never reaching `backmerge_target`, the prerelease guard then fails every +later push to that branch until someone backmerges by hand. + +The case that hits this is the **first stable release of a repository with a long +prerelease history**: with no previous stable tag, `@semantic-release/release-notes-generator` +builds the notes from the whole history. + +To prevent it, the release job runs semantic-release in dry-run mode on every branch +and measures the notes it would publish. Over `release_notes_max_chars`, the run fails +there — before the real semantic-release step, so no tag exists and there is nothing to +clean up. + +When the guard fires, publish that release by hand with the notes of the last prerelease +on the promoted channel (for example the last `-rc.N`), or narrow what the notes cover in +`.releaserc`. Set `release_notes_max_chars: 0` to turn the check off. + ## Release Announcement After a successful release, the `announce_release` job calls From be20f9d6f9d3e054ed96c319c4171dbd799f307b Mon Sep 17 00:00:00 2001 From: Lucas Bedatty Date: Fri, 9 Oct 2026 20:28:31 -0300 Subject: [PATCH 2/3] fix(release): validate the notes limit and count characters in UTF-8 --- .github/workflows/release.yml | 26 ++++++++++++++++++++++---- docs/release.md | 3 ++- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 50e31472..10385fd9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -459,15 +459,33 @@ jobs: # the whole history. Checking the dry-run notes stops the run before any # tag exists, so a failure here leaves nothing behind to clean up. - name: Guard against oversized release notes - if: | - steps.semantic_dry.outputs.new_release_published == 'true' && - inputs.release_notes_max_chars > 0 + # The input is validated here rather than in the `if:` above, so a bad + # value fails loudly instead of quietly turning the guard off. + if: steps.semantic_dry.outputs.new_release_published == 'true' env: NOTES: ${{ steps.semantic_dry.outputs.new_release_notes }} MAX_CHARS: ${{ inputs.release_notes_max_chars }} NEXT_VERSION: ${{ steps.semantic_dry.outputs.new_release_version }} run: | - size="$(printf '%s' "$NOTES" | wc -m | tr -d '[:space:]')" + # A character limit is a whole number. Anything else — a fraction, a + # negative, the exponent notation GitHub can produce when it renders a + # large `number` input as a string — is a configuration mistake, and + # `test -le` would report it as oversized notes instead. + case "$MAX_CHARS" in + '' | *[!0-9]*) + echo "::error::release_notes_max_chars must be a non-negative whole number (got '${MAX_CHARS}')" + exit 1 + ;; + esac + + if [ "$MAX_CHARS" -eq 0 ]; then + echo "Release notes size check disabled (release_notes_max_chars=0)" + exit 0 + fi + + # Without a UTF-8 locale `wc -m` counts bytes, and notes with accents + # or emoji would measure larger than the API counts them. + size="$(printf '%s' "$NOTES" | LC_ALL=C.UTF-8 wc -m | tr -d '[:space:]')" echo "Release notes for v${NEXT_VERSION}: ${size} characters (limit ${MAX_CHARS})" if [ "$size" -le "$MAX_CHARS" ]; then diff --git a/docs/release.md b/docs/release.md index f2c181e0..961c27d9 100644 --- a/docs/release.md +++ b/docs/release.md @@ -157,7 +157,8 @@ clean up. When the guard fires, publish that release by hand with the notes of the last prerelease on the promoted channel (for example the last `-rc.N`), or narrow what the notes cover in -`.releaserc`. Set `release_notes_max_chars: 0` to turn the check off. +`.releaserc`. Set `release_notes_max_chars: 0` to turn the check off. Any other non-whole or +negative value fails the step rather than silently disabling it. ## Release Announcement From f9bc03d087a074c767c63707ba3e4e519041f400 Mon Sep 17 00:00:00 2001 From: Lucas Bedatty Date: Fri, 9 Oct 2026 20:49:58 -0300 Subject: [PATCH 3/3] fix(release): count release notes with node instead of locale-sensitive wc --- .github/workflows/release.yml | 9 ++++++--- docs/release.md | 3 ++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 10385fd9..57d7412b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -483,9 +483,12 @@ jobs: exit 0 fi - # Without a UTF-8 locale `wc -m` counts bytes, and notes with accents - # or emoji would measure larger than the API counts them. - size="$(printf '%s' "$NOTES" | LC_ALL=C.UTF-8 wc -m | tr -d '[:space:]')" + # Counted with node, already installed above, rather than `wc -m`: + # the caller picks the runner, and on one without the C.UTF-8 locale + # `wc -m` silently falls back to counting bytes, so notes with accents + # or emoji would measure larger than the API counts them. Spreading + # the string iterates code points, which is what the API limit counts. + size="$(node -e 'process.stdout.write(String([...(process.env.NOTES || "")].length))')" echo "Release notes for v${NEXT_VERSION}: ${size} characters (limit ${MAX_CHARS})" if [ "$size" -le "$MAX_CHARS" ]; then diff --git a/docs/release.md b/docs/release.md index 961c27d9..07d92906 100644 --- a/docs/release.md +++ b/docs/release.md @@ -151,7 +151,8 @@ prerelease history**: with no previous stable tag, `@semantic-release/release-no builds the notes from the whole history. To prevent it, the release job runs semantic-release in dry-run mode on every branch -and measures the notes it would publish. Over `release_notes_max_chars`, the run fails +and measures the notes it would publish, counting Unicode code points so the +verdict does not depend on the publish runner's locale. Over `release_notes_max_chars`, the run fails there — before the real semantic-release step, so no tag exists and there is nothing to clean up.