From f0bda0f94241327164f049bde3506a647a3b6eb7 Mon Sep 17 00:00:00 2001 From: Rob Bavey Date: Mon, 20 Apr 2026 16:15:19 -0400 Subject: [PATCH 1/9] feat: introduce changelog fragment system for release notes Replaces free-form PR template release notes with per-PR YAML fragment files (docs/changelog/.yaml), modelled on the approach used by elastic/elasticsearch. Adds a CI workflow to validate fragments on every PR and updates the release notes generator to consume fragments instead of scraping the GitHub API. Co-Authored-By: Claude Sonnet 4.6 --- .github/PULL_REQUEST_TEMPLATE.md | 6 +- .github/workflows/validate_changelog.yml | 34 +++++++++ docs/changelog/README.md | 76 +++++++++++++++++++ tools/release/generate_release_notes_md.rb | 67 ++++++++++++----- tools/release/validate_changelog_fragment.rb | 77 ++++++++++++++++++++ 5 files changed, 241 insertions(+), 19 deletions(-) create mode 100644 .github/workflows/validate_changelog.yml create mode 100644 docs/changelog/README.md create mode 100755 tools/release/validate_changelog_fragment.rb diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index c97b7fcdf2..4914bf9c43 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -6,8 +6,10 @@ Please label this PR with the release version and one of the following labels, d - doc --> -## Release notes - +## Changelog fragment + ## What does this PR do? diff --git a/.github/workflows/validate_changelog.yml b/.github/workflows/validate_changelog.yml new file mode 100644 index 0000000000..1e71a6dce9 --- /dev/null +++ b/.github/workflows/validate_changelog.yml @@ -0,0 +1,34 @@ +name: Validate changelog fragment + +on: + pull_request: + types: [opened, synchronize, reopened, edited] + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Check for rn:skip + id: rn_skip + run: | + if echo "${{ github.event.pull_request.body }}" | grep -q '\[rn:skip\]'; then + echo "skip=true" >> $GITHUB_OUTPUT + else + echo "skip=false" >> $GITHUB_OUTPUT + fi + + - name: Validate fragment exists and is valid + if: steps.rn_skip.outputs.skip == 'false' + run: | + PR_NUMBER="${{ github.event.pull_request.number }}" + FRAGMENT="docs/changelog/${PR_NUMBER}.yaml" + + if [[ ! -f "${FRAGMENT}" ]]; then + echo "ERROR: No changelog fragment found at ${FRAGMENT}." + echo "Add a fragment file (see docs/changelog/README.md) or add [rn:skip] to the PR description to skip." + exit 1 + fi + + ruby tools/release/validate_changelog_fragment.rb "${FRAGMENT}" diff --git a/docs/changelog/README.md b/docs/changelog/README.md new file mode 100644 index 0000000000..3b712520a7 --- /dev/null +++ b/docs/changelog/README.md @@ -0,0 +1,76 @@ +# Changelog Fragments + +Each pull request that affects the Logstash release notes should include a YAML fragment file in this directory named `.yaml`. + +## Format + +```yaml +pr: 12345 +summary: "Brief, user-facing description of the change" +area: Pipeline +type: enhancement +issues: + - 12344 +``` + +### Required fields + +| Field | Description | +|-------|-------------| +| `pr` | Pull request number (must match the filename) | +| `summary` | One-line, user-facing description. Write for the person upgrading, not the reviewer. | +| `area` | Component area — see allowed values below | +| `type` | Type of change — see allowed values below | + +### Optional fields + +| Field | Description | +|-------|-------------| +| `issues` | List of linked issue numbers (use `[]` if none) | +| `highlight` | For notable features — adds an extended description to release highlights | + +### Allowed values for `area` + +- `Pipeline` — pipeline execution, persistent queue, dead letter queue +- `Config` — configuration parsing, settings, logstash.yml +- `Monitoring` — x-pack monitoring, metrics, health API +- `API` — HTTP API endpoints +- `Performance` — throughput, memory, CPU improvements +- `Plugins` — plugin framework, plugin management, gem handling +- `Security` — TLS, authentication, keystore +- `Packaging` — Docker images, RPM/DEB/ZIP distributions +- `Build` — build system, Gradle, CI tooling +- `Core` — other core Logstash changes +- `Docs` — documentation-only changes + +### Allowed values for `type` + +- `bug` — bug fix +- `enhancement` — improvement to an existing feature +- `feature` — new feature +- `breaking_change` — removes or incompatibly changes existing behaviour +- `deprecation` — marks something for future removal +- `dependency` — dependency update (include CVE number in summary if security-related) +- `doc` — documentation only + +## Skipping release notes + +If your PR should not appear in release notes (e.g. CI fixes, test-only changes), add `[rn:skip]` to the PR description instead of creating a fragment file. + +## highlight format + +For significant features, add a `highlight` block: + +```yaml +pr: 12345 +summary: "Add native OpenTelemetry output support" +area: Plugins +type: feature +issues: [] +highlight: + title: "Native OpenTelemetry output" + notable: true + body: |- + Logstash now ships a built-in output plugin for sending data directly to any + OpenTelemetry-compatible endpoint, without requiring a separate collector. +``` diff --git a/tools/release/generate_release_notes_md.rb b/tools/release/generate_release_notes_md.rb index d2c16c339f..cce36846f0 100755 --- a/tools/release/generate_release_notes_md.rb +++ b/tools/release/generate_release_notes_md.rb @@ -28,6 +28,19 @@ require 'net/http' RELEASE_NOTES_PATH = "docs/release-notes/index.md" +CHANGELOG_FRAGMENTS_PATH = "docs/changelog" + +SECTION_ORDER = %w[feature enhancement bug breaking_change deprecation dependency doc].freeze +SECTION_LABELS = { + "feature" => "New features", + "enhancement" => "Enhancements", + "bug" => "Bug fixes", + "breaking_change" => "Breaking changes", + "deprecation" => "Deprecations", + "dependency" => "Dependency updates", + "doc" => "Documentation", +}.freeze + release_branch = ARGV[0] previous_release_tag = ARGV[1] user = ARGV[2] @@ -45,28 +58,48 @@ release_notes_entry_index = coming_tag_index || release_notes.find_index {|line| line.match(/^## .*\[logstash-.*-release-notes\]$/) } unless coming_tag_index - report << "## #{current_release} [logstash-#{current_release}-release-notes]\n\n" - report << "### Features and enhancements [logstash-#{current_release}-features-enhancements]\n" + report << "## #{current_release} [logstash-#{current_release}-release-notes]\n" end -plugin_changes = {} +# Load changelog fragments and group by type +fragments = Dir.glob("#{CHANGELOG_FRAGMENTS_PATH}/*.yaml").sort.map do |path| + YAML.safe_load(File.read(path), permitted_classes: [Integer]) +rescue => e + $stderr.puts "Warning: skipping #{path}: #{e.message}" + nil +end.compact + +highlights = fragments.select { |f| f['highlight']&.fetch('notable', false) } +unless highlights.empty? + report << "### Highlights [logstash-#{current_release}-highlights]\n" + highlights.each do |f| + h = f['highlight'] + report << "#### #{h['title']}\n" + report << h['body'].to_s + report << "" + end +end -report << "---------- GENERATED CONTENT STARTS HERE ------------" -report << "=== Logstash Pull Requests with label v#{current_release}\n" +by_type = fragments.group_by { |f| f['type'] } + +SECTION_ORDER.each do |type| + entries = by_type[type] + next unless entries&.any? -uri = URI.parse("https://api.github.com/search/issues?q=repo:elastic/logstash+is:pr+is:closed+label:v#{current_release}&sort=created&order=asc") -pull_requests = JSON.parse(Net::HTTP.get(uri)) -pull_requests['items'].each do |prs| - report << "* #{prs['title']} #{prs['html_url']}[##{prs['number']}]" + anchor = "logstash-#{current_release}-#{type.tr('_', '-')}" + report << "### #{SECTION_LABELS[type]} [#{anchor}]\n" + entries.sort_by { |f| f['pr'] }.each do |f| + issue_links = Array(f['issues']).map { |i| "[##{i}](https://github.com/elastic/logstash/issues/#{i})" }.join(", ") + suffix = issue_links.empty? ? "" : " (#{issue_links})" + report << "* #{f['summary']} [##{f['pr']}](https://github.com/elastic/logstash/pull/#{f['pr']})#{suffix}" + end + report << "" end -report << "" - -report << "=== Logstash Commits between #{release_branch} and #{previous_release_tag}\n" -report << "Computed with \"git log --pretty=format:'%h -%d %s (%cr) <%an>' --abbrev-commit --date=relative v#{previous_release_tag}..#{release_branch}\"" -report << "" -logstash_prs = `git log --pretty=format:'%h -%d %s (%cr) <%an>' --abbrev-commit --date=relative v#{previous_release_tag}..#{release_branch}` -report << logstash_prs -report << "\n=== Logstash Plugin Release Changelogs ===" + +plugin_changes = {} + +report << "---------- GENERATED CONTENT STARTS HERE ------------" +report << "=== Logstash Plugin Release Changelogs ===" report << "Computed from \"git diff v#{previous_release_tag}..#{release_branch} *.release\"" result = `git diff v#{previous_release_tag}..#{release_branch} *.release`.split("\n") diff --git a/tools/release/validate_changelog_fragment.rb b/tools/release/validate_changelog_fragment.rb new file mode 100755 index 0000000000..34c4a13947 --- /dev/null +++ b/tools/release/validate_changelog_fragment.rb @@ -0,0 +1,77 @@ +#!/usr/bin/env ruby +# Validates changelog fragment YAML files under docs/changelog/. +# Usage: +# ruby validate_changelog_fragment.rb docs/changelog/12345.yaml [...] +# ruby validate_changelog_fragment.rb --all +# +# Exits non-zero if any fragment is invalid. + +require 'yaml' +require 'set' + +VALID_TYPES = %w[bug enhancement feature breaking_change deprecation dependency doc].freeze +VALID_AREAS = %w[Pipeline Config Monitoring API Performance Plugins Security Packaging Build Core Docs].freeze + +errors = [] + +files = if ARGV.include?('--all') + Dir.glob(File.join(__dir__, '../../docs/changelog/*.yaml')).sort +else + ARGV.reject { |a| a.start_with?('-') } +end + +if files.empty? + puts "No fragment files to validate." + exit 0 +end + +files.each do |path| + basename = File.basename(path, '.yaml') + + unless basename.match?(/\A\d+\z/) + errors << "#{path}: filename must be a PR number (e.g. 12345.yaml)" + next + end + + fragment = YAML.safe_load(File.read(path), permitted_classes: [Integer]) + + %w[pr summary area type].each do |field| + errors << "#{path}: missing required field '#{field}'" unless fragment.key?(field) + end + + if fragment['pr'] && fragment['pr'].to_s != basename + errors << "#{path}: 'pr' field (#{fragment['pr']}) does not match filename (#{basename})" + end + + if fragment['type'] && !VALID_TYPES.include?(fragment['type']) + errors << "#{path}: invalid type '#{fragment['type']}' — must be one of: #{VALID_TYPES.join(', ')}" + end + + if fragment['area'] && !VALID_AREAS.include?(fragment['area']) + errors << "#{path}: invalid area '#{fragment['area']}' — must be one of: #{VALID_AREAS.join(', ')}" + end + + if fragment['issues'] && !fragment['issues'].is_a?(Array) + errors << "#{path}: 'issues' must be a list (use [] for none)" + end + + if fragment['highlight'] + h = fragment['highlight'] + %w[title body].each do |f| + errors << "#{path}: highlight.#{f} is required when highlight is present" unless h[f] + end + end + +rescue Psych::SyntaxError => e + errors << "#{path}: YAML parse error — #{e.message}" +rescue => e + errors << "#{path}: #{e.message}" +end + +if errors.empty? + puts "All #{files.size} fragment(s) valid." + exit 0 +else + errors.each { |e| $stderr.puts "ERROR: #{e}" } + exit 1 +end From 9f194736cd2b15b0b0720b5416a815cc952bade5 Mon Sep 17 00:00:00 2001 From: Rob Bavey Date: Mon, 20 Apr 2026 16:21:00 -0400 Subject: [PATCH 2/9] fixup: update areas to Logstash-specific values and use real 9.3.0 highlight example Co-Authored-By: Claude Sonnet 4.6 --- docs/changelog/README.md | 35 ++++++++++---------- tools/release/validate_changelog_fragment.rb | 2 +- 2 files changed, 18 insertions(+), 19 deletions(-) diff --git a/docs/changelog/README.md b/docs/changelog/README.md index 3b712520a7..d3a9d5601f 100644 --- a/docs/changelog/README.md +++ b/docs/changelog/README.md @@ -7,7 +7,7 @@ Each pull request that affects the Logstash release notes should include a YAML ```yaml pr: 12345 summary: "Brief, user-facing description of the change" -area: Pipeline +area: core type: enhancement issues: - 12344 @@ -31,17 +31,14 @@ issues: ### Allowed values for `area` -- `Pipeline` — pipeline execution, persistent queue, dead letter queue -- `Config` — configuration parsing, settings, logstash.yml -- `Monitoring` — x-pack monitoring, metrics, health API -- `API` — HTTP API endpoints -- `Performance` — throughput, memory, CPU improvements -- `Plugins` — plugin framework, plugin management, gem handling -- `Security` — TLS, authentication, keystore -- `Packaging` — Docker images, RPM/DEB/ZIP distributions -- `Build` — build system, Gradle, CI tooling -- `Core` — other core Logstash changes -- `Docs` — documentation-only changes +- `core` — general Logstash core changes +- `performance` — throughput, memory, or CPU improvements +- `pq` — persistent queue +- `dlq` — dead letter queue +- `docs` — documentation-only changes +- `monitoring` — x-pack monitoring, metrics, health API +- `central management` — Kibana-based central pipeline management +- `pipeline->pipeline` — pipeline-to-pipeline communication ### Allowed values for `type` @@ -62,15 +59,17 @@ If your PR should not appear in release notes (e.g. CI fixes, test-only changes) For significant features, add a `highlight` block: ```yaml -pr: 12345 -summary: "Add native OpenTelemetry output support" -area: Plugins +pr: 18377 +summary: "Add wait_for_status and timeout parameters to the Logstash root API endpoint" +area: monitoring type: feature issues: [] highlight: - title: "Native OpenTelemetry output" + title: "Wait for status on the Logstash API" notable: true body: |- - Logstash now ships a built-in output plugin for sending data directly to any - OpenTelemetry-compatible endpoint, without requiring a separate collector. + The Logstash root endpoint `/` now accepts `wait_for_status` and `timeout` + query parameters. When set, the call blocks until Logstash reaches (or + exceeds) the requested status, or the timeout expires. This makes it + straightforward to script startup readiness checks without polling. ``` diff --git a/tools/release/validate_changelog_fragment.rb b/tools/release/validate_changelog_fragment.rb index 34c4a13947..f0c58822f2 100755 --- a/tools/release/validate_changelog_fragment.rb +++ b/tools/release/validate_changelog_fragment.rb @@ -10,7 +10,7 @@ require 'set' VALID_TYPES = %w[bug enhancement feature breaking_change deprecation dependency doc].freeze -VALID_AREAS = %w[Pipeline Config Monitoring API Performance Plugins Security Packaging Build Core Docs].freeze +VALID_AREAS = ["core", "performance", "pq", "dlq", "docs", "monitoring", "central management", "pipeline->pipeline"].freeze errors = [] From fcef3d7e562d94af0d66844e39ed1dffbecf02e3 Mon Sep 17 00:00:00 2001 From: Rob Bavey Date: Mon, 20 Apr 2026 16:25:42 -0400 Subject: [PATCH 3/9] fixup: limit validation to 9.x/main branches, skip backports --- .github/workflows/validate_changelog.yml | 27 +++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/.github/workflows/validate_changelog.yml b/.github/workflows/validate_changelog.yml index 1e71a6dce9..82f6d6de42 100644 --- a/.github/workflows/validate_changelog.yml +++ b/.github/workflows/validate_changelog.yml @@ -3,6 +3,9 @@ name: Validate changelog fragment on: pull_request: types: [opened, synchronize, reopened, edited] + branches: + - main + - 9.* jobs: validate: @@ -10,24 +13,38 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Check for rn:skip - id: rn_skip + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.3' + + - name: Check for rn:skip or backport label + id: skip_check + env: + PR_BODY: ${{ github.event.pull_request.body }} + PR_LABELS: ${{ toJSON(github.event.pull_request.labels.*.name) }} run: | - if echo "${{ github.event.pull_request.body }}" | grep -q '\[rn:skip\]'; then + if echo "${PR_BODY}" | grep -qF '[rn:skip]'; then + echo "skip=true" >> $GITHUB_OUTPUT + echo "Skipping: [rn:skip] found in PR body" + elif echo "${PR_LABELS}" | grep -qF 'backport'; then echo "skip=true" >> $GITHUB_OUTPUT + echo "Skipping: backport label detected (original fragment included via cherry-pick)" else echo "skip=false" >> $GITHUB_OUTPUT fi - name: Validate fragment exists and is valid - if: steps.rn_skip.outputs.skip == 'false' + if: steps.skip_check.outputs.skip == 'false' run: | PR_NUMBER="${{ github.event.pull_request.number }}" FRAGMENT="docs/changelog/${PR_NUMBER}.yaml" if [[ ! -f "${FRAGMENT}" ]]; then echo "ERROR: No changelog fragment found at ${FRAGMENT}." - echo "Add a fragment file (see docs/changelog/README.md) or add [rn:skip] to the PR description to skip." + echo "" + echo "Add a fragment file (see docs/changelog/README.md) or add [rn:skip] to the" + echo "PR description to skip release notes for this PR." exit 1 fi From 3517224d92f632ad1b67f4a26bc0f8dcc71c5379 Mon Sep 17 00:00:00 2001 From: Rob Bavey Date: Mon, 20 Apr 2026 17:02:11 -0400 Subject: [PATCH 4/9] fixup: delete consumed fragment files after generating release notes Ensures fragments for a given release cycle don't accumulate into the next patch release's notes. Co-Authored-By: Claude Sonnet 4.6 --- tools/release/generate_release_notes_md.rb | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tools/release/generate_release_notes_md.rb b/tools/release/generate_release_notes_md.rb index cce36846f0..30133e2a1d 100755 --- a/tools/release/generate_release_notes_md.rb +++ b/tools/release/generate_release_notes_md.rb @@ -153,10 +153,15 @@ exit end +fragment_files = Dir.glob("#{CHANGELOG_FRAGMENTS_PATH}/*.yaml") +fragment_files.each { |f| File.delete(f) } + puts "Creating commit.." branch_name = "update_release_notes_#{Time.now.to_i}" `git checkout -b #{branch_name}` -`git commit #{RELEASE_NOTES_PATH} -m "Update release notes for #{current_release}"` +files_to_commit = ([RELEASE_NOTES_PATH] + fragment_files).join(" ") +`git add #{files_to_commit}` +`git commit -m "Update release notes for #{current_release}"` puts "Pushing commit.." `git remote set-url origin https://x-access-token:#{token}@github.com/elastic/logstash.git` From 9475aa0fe5342b95ec6f20a1e44c7f2a07ffa5bc Mon Sep 17 00:00:00 2001 From: Rob Bavey Date: Mon, 20 Apr 2026 17:18:03 -0400 Subject: [PATCH 5/9] fixup: write bundle on generation, prune fragments separately Generation is now re-runnable: it writes a bundle file recording which fragments went into the release, but does not delete the fragments. prune_changelog_fragments.rb is run explicitly once the release notes are finalised, removing fragments that appear in any bundle. Co-Authored-By: Claude Sonnet 4.6 --- tools/release/generate_release_notes_md.rb | 20 ++++++-- tools/release/prune_changelog_fragments.rb | 53 ++++++++++++++++++++++ 2 files changed, 68 insertions(+), 5 deletions(-) create mode 100755 tools/release/prune_changelog_fragments.rb diff --git a/tools/release/generate_release_notes_md.rb b/tools/release/generate_release_notes_md.rb index 30133e2a1d..1db8448ea8 100755 --- a/tools/release/generate_release_notes_md.rb +++ b/tools/release/generate_release_notes_md.rb @@ -26,9 +26,11 @@ require 'yaml' require 'json' require 'net/http' +require 'fileutils' RELEASE_NOTES_PATH = "docs/release-notes/index.md" CHANGELOG_FRAGMENTS_PATH = "docs/changelog" +CHANGELOG_BUNDLES_PATH = "docs/release-notes/changelog-bundles" SECTION_ORDER = %w[feature enhancement bug breaking_change deprecation dependency doc].freeze SECTION_LABELS = { @@ -148,19 +150,27 @@ IO.write(RELEASE_NOTES_PATH, release_notes.join("\n")) +# Write a bundle file recording which fragments went into this release. +# This is the input for prune_changelog_fragments.rb, which is run separately +# once the release notes are finalised — keeping generation re-runnable. +FileUtils.mkdir_p(CHANGELOG_BUNDLES_PATH) +bundle = { + "version" => current_release, + "generated" => Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ"), + "changelogs" => fragments +} +bundle_path = File.join(CHANGELOG_BUNDLES_PATH, "#{current_release}.yml") +IO.write(bundle_path, YAML.dump(bundle)) + if token.nil? puts "No token provided, skipping commit and push" exit end -fragment_files = Dir.glob("#{CHANGELOG_FRAGMENTS_PATH}/*.yaml") -fragment_files.each { |f| File.delete(f) } - puts "Creating commit.." branch_name = "update_release_notes_#{Time.now.to_i}" `git checkout -b #{branch_name}` -files_to_commit = ([RELEASE_NOTES_PATH] + fragment_files).join(" ") -`git add #{files_to_commit}` +`git add #{RELEASE_NOTES_PATH} #{bundle_path}` `git commit -m "Update release notes for #{current_release}"` puts "Pushing commit.." diff --git a/tools/release/prune_changelog_fragments.rb b/tools/release/prune_changelog_fragments.rb new file mode 100755 index 0000000000..f358bb7bcb --- /dev/null +++ b/tools/release/prune_changelog_fragments.rb @@ -0,0 +1,53 @@ +#!/usr/bin/env ruby +# Deletes changelog fragment files that have already been captured in a bundle. +# +# Run this once you are satisfied with the generated release notes for a version. +# It is intentionally separate from generate_release_notes_md.rb so that +# generation remains re-runnable (e.g. when a late-breaking change arrives +# after the first draft). +# +# Usage: +# ruby prune_changelog_fragments.rb +# ruby prune_changelog_fragments.rb --dry-run + +require 'yaml' + +CHANGELOG_FRAGMENTS_PATH = "docs/changelog" +CHANGELOG_BUNDLES_PATH = "docs/release-notes/changelog-bundles" + +dry_run = ARGV.include?('--dry-run') + +bundle_files = Dir.glob("#{CHANGELOG_BUNDLES_PATH}/*.yml").sort +if bundle_files.empty? + puts "No bundle files found in #{CHANGELOG_BUNDLES_PATH} — nothing to prune." + exit 0 +end + +bundled_prs = bundle_files.flat_map do |path| + bundle = YAML.safe_load(File.read(path), permitted_classes: [Integer]) + Array(bundle['changelogs']).map { |c| c['pr'].to_s } +rescue => e + $stderr.puts "Warning: skipping bundle #{path}: #{e.message}" + [] +end.to_set + +pruned = [] +Dir.glob("#{CHANGELOG_FRAGMENTS_PATH}/*.yaml").sort.each do |path| + pr = File.basename(path, '.yaml') + next unless bundled_prs.include?(pr) + + if dry_run + puts "Would delete: #{path}" + else + File.delete(path) + puts "Deleted: #{path}" + end + pruned << path +end + +if pruned.empty? + puts "No fragment files matched bundled PRs — nothing pruned." +else + puts "#{dry_run ? 'Would prune' : 'Pruned'} #{pruned.size} fragment(s)." + puts "Stage and commit the deletions with: git add docs/changelog/ && git commit -m 'Prune changelog fragments for released versions'" unless dry_run +end From 312973c1d616b0203a77526099024d860a0fdfde Mon Sep 17 00:00:00 2001 From: Rob Bavey Date: Mon, 20 Apr 2026 17:26:18 -0400 Subject: [PATCH 6/9] fixup: verify fragment is present in diff for backport PRs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rather than skipping validation entirely for backports, check that at least one docs/changelog/*.yaml file appears in the PR diff — catching cases where the fragment was accidentally dropped during cherry-pick. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/validate_changelog.yml | 42 ++++++++++++++++++++---- 1 file changed, 35 insertions(+), 7 deletions(-) diff --git a/.github/workflows/validate_changelog.yml b/.github/workflows/validate_changelog.yml index 82f6d6de42..42d65baed6 100644 --- a/.github/workflows/validate_changelog.yml +++ b/.github/workflows/validate_changelog.yml @@ -12,30 +12,39 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Set up Ruby uses: ruby/setup-ruby@v1 with: ruby-version: '3.3' - - name: Check for rn:skip or backport label + - name: Check for rn:skip id: skip_check env: PR_BODY: ${{ github.event.pull_request.body }} - PR_LABELS: ${{ toJSON(github.event.pull_request.labels.*.name) }} run: | if echo "${PR_BODY}" | grep -qF '[rn:skip]'; then echo "skip=true" >> $GITHUB_OUTPUT echo "Skipping: [rn:skip] found in PR body" - elif echo "${PR_LABELS}" | grep -qF 'backport'; then - echo "skip=true" >> $GITHUB_OUTPUT - echo "Skipping: backport label detected (original fragment included via cherry-pick)" else echo "skip=false" >> $GITHUB_OUTPUT fi - - name: Validate fragment exists and is valid - if: steps.skip_check.outputs.skip == 'false' + - name: Check for backport label + id: backport_check + env: + PR_LABELS: ${{ toJSON(github.event.pull_request.labels.*.name) }} + run: | + if echo "${PR_LABELS}" | grep -qF 'backport'; then + echo "is_backport=true" >> $GITHUB_OUTPUT + else + echo "is_backport=false" >> $GITHUB_OUTPUT + fi + + - name: Validate fragment exists and is valid (normal PR) + if: steps.skip_check.outputs.skip == 'false' && steps.backport_check.outputs.is_backport == 'false' run: | PR_NUMBER="${{ github.event.pull_request.number }}" FRAGMENT="docs/changelog/${PR_NUMBER}.yaml" @@ -49,3 +58,22 @@ jobs: fi ruby tools/release/validate_changelog_fragment.rb "${FRAGMENT}" + + - name: Validate fragment present in diff (backport PR) + if: steps.skip_check.outputs.skip == 'false' && steps.backport_check.outputs.is_backport == 'true' + run: | + BASE="${{ github.event.pull_request.base.sha }}" + HEAD="${{ github.event.pull_request.head.sha }}" + + FRAGMENT=$(git diff --name-only "${BASE}".."${HEAD}" -- 'docs/changelog/*.yaml' | head -1) + + if [[ -z "${FRAGMENT}" ]]; then + echo "ERROR: Backport PR contains no changelog fragment in docs/changelog/." + echo "" + echo "The cherry-pick should have included the fragment from the original PR." + echo "Check that the fragment file was not dropped during conflict resolution." + exit 1 + fi + + echo "Found fragment in diff: ${FRAGMENT}" + ruby tools/release/validate_changelog_fragment.rb "${FRAGMENT}" From d395ea1c86f0519c06b53176653b10e54a3f0331 Mon Sep 17 00:00:00 2001 From: Rob Bavey Date: Mon, 20 Apr 2026 17:33:27 -0400 Subject: [PATCH 7/9] fixup: expand README with backport, generation, prune, and AI agent guidance Co-Authored-By: Claude Sonnet 4.6 --- docs/changelog/README.md | 80 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 78 insertions(+), 2 deletions(-) diff --git a/docs/changelog/README.md b/docs/changelog/README.md index d3a9d5601f..24c7218c47 100644 --- a/docs/changelog/README.md +++ b/docs/changelog/README.md @@ -2,6 +2,8 @@ Each pull request that affects the Logstash release notes should include a YAML fragment file in this directory named `.yaml`. +This applies to human contributors and AI agents (Claude, Copilot, and others) equally. If you are an AI agent opening or assisting with a PR, you must follow all rules in this document. + ## Format ```yaml @@ -52,9 +54,9 @@ issues: ## Skipping release notes -If your PR should not appear in release notes (e.g. CI fixes, test-only changes), add `[rn:skip]` to the PR description instead of creating a fragment file. +If your PR should not appear in release notes (e.g. CI fixes, test-only changes), add `[rn:skip]` to the PR description instead of creating a fragment file. This applies to backport PRs too — see below. -## highlight format +## Highlight format For significant features, add a `highlight` block: @@ -73,3 +75,77 @@ highlight: exceeds) the requested status, or the timeout expires. This makes it straightforward to script startup readiness checks without polling. ``` + +## Backports + +When a PR is backported to a maintenance branch (e.g. `9.3`), the automated +backport bot cherry-picks the original commits, which includes the fragment +file. **Do not create a new fragment for the backport PR.** + +The original fragment (named after the original PR number) will appear in the +release notes of every version it lands in. For example, if PR #500 is merged +to `main` and backported to `9.3`: + +- `9.4.0` release notes will include the entry from `500.yaml` (via `main`) +- `9.3.x` release notes will include the same entry (via the cherry-pick) + +### CI behaviour for backport PRs + +CI does not require a fragment named after the backport PR number. Instead it +verifies that **at least one `docs/changelog/*.yaml` file appears in the PR +diff** — confirming the fragment was not accidentally dropped during +cherry-pick conflict resolution. + +If a fragment is missing from a backport PR diff, check whether it was dropped +during conflict resolution and re-add it manually. + +If the backport genuinely needs no release notes entry (e.g. a CI-only fix), +add `[rn:skip]` to the backport PR description. + +### Branch scope + +Fragment validation only runs on PRs targeting `main` and `9.*` branches. +PRs targeting `8.19` (which uses a different asciidoc-based release notes +process) are not affected. + +## Release notes generation + +Release notes are generated by `tools/release/generate_release_notes_md.rb`. +Running the generator is safe to repeat: it reads all `*.yaml` files currently +in this directory, writes the release notes, and records the consumed fragments +in a bundle file at `docs/release-notes/changelog-bundles/.yml`. +**It does not delete fragment files.** This means the generator can be re-run +as many times as needed — for example, when a late-breaking change arrives +after the first draft. + +## Pruning fragments after release + +Once the release notes for a version are finalised and the release has shipped, +run the prune script to remove fragment files that have been captured in a bundle: + +```bash +# Preview what would be deleted +ruby tools/release/prune_changelog_fragments.rb --dry-run + +# Delete and stage +ruby tools/release/prune_changelog_fragments.rb +git add docs/changelog/ +git commit -m "Prune changelog fragments for " +``` + +The prune step reads all bundle files and deletes any fragment whose PR number +appears in a bundle. It is safe to run repeatedly. + +## Validation + +To validate fragment files locally before pushing: + +```bash +# Validate a specific fragment +ruby tools/release/validate_changelog_fragment.rb docs/changelog/12345.yaml + +# Validate all fragments +ruby tools/release/validate_changelog_fragment.rb --all +``` + +CI runs this check automatically on every PR. From 7a6936dfaa84825b0f2e611b95af252bd52a999f Mon Sep 17 00:00:00 2001 From: Rob Bavey Date: Mon, 20 Apr 2026 17:46:32 -0400 Subject: [PATCH 8/9] add changelog fragment for #19027 Co-Authored-By: Claude Sonnet 4.6 --- docs/changelog/19027.yaml | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 docs/changelog/19027.yaml diff --git a/docs/changelog/19027.yaml b/docs/changelog/19027.yaml new file mode 100644 index 0000000000..a530b0edf8 --- /dev/null +++ b/docs/changelog/19027.yaml @@ -0,0 +1,5 @@ +pr: 19027 +summary: "Introduce per-PR changelog fragment system for structured release notes" +area: docs +type: feature +issues: [] From 32430194f4af66e1ea20d2731c98f90e7855db66 Mon Sep 17 00:00:00 2001 From: Rob Bavey Date: Mon, 20 Apr 2026 18:08:32 -0400 Subject: [PATCH 9/9] fix: address Copilot review comments - Remove literal [rn:skip] from PR template comment to prevent CI always treating PRs as skipped; use grep -x to require whole-line match - Match exact "backport" label in JSON to avoid false positives from backport-9.x request labels - Add require 'set' to prune script; remove unused require from validator - Permit Time class when loading bundles to handle YAML timestamp fields - Validate fragment is a Hash before accessing fields - Enforce pr field is an unquoted integer so sort is always numeric Co-Authored-By: Claude Sonnet 4.6 --- .github/PULL_REQUEST_TEMPLATE.md | 3 ++- .github/workflows/validate_changelog.yml | 4 ++-- tools/release/prune_changelog_fragments.rb | 3 ++- tools/release/validate_changelog_fragment.rb | 15 ++++++++++++--- 4 files changed, 18 insertions(+), 7 deletions(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 4914bf9c43..30b82ca557 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -9,7 +9,8 @@ Please label this PR with the release version and one of the following labels, d ## Changelog fragment + If this PR should not appear in release notes (CI fix, test-only, etc.), replace this comment + with the release-notes skip marker instead. --> ## What does this PR do? diff --git a/.github/workflows/validate_changelog.yml b/.github/workflows/validate_changelog.yml index 42d65baed6..721ea8432c 100644 --- a/.github/workflows/validate_changelog.yml +++ b/.github/workflows/validate_changelog.yml @@ -25,7 +25,7 @@ jobs: env: PR_BODY: ${{ github.event.pull_request.body }} run: | - if echo "${PR_BODY}" | grep -qF '[rn:skip]'; then + if echo "${PR_BODY}" | grep -qxF '[rn:skip]'; then echo "skip=true" >> $GITHUB_OUTPUT echo "Skipping: [rn:skip] found in PR body" else @@ -37,7 +37,7 @@ jobs: env: PR_LABELS: ${{ toJSON(github.event.pull_request.labels.*.name) }} run: | - if echo "${PR_LABELS}" | grep -qF 'backport'; then + if echo "${PR_LABELS}" | grep -qF '"backport"'; then echo "is_backport=true" >> $GITHUB_OUTPUT else echo "is_backport=false" >> $GITHUB_OUTPUT diff --git a/tools/release/prune_changelog_fragments.rb b/tools/release/prune_changelog_fragments.rb index f358bb7bcb..5c9737e7b7 100755 --- a/tools/release/prune_changelog_fragments.rb +++ b/tools/release/prune_changelog_fragments.rb @@ -11,6 +11,7 @@ # ruby prune_changelog_fragments.rb --dry-run require 'yaml' +require 'set' CHANGELOG_FRAGMENTS_PATH = "docs/changelog" CHANGELOG_BUNDLES_PATH = "docs/release-notes/changelog-bundles" @@ -24,7 +25,7 @@ end bundled_prs = bundle_files.flat_map do |path| - bundle = YAML.safe_load(File.read(path), permitted_classes: [Integer]) + bundle = YAML.safe_load(File.read(path), permitted_classes: [Integer, Time]) Array(bundle['changelogs']).map { |c| c['pr'].to_s } rescue => e $stderr.puts "Warning: skipping bundle #{path}: #{e.message}" diff --git a/tools/release/validate_changelog_fragment.rb b/tools/release/validate_changelog_fragment.rb index f0c58822f2..da96a33cf1 100755 --- a/tools/release/validate_changelog_fragment.rb +++ b/tools/release/validate_changelog_fragment.rb @@ -7,7 +7,6 @@ # Exits non-zero if any fragment is invalid. require 'yaml' -require 'set' VALID_TYPES = %w[bug enhancement feature breaking_change deprecation dependency doc].freeze VALID_AREAS = ["core", "performance", "pq", "dlq", "docs", "monitoring", "central management", "pipeline->pipeline"].freeze @@ -35,12 +34,22 @@ fragment = YAML.safe_load(File.read(path), permitted_classes: [Integer]) + unless fragment.is_a?(Hash) + errors << "#{path}: YAML document must be a mapping, got #{fragment.class}" + next + end + %w[pr summary area type].each do |field| errors << "#{path}: missing required field '#{field}'" unless fragment.key?(field) end - if fragment['pr'] && fragment['pr'].to_s != basename - errors << "#{path}: 'pr' field (#{fragment['pr']}) does not match filename (#{basename})" + if fragment['pr'] + unless fragment['pr'].is_a?(Integer) + errors << "#{path}: 'pr' must be an unquoted integer, got #{fragment['pr'].class}" + end + if fragment['pr'].to_s != basename + errors << "#{path}: 'pr' field (#{fragment['pr']}) does not match filename (#{basename})" + end end if fragment['type'] && !VALID_TYPES.include?(fragment['type'])