@redpanda-data/docs-extensions-and-macros 5.54.0 → 5.55.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/bin/doc-tools-mcp.js +4 -5
  2. package/bin/doc-tools.js +39 -2
  3. package/bin/mcp-tools/generated-docs-review.js +26 -9
  4. package/bin/mcp-tools/overrides-audit.js +1 -1
  5. package/bin/mcp-tools/rpcn-docs.js +4 -14
  6. package/cli-utils/format-helm-spec.js +4 -3
  7. package/cli-utils/helm-commented-values.js +88 -18
  8. package/cli-utils/self-managed-docs-branch.js +36 -30
  9. package/extension-utils/html-utils.js +27 -0
  10. package/extensions/REFERENCE.adoc +20 -4
  11. package/extensions/add-git-dates.js +13 -1
  12. package/extensions/aggregate-terms.js +34 -20
  13. package/extensions/modify-connect-tag-playbook.js +181 -3
  14. package/extensions/resolve-xrefs-in-attachments.js +1 -12
  15. package/extensions/util/connect-docs-asset.js +6 -5
  16. package/extensions/version-fetcher/fetch-latest-docker-tag.js +35 -13
  17. package/extensions/version-fetcher/get-latest-redpanda-helm-version-from-operator.js +64 -40
  18. package/extensions/version-fetcher/list-connect-releases.js +3 -3
  19. package/extensions/version-fetcher/release-lines.js +64 -0
  20. package/extensions/version-fetcher/set-latest-version.js +73 -3
  21. package/macros/README.adoc +1 -0
  22. package/macros/REFERENCE.adoc +99 -0
  23. package/macros/badge.js +5 -8
  24. package/macros/enterprise.js +2 -5
  25. package/macros/glossary.js +4 -14
  26. package/macros/iceberg-explorer.js +242 -0
  27. package/macros/prop.js +3 -13
  28. package/mcp/USER_GUIDE.adoc +25 -135
  29. package/package.json +4 -2
  30. package/tools/doc-impact-recording/index.js +190 -0
  31. package/tools/overrides-audit/README.adoc +1 -2
  32. package/tools/overrides-audit/adapters/properties.js +1 -1
  33. package/tools/overrides-audit/index.js +3 -4
  34. package/tools/redpanda-connect/AUTOMATION.md +3 -36
  35. package/tools/redpanda-connect/README.adoc +5 -33
  36. package/tools/redpanda-connect/generate-rpcn-connector-docs.js +3 -3
  37. package/tools/redpanda-connect/pr-summary-formatter.js +6 -4
  38. package/tools/redpanda-connect/report-delta.js +6 -3
  39. package/tools/redpanda-connect/rpcn-connector-docs-handler.js +38 -16
  40. package/tools/redpanda-release-notes/generate-release-notes.js +116 -1
  41. package/tools/rpk-docs/templates/command.hbs +1 -1
  42. package/tools/overrides-audit/adapters/connect.js +0 -144
@@ -127,32 +127,46 @@ module.exports.register = function ({ config }) {
127
127
  })
128
128
 
129
129
  .on('contentClassified', ({ siteCatalog, contentCatalog }) => {
130
- const components = contentCatalog.getComponents();
130
+ // Appends every term, by category, to a glossary page.
131
+ function mergeTerms (glossaryPage) {
132
+ let glossaryContent = glossaryPage.contents.toString('utf8');
133
+ Object.keys(siteCatalog.termsByCategory).sort().forEach(category => {
134
+ let categoryContent = `\n\n== ${category}\n`;
135
+ siteCatalog.termsByCategory[category].sort((a, b) => a.name.localeCompare(b.name)).forEach(term => {
136
+ categoryContent += `\n\n${processTermContent(term.content)}`;
137
+ });
138
+ glossaryContent += categoryContent;
139
+ });
140
+ glossaryPage.contents = Buffer.from(glossaryContent, 'utf8');
141
+ }
142
+
131
143
  try {
132
- components.forEach(({ versions }) => {
144
+ // With `glossarypage`, one page holds the glossary for the whole site,
145
+ // and every component's glossterm links point at it. Glossary pages
146
+ // components still have are filled too, so they stay complete until
147
+ // they are removed.
148
+ let sitePage = null;
149
+ if (config.glossarypage) {
150
+ sitePage = contentCatalog.resolvePage(config.glossarypage);
151
+ if (sitePage) {
152
+ mergeTerms(sitePage);
153
+ logger.info(`Merged terms into the site glossary ${config.glossarypage}.`);
154
+ } else {
155
+ logger.warn(`The glossarypage ${config.glossarypage} does not exist, so each component links to its own reference:glossary.adoc instead.`);
156
+ }
157
+ }
158
+
159
+ contentCatalog.getComponents().forEach(({ versions }) => {
133
160
  versions.forEach(({ name: component, version, asciidoc, title }) => {
134
161
  if (component == 'shared') return;
162
+ if (sitePage) asciidoc.attributes['glossary-page'] = config.glossarypage;
135
163
 
136
164
  const glossaryPage = contentCatalog.resolvePage(`${version ? version + '@' : ''}${component}:reference:glossary.adoc`);
137
-
138
- if (glossaryPage) {
139
- asciidoc.attributes['glossary-page'] = 'reference:glossary.adoc';
140
- let glossaryContent = glossaryPage.contents.toString('utf8');
141
-
142
- Object.keys(siteCatalog.termsByCategory).sort().forEach(category => {
143
- let categoryContent = `\n\n== ${category}\n`;
144
-
145
- siteCatalog.termsByCategory[category].sort((a, b) => a.name.localeCompare(b.name)).forEach(term => {
146
- let processedContent = processTermContent(term.content);
147
- categoryContent += `\n\n${processedContent}`;
148
- });
149
-
150
- glossaryContent += categoryContent;
151
- });
152
-
153
- glossaryPage.contents = Buffer.from(glossaryContent, 'utf8');
165
+ if (glossaryPage && glossaryPage !== sitePage) {
166
+ if (!sitePage) asciidoc.attributes['glossary-page'] = 'reference:glossary.adoc';
167
+ mergeTerms(glossaryPage);
154
168
  logger.info(`Merged terms into glossary for ${component} component${version ? ' version ' + version : ''}.`);
155
- } else {
169
+ } else if (!glossaryPage && !sitePage) {
156
170
  logger.info(`Skipping ${title} ${version ? ' version ' + version : ''} - No glossary page (reference:glossary.adoc) found`);
157
171
  }
158
172
  });
@@ -75,6 +75,16 @@ const LOCAL_DIR_ENV = 'REDPANDA_CONNECT_DOCS_DIR'
75
75
  // the probe fallback tries, when the latest release has no asset yet.
76
76
  const FALLBACK_RELEASE_LIMIT = 10
77
77
  const FALLBACK_PROBE_LIMIT = 3
78
+ // Dating pages by release (only when add-git-dates is in the playbook): the
79
+ // page size of the releases listing, the first release that has the asset
80
+ // (no older release can be compared), and the most older releases the walk
81
+ // downloads per build.
82
+ const DATE_RELEASE_PAGE_SIZE = 100
83
+ const FIRST_ASSET_RELEASE = 'v4.113.0'
84
+ const DATE_RELEASE_LIMIT = 10
85
+ // How deep the walk follows includes inside the asset's own partials, such as
86
+ // bloblang/methods.adoc including every bloblang-methods/* partial.
87
+ const NESTED_INCLUDE_DEPTH = 3
78
88
 
79
89
  // HTTPS, SSH (ssh://), and scp-style (git@host:owner/repo) URLs are all
80
90
  // remote content sources in Antora.
@@ -284,6 +294,163 @@ async function findOlderReleaseWithAsset (latestTag, url, logger) {
284
294
  return null
285
295
  }
286
296
 
297
+ // The resource keys (`partial$<relative>`, `example$<relative>`) of generated
298
+ // files that differ between two releases' assets, or are new in `files`.
299
+ function changedReferenceKeys (files, previousFiles) {
300
+ const before = new Map()
301
+ for (const f of previousFiles) {
302
+ const r = asset.toResource(f.path)
303
+ if (r && !before.has(`${r.family}$${r.relative}`)) before.set(`${r.family}$${r.relative}`, f.contents)
304
+ }
305
+ const changed = new Set()
306
+ const seen = new Set()
307
+ for (const f of files) {
308
+ const r = asset.toResource(f.path)
309
+ if (!r || r.family === 'attachment') continue
310
+ const key = `${r.family}$${r.relative}`
311
+ // addConnectDocs retains the first copy of a resource in an asset.
312
+ if (seen.has(key)) continue
313
+ seen.add(key)
314
+ const old = before.get(key)
315
+ if (old === undefined || Buffer.compare(Buffer.from(old), Buffer.from(f.contents)) !== 0) changed.add(key)
316
+ }
317
+ return changed
318
+ }
319
+
320
+ // Includes of the connect components module in a page: `partial$x` and
321
+ // `example$x` keys. In the connect component's own components module the
322
+ // module may be implicit; elsewhere only fully qualified includes count. With
323
+ // a content catalog, only files this asset added count, and includes inside
324
+ // those files are followed too (up to NESTED_INCLUDE_DEPTH levels), so a page
325
+ // that includes an aggregate partial is dated by the partials it pulls in.
326
+ const INCLUDE_RX = /^include::([^[\s]+)\[/gm
327
+ function includeKey (target, src) {
328
+ const own = src.component === COMPONENT && src.module === asset.MODULE
329
+ const q = target.match(/^connect:components:((?:partial|example)\$.+)$/) ||
330
+ (own ? target.match(/^(?:components:)?((?:partial|example)\$.+)$/) : null)
331
+ return q ? q[1] : null
332
+ }
333
+ function connectIncludes (page, contentCatalog, origin) {
334
+ const keys = []
335
+ const seen = new Set()
336
+ const visit = (file, depth) => {
337
+ for (const m of file.contents.toString('utf8').matchAll(INCLUDE_RX)) {
338
+ const key = includeKey(m[1], file.src)
339
+ if (!key) continue
340
+ if (!contentCatalog) {
341
+ keys.push(key)
342
+ continue
343
+ }
344
+ const included = contentCatalog.resolveResource(m[1], file.src)
345
+ // Only files inserted from this asset can acquire a release date.
346
+ if (!included || included.src.origin !== origin || seen.has(included)) continue
347
+ seen.add(included)
348
+ keys.push(key)
349
+ if (depth < NESTED_INCLUDE_DEPTH) visit(included, depth + 1)
350
+ }
351
+ }
352
+ visit(page, 1)
353
+ return keys
354
+ }
355
+
356
+ // Marks pages with the newest change date of their included asset references.
357
+ // Catalog resolution excludes retained duplicates and respects each version.
358
+ // Cloud stubs inherit the date of the connect page they actually include.
359
+ function markReferenceChanges (contentCatalog, dates, origin) {
360
+ if (!dates.size) return 0
361
+ const marked = new Set()
362
+ const pages = contentCatalog.getPages()
363
+ const mark = (page, date) => {
364
+ if (!date) return
365
+ if (!page.connectReferenceModified || date > page.connectReferenceModified) page.connectReferenceModified = date
366
+ marked.add(page)
367
+ }
368
+ for (const page of pages) {
369
+ for (const key of connectIncludes(page, contentCatalog, origin)) mark(page, dates.get(key))
370
+ }
371
+ for (const page of pages) {
372
+ for (const m of page.contents.toString('utf8').matchAll(INCLUDE_RX)) {
373
+ if (!/^connect:([^:$]+):([^$]+\.adoc)$/.test(m[1])) continue
374
+ const included = contentCatalog.resolvePage(m[1], page.src)
375
+ if (marked.has(included)) mark(page, included.connectReferenceModified)
376
+ }
377
+ }
378
+ return marked.size
379
+ }
380
+
381
+ // The date of the newest release that changed each of `keys`, as a Map of
382
+ // key to YYYY-MM-DD. It compares the release with older releases that have
383
+ // the asset, newest first, so a file left unchanged by the current release
384
+ // keeps the date of the release that last changed it. Bounded so the cost
385
+ // does not grow with every connect release: the listing stops paging at the
386
+ // first release older than FIRST_ASSET_RELEASE, and the walk downloads at
387
+ // most DATE_RELEASE_LIMIT older releases. A file still unchanged at the end
388
+ // of the walk gets no date. A failed listing, download, or read only costs
389
+ // dates: it logs a warning, and every file not dated yet keeps its git date.
390
+ async function referenceReleaseDates (tag, files, keys, logger) {
391
+ const dates = new Map()
392
+ if (!keys.size) return dates
393
+ const floor = stableVersion(FIRST_ASSET_RELEASE)
394
+ const releases = []
395
+ try {
396
+ const github = await createGitHub()
397
+ for (let page = 1; ; page++) {
398
+ const batch = await listConnectReleases(github, OWNER, REPO, DATE_RELEASE_PAGE_SIZE, page)
399
+ releases.push(...batch)
400
+ if (batch.length < DATE_RELEASE_PAGE_SIZE) break
401
+ // Newest first: past the first release older than the asset, no
402
+ // release can have it.
403
+ if (batch.some((r) => r && stableVersion(r.tag_name) && compareVersions(stableVersion(r.tag_name), floor) < 0)) break
404
+ }
405
+ } catch (error) {
406
+ logger.warn(`Couldn't list Redpanda Connect releases (${error.message}), so Connect pages keep their git modified dates`)
407
+ return dates
408
+ }
409
+ let newer = releases.find((r) => r && r.tag_name === tag)
410
+ if (!newer || !newer.published_at) return dates
411
+ const latest = stableVersion(tag)
412
+ const older = releases
413
+ .filter((r) => r && !r.draft && !r.prerelease && stableVersion(r.tag_name) && latest && compareVersions(stableVersion(r.tag_name), latest) < 0)
414
+ .filter((r) => compareVersions(stableVersion(r.tag_name), floor) >= 0)
415
+ .filter((r) => (r.assets || []).some((a) => a && a.name === asset.ASSET_NAME))
416
+ .sort((a, b) => compareVersions(stableVersion(b.tag_name), stableVersion(a.tag_name)))
417
+ .slice(0, DATE_RELEASE_LIMIT)
418
+ const pending = new Set(keys)
419
+ for (const release of older) {
420
+ let previousFiles
421
+ try {
422
+ const archive = await downloadRelease(release.tag_name, logger)
423
+ if (!archive) throw new Error(`the release lists a ${asset.ASSET_NAME} asset but its download returns 404`)
424
+ previousFiles = await asset.readTarGz(archive)
425
+ } catch (error) {
426
+ // Skipping the release would credit its changes to the newer release,
427
+ // so stop here: dates found so far are exact, the rest keep git dates.
428
+ logger.warn(`Couldn't read the Redpanda Connect ${release.tag_name} reference docs to date pages (${error.message}), so ${pending.size} generated files keep their pages' git modified dates`)
429
+ break
430
+ }
431
+ const changed = changedReferenceKeys(files, previousFiles)
432
+ for (const key of pending) {
433
+ if (!changed.has(key)) continue
434
+ if (newer.published_at) dates.set(key, String(newer.published_at).slice(0, 10))
435
+ pending.delete(key)
436
+ }
437
+ if (!pending.size) break
438
+ files = previousFiles
439
+ newer = release
440
+ }
441
+ return dates
442
+ }
443
+
444
+ // True when the playbook registers add-git-dates, the only reader of the
445
+ // release dates. Without it, the dating step is skipped.
446
+ function usesGitDates (playbook) {
447
+ const extensions = (playbook && playbook.antora && playbook.antora.extensions) || []
448
+ return [].concat(extensions).some((ext) => {
449
+ const id = typeof ext === 'string' ? ext : ext && ext.enabled !== false ? ext.require || ext.id : null
450
+ return /(^|[/\\])add-git-dates(\.js)?$/.test(String(id || ''))
451
+ })
452
+ }
453
+
287
454
  // Adds the generated files to every version of the connect component.
288
455
  // Mutates the content catalog and returns what it added, replaced, and
289
456
  // skipped. A file another source already provides is skipped, unless
@@ -332,6 +499,8 @@ module.exports.register = function ({ config }) {
332
499
  // Set in contextStarted: true when the playbook still lists a connect
333
500
  // content source with explicit refs.
334
501
  let gitSourceMode = false
502
+ // Set in contextStarted: true when add-git-dates is in the playbook.
503
+ let dateReferencePages = false
335
504
 
336
505
  this.on('contextStarted', ({ playbook }) => {
337
506
  // Clear a ref left over from an earlier build in the same process.
@@ -345,6 +514,7 @@ module.exports.register = function ({ config }) {
345
514
  )
346
515
  }
347
516
  gitSourceMode = hasConnectSource(playbook)
517
+ dateReferencePages = usesGitDates(playbook)
348
518
  if (gitSourceMode && (process.env[LOCAL_DIR_ENV] || '').trim()) {
349
519
  logger.warn(`${LOCAL_DIR_ENV} is ignored because the playbook lists the connect repo as a content source`)
350
520
  }
@@ -356,7 +526,7 @@ module.exports.register = function ({ config }) {
356
526
  if (r.kept + r.outsideGenerated + r.providedElsewhere + r.otherComponents === 0) return
357
527
  logger.info(
358
528
  `Redpanda Connect content: kept ${r.kept} generated files; skipped ${r.providedElsewhere} already provided by another source, ` +
359
- `${r.outsideGenerated} outside the generated partials and examples, and ${r.otherComponents} from other components`
529
+ `${r.outsideGenerated} outside the generated partials, examples, and attachments, and ${r.otherComponents} from other components`
360
530
  )
361
531
  })
362
532
 
@@ -432,9 +602,17 @@ module.exports.register = function ({ config }) {
432
602
  (localDir
433
603
  ? `replaced ${r.replaced} provided by another source; skipped ${r.outsideGenerated}`
434
604
  : `skipped ${r.providedElsewhere} already provided by another source and ${r.outsideGenerated}`) +
435
- ' outside the generated partials and examples'
605
+ ' outside the generated partials, examples, and attachments'
436
606
  )
607
+ // Resolve only included files supplied by this asset. Retained copies
608
+ // from another source keep their git dates, even if the asset differs.
609
+ if (!localDir && dateReferencePages) {
610
+ const keys = new Set(contentCatalog.getPages().flatMap((page) => connectIncludes(page, contentCatalog, origin)))
611
+ const dates = await referenceReleaseDates(origin.tag, files, keys, logger)
612
+ const count = markReferenceChanges(contentCatalog, dates, origin)
613
+ logger.info(`Redpanda Connect ${origin.tag}: resolved last change dates for ${dates.size} included generated files; ${count} pages use those release dates when later than their last commit`)
614
+ }
437
615
  })
438
616
  }
439
617
 
440
- module.exports._internal = { isRemote, isUpstreamConnect, redact, isConnectSource, isConnectOrigin, toTag, highestStableTag, stableTags, findOlderReleaseWithAsset, resolveLatestTag, FALLBACK_RELEASE_LIMIT, FALLBACK_PROBE_LIMIT, wantsLatest, removeLatestConnectSources, filterConnectContent, hasConnectSource, addConnectDocs, LOCAL_DIR_ENV }
618
+ module.exports._internal = { isRemote, isUpstreamConnect, redact, isConnectSource, isConnectOrigin, toTag, highestStableTag, stableTags, findOlderReleaseWithAsset, resolveLatestTag, FALLBACK_RELEASE_LIMIT, FALLBACK_PROBE_LIMIT, wantsLatest, removeLatestConnectSources, filterConnectContent, hasConnectSource, addConnectDocs, LOCAL_DIR_ENV, changedReferenceKeys, connectIncludes, markReferenceChanges, usesGitDates, FIRST_ASSET_RELEASE, DATE_RELEASE_LIMIT }
@@ -1,6 +1,7 @@
1
1
  'use strict'
2
2
 
3
3
  const { raiseListenerLimit } = require('./util/raise-listener-limit')
4
+ const { escapeHtml } = require('../extension-utils/html-utils')
4
5
 
5
6
  /**
6
7
  * Resolves AsciiDoc xrefs in JSON attachment files to HTML links.
@@ -240,15 +241,3 @@ function resolveXrefsInString (text, contentCatalog, context, logger) {
240
241
  }
241
242
  })
242
243
  }
243
-
244
- /**
245
- * Escape HTML special characters to prevent XSS.
246
- */
247
- function escapeHtml (text) {
248
- if (!text) return ''
249
- return String(text)
250
- .replace(/&/g, '&amp;')
251
- .replace(/</g, '&lt;')
252
- .replace(/>/g, '&gt;')
253
- .replace(/"/g, '&quot;')
254
- }
@@ -5,8 +5,9 @@
5
5
  // Every connect release (vX.Y.Z, including prereleases) publishes a
6
6
  // redpanda-connect-docs.tar.gz asset whose root is `modules/`: the tree that
7
7
  // connect's docs generator writes under its docs/ directory. Only
8
- // modules/components/partials/** and modules/components/examples/** belong to
9
- // the reference. Releases from before the asset existed answer 404.
8
+ // modules/components/partials/**, modules/components/examples/**, and
9
+ // modules/components/attachments/** (the release's connect-<version>.json)
10
+ // belong to the reference. Releases from before the asset existed answer 404.
10
11
  //
11
12
  // The same tree can come from a local directory instead (a connect checkout's
12
13
  // docs/ after running its generator), or from a local copy of the tarball.
@@ -19,7 +20,7 @@ const OWNER = 'redpanda-data'
19
20
  const REPO = 'connect'
20
21
  const ASSET_NAME = 'redpanda-connect-docs.tar.gz'
21
22
  const MODULE = 'components'
22
- const FAMILY_DIRS = { partials: 'partial', examples: 'example' }
23
+ const FAMILY_DIRS = { partials: 'partial', examples: 'example', attachments: 'attachment' }
23
24
  const DOWNLOAD_ATTEMPTS = 3
24
25
  const DOWNLOAD_TIMEOUT_MS = 120000
25
26
 
@@ -28,10 +29,10 @@ function assetUrl (tag) {
28
29
  }
29
30
 
30
31
  // The Antora resource of a path inside the tree, or null for anything outside
31
- // modules/components/partials and modules/components/examples.
32
+ // modules/components/partials, examples, and attachments.
32
33
  function toResource (entryPath) {
33
34
  const p = String(entryPath || '').replace(/\\/g, '/').replace(/^(\.\/)+/, '')
34
- const m = p.match(/^modules\/components\/(partials|examples)\/(.+)$/)
35
+ const m = p.match(/^modules\/components\/(partials|examples|attachments)\/(.+)$/)
35
36
  if (!m || m[2].endsWith('/')) return null
36
37
  // A path that climbs out of its directory is not part of the tree.
37
38
  if (m[2].split('/').some((segment) => segment === '..' || segment === '')) return null
@@ -4,30 +4,50 @@
4
4
  * The function separates tags into stable (tags not including "-beta") and beta (tags including "-beta"),
5
5
  * sorts each group by their major.minor version in descending order, and returns the top tag from each.
6
6
  *
7
+ * It also returns every stable tag it saw (`stableReleases`), so callers can
8
+ * pick the newest release of an older line, not just the newest overall.
9
+ * Docker Hub lists the most recently pushed tags first, so the first page
10
+ * already holds every line that still gets patches. Pass `maxPages` to reach
11
+ * further back.
12
+ *
7
13
  * @param {string} dockerNamespace - The Docker Hub namespace (organization or username)
8
14
  * @param {string} dockerRepo - The repository name on Docker Hub
9
15
  * @param {Object} logger - Optional Antora logger instance for logging
10
- * @returns {Promise<{ latestStableRelease: string|null, latestBetaRelease: string|null }>}
16
+ * @param {{ maxPages?: number, fetch?: Function }} options - How many pages of 100 tags to read (default 1), and an optional fetch implementation for tests
17
+ * @returns {Promise<{ latestStableRelease: string|null, latestBetaRelease: string|null, stableReleases: string[] }>}
11
18
  */
12
- module.exports = async (dockerNamespace, dockerRepo, logger = null) => {
13
- const { default: fetch } = await import('node-fetch');
19
+ module.exports = async (dockerNamespace, dockerRepo, logger = null, { maxPages = 1, fetch: fetchImpl } = {}) => {
20
+ const fetch = fetchImpl || (await import('node-fetch')).default;
14
21
 
15
22
  try {
16
- // Fetch a list of tags from Docker Hub.
17
- const url = `https://hub.docker.com/v2/repositories/${dockerNamespace}/${dockerRepo}/tags?page_size=100`;
18
- const response = await fetch(url);
23
+ // Fetch a list of tags from Docker Hub. Only the first page is required:
24
+ // a later page that fails costs older lines, not the latest release.
25
+ const results = [];
26
+ let url = `https://hub.docker.com/v2/repositories/${dockerNamespace}/${dockerRepo}/tags?page_size=100`;
27
+ for (let page = 1; url && page <= maxPages; page++) {
28
+ const response = await fetch(url);
19
29
 
20
- if (!response.ok) {
21
- throw new Error(`Docker Hub API responded with status ${response.status}`);
22
- }
30
+ if (!response.ok) {
31
+ const message = `Docker Hub API responded with status ${response.status}`;
32
+ if (page === 1) throw new Error(message);
33
+ if (logger) {
34
+ logger.warn(`${message} for page ${page} of ${dockerRepo} tags; using the first ${results.length} tags.`);
35
+ } else {
36
+ console.warn(`${message} for page ${page} of ${dockerRepo} tags; using the first ${results.length} tags.`);
37
+ }
38
+ break;
39
+ }
23
40
 
24
- const data = await response.json();
41
+ const data = await response.json();
42
+ results.push(...(data.results || []));
43
+ url = data.next;
44
+ }
25
45
 
26
46
  // Regex to capture major and minor version numbers (e.g. "v2.3")
27
47
  const versionRegex = /^v(\d+)\.(\d+)/;
28
48
 
29
49
  // Filter tags to include only those matching the version pattern.
30
- let tags = data.results.filter(tag => versionRegex.test(tag.name));
50
+ let tags = results.filter(tag => versionRegex.test(tag.name));
31
51
 
32
52
  // For specific repositories (e.g. "redpanda-operator"), you might want to filter out certain versions.
33
53
  if (dockerRepo === 'redpanda-operator') {
@@ -61,7 +81,8 @@ module.exports = async (dockerNamespace, dockerRepo, logger = null) => {
61
81
 
62
82
  return {
63
83
  latestStableRelease: latestStableReleaseVersion || null,
64
- latestBetaRelease: latestBetaReleaseVersion || null
84
+ latestBetaRelease: latestBetaReleaseVersion || null,
85
+ stableReleases: sortedStable.map(tag => tag.name)
65
86
  };
66
87
 
67
88
  } catch (error) {
@@ -72,7 +93,8 @@ module.exports = async (dockerNamespace, dockerRepo, logger = null) => {
72
93
  }
73
94
  return {
74
95
  latestStableRelease: null,
75
- latestBetaRelease: null
96
+ latestBetaRelease: null,
97
+ stableReleases: []
76
98
  };
77
99
  }
78
100
  };
@@ -1,7 +1,17 @@
1
1
  /**
2
- * Fetches the latest Helm chart version from the redpanda-operator repository
3
- * by converting docker tag format v<major>.<minor>.<patch> to release/v<major>.<minor>.x
4
- * and checking the Chart.yaml file in that branch.
2
+ * Fetches the latest Helm chart version for an operator release line from the
3
+ * redpanda-operator repository.
4
+ *
5
+ * A stable chart version comes from the newest stable `charts/redpanda/v<major>.<minor>.*`
6
+ * tag, because a tag marks a chart that was actually published (what
7
+ * `helm search repo` reports). A release branch's Chart.yaml can be bumped
8
+ * ahead of the published chart, so it is only the fallback, and the only
9
+ * source for beta versions.
10
+ *
11
+ * The chart has lived at more than one path over time (for example,
12
+ * `charts/redpanda/Chart.yaml` on release/v25.1.x and
13
+ * `charts/redpanda/chart/Chart.yaml` from release/v25.2.x on), so the fallback
14
+ * tries each known path in order.
5
15
  *
6
16
  * @param {object} github - The GitHub API client.
7
17
  * @param {string} owner - The repository owner.
@@ -13,7 +23,50 @@
13
23
  */
14
24
  module.exports = async (github, owner, repo, stableDockerTag, betaDockerTag, logger = null) => {
15
25
  const yaml = require('js-yaml');
16
- const path = 'charts/redpanda/chart/Chart.yaml';
26
+ const semver = require('semver');
27
+ const chartPaths = ['charts/redpanda/chart/Chart.yaml', 'charts/redpanda/Chart.yaml'];
28
+ const warn = (message) => (logger ? logger.warn(message) : console.warn(message));
29
+ const logError = (message) => (logger ? logger.error(message) : console.error(message));
30
+
31
+ // Returns the newest stable published chart version of a release line, or
32
+ // null when the line has no stable chart tag or the lookup fails.
33
+ const getChartVersionFromTags = async (major, minor) => {
34
+ if (!github.git?.listMatchingRefs) return null;
35
+ const tagPrefix = 'charts/redpanda/v';
36
+ const prefix = `${tagPrefix}${major}.${minor}.`;
37
+ try {
38
+ const { data } = await github.git.listMatchingRefs({ owner, repo, ref: `tags/${prefix}` });
39
+ let newest = null;
40
+ for (const { ref } of data || []) {
41
+ const version = semver.valid(String(ref).slice(`refs/tags/${tagPrefix}`.length));
42
+ if (!version || semver.prerelease(version)) continue;
43
+ if (semver.major(version) !== Number(major) || semver.minor(version) !== Number(minor)) continue;
44
+ if (!newest || semver.gt(version, newest)) newest = version;
45
+ }
46
+ return newest;
47
+ } catch (error) {
48
+ warn(`Could not list ${prefix}* tags in ${owner}/${repo}: ${error.message || error}`);
49
+ return null;
50
+ }
51
+ };
52
+
53
+ // Returns the chart version from the first known Chart.yaml path on a
54
+ // branch, or null when none of the paths exist there.
55
+ const getChartVersionFromBranch = async (dockerTag, branchName) => {
56
+ for (const path of chartPaths) {
57
+ try {
58
+ const contentResponse = await github.repos.getContent({ owner, repo, path, ref: branchName });
59
+ const contentDecoded = Buffer.from(contentResponse.data.content, 'base64').toString('utf8');
60
+ return yaml.load(contentDecoded)?.version || null;
61
+ } catch (error) {
62
+ if (error.status === 404) continue;
63
+ logError(`Failed to fetch ${path} from branch ${branchName}: ${error.message || error}`);
64
+ return null;
65
+ }
66
+ }
67
+ warn(`Fetched operator ${dockerTag} from Docker Hub but found no Helm chart for it: no ${chartPaths.join(' or ')} on branch ${branchName} (the branch may not exist yet)`);
68
+ return null;
69
+ };
17
70
 
18
71
  /**
19
72
  * Helper function to fetch chart version from a branch derived from a docker tag
@@ -38,44 +91,15 @@ module.exports = async (github, owner, repo, stableDockerTag, betaDockerTag, log
38
91
  return null;
39
92
  }
40
93
 
41
- const major = versionMatch[1];
42
- const minor = versionMatch[2];
43
-
44
- // Convert to release/v<major>.<minor>.x format
45
- const branchName = `release/v${major}.${minor}.x`;
46
-
47
- try {
48
-
49
- // Fetch Chart.yaml from the specified branch
50
- const contentResponse = await github.repos.getContent({
51
- owner,
52
- repo,
53
- path,
54
- ref: branchName,
55
- });
94
+ const [, major, minor] = versionMatch;
95
+ const isBeta = /-beta/.test(dockerTag);
56
96
 
57
- const contentBase64 = contentResponse.data.content;
58
- const contentDecoded = Buffer.from(contentBase64, 'base64').toString('utf8');
59
- const chartYaml = yaml.load(contentDecoded);
60
- const version = chartYaml.version || null;
61
- return version;
62
- } catch (error) {
63
- if (error.status === 404) {
64
- // Branch doesn't exist yet - this is expected for unreleased versions
65
- if (logger) {
66
- logger.warn(`Fetched operator ${dockerTag} from Docker Hub but branch ${branchName} not found in GitHub (source may not be released yet)`);
67
- } else {
68
- console.warn(`Fetched operator ${dockerTag} from Docker Hub but branch ${branchName} not found in GitHub (source may not be released yet)`);
69
- }
70
- } else {
71
- if (logger) {
72
- logger.error(`Failed to fetch Chart.yaml for branch ${branchName}:`, error.message);
73
- } else {
74
- console.error(`Failed to fetch Chart.yaml for branch ${branchName}:`, error.message);
75
- }
76
- }
77
- return null;
97
+ if (!isBeta) {
98
+ const published = await getChartVersionFromTags(major, minor);
99
+ if (published) return published;
78
100
  }
101
+
102
+ return await getChartVersionFromBranch(dockerTag, `release/v${major}.${minor}.x`);
79
103
  } catch (error) {
80
104
  if (logger) {
81
105
  logger.error(`Error processing docker tag ${dockerTag}:`, error.message);
@@ -1,10 +1,10 @@
1
1
  'use strict'
2
2
 
3
3
  // The most recent releases of a repository, newest first, as the GitHub API
4
- // lists them (one page of at most `limit` releases). Each release carries its
4
+ // lists them (the requested page of at most `limit` releases). Each release carries its
5
5
  // assets, so a caller can check for an asset without downloading it. Throws
6
6
  // when the API is unavailable, so the caller can fall back to git tags.
7
- module.exports = async (github, owner, repo, limit) => {
8
- const { data } = await github.rest.repos.listReleases({ owner, repo, per_page: limit })
7
+ module.exports = async (github, owner, repo, limit, page = 1) => {
8
+ const { data } = await github.rest.repos.listReleases({ owner, repo, per_page: limit, page })
9
9
  return Array.isArray(data) ? data.slice(0, limit) : []
10
10
  }
@@ -0,0 +1,64 @@
1
+ 'use strict';
2
+
3
+ // Helpers for pinning an attribute to the release line a component version
4
+ // documents, rather than to the newest release overall. The lines come from
5
+ // the fetched release tags themselves, so a new release line is picked up as
6
+ // soon as it ships and nothing needs a manual version map.
7
+
8
+ const semver = require('semver');
9
+
10
+ /**
11
+ * Returns the newest stable tag of each major.minor release line.
12
+ *
13
+ * Only plain `v<major>.<minor>.<patch>` tags count. Prereleases are skipped,
14
+ * and so are the legacy operator tags such as `v2.3.15-24.3.18`, whose suffix
15
+ * semver reads as a prerelease. Patches are compared as semver, so the result
16
+ * does not depend on the order the registry lists tags in.
17
+ *
18
+ * @param {string[]} tags - Release tags, for example ['v26.1.12', 'v26.1.9'].
19
+ * @returns {Map<string, string>} Release line (for example '26.1') to its newest tag.
20
+ */
21
+ function latestStablePerLine (tags) {
22
+ const lines = new Map();
23
+ for (const tag of tags || []) {
24
+ if (typeof tag !== 'string' || !tag.startsWith('v')) continue;
25
+ const parsed = semver.parse(tag.slice(1));
26
+ if (!parsed || parsed.prerelease.length || parsed.build.length) continue;
27
+ const line = `${parsed.major}.${parsed.minor}`;
28
+ const current = lines.get(line);
29
+ if (!current || semver.gt(parsed, current.slice(1))) lines.set(line, tag);
30
+ }
31
+ return lines;
32
+ }
33
+
34
+ /**
35
+ * Returns the release line an Antora component version names, or null when
36
+ * the version is not a `major.minor` pair (for example, an unversioned
37
+ * component or a version named after something other than a release).
38
+ *
39
+ * @param {string|null|undefined} version - The component version, for example '25.3'.
40
+ * @returns {string|null}
41
+ */
42
+ function releaseLineOf (version) {
43
+ const match = /^(\d+)\.(\d+)$/.exec(String(version ?? ''));
44
+ return match ? `${Number(match[1])}.${Number(match[2])}` : null;
45
+ }
46
+
47
+ /**
48
+ * Returns true when a component version documents an older release line than
49
+ * the component's latest version. Only those versions are pinned to their own
50
+ * line. The latest version, prereleases ahead of it, and unversioned
51
+ * components keep the newest release overall.
52
+ *
53
+ * @param {string} version - The component version.
54
+ * @param {string} latestVersion - The component's latest version.
55
+ * @returns {boolean}
56
+ */
57
+ function isOlderReleaseLine (version, latestVersion) {
58
+ const line = releaseLineOf(version);
59
+ const latestLine = releaseLineOf(latestVersion);
60
+ if (!line || !latestLine) return false;
61
+ return semver.lt(`${line}.0`, `${latestLine}.0`);
62
+ }
63
+
64
+ module.exports = { latestStablePerLine, releaseLineOf, isOlderReleaseLine };