From 8f35c11a72ecd9cbdc60cf272a0addcf48b17166 Mon Sep 17 00:00:00 2001 From: lcawl Date: Thu, 20 Aug 2026 15:52:08 -0500 Subject: [PATCH 1/3] Add changelog bundle --infer option and config --- config/changelog.example.yml | 11 +- docs/cli-schema.json | 12 +- docs/cli/changelog/cmd-bundle.md | 23 ++- docs/cli/changelog/cmd-remove.md | 2 +- docs/data/release-notes/bundle.md | 6 +- docs/data/release-notes/configure-ref.md | 11 +- .../Changelog/BundleConfiguration.cs | 16 ++ .../Changelog/ChangelogConfigurationLoader.cs | 2 + .../Changelog/ChangelogConfigurationYaml.cs | 12 ++ .../Bundling/ChangelogBundlingService.cs | 34 +++- .../Bundling/GitRangeEntryResolver.cs | 32 +++- .../docs-builder/Commands/ChangelogCommand.cs | 15 +- .../Changelogs/BundleGitRefTests.cs | 147 +++++++++++++++++- .../Changelogs/ChangelogConfigurationTests.cs | 29 ++++ 14 files changed, 309 insertions(+), 43 deletions(-) diff --git a/config/changelog.example.yml b/config/changelog.example.yml index 1a2e480849..3ccc5d14e2 100644 --- a/config/changelog.example.yml +++ b/config/changelog.example.yml @@ -246,20 +246,25 @@ bundle: # repo: elasticsearch # Optional: default GitHub owner applied to all profiles that do not specify their own. # owner: elastic - # Optional: branch whose CDN changelog pool (changelog/{org}/{repo}/{branch}/...) is sourced from when + # Optional: branch whose CDN changelog entries (changelog/{org}/{repo}/{branch}/...) are sourced from when # bundling entries from the CDN. Defaults to "main" when unset. Can be overridden per profile. # branch: main # Optional: control auto-population of release-date for all profiles by default. # When true (default), auto-populate release dates. Profiles can override this setting. # release_dates: true + # Optional: when true, git-ref bundling synthesizes changelog entries from GitHub PR metadata + # when no matching changelog YAML is found on the CDN (or in the local folder when local + # sourcing is forced). Default false: unmatched PRs are warned and omitted like --prs. + # Override per profile or with CLI --infer. + # infer_missing_changelogs: false # Named bundle profiles for different release scenarios. # Profiles can be used with both 'changelog bundle' and 'changelog remove': # docs-builder changelog bundle elasticsearch-release 9.2.0 # docs-builder changelog remove elasticsearch-release 9.2.0 # When used with 'changelog remove', only the 'products' field is applied. - # The 'output', 'output_products', 'repo', 'owner', and 'hide_features' fields are - # bundle-specific and are ignored for removal. + # The 'output', 'output_products', 'repo', 'owner', 'hide_features', and + # 'infer_missing_changelogs' fields are bundle-specific and are ignored for removal. profiles: # Example: Elasticsearch release profile (filter by changelog fields) # elasticsearch-release: diff --git a/docs/cli-schema.json b/docs/cli-schema.json index 9298a52cc8..3fb674c9ae 100644 --- a/docs/cli-schema.json +++ b/docs/cli-schema.json @@ -3240,7 +3240,7 @@ "name": "start-git-ref", "type": "string", "required": false, - "summary": "Start ref (exclusive) of a git commit range to bundle, for example the previously published endpoint ref. Must be provided together with --end-git-ref; the start ref is never inferred. The PR list is derived from the range itself (GitHub compare API \u002B GraphQL associatedPullRequests), each PR\u0027s entry is sourced pool-first with PR-metadata fallback, and requires GITHUB_TOKEN. Supported in profile-based commands (for example, \u0027bundle serverless-release 2026-08-13 --start-git-ref abc123 --end-git-ref def456\u0027); mutually exclusive with all other filter options." + "summary": "Start ref (exclusive) of a git commit range to bundle, for example the previously published endpoint ref. Must be provided together with --end-git-ref; the start ref is never inferred. The PR list is derived from the range itself (GitHub compare API \u002B GraphQL associatedPullRequests). Each PR\u0027s changelog YAML is sourced from the CDN (or the local folder when local sourcing is forced); unmatched PRs are warned and omitted unless --infer or bundle.infer_missing_changelogs is set. Requires GITHUB_TOKEN. Supported in profile-based commands (for example, \u0027bundle serverless-release 2026-08-13 --start-git-ref abc123 --end-git-ref def456\u0027); mutually exclusive with all other filter options." }, { "role": "flag", @@ -3254,7 +3254,15 @@ "name": "dry-run", "type": "boolean", "required": false, - "summary": "Resolve the commit range and print the run report (resolved PR list with per-PR entry source: pool, inferred, or missing) as Markdown without writing a bundle. Only valid together with --start-git-ref/--end-git-ref. Supported in profile-based commands.", + "summary": "Resolve the commit range and print the run report (resolved PR list with per-PR entry source: pool, no changelog, inferred, or missing) as Markdown without writing a bundle. Only valid together with --start-git-ref/--end-git-ref. Supported in profile-based commands.", + "defaultValue": "false" + }, + { + "role": "flag", + "name": "infer", + "type": "boolean", + "required": false, + "summary": "When bundling a git commit range, synthesize in-memory changelog entries from GitHub PR metadata for PRs with no matching changelog YAML on the CDN (or in the local folder when using --force-local). Default: unmatched PRs are warned and omitted like --prs. Equivalent to bundle.infer_missing_changelogs: true without editing config. Allowed in profile-based commands. Only valid together with --start-git-ref/--end-git-ref.", "defaultValue": "false" }, { diff --git a/docs/cli/changelog/cmd-bundle.md b/docs/cli/changelog/cmd-bundle.md index 726c63025a..dc44ca8867 100644 --- a/docs/cli/changelog/cmd-bundle.md +++ b/docs/cli/changelog/cmd-bundle.md @@ -54,6 +54,8 @@ Exactly one of the following filter flags is required: `--force-local` is not a filter. It forces local entry sourcing for the run (equivalent to `bundle.use_local_changelogs: true` without editing config) and is allowed in both option-based and profile-based modes. +`--infer` is not a filter. It is allowed in both option-based and profile-based modes, but only together with `--start-git-ref`/`--end-git-ref`. Equivalent to `bundle.infer_missing_changelogs: true`. + ```sh # Bundle all changelogs in docs/changelog/ docs-builder changelog bundle --all --directory docs/changelog @@ -90,10 +92,10 @@ Both refs are always required together — the start ref is never inferred from 1. Enumerates the commits in `start..end` via the GitHub compare API (paginated). 2. Resolves each commit to its merged pull request via GraphQL `associatedPullRequests`. Works for squash and merge commits on protected integration branches; commits with no associated PR are reported, never silently dropped. When a commit is associated with multiple merged PRs, the command warns and picks deterministically (merge-commit match first, then lowest PR number). -3. Sources each PR's changelog entry with a fixed precedence: - - **A checked-in entry from the entry pool wins.** Pool entries are matched by file-name-derived PR numbers (file names survive scrubbing, so this works for private repos whose `prs` references were removed from public copies) or by the entry's `prs` references. - - **Otherwise the entry is synthesized from PR metadata** — the same extraction path `changelog add` uses: release-note text from the PR body becomes the description, and labels map to type/areas/products/feature-id via the `pivot.*` configuration. `rules.create` label rules decide inclusion. - - **PRs whose metadata cannot be fetched are reported as missing** with a warning. +3. Sources each PR's changelog YAML from the **CDN** (default) or the **local folder** (`--force-local`, `--directory`, or `bundle.use_local_changelogs`): + - Entries are matched by file-name-derived PR numbers (file names survive scrubbing, so this works for private repos whose `prs` references were removed from public CDN copies) or by the entry's `prs` references. + - PRs with no matching changelog YAML are **warned and omitted**, the same as `--prs`. A missing changelog is valid when the change is not notable. The run succeeds as long as at least one matching entry remains. + - Pass `--infer` (or set `bundle.infer_missing_changelogs: true` / `bundle.profiles..infer_missing_changelogs: true`) to **synthesize** an in-memory entry from GitHub PR metadata instead — the same extraction path `changelog add` uses: release-note text from the PR body becomes the description, and labels map to type/areas/products/feature-id via the `pivot.*` configuration. `rules.create` label rules decide inclusion. PRs whose metadata cannot be fetched are reported as missing with a warning. 4. Records the end ref in the bundle output as the `git_ref` metadata field. Commit-range mode works in both profile-based and option-based commands and is mutually exclusive with every other filter. In profile-based commands the profile contributes output metadata only (`output_products`, `repo`, `owner`, `rules`, and so on) — it must not set a `products` pattern or `source: github_release`. When the profile has no explicit `output` pattern, the bundle name follows the `{product}-{version}.yaml` convention. @@ -106,13 +108,20 @@ Commit-range mode requires a `GITHUB_TOKEN` environment variable: the GraphQL AP ### Dry run -Pass `--dry-run` to resolve the range and print the run report — the resolved PR list with each PR's entry source (`pool`, `inferred (PR body)`, `inferred (title)`, `excluded (rules)`, or `missing`) plus any commits without an associated PR — as Markdown, without writing a bundle. The report is suitable for a release PR body or a CI job summary. +Pass `--dry-run` to resolve the range and print the run report — the resolved PR list with each PR's entry source (`cdn`, `no changelog`, `inferred (PR body)`, `inferred (title)`, `excluded (rules)`, or `missing`) plus any commits without an associated PR — as Markdown, without writing a bundle. The report is suitable for a release PR body or a CI job summary. `cdn` means a matching changelog YAML was found (from the public CDN, or from the local folder when you force local sourcing). By default, unmatched PRs appear as `no changelog`; `inferred` rows appear only when `--infer` (or the equivalent YAML) is set. ```sh docs-builder changelog bundle serverless-release 2026-08-13 \ --start-git-ref abc123 --end-git-ref def456 --dry-run ``` +To include synthesized copy for PRs that have no matching changelog YAML on the CDN: + +```sh +docs-builder changelog bundle serverless-release 2026-08-13 \ + --start-git-ref abc123 --end-git-ref def456 --infer +``` + ## Bundles are self-contained Every bundle embeds the full content of each changelog entry (`title`, `type`, `products`, and so on), plus a `file` block recording the source file name and checksum for provenance. Rendering — via the `{changelog}` directive, `changelog render`, or the CDN pipeline — never reads the original changelog files, so you can clean them up with `docs-builder changelog remove` immediately after bundling. @@ -408,7 +417,7 @@ In profile mode, pass the same path list as a positional argument: docs-builder changelog bundle serverless-release 2026-07-07 ./docs/temp/changelog_files.txt ``` -`--files` / path-list selection follows the standard entry-sourcing rules. When entries are sourced from the CDN (the default when `bundle.repo` resolves), the listed paths are matched to CDN pool entries by file name and do not need to exist locally — useful for private repositories whose entries exist only in S3 and whose public copies have PR/issue references scrubbed, so PR-based filters cannot match. With local sourcing (`--force-local`, `--directory`, or `bundle.use_local_changelogs`), the listed files are read from disk and must exist. In either mode, a listed entry that cannot be found fails the run, and `rules.bundle` still applies after selection. +`--files` / path-list selection follows the standard entry-sourcing rules. When entries are sourced from the CDN (the default when `bundle.repo` resolves), the listed paths are matched to CDN entries by file name and do not need to exist locally — useful for private repositories whose entries exist only in S3 and whose public copies have PR/issue references scrubbed, so PR-based filters cannot match. With local sourcing (`--force-local`, `--directory`, or `bundle.use_local_changelogs`), the listed files are read from disk and must exist. In either mode, a listed entry that cannot be found fails the run, and `rules.bundle` still applies after selection. ### Force local entry sourcing [changelog-bundle-force-local] @@ -420,7 +429,7 @@ docs-builder changelog bundle serverless-release 2026-07-07 ./docs/temp/prs.txt ``` `--force-local` is allowed in both option-based and profile-based commands. -Use it with path-list / `--files` filters when the listed files should be read from disk instead of matched against the CDN pool. +Use it with path-list / `--files` filters when the listed files should be read from disk instead of matched against the CDN. ### Hide features [changelog-bundle-hide-features] diff --git a/docs/cli/changelog/cmd-remove.md b/docs/cli/changelog/cmd-remove.md index 441553d72d..4703ed9231 100644 --- a/docs/cli/changelog/cmd-remove.md +++ b/docs/cli/changelog/cmd-remove.md @@ -58,6 +58,6 @@ The `--products` filter supports wildcards: When `changelog.yml` defines `bundle.profiles`, use those same profiles with `changelog remove` to remove exactly the changelogs that would be included in a matching bundle. -Profile-based commands discover the changelog configuration automatically: they look for `changelog.yml` in the current directory, then `docs/changelog.yml`. +Profile-based commands discover the changelog configuration automatically: they look for `changelog.yml` in the current directory, then `docs/changelog.yml`. Profile fields that only affect bundle output (`output`, `output_products`, `hide_features`, `infer_missing_changelogs`) are ignored. Refer to [](/data/release-notes/bundle.md#changelog-remove) for examples. diff --git a/docs/data/release-notes/bundle.md b/docs/data/release-notes/bundle.md index beae7c8d16..3f9abfef33 100644 --- a/docs/data/release-notes/bundle.md +++ b/docs/data/release-notes/bundle.md @@ -128,8 +128,8 @@ bundle: output_products: "cloud-serverless {version}" <2> ``` -1. The authoring repository whose commit range is resolved and whose entry pool is consulted. -2. Also applied to entries synthesized from PR metadata when the PR's labels map to no product. When the profile has no `output` pattern, the bundle is named `{product}-{version}.yaml` by convention. +1. The authoring repository whose commit range is resolved and whose uploaded changelog entries (CDN, or local folder when local sourcing is forced) are consulted. +2. Also applied to entries synthesized from PR metadata when inferral is on (`--infer` or `infer_missing_changelogs: true`) and the PR's labels map to no product. When the profile has no `output` pattern, the bundle is named `{product}-{version}.yaml` by convention. ```sh docs-builder changelog bundle serverless-release 2026-08-13 \ @@ -137,7 +137,7 @@ docs-builder changelog bundle serverless-release 2026-08-13 \ --end-git-ref ``` -For each PR in the range, a checked-in changelog entry (already uploaded to the entry pool) wins; otherwise an entry is synthesized from the PR's title, labels, and release-note text using the same extraction path as `changelog add`. The bundle records the end ref in a `git_ref` metadata field. Refer to [Commit-range mode](/cli/changelog/bundle.md#git-ref-mode) for the full behavior, including the `--dry-run` run report. +For each PR in the range, a matching changelog YAML on the CDN (or in the local folder when you force local sourcing) is included. PRs with no matching changelog are warned and omitted (the change may not be notable), matching `--prs`. Pass `--infer` or set `infer_missing_changelogs: true` on the bundle or profile to synthesize an entry from the PR's title, labels, and release-note text using the same extraction path as `changelog add`. The bundle records the end ref in a `git_ref` metadata field. Refer to [Commit-range mode](/cli/changelog/bundle.md#git-ref-mode) for the full behavior, including the `--dry-run` run report. ### Bundle by folder or changelog product diff --git a/docs/data/release-notes/configure-ref.md b/docs/data/release-notes/configure-ref.md index 5e8add87d2..f9590171d0 100644 --- a/docs/data/release-notes/configure-ref.md +++ b/docs/data/release-notes/configure-ref.md @@ -46,8 +46,9 @@ These settings are relevant to one or all of the `changelog bundle`, `changelog | Setting | Description | | ------------------------- | ----------- | -| `bundle.branch` | Branch whose CDN changelog pool (`changelog/{org}/{repo}/{branch}/...`) entries are sourced from when bundling (default: `main`). Refer to [Entry sourcing](#bundle-entry-sourcing). | +| `bundle.branch` | Branch whose CDN changelog entries (`changelog/{org}/{repo}/{branch}/...`) are sourced from when bundling (default: `main`). Refer to [Entry sourcing](#bundle-entry-sourcing). | | `bundle.directory` | Input directory containing changelog YAML files (default: `docs/changelog`). | +| `bundle.infer_missing_changelogs` | When `true`, git-ref bundling synthesizes in-memory changelog entries from GitHub PR metadata when no matching changelog YAML is found on the CDN (or in the local folder when local sourcing is forced). Default `false`: unmatched PRs are warned and omitted like `--prs`. Override with `--infer` or a profile-level setting. | | `bundle.link_allow_repos` | List of `owner/repo` pairs whose PR/issue links are preserved. When set (including empty `[]`), links to unlisted repos become `# PRIVATE:` sentinels. | | `bundle.output_directory` | Output directory for bundled files (default: `docs/releases`). | | `bundle.owner` | Default GitHub repository owner (for example, `elastic`). Also the org segment of uploaded changelog-entry keys (`changelog/{org}/{repo}/{branch}/...`) and CDN entry sourcing. | @@ -73,9 +74,9 @@ The authoring repo is resolved with the same precedence as `changelog upload`: ` Sourcing is decided per run: - **Local folder.** Used when `bundle.use_local_changelogs: true`, when `--force-local` is passed, when `--directory` is passed, or when the authoring repo cannot be resolved. The folder must contain the changelog files. -- **CDN (default when a repo resolves).** Used when the authoring repo resolves, local sourcing is not forced, and a CDN base URL is configured (`DOCS_BUILDER_CHANGELOG_CDN`, defaulting to the public distribution). The command fetches `changelog/{org}/{repo}/{branch}/registry.json` and the entries it lists, then applies the bundle's own product/PR/issue/file filters to the downloaded set. Path-list / `--files` filters match pool entries by file name, so the listed paths do not need to exist locally. +- **CDN (default when a repo resolves).** Used when the authoring repo resolves, local sourcing is not forced, and a CDN base URL is configured (`DOCS_BUILDER_CHANGELOG_CDN`, defaulting to the public distribution). The command fetches `changelog/{org}/{repo}/{branch}/registry.json` and the entries it lists, then applies the bundle's own product/PR/issue/file filters to the downloaded set. Path-list / `--files` filters match CDN entries by file name, so the listed paths do not need to exist locally. -Use `--force-local` for uncommon ad hoc runs that need the local folder without editing `changelog.yml` — including path-list / `--files` runs that should read freshly authored files from disk instead of the CDN pool. +Use `--force-local` for uncommon ad hoc runs that need the local folder without editing `changelog.yml` — including path-list / `--files` runs that should read freshly authored files from disk instead of the CDN. Because entries are org/repo/branch-scoped, one repository can produce a bundle for a shared product (for example, `cloud-serverless`) while sourcing its own entries from `changelog/{org}/{repo}/{branch}/`, without that product appearing in the repository's `docset.yml`. The `{changelog}` directive's `:cdn:` mode still consumes product-scoped *bundles*, so a repository that also renders its own release notes declares each product under `release_notes` as before. @@ -123,6 +124,10 @@ These settings are located in the `bundle.profiles.` section of the config : Feature IDs to mark as hidden in the bundle. : When the bundle is rendered, entries with matching `feature-id` values are commented out. +`infer_missing_changelogs` +: Overrides [bundle.infer_missing_changelogs](#bundle-basic). +: When `true`, git-ref bundling synthesizes entries from GitHub PR metadata for PRs with no matching changelog YAML on the CDN (or in the local folder when local sourcing is forced). Default is omit-with-warning. CLI `--infer` turns this on for a single run. + `output` : The output filename pattern for the bundle file. : Supports `{version}` and `{lifecycle}` placeholders. diff --git a/src/Elastic.Documentation.Configuration/Changelog/BundleConfiguration.cs b/src/Elastic.Documentation.Configuration/Changelog/BundleConfiguration.cs index f75564bda3..c8eed41479 100644 --- a/src/Elastic.Documentation.Configuration/Changelog/BundleConfiguration.cs +++ b/src/Elastic.Documentation.Configuration/Changelog/BundleConfiguration.cs @@ -64,6 +64,14 @@ public record BundleConfiguration /// public bool? ReleaseDates { get; init; } + /// + /// When true, git-ref bundling synthesizes in-memory changelog entries from GitHub PR metadata + /// when no matching changelog YAML is found on the CDN (or in the local folder when local + /// sourcing is forced). Defaults to false: unmatched PRs are warned and omitted. + /// Overridden by CLI --infer or a profile-level setting. + /// + public bool? InferMissingChangelogs { get; init; } + /// /// Named bundle profiles for different release scenarios. /// @@ -135,6 +143,14 @@ public record BundleProfile /// public bool? ReleaseDates { get; init; } + /// + /// When true, git-ref bundling synthesizes in-memory changelog entries from GitHub PR metadata + /// when no matching changelog YAML is found on the CDN (or in the local folder when local + /// sourcing is forced). Overrides . + /// Defaults to false when omitted at both levels. Overridden by CLI --infer. + /// + public bool? InferMissingChangelogs { get; init; } + /// /// Profile source type. When set to "github_release", the profile fetches /// PR references directly from a GitHub release and uses them as the bundle filter. diff --git a/src/Elastic.Documentation.Configuration/Changelog/ChangelogConfigurationLoader.cs b/src/Elastic.Documentation.Configuration/Changelog/ChangelogConfigurationLoader.cs index 733fc51586..221a27c192 100644 --- a/src/Elastic.Documentation.Configuration/Changelog/ChangelogConfigurationLoader.cs +++ b/src/Elastic.Documentation.Configuration/Changelog/ChangelogConfigurationLoader.cs @@ -530,6 +530,7 @@ private static PivotConfiguration ConvertPivot(PivotConfigurationYaml yamlPivot) Branch = kvp.Value.Branch, HideFeatures = kvp.Value.HideFeatures?.Values, ReleaseDates = kvp.Value.ReleaseDates, + InferMissingChangelogs = kvp.Value.InferMissingChangelogs, Source = kvp.Value.Source }); } @@ -544,6 +545,7 @@ private static PivotConfiguration ConvertPivot(PivotConfigurationYaml yamlPivot) Owner = yaml.Owner, Branch = yaml.Branch, ReleaseDates = yaml.ReleaseDates, + InferMissingChangelogs = yaml.InferMissingChangelogs, LinkAllowRepos = linkAllowRepos, Profiles = profiles }; diff --git a/src/Elastic.Documentation.Configuration/Changelog/ChangelogConfigurationYaml.cs b/src/Elastic.Documentation.Configuration/Changelog/ChangelogConfigurationYaml.cs index 09acb46e9d..2cc8243dc5 100644 --- a/src/Elastic.Documentation.Configuration/Changelog/ChangelogConfigurationYaml.cs +++ b/src/Elastic.Documentation.Configuration/Changelog/ChangelogConfigurationYaml.cs @@ -321,6 +321,12 @@ internal sealed record BundleConfigurationYaml /// public bool? ReleaseDates { get; set; } + /// + /// When true, git-ref bundling synthesizes changelog entries from GitHub PR metadata when no + /// matching changelog YAML is found on the CDN. Defaults to false. + /// + public bool? InferMissingChangelogs { get; set; } + /// /// When set, only PR/issue links targeting these owner/repo values are kept; others become # PRIVATE: sentinels. /// @@ -386,6 +392,12 @@ internal sealed record BundleProfileYaml /// public bool? ReleaseDates { get; set; } + /// + /// When true, git-ref bundling synthesizes changelog entries from GitHub PR metadata when no + /// matching changelog YAML is found on the CDN. Overrides bundle-level infer_missing_changelogs. + /// + public bool? InferMissingChangelogs { get; set; } + /// /// Profile source type. When set to "github_release", the profile fetches /// PR references directly from a GitHub release and uses them as the bundle filter. diff --git a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs index c9b9e65bda..8cc6cc1aa2 100644 --- a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs +++ b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs @@ -52,6 +52,13 @@ public record BundleChangelogsArguments /// public bool ForceLocal { get; init; } + /// + /// When true, git-ref bundling synthesizes in-memory changelog entries from GitHub PR metadata + /// when no matching changelog YAML is found on the CDN (CLI --infer). Combined with + /// bundle.infer_missing_changelogs / profile override; default is false. + /// + public bool InferMissingChangelogs { get; init; } + public string? Owner { get; init; } public string? Repo { get; init; } @@ -271,7 +278,7 @@ public async Task BundleChangelogs(IDiagnosticsCollector collector, Bundle var useCdn = ShouldSourceFromCdn(authoringRepo, useLocalChangelogs: useLocalChangelogs, explicitDirectory: explicitDirectory); // Commit-range mode replaces the filter pipeline: the PR list is derived from git and - // each PR's entry is sourced pool-first with PR-metadata fallback. + // each PR's entry is sourced pool-first (PR-metadata inferral is opt-in via --infer). if (!string.IsNullOrWhiteSpace(input.StartGitRef)) { var sourcing = new GitRangeSourcingContext @@ -595,6 +602,7 @@ private async Task BuildAndWriteBundle( string[]? mergedHideFeatures = null; string? profileDescription = null; var profileSuppressReleaseDate = false; + var inferMissingChangelogs = input.InferMissingChangelogs; if (config?.Bundle?.Profiles != null && config.Bundle.Profiles.TryGetValue(input.Profile!, out var profile)) { @@ -653,6 +661,8 @@ private async Task BuildAndWriteBundle( branch = profile.Branch ?? config.Bundle.Branch; mergedHideFeatures = profile.HideFeatures?.Count > 0 ? [.. profile.HideFeatures] : null; profileSuppressReleaseDate = !(profile.ReleaseDates ?? config.Bundle.ReleaseDates ?? true); + inferMissingChangelogs = input.InferMissingChangelogs + || (profile.InferMissingChangelogs ?? config.Bundle.InferMissingChangelogs ?? false); // Handle profile-specific description with placeholder substitution var descriptionTemplate = profile.Description ?? config.Bundle.Description; @@ -700,7 +710,8 @@ private async Task BuildAndWriteBundle( Branch = branch, HideFeatures = mergedHideFeatures, Description = profileDescription, - SuppressReleaseDate = profileSuppressReleaseDate + SuppressReleaseDate = profileSuppressReleaseDate, + InferMissingChangelogs = inferMissingChangelogs }; } @@ -721,6 +732,12 @@ private static bool ValidateGitRefArguments(IDiagnosticsCollector collector, Bun return false; } + if (input.InferMissingChangelogs) + { + collector.EmitError(string.Empty, "--infer is only supported when bundling a git commit range (--start-git-ref/--end-git-ref)."); + return false; + } + return true; } @@ -811,8 +828,8 @@ private sealed record GitRangeSourcingContext /// /// Bundles a git commit range: resolves the range to a PR list (compare API + - /// associatedPullRequests), sources each PR's entry with the pool-first / - /// inferred-from-PR-metadata precedence, and reports PRs and commits that produced no entry. + /// associatedPullRequests), sources each PR's entry pool-first, optionally synthesizes + /// from PR metadata when inferral is on, and reports PRs and commits that produced no entry. /// In dry-run mode prints the run report instead of writing the bundle. /// private async Task BundleFromGitRange( @@ -857,7 +874,8 @@ private async Task BundleFromGitRange( Repo = sourcing.Repo, StartRef = input.StartGitRef!, EndRef = input.EndGitRef!, - FallbackProducts = input.OutputProducts + FallbackProducts = input.OutputProducts, + InferMissingChangelogs = input.InferMissingChangelogs }, ctx); var report = result.Report.ToMarkdown(); @@ -935,6 +953,9 @@ private BundleChangelogsArguments ApplyConfigDefaults(BundleChangelogsArguments ? input.SuppressReleaseDate : input.SuppressReleaseDate || !(config.Bundle.ReleaseDates ?? true); + var inferMissingChangelogs = input.InferMissingChangelogs + || (config.Bundle.InferMissingChangelogs ?? false); + return input with { Directory = directory, @@ -944,6 +965,7 @@ private BundleChangelogsArguments ApplyConfigDefaults(BundleChangelogsArguments Branch = branch, Description = description, SuppressReleaseDate = suppressReleaseDate, + InferMissingChangelogs = inferMissingChangelogs, LinkAllowRepos = config.Bundle.LinkAllowRepos }; } @@ -962,7 +984,7 @@ private BundleChangelogsArguments ApplyConfigDefaults(BundleChangelogsArguments var needsNetwork = hasReleaseVersion; var needsGithubToken = hasReleaseVersion; - // Commit-range bundling always needs the GitHub API (compare + GraphQL + PR metadata fallback). + // Commit-range bundling always needs the GitHub API (compare + GraphQL). if (!string.IsNullOrWhiteSpace(input.StartGitRef) || !string.IsNullOrWhiteSpace(input.EndGitRef)) { needsNetwork = true; diff --git a/src/services/Elastic.Changelog/Bundling/GitRangeEntryResolver.cs b/src/services/Elastic.Changelog/Bundling/GitRangeEntryResolver.cs index f4f4ed86fb..5b7913fc70 100644 --- a/src/services/Elastic.Changelog/Bundling/GitRangeEntryResolver.cs +++ b/src/services/Elastic.Changelog/Bundling/GitRangeEntryResolver.cs @@ -38,6 +38,13 @@ public record GitRangeEntryResolutionOptions /// to any product (typically the profile's output_products). Wildcards are ignored. /// public IReadOnlyList? FallbackProducts { get; init; } + + /// + /// When true, synthesize an in-memory entry from GitHub PR metadata if no matching changelog + /// YAML is found on the CDN (or in the local folder when local sourcing is forced). + /// When false (the default), unmatched PRs are warned and omitted like --prs. + /// + public bool InferMissingChangelogs { get; init; } } /// How a pull request's changelog entry was sourced for a commit-range bundle. @@ -46,6 +53,9 @@ public enum GitRangePrSourceKind /// A checked-in entry existed in the entry pool (CDN or local directory). Pool, + /// No pool entry and inferral is off; the PR is omitted (not notable / no YAML). + Unmatched, + /// Synthesized from PR metadata, including release-note text extracted from the PR body. InferredPrBody, @@ -97,7 +107,8 @@ public string ToMarkdown() { var source = row.Source switch { - GitRangePrSourceKind.Pool => "pool", + GitRangePrSourceKind.Pool => "cdn", + GitRangePrSourceKind.Unmatched => "no changelog", GitRangePrSourceKind.InferredPrBody => "inferred (PR body)", GitRangePrSourceKind.InferredTitle => "inferred (title)", GitRangePrSourceKind.Excluded => "excluded (rules)", @@ -129,12 +140,12 @@ public record GitRangeEntryResolutionResult } /// -/// Resolves the changelog entries for the pull requests of a commit range, applying the RFC's -/// sourcing precedence per PR: a checked-in entry from the pool (matched by file-name-derived PR -/// numbers — file names survive scrubbing — or by the entry's prs references) wins over an -/// entry synthesized from PR metadata via the same extraction path changelog add uses -/// (release-note text from the PR body, label-mapped type/areas/products); PRs whose metadata -/// cannot be fetched are reported as missing rather than silently dropped. +/// Resolves the changelog entries for the pull requests of a commit range. A checked-in entry from +/// the pool (matched by file-name-derived PR numbers — file names survive scrubbing — or by the +/// entry's prs references) wins. When +/// is true, a pool miss synthesizes an entry from PR metadata via the same extraction path +/// changelog add uses; otherwise the PR is warned and omitted like --prs. PRs whose +/// metadata cannot be fetched (inferral on) are reported as missing rather than silently dropped. /// public class GitRangeEntryResolver(IGitHubPrService prService, ILogger logger) { @@ -189,6 +200,13 @@ public async Task ResolveAsync( continue; } + if (!options.InferMissingChangelogs) + { + collector.EmitWarning(string.Empty, $"No changelog file found for PR: {pr.Url}"); + rows.Add(Row(pr, GitRangePrSourceKind.Unmatched)); + continue; + } + var (row, synthesized, failed) = await SynthesizeFromPrMetadata(collector, pr, config, options, ctx); rows.Add(row); if (synthesized != null) diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs index 1d4cd6df3d..f9b65ea765 100644 --- a/src/tooling/docs-builder/Commands/ChangelogCommand.cs +++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs @@ -573,9 +573,10 @@ async static (s, collector, state, ctx) => await s.CreateChangelog(collector, st /// Branch whose CDN changelog entry pool (changelog/{org}/{repo}/{branch}/...) is sourced from. Falls back to bundle.branch or "main". This option is not supported in profile-based commands. The equivalent configuration options are bundle.branch or bundle.profiles.<name>.branch. /// Filter by pull request URLs (comma-separated), or a path to a newline-delimited file containing fully-qualified GitHub PR URLs. Can be specified multiple times. This option is not supported in profile-based commands. Pass a promotion report as the second or third positional argument instead, or set source: github_release on the profile. /// Filter by changelog YAML paths (comma-separated), or a path to a newline-delimited file containing changelog paths. Can be specified multiple times. When entries are sourced from the CDN, paths are matched to pool entries by file name and do not need to exist locally; with local sourcing (--force-local, --directory, or bundle.use_local_changelogs) the paths must exist on disk. This option is not supported in profile-based commands; pass a path list file as the second or third positional argument instead. - /// Start ref (exclusive) of a git commit range to bundle, for example the previously published endpoint ref. Must be provided together with --end-git-ref; the start ref is never inferred. The PR list is derived from the range itself (GitHub compare API + GraphQL associatedPullRequests), each PR's entry is sourced pool-first with PR-metadata fallback, and requires GITHUB_TOKEN. Supported in profile-based commands (for example, 'bundle serverless-release 2026-08-13 --start-git-ref abc123 --end-git-ref def456'); mutually exclusive with all other filter options. + /// Start ref (exclusive) of a git commit range to bundle, for example the previously published endpoint ref. Must be provided together with --end-git-ref; the start ref is never inferred. The PR list is derived from the range itself (GitHub compare API + GraphQL associatedPullRequests). Each PR's changelog YAML is sourced from the CDN (or the local folder when local sourcing is forced); unmatched PRs are warned and omitted unless --infer or bundle.infer_missing_changelogs is set. Requires GITHUB_TOKEN. Supported in profile-based commands (for example, 'bundle serverless-release 2026-08-13 --start-git-ref abc123 --end-git-ref def456'); mutually exclusive with all other filter options. /// End ref (inclusive) of the git commit range to bundle — the currently published endpoint ref. Must be provided together with --start-git-ref. Recorded in the bundle output as the git_ref metadata field. - /// Resolve the commit range and print the run report (resolved PR list with per-PR entry source: pool, inferred, or missing) as Markdown without writing a bundle. Only valid together with --start-git-ref/--end-git-ref. Supported in profile-based commands. + /// Resolve the commit range and print the run report (resolved PR list with per-PR entry source: pool, no changelog, inferred, or missing) as Markdown without writing a bundle. Only valid together with --start-git-ref/--end-git-ref. Supported in profile-based commands. + /// When bundling a git commit range, synthesize in-memory changelog entries from GitHub PR metadata for PRs with no matching changelog YAML on the CDN (or in the local folder when using --force-local). Default: unmatched PRs are warned and omitted like --prs. Equivalent to bundle.infer_missing_changelogs: true without editing config. Allowed in profile-based commands. Only valid together with --start-git-ref/--end-git-ref. /// Force local entry sourcing for this run (equivalent to bundle.use_local_changelogs: true without editing config). Allowed in profile-based commands. /// GitHub repository name for PR/issue numbers or --release-version. Falls back to bundle.repo or the product ID. This option is not supported in profile-based commands. The equivalent configuration options are bundle.repo or bundle.profiles.<name>.repo. /// URL or file path to a promotion report; extracts PR URLs as the filter. This option is not supported in profile-based commands. Pass the report as the second or third positional argument instead. @@ -610,6 +611,7 @@ public async Task Bundle( string? startGitRef = null, string? endGitRef = null, bool dryRun = false, + bool infer = false, CancellationToken ct = default ) { @@ -634,6 +636,12 @@ public async Task Bundle( return 1; } + if (infer && !isGitRefMode) + { + collector.EmitError(string.Empty, "--infer is only supported when bundling a git commit range (--start-git-ref/--end-git-ref)."); + return 1; + } + if (isGitRefMode && releaseVersion != null) { collector.EmitError(string.Empty, "--start-git-ref/--end-git-ref and --release-version are mutually exclusive."); @@ -957,7 +965,8 @@ public async Task Bundle( SuppressReleaseDate = noReleaseDate, StartGitRef = startGitRef, EndGitRef = endGitRef, - DryRun = dryRun + DryRun = dryRun, + InferMissingChangelogs = infer }; serviceInvoker.AddCommand(service, input, diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleGitRefTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleGitRefTests.cs index a3c2373a27..beb9bcc230 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/BundleGitRefTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleGitRefTests.cs @@ -15,9 +15,8 @@ namespace Elastic.Changelog.Tests.Changelogs; /// /// Tests for commit-range bundling (changelog bundle --start-git-ref --end-git-ref): -/// the PR list is derived from the range, each PR's entry follows the pool-first / -/// inferred-from-PR-metadata precedence, the bundle records git_ref, and dry-run -/// reports without writing. +/// the PR list is derived from the range, each PR's entry is sourced from the pool (inferral from +/// PR metadata is opt-in), the bundle records git_ref, and dry-run reports without writing. /// public class BundleGitRefTests(ITestOutputHelper output) : ChangelogTestBase(output) { @@ -87,11 +86,12 @@ private static IGitHubCommitRangeService RangeService(params int[] prNumbers) return service; } - private async Task WriteProfileConfig(string outputDir) + private async Task WriteProfileConfig(string outputDir, bool inferMissingChangelogs = false) { + var inferYaml = inferMissingChangelogs.ToString().ToLowerInvariant(); // language=yaml var configContent = - """ + $$""" pivot: types: feature: ">feature" @@ -104,6 +104,7 @@ private async Task WriteProfileConfig(string outputDir) profiles: promotion: output_products: "cloud-hosted {version}" + infer_missing_changelogs: {{inferYaml}} """.Replace("PLACEHOLDER", outputDir); var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); @@ -149,7 +150,8 @@ public async Task ProfileMode_PoolFirstWithInferredFallback_WritesBundleWithGitR ProfileArgument = "2026-08-13", Config = configPath, StartGitRef = StartRef, - EndGitRef = EndRef + EndGitRef = EndRef, + InferMissingChangelogs = true }; var result = await service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); @@ -310,7 +312,8 @@ public async Task InferredEntry_NoTypeLabel_DefaultsToOtherWithWarning() ProfileArgument = "2026-08-13", Config = configPath, StartGitRef = StartRef, - EndGitRef = EndRef + EndGitRef = EndRef, + InferMissingChangelogs = true }; var result = await service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); @@ -366,6 +369,7 @@ public void GitRangeReport_ToMarkdown_ListsPrSourcesAndOrphanCommits() Rows = [ new GitRangePrReportRow { Number = 100, Url = "https://github.com/elastic/widget/pull/100", Source = GitRangePrSourceKind.Pool, EntryFileNames = ["100.yaml"] }, + new GitRangePrReportRow { Number = 250, Url = "https://github.com/elastic/widget/pull/250", Source = GitRangePrSourceKind.Unmatched }, new GitRangePrReportRow { Number = 300, Url = "https://github.com/elastic/widget/pull/300", Source = GitRangePrSourceKind.InferredPrBody, EntryFileNames = ["300.yaml"] }, new GitRangePrReportRow { Number = 400, Url = "https://github.com/elastic/widget/pull/400", Source = GitRangePrSourceKind.Missing } ], @@ -375,12 +379,139 @@ public void GitRangeReport_ToMarkdown_ListsPrSourcesAndOrphanCommits() var markdown = report.ToMarkdown(); markdown.Should().Contain($"`{StartRef}..{EndRef}`"); - markdown.Should().Contain("| [#100](https://github.com/elastic/widget/pull/100) | pool | `100.yaml` |"); + markdown.Should().Contain("| [#100](https://github.com/elastic/widget/pull/100) | cdn | `100.yaml` |"); + markdown.Should().Contain("| [#250](https://github.com/elastic/widget/pull/250) | no changelog | — |"); markdown.Should().Contain("| [#300](https://github.com/elastic/widget/pull/300) | inferred (PR body) | `300.yaml` |"); markdown.Should().Contain("| [#400](https://github.com/elastic/widget/pull/400) | missing | — |"); markdown.Should().Contain("- `deadbeef`"); } + [Fact] + public async Task ProfileMode_PoolOnlyDefault_OmitsUnmatchedPrsWithWarning() + { + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(outputDir); + var configPath = await WriteProfileConfig(outputDir); + + var prService = A.Fake(); + var service = Service(PoolHandler(), RangeService(100, 200, 300), prService); + + var input = new BundleChangelogsArguments + { + Profile = "promotion", + ProfileArgument = "2026-08-13", + Config = configPath, + StartGitRef = StartRef, + EndGitRef = EndRef + }; + + var result = await service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); + + result.Should().BeTrue($"Errors: {string.Join("; ", Collector.Diagnostics.Where(d => d.Severity == Severity.Error).Select(d => d.Message))}"); + Collector.Errors.Should().Be(0); + A.CallTo(() => prService.FetchPrInfoAsync(A._, A._, A._, A._)) + .MustNotHaveHappened(); + + Collector.Diagnostics.Should().Contain(d => + d.Severity == Severity.Warning && + d.Message.Contains("No changelog file found for PR: https://github.com/elastic/widget/pull/300")); + + var outputFiles = FileSystem.Directory.GetFiles(outputDir, "*.yaml"); + outputFiles.Should().ContainSingle(); + var bundle = await FileSystem.File.ReadAllTextAsync(outputFiles[0], TestContext.Current.CancellationToken); + bundle.Should().Contain("Faster hosted search"); + bundle.Should().Contain("Sturdier snapshots"); + bundle.Should().NotContain("300.yaml"); + bundle.Should().NotContain("Sharper autocomplete"); + } + + [Fact] + public async Task ProfileMode_PoolOnly_AllUnmatched_FailsEmptyFilter() + { + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(outputDir); + var configPath = await WriteProfileConfig(outputDir); + + var service = Service(PoolHandler(), RangeService(300)); + + var input = new BundleChangelogsArguments + { + Profile = "promotion", + ProfileArgument = "2026-08-13", + Config = configPath, + StartGitRef = StartRef, + EndGitRef = EndRef + }; + + var result = await service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); + + result.Should().BeFalse(); + Collector.Diagnostics.Should().Contain(d => + d.Severity == Severity.Error && d.Message.Contains("No changelog entries could be resolved")); + Collector.Diagnostics.Should().Contain(d => + d.Severity == Severity.Warning && + d.Message.Contains("No changelog file found for PR: https://github.com/elastic/widget/pull/300")); + FileSystem.Directory.GetFiles(outputDir, "*.yaml").Should().BeEmpty(); + } + + [Fact] + public async Task ProfileMode_InferMissingChangelogsYaml_SynthesizesUnmatchedPr() + { + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(outputDir); + var configPath = await WriteProfileConfig(outputDir, inferMissingChangelogs: true); + + var prService = A.Fake(); + _ = A.CallTo(() => prService.FetchPrInfoAsync("https://github.com/elastic/widget/pull/300", A._, A._, A._)) + .Returns(new GitHubPrInfo + { + Title = "Sharper autocomplete", + Body = "Some context.\n\n## Release Note\nAutocomplete now ranks recent indices first.\n\nInternal details.", + Labels = [">feature"], + LinkedIssues = [] + }); + + var service = Service(PoolHandler(), RangeService(100, 300), prService); + + var input = new BundleChangelogsArguments + { + Profile = "promotion", + ProfileArgument = "2026-08-13", + Config = configPath, + StartGitRef = StartRef, + EndGitRef = EndRef + }; + + var result = await service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); + + result.Should().BeTrue($"Errors: {string.Join("; ", Collector.Diagnostics.Where(d => d.Severity == Severity.Error).Select(d => d.Message))}"); + var bundle = await FileSystem.File.ReadAllTextAsync( + FileSystem.Directory.GetFiles(outputDir, "*.yaml").Single(), + TestContext.Current.CancellationToken); + bundle.Should().Contain("Faster hosted search"); + bundle.Should().Contain("Sharper autocomplete"); + bundle.Should().Contain("name: 300.yaml"); + } + + [Fact] + public async Task InferWithoutGitRef_Errors() + { + var service = Service(PoolHandler(), RangeService()); + + var input = new BundleChangelogsArguments + { + Repo = "widget", + All = true, + InferMissingChangelogs = true + }; + + var result = await service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); + + result.Should().BeFalse(); + Collector.Diagnostics.Should().Contain(d => + d.Severity == Severity.Error && d.Message.Contains("--infer is only supported")); + } + [Theory] [InlineData("100.yaml", new[] { 100 })] [InlineData("100-200.yaml", new[] { 100, 200 })] diff --git a/tests/Elastic.Changelog.Tests/Changelogs/ChangelogConfigurationTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/ChangelogConfigurationTests.cs index f0b1af079f..4b3933996c 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/ChangelogConfigurationTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/ChangelogConfigurationTests.cs @@ -1824,4 +1824,33 @@ public async Task LoadChangelogConfiguration_UseLocalChangelogs_True_Parses() Collector.Errors.Should().Be(0); config.Bundle!.UseLocalChangelogs.Should().BeTrue(); } + + [Fact] + public async Task LoadChangelogConfiguration_InferMissingChangelogs_ParsesBundleAndProfile() + { + var configLoader = new ChangelogConfigurationLoader(LoggerFactory, ConfigurationContext, FileSystem); + var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(configPath)!); + + // language=yaml + var configContent = + """ + bundle: + infer_missing_changelogs: false + profiles: + promotion: + infer_missing_changelogs: true + inherit: + output: "inherit.yaml" + """; + await FileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); + + var config = await configLoader.LoadChangelogConfiguration(Collector, configPath, TestContext.Current.CancellationToken); + + config.Should().NotBeNull(); + Collector.Errors.Should().Be(0); + config.Bundle!.InferMissingChangelogs.Should().BeFalse(); + config.Bundle.Profiles!["promotion"].InferMissingChangelogs.Should().BeTrue(); + config.Bundle.Profiles["inherit"].InferMissingChangelogs.Should().BeNull(); + } } From 074c2083d1d776a7372e92fa3f89836557add0ab Mon Sep 17 00:00:00 2001 From: lcawl Date: Thu, 20 Aug 2026 16:46:32 -0500 Subject: [PATCH 2/3] Add more docs about updating inferred changelogs --- docs/cli/changelog/cmd-bundle-amend.md | 6 ++++++ docs/cli/changelog/cmd-bundle.md | 18 ++++++++++++++++++ docs/data/release-notes/bundle.md | 4 +++- 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/docs/cli/changelog/cmd-bundle-amend.md b/docs/cli/changelog/cmd-bundle-amend.md index a0a1b67fb6..342d467559 100644 --- a/docs/cli/changelog/cmd-bundle-amend.md +++ b/docs/cli/changelog/cmd-bundle-amend.md @@ -83,6 +83,12 @@ docs-builder changelog bundle-amend \ The CLI computes the file checksum automatically and matches it against the effective bundle (parent plus any existing amend files). If the bundle contains the file with a different checksum, the command fails unless you pass `--force` to remove by file name only. +### Remove an inferred git-ref entry [inferred-git-ref-entry] + +Git-ref bundles created with `--infer` (or `infer_missing_changelogs: true`) can include entries that were synthesized from GitHub PR metadata. Those entries live only in the bundle. Their `file.name` is `{pull-request-number}.yaml`, and there is no changelog YAML on disk or on the CDN whose checksum you can match. + +`--remove` still requires a file that exists. Create a dummy file with that name and pass `--force` so the command excludes by filename only. Refer to [](/cli/changelog/cmd-bundle.md#inferred-entries) for the full workflow, including how to replace inferred copy with a real changelog. + ### Add multiple changelogs to a bundle Comma-separated list: diff --git a/docs/cli/changelog/cmd-bundle.md b/docs/cli/changelog/cmd-bundle.md index dc44ca8867..eef5c2fd88 100644 --- a/docs/cli/changelog/cmd-bundle.md +++ b/docs/cli/changelog/cmd-bundle.md @@ -122,6 +122,24 @@ docs-builder changelog bundle serverless-release 2026-08-13 \ --start-git-ref abc123 --end-git-ref def456 --infer ``` +### Inferred entries and bundle-amend [inferred-entries] + +An inferred entry is embedded in the bundle like any other changelog: full `title`, `type`, `products`, and the rest of the fields. Its `file.name` is `{pull-request-number}.yaml` (for example `300.yaml` for PR 300). That name is what the `--dry-run` report lists in the Entry column. `docs-builder` does not write a changelog YAML file for that PR to disk, S3, or the CDN. + +`changelog bundle-amend --add` and `--remove` both require a path to a file that exists. There is no source YAML to pass unless you create a stand-in file whose **name** matches `file.name`. + +To exclude an inferred entry, create a dummy file with that name and pass `--force` so matching is by filename only (the dummy file's checksum will not match the bundle): + +```sh +touch /tmp/300.yaml +docs-builder changelog bundle-amend ./docs/releases/cloud-hosted-2026-08-13.yaml \ + --remove /tmp/300.yaml --force +``` + +To replace the inferred copy, exclude it that way, then `--add` a real changelog YAML. The added file's on-disk name becomes the new `file.name`. + +If you can, author a real changelog (`changelog add`, submit, and upload) so the next git-ref bundle picks it up from the CDN. You can then amend it with a normal `--remove` / `--add` path. Refer to [](/cli/changelog/cmd-bundle-amend.md#inferred-git-ref-entry). + ## Bundles are self-contained Every bundle embeds the full content of each changelog entry (`title`, `type`, `products`, and so on), plus a `file` block recording the source file name and checksum for provenance. Rendering — via the `{changelog}` directive, `changelog render`, or the CDN pipeline — never reads the original changelog files, so you can clean them up with `docs-builder changelog remove` immediately after bundling. diff --git a/docs/data/release-notes/bundle.md b/docs/data/release-notes/bundle.md index 3f9abfef33..47eb2738e1 100644 --- a/docs/data/release-notes/bundle.md +++ b/docs/data/release-notes/bundle.md @@ -137,7 +137,7 @@ docs-builder changelog bundle serverless-release 2026-08-13 \ --end-git-ref ``` -For each PR in the range, a matching changelog YAML on the CDN (or in the local folder when you force local sourcing) is included. PRs with no matching changelog are warned and omitted (the change may not be notable), matching `--prs`. Pass `--infer` or set `infer_missing_changelogs: true` on the bundle or profile to synthesize an entry from the PR's title, labels, and release-note text using the same extraction path as `changelog add`. The bundle records the end ref in a `git_ref` metadata field. Refer to [Commit-range mode](/cli/changelog/bundle.md#git-ref-mode) for the full behavior, including the `--dry-run` run report. +For each PR in the range, a matching changelog YAML on the CDN (or in the local folder when you force local sourcing) is included. PRs with no matching changelog are warned and omitted (the change may not be notable), matching `--prs`. Pass `--infer` or set `infer_missing_changelogs: true` on the bundle or profile to synthesize an entry from the PR's title, labels, and release-note text using the same extraction path as `changelog add`. Inferred entries are stored only in the bundle, with a synthetic `file.name` of `{pull-request-number}.yaml` — they are not uploaded to the CDN. [](/cli/changelog/cmd-bundle.md#inferred-entries) covers how to update or remove one. The bundle records the end ref in a `git_ref` metadata field. Refer to [Commit-range mode](/cli/changelog/bundle.md#git-ref-mode) for the full behavior, including the `--dry-run` run report. ### Bundle by folder or changelog product @@ -306,6 +306,8 @@ docs-builder changelog bundle-amend \ This creates an amend file with `exclude-entries` that is merged when the bundle is rendered. +`--remove` needs a changelog file on disk so it can match name and checksum. Inferred git-ref entries have no such file; use `--force` with a dummy path named `{pull-request-number}.yaml`. Refer to [](/cli/changelog/cmd-bundle-amend.md#inferred-git-ref-entry). + When bundles are turned into docs (either via the `changelog render` command or the `{changelog}` directive), amend files are **automatically merged** with their parent bundles. The changelogs from all matching amend files are combined with the parent bundle's changelogs and the result is rendered as a single release. From c29676099f79845be3b122efd6eb02dfec901104 Mon Sep 17 00:00:00 2001 From: lcawl Date: Thu, 20 Aug 2026 17:22:16 -0500 Subject: [PATCH 3/3] Fix links and setting override --- docs/cli/changelog/cmd-bundle-amend.md | 2 +- docs/cli/changelog/cmd-bundle.md | 2 +- docs/data/release-notes/bundle.md | 4 +- .../Bundling/ChangelogBundlingService.cs | 7 +- .../Changelogs/BundleGitRefTests.cs | 64 +++++++++++++++++-- .../DiagnosticsCollectorDisposeTests.cs | 2 +- tests/Elastic.Changelog.Tests/TestHelpers.cs | 20 ++++-- 7 files changed, 84 insertions(+), 17 deletions(-) diff --git a/docs/cli/changelog/cmd-bundle-amend.md b/docs/cli/changelog/cmd-bundle-amend.md index 342d467559..2eed568467 100644 --- a/docs/cli/changelog/cmd-bundle-amend.md +++ b/docs/cli/changelog/cmd-bundle-amend.md @@ -87,7 +87,7 @@ If the bundle contains the file with a different checksum, the command fails unl Git-ref bundles created with `--infer` (or `infer_missing_changelogs: true`) can include entries that were synthesized from GitHub PR metadata. Those entries live only in the bundle. Their `file.name` is `{pull-request-number}.yaml`, and there is no changelog YAML on disk or on the CDN whose checksum you can match. -`--remove` still requires a file that exists. Create a dummy file with that name and pass `--force` so the command excludes by filename only. Refer to [](/cli/changelog/cmd-bundle.md#inferred-entries) for the full workflow, including how to replace inferred copy with a real changelog. +`--remove` still requires a file that exists. Create a dummy file with that name and pass `--force` so the command excludes by filename only. Refer to [](/cli/changelog/bundle.md#inferred-entries) for the full workflow, including how to replace inferred copy with a real changelog. ### Add multiple changelogs to a bundle diff --git a/docs/cli/changelog/cmd-bundle.md b/docs/cli/changelog/cmd-bundle.md index eef5c2fd88..f0b2e0be13 100644 --- a/docs/cli/changelog/cmd-bundle.md +++ b/docs/cli/changelog/cmd-bundle.md @@ -138,7 +138,7 @@ docs-builder changelog bundle-amend ./docs/releases/cloud-hosted-2026-08-13.yaml To replace the inferred copy, exclude it that way, then `--add` a real changelog YAML. The added file's on-disk name becomes the new `file.name`. -If you can, author a real changelog (`changelog add`, submit, and upload) so the next git-ref bundle picks it up from the CDN. You can then amend it with a normal `--remove` / `--add` path. Refer to [](/cli/changelog/cmd-bundle-amend.md#inferred-git-ref-entry). +If you can, author a real changelog (`changelog add`, submit, and upload) so the next git-ref bundle picks it up from the CDN. You can then amend it with a normal `--remove` / `--add` path. Refer to [](/cli/changelog/bundle-amend.md#inferred-git-ref-entry). ## Bundles are self-contained diff --git a/docs/data/release-notes/bundle.md b/docs/data/release-notes/bundle.md index 47eb2738e1..703b958739 100644 --- a/docs/data/release-notes/bundle.md +++ b/docs/data/release-notes/bundle.md @@ -137,7 +137,7 @@ docs-builder changelog bundle serverless-release 2026-08-13 \ --end-git-ref ``` -For each PR in the range, a matching changelog YAML on the CDN (or in the local folder when you force local sourcing) is included. PRs with no matching changelog are warned and omitted (the change may not be notable), matching `--prs`. Pass `--infer` or set `infer_missing_changelogs: true` on the bundle or profile to synthesize an entry from the PR's title, labels, and release-note text using the same extraction path as `changelog add`. Inferred entries are stored only in the bundle, with a synthetic `file.name` of `{pull-request-number}.yaml` — they are not uploaded to the CDN. [](/cli/changelog/cmd-bundle.md#inferred-entries) covers how to update or remove one. The bundle records the end ref in a `git_ref` metadata field. Refer to [Commit-range mode](/cli/changelog/bundle.md#git-ref-mode) for the full behavior, including the `--dry-run` run report. +For each PR in the range, a matching changelog YAML on the CDN (or in the local folder when you force local sourcing) is included. PRs with no matching changelog are warned and omitted (the change may not be notable), matching `--prs`. Pass `--infer` or set `infer_missing_changelogs: true` on the bundle or profile to synthesize an entry from the PR's title, labels, and release-note text using the same extraction path as `changelog add`. Inferred entries are stored only in the bundle, with a synthetic `file.name` of `{pull-request-number}.yaml` — they are not uploaded to the CDN. [](/cli/changelog/bundle.md#inferred-entries) covers how to update or remove one. The bundle records the end ref in a `git_ref` metadata field. Refer to [Commit-range mode](/cli/changelog/bundle.md#git-ref-mode) for the full behavior, including the `--dry-run` run report. ### Bundle by folder or changelog product @@ -306,7 +306,7 @@ docs-builder changelog bundle-amend \ This creates an amend file with `exclude-entries` that is merged when the bundle is rendered. -`--remove` needs a changelog file on disk so it can match name and checksum. Inferred git-ref entries have no such file; use `--force` with a dummy path named `{pull-request-number}.yaml`. Refer to [](/cli/changelog/cmd-bundle-amend.md#inferred-git-ref-entry). +`--remove` needs a changelog file on disk so it can match name and checksum. Inferred git-ref entries have no such file; use `--force` with a dummy path named `{pull-request-number}.yaml`. Refer to [](/cli/changelog/bundle-amend.md#inferred-git-ref-entry). When bundles are turned into docs (either via the `changelog render` command or the `{changelog}` directive), amend files are **automatically merged** with their parent bundles. The changelogs from all matching amend files are combined with the parent bundle's changelogs and the result is rendered as a single release. diff --git a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs index 8cc6cc1aa2..f3891b04dd 100644 --- a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs +++ b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs @@ -953,8 +953,11 @@ private BundleChangelogsArguments ApplyConfigDefaults(BundleChangelogsArguments ? input.SuppressReleaseDate : input.SuppressReleaseDate || !(config.Bundle.ReleaseDates ?? true); - var inferMissingChangelogs = input.InferMissingChangelogs - || (config.Bundle.InferMissingChangelogs ?? false); + // In profile mode, profile has already resolved inheritance (profile ?? bundle), so skip + // re-applying the bundle-level flag — otherwise profile false cannot override bundle true. + var inferMissingChangelogs = !string.IsNullOrWhiteSpace(input.Profile) + ? input.InferMissingChangelogs + : input.InferMissingChangelogs || (config.Bundle.InferMissingChangelogs ?? false); return input with { diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleGitRefTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleGitRefTests.cs index beb9bcc230..b3d4c01927 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/BundleGitRefTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleGitRefTests.cs @@ -86,9 +86,15 @@ private static IGitHubCommitRangeService RangeService(params int[] prNumbers) return service; } - private async Task WriteProfileConfig(string outputDir, bool inferMissingChangelogs = false) + private async Task WriteProfileConfig( + string outputDir, + bool inferMissingChangelogs = false, + bool? bundleInferMissingChangelogs = null) { - var inferYaml = inferMissingChangelogs.ToString().ToLowerInvariant(); + var profileInferYaml = inferMissingChangelogs.ToString().ToLowerInvariant(); + var bundleInferLine = bundleInferMissingChangelogs is { } bundleInfer + ? $"\n infer_missing_changelogs: {bundleInfer.ToString().ToLowerInvariant()}" + : ""; // language=yaml var configContent = $$""" @@ -100,11 +106,11 @@ private async Task WriteProfileConfig(string outputDir, bool inferMissin bundle: output_directory: PLACEHOLDER repo: widget - owner: elastic + owner: elastic{{bundleInferLine}} profiles: promotion: output_products: "cloud-hosted {version}" - infer_missing_changelogs: {{inferYaml}} + infer_missing_changelogs: {{profileInferYaml}} """.Replace("PLACEHOLDER", outputDir); var configPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog.yml"); @@ -409,8 +415,7 @@ public async Task ProfileMode_PoolOnlyDefault_OmitsUnmatchedPrsWithWarning() result.Should().BeTrue($"Errors: {string.Join("; ", Collector.Diagnostics.Where(d => d.Severity == Severity.Error).Select(d => d.Message))}"); Collector.Errors.Should().Be(0); - A.CallTo(() => prService.FetchPrInfoAsync(A._, A._, A._, A._)) - .MustNotHaveHappened(); + A.CallTo(prService).MustNotHaveHappened(); Collector.Diagnostics.Should().Contain(d => d.Severity == Severity.Warning && @@ -493,6 +498,53 @@ public async Task ProfileMode_InferMissingChangelogsYaml_SynthesizesUnmatchedPr( bundle.Should().Contain("name: 300.yaml"); } + [Fact] + public async Task ProfileMode_ProfileFalseOverridesBundleTrue_OmitsUnmatchedPr() + { + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(outputDir); + var configPath = await WriteProfileConfig( + outputDir, + inferMissingChangelogs: false, + bundleInferMissingChangelogs: true); + + var prService = A.Fake(); + _ = A.CallTo(() => prService.FetchPrInfoAsync("https://github.com/elastic/widget/pull/300", A._, A._, A._)) + .Returns(new GitHubPrInfo + { + Title = "Sharper autocomplete", + Body = "Some context.\n\n## Release Note\nAutocomplete now ranks recent indices first.\n\nInternal details.", + Labels = [">feature"], + LinkedIssues = [] + }); + + var service = Service(PoolHandler(), RangeService(100, 300), prService); + + var input = new BundleChangelogsArguments + { + Profile = "promotion", + ProfileArgument = "2026-08-13", + Config = configPath, + StartGitRef = StartRef, + EndGitRef = EndRef + }; + + var result = await service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); + + result.Should().BeTrue($"Errors: {string.Join("; ", Collector.Diagnostics.Where(d => d.Severity == Severity.Error).Select(d => d.Message))}"); + Collector.Diagnostics.Should().Contain(d => + d.Severity == Severity.Warning && + d.Message.Contains("No changelog file found for PR: https://github.com/elastic/widget/pull/300")); + A.CallTo(prService).MustNotHaveHappened(); + + var bundle = await FileSystem.File.ReadAllTextAsync( + FileSystem.Directory.GetFiles(outputDir, "*.yaml").Single(), + TestContext.Current.CancellationToken); + bundle.Should().Contain("Faster hosted search"); + bundle.Should().NotContain("300.yaml"); + bundle.Should().NotContain("Sharper autocomplete"); + } + [Fact] public async Task InferWithoutGitRef_Errors() { diff --git a/tests/Elastic.Changelog.Tests/Changelogs/DiagnosticsCollectorDisposeTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/DiagnosticsCollectorDisposeTests.cs index 17c16085cf..4808ba45f6 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/DiagnosticsCollectorDisposeTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/DiagnosticsCollectorDisposeTests.cs @@ -78,7 +78,7 @@ public async Task WaitForDrain_WithoutStartAsync_ThrowsImmediately() var collector = new DiagnosticsCollector([]); collector.EmitWarning(string.Empty, "queued"); - Func act = () => ((IDiagnosticsCollector)collector).WaitForDrain(); + var act = () => ((IDiagnosticsCollector)collector).WaitForDrain(); _ = await act.Should().ThrowAsync(); } diff --git a/tests/Elastic.Changelog.Tests/TestHelpers.cs b/tests/Elastic.Changelog.Tests/TestHelpers.cs index f8aafe19ed..c976572c56 100644 --- a/tests/Elastic.Changelog.Tests/TestHelpers.cs +++ b/tests/Elastic.Changelog.Tests/TestHelpers.cs @@ -46,7 +46,11 @@ private sealed class NullScope : IDisposable public void Dispose() { } } - public IDisposable BeginScope(TState state) where TState : notnull => new NullScope(); + public IDisposable BeginScope(TState state) where TState : notnull + { + _ = state; + return new NullScope(); + } public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Trace; @@ -58,15 +62,23 @@ public class TestLoggerProvider(ITestOutputHelper? output) : ILoggerProvider { public void Dispose() => GC.SuppressFinalize(this); - public ILogger CreateLogger(string categoryName) => new TestLogger(output); + public ILogger CreateLogger(string categoryName) + { + _ = categoryName; + return new TestLogger(output); + } } public class TestLoggerFactory(ITestOutputHelper? output) : ILoggerFactory { public void Dispose() => GC.SuppressFinalize(this); - public void AddProvider(ILoggerProvider provider) { } + public void AddProvider(ILoggerProvider provider) => _ = provider; - public ILogger CreateLogger(string categoryName) => new TestLogger(output); + public ILogger CreateLogger(string categoryName) + { + _ = categoryName; + return new TestLogger(output); + } }