@redpanda-data/docs-extensions-and-macros 5.38.10 → 5.42.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.
package/bin/doc-tools.js CHANGED
@@ -76,6 +76,11 @@ programCli
76
76
  * - Node.js and npm
77
77
  * - Python 3.9 or higher
78
78
  * - Docker (for some dependencies)
79
+ * - Network access to https://rpk.redpanda.com (rpk is downloaded from the
80
+ * rpk distribution CDN and checksum-verified; no GitHub token needed).
81
+ * Optional: RPK_VERSION=vX.Y.Z pins the rpk version. Without it, the
82
+ * newest GA is resolved from streaming-enterprise tags when a GitHub token
83
+ * is available, otherwise from latest/ on the CDN.
79
84
  */
80
85
  programCli
81
86
  .command('install-test-dependencies')
@@ -1358,6 +1363,10 @@ automation
1358
1363
  * - A GitHub token (resolved from GIT_CREDENTIALS, REDPANDA_GITHUB_TOKEN, ACTIONS_BOT_TOKEN, GITHUB_TOKEN, VBOT_GITHUB_API_TOKEN, or GH_TOKEN, in that priority order) with
1359
1364
  * access to redpanda-data/streaming-enterprise, which is private (not
1360
1365
  * needed when --from-source points at an existing local checkout)
1366
+ * - --plugin refreshes need none of the above when the snapshot's rpk_version
1367
+ * is a published GA or RC tag: the rpk binary is downloaded from
1368
+ * https://rpk.redpanda.com and checksum-verified. Go, Git and the token are
1369
+ * only used as a fallback when the CDN has no build for that tag.
1361
1370
  */
1362
1371
  automation
1363
1372
  .command('rpk-docs')
@@ -414,166 +414,65 @@ ensure_dependencies_installed() {
414
414
  log_info "Installing/checking dependencies for doc-tools CLI commands..."
415
415
  ensure_dependencies_installed
416
416
 
417
- # install_rpk installs Redpanda's rpk CLI into ~/.local/bin by downloading the appropriate release for the current OS and architecture, adding it to PATH for the current and future sessions, and verifying the installation; returns 0 on success and non-zero on failure.
417
+ # install_rpk installs Redpanda's rpk CLI into ~/.local/bin from the rpk
418
+ # distribution CDN (https://rpk.redpanda.com), adds it to PATH for the current
419
+ # and future sessions, and verifies the installation; returns 0 on success and
420
+ # non-zero on failure. Set RPK_VERSION=vX.Y.Z to pin a version. The URL layout,
421
+ # version resolution and checksum verification live in cli-utils/rpk-cdn.js so
422
+ # this script and the rpk docs generator share one implementation.
418
423
  install_rpk() {
419
424
  if command_exists rpk; then
420
425
  log_info "rpk is already installed. Version information:"
421
426
  rpk --version
422
427
  return 0
423
428
  fi
424
-
425
- log_info "Installing rpk..."
426
-
427
- # Detect OS and architecture
428
- local os_name=$(uname -s)
429
- local arch_name=$(uname -m)
430
-
431
- # Map OS name to rpk release format
432
- local rpk_os=""
433
- case "$os_name" in
434
- "Darwin")
435
- rpk_os="darwin"
436
- ;;
437
- "Linux")
438
- rpk_os="linux"
439
- ;;
440
- *)
441
- log_warn "Unsupported operating system: $os_name"
442
- log_warn "Please install rpk manually:"
443
- log_warn "https://docs.redpanda.com/current/get-started/rpk-install/"
444
- return 1
445
- ;;
446
- esac
447
-
448
- # Map architecture to rpk release format
449
- local rpk_arch=""
450
- case "$arch_name" in
451
- "x86_64" | "amd64")
452
- rpk_arch="amd64"
453
- ;;
454
- "arm64" | "aarch64")
455
- rpk_arch="arm64"
456
- ;;
457
- *)
458
- log_warn "Unsupported architecture: $arch_name"
459
- log_warn "Please install rpk manually:"
460
- log_warn "https://docs.redpanda.com/current/get-started/rpk-install/"
461
- return 1
462
- ;;
463
- esac
464
-
465
- # rpk releases now publish to the private redpanda-data/streaming-enterprise,
466
- # not the public redpanda-data/redpanda, so a token is required (same
467
- # priority order as cli-utils/github-token.js's getGitHubToken(), minus
468
- # GIT_CREDENTIALS, which is Antora's own credential store and doesn't
469
- # apply to this standalone installer script).
470
- local gh_token="${REDPANDA_GITHUB_TOKEN:-${ACTIONS_BOT_TOKEN:-${GITHUB_TOKEN:-${VBOT_GITHUB_API_TOKEN:-${GH_TOKEN:-}}}}}"
471
- if [ -z "$gh_token" ]; then
472
- log_warn "No GitHub token found (checked REDPANDA_GITHUB_TOKEN, ACTIONS_BOT_TOKEN, GITHUB_TOKEN, VBOT_GITHUB_API_TOKEN, GH_TOKEN)."
473
- log_warn "rpk releases are now published to the private redpanda-data/streaming-enterprise repo and require authentication."
474
- log_warn "Please install rpk manually:"
475
- log_warn "https://docs.redpanda.com/current/get-started/rpk-install/"
476
- return 1
477
- fi
478
429
 
479
- local rpk_filename="rpk-${rpk_os}-${rpk_arch}.zip"
480
- local releases_api="https://api.github.com/repos/redpanda-data/streaming-enterprise/releases/latest"
430
+ log_info "Installing rpk..."
481
431
 
482
- log_info "Detected ${os_name} ${arch_name}, looking up the latest rpk release..."
432
+ local script_dir
433
+ script_dir="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
434
+ local tmp_dir
435
+ tmp_dir="$(mktemp -d)"
436
+ local rpk_bin
483
437
 
484
- # The Authorization header reaches curl through a config file on stdin
485
- # (--config -), never as an -H argument: an argv header is readable by
486
- # any process on the host (ps, /proc) for as long as the transfer runs.
487
- local release_json
488
- if ! release_json=$(curl -fsSL --retry 5 --retry-all-errors \
489
- --connect-timeout 30 --max-time 60 --retry-max-time 120 \
490
- --config - \
491
- -H "Accept: application/vnd.github+json" \
492
- "$releases_api" <<EOF
493
- header = "Authorization: token ${gh_token}"
494
- EOF
495
- ); then
496
- log_warn "Failed to query the latest release from streaming-enterprise"
497
- log_warn "Please install rpk manually:"
438
+ # node is always present: this script is launched by bin/doc-tools.js.
439
+ # Without RPK_VERSION the module resolves the latest release itself
440
+ # (GitHub tags when a token is available, otherwise latest/ on the CDN).
441
+ if ! rpk_bin=$(node "${script_dir}/rpk-cdn.js" install --dest "${tmp_dir}" ${RPK_VERSION:+--version "${RPK_VERSION}"}); then
442
+ rm -rf "${tmp_dir}"
443
+ log_warn "Could not install rpk from https://rpk.redpanda.com (see the message above)."
444
+ log_warn "Set RPK_VERSION=vX.Y.Z to pin a version, or install rpk manually:"
498
445
  log_warn "https://docs.redpanda.com/current/get-started/rpk-install/"
499
446
  return 1
500
447
  fi
501
448
 
502
- # Resolve the asset's API url (not browser_download_url): only the API
503
- # asset endpoint accepts the Authorization header this private repo needs.
504
- local rpk_url
505
- rpk_url=$(printf '%s' "$release_json" | jq -r --arg name "$rpk_filename" '.assets[] | select(.name == $name) | .url')
506
- if [ -z "$rpk_url" ] || [ "$rpk_url" = "null" ]; then
507
- log_warn "Latest streaming-enterprise release has no asset named ${rpk_filename}"
449
+ mkdir -p ~/.local/bin
450
+ if ! mv "${rpk_bin}" ~/.local/bin/rpk; then
451
+ rm -rf "${tmp_dir}"
452
+ log_warn "Failed to move rpk binary to ~/.local/bin/"
508
453
  log_warn "Please install rpk manually:"
509
454
  log_warn "https://docs.redpanda.com/current/get-started/rpk-install/"
510
455
  return 1
511
456
  fi
457
+ rm -rf "${tmp_dir}"
512
458
 
513
- log_info "Downloading ${rpk_filename}..."
459
+ # Add to PATH for current session
460
+ export PATH=$HOME/.local/bin:$PATH
514
461
 
515
- # Try to download and install rpk.
516
- # Use -f so HTTP errors (e.g. 403/404/429/503) fail instead of writing an
517
- # error page into the zip, and retry with curl's default exponential
518
- # backoff (1s, 2s, 4s, ...) to ride out transient GitHub/CDN and network
519
- # errors, including rate limits that need time to clear. --retry-max-time
520
- # caps the whole retry window so CI fails fast on persistent errors.
521
- # Accept: application/octet-stream is required on the API asset url to
522
- # receive the binary itself instead of its JSON metadata.
523
- # Same as the release lookup above: the token travels on stdin, not argv.
524
- if curl -fL --retry 5 --retry-all-errors \
525
- --connect-timeout 30 --max-time 300 --retry-max-time 600 \
526
- --config - \
527
- -H "Accept: application/octet-stream" \
528
- -o "$rpk_filename" "$rpk_url" <<EOF
529
- header = "Authorization: token ${gh_token}"
530
- EOF
531
- then
532
- if unzip "$rpk_filename" 2>/dev/null; then
533
- mkdir -p ~/.local/bin
534
- if mv rpk ~/.local/bin/ 2>/dev/null; then
535
- rm "$rpk_filename"
536
-
537
- # Add to PATH for current session
538
- export PATH=$HOME/.local/bin:$PATH
539
-
540
- # Add the target directory to PATH for future sessions
541
- if ! grep -q 'export PATH=$HOME/.local/bin:$PATH' ~/.bashrc 2>/dev/null; then
542
- echo 'export PATH=$HOME/.local/bin:$PATH' >> ~/.bashrc
543
- fi
544
-
545
- # Verify installation
546
- if command_exists rpk; then
547
- log_info "rpk has been installed successfully. Version information:"
548
- rpk --version
549
- return 0
550
- else
551
- log_warn "rpk installation may have failed. Please install manually:"
552
- log_warn "https://docs.redpanda.com/current/get-started/rpk-install/"
553
- return 1
554
- fi
555
- else
556
- log_warn "Failed to move rpk binary to ~/.local/bin/"
557
- rm -f "$rpk_filename" rpk 2>/dev/null
558
- log_warn "Please install rpk manually:"
559
- log_warn "https://docs.redpanda.com/current/get-started/rpk-install/"
560
- return 1
561
- fi
562
- else
563
- log_warn "Failed to unzip $rpk_filename (may not exist for ${rpk_os}-${rpk_arch})"
564
- rm -f "$rpk_filename" 2>/dev/null
565
- log_warn "Please install rpk manually:"
566
- log_warn "https://docs.redpanda.com/current/get-started/rpk-install/"
567
- return 1
568
- fi
569
- else
570
- log_warn "Failed to download $rpk_url"
571
- # A mid-transfer failure can leave a partial file behind even with -f.
572
- rm -f "$rpk_filename" 2>/dev/null
573
- log_warn "Please install rpk manually:"
574
- log_warn "https://docs.redpanda.com/current/get-started/rpk-install/"
575
- return 1
462
+ # Add the target directory to PATH for future sessions
463
+ if ! grep -q 'export PATH=$HOME/.local/bin:$PATH' ~/.bashrc 2>/dev/null; then
464
+ echo 'export PATH=$HOME/.local/bin:$PATH' >> ~/.bashrc
576
465
  fi
466
+
467
+ # Verify installation
468
+ if command_exists rpk; then
469
+ log_info "rpk has been installed successfully. Version information:"
470
+ rpk --version
471
+ return 0
472
+ fi
473
+ log_warn "rpk installation may have failed. Please install manually:"
474
+ log_warn "https://docs.redpanda.com/current/get-started/rpk-install/"
475
+ return 1
577
476
  }
578
477
 
579
478
  # Install rpk for rpcn-connector-docs command
@@ -0,0 +1,549 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * rpk binary distribution on https://rpk.redpanda.com (DEVPROD-4723).
5
+ *
6
+ * rpk release binaries stopped shipping as GitHub Release assets in
7
+ * September 2026 and are served from an S3-backed CDN instead. This module
8
+ * is the single definition of that layout for every consumer in this
9
+ * package (the rpk docs generator and install-test-dependencies.sh):
10
+ *
11
+ * https://rpk.redpanda.com/v<tag>/rpk-<os>-<arch>.zip
12
+ * https://rpk.redpanda.com/v<tag>/rpk_<version>_checksums.txt (sha256sum format)
13
+ * https://rpk.redpanda.com/latest/... (newest GA, copied at promote)
14
+ *
15
+ * Both GA (vX.Y.Z) and RC (vX.Y.Z-rcN) tags are published under v<tag>/.
16
+ * No authentication is needed. The bucket has no listing, and a missing key
17
+ * answers HTTP 403 (AccessDenied), not 404, so "is this version published"
18
+ * is a request for its checksums file and both codes mean "not published".
19
+ *
20
+ * Runs as a CLI too (see usage() below) so the bash installer shares this
21
+ * code instead of carrying its own copy of the URL layout.
22
+ */
23
+
24
+ const fs = require('fs')
25
+ const os = require('os')
26
+ const path = require('path')
27
+ const crypto = require('crypto')
28
+ const { spawnSync } = require('child_process')
29
+
30
+ const RPK_CDN_BASE = 'https://rpk.redpanda.com'
31
+ const RPK_CDN_LATEST_PREFIX = 'latest'
32
+ // GA and RC tags both have a build under v<tag>/
33
+ const RPK_RELEASE_TAG_RE = /^v\d+\.\d+\.\d+(-rc\d+)?$/
34
+ // Only GA tags are ever promoted to latest/
35
+ const RPK_GA_TAG_RE = /^v\d+\.\d+\.\d+$/
36
+ const RPK_OS_BY_PLATFORM = { darwin: 'darwin', linux: 'linux', win32: 'windows' }
37
+ const RPK_ARCH_BY_NODE_ARCH = { arm64: 'arm64', x64: 'amd64' }
38
+ // --platform/--arch are Node's own names (process.platform/process.arch), but
39
+ // the CLI asset spelling ("windows", "amd64") leaks into error messages, so
40
+ // callers reasonably try that spelling too. Accept both.
41
+ const PLATFORM_ALIASES = { windows: 'win32' }
42
+ const ARCH_ALIASES = { amd64: 'x64', x86_64: 'x64', aarch64: 'arm64' }
43
+ // S3 without ListBucket answers 403 for a missing key; a 404 would mean the
44
+ // same thing if the distribution ever changes.
45
+ const NOT_PUBLISHED_HTTP_CODES = new Set([403, 404])
46
+
47
+ const TAGS_OWNER = 'redpanda-data'
48
+ const TAGS_REPO = 'streaming-enterprise'
49
+
50
+ /**
51
+ * Normalise a version to the tag shape the CDN publishes under.
52
+ * @param {string} version - "26.2.2", "v26.2.2" or "v26.3.0-rc1"
53
+ * @returns {string|null} "v"-prefixed tag, or null when the shape is not a release tag
54
+ */
55
+ function normalizeRpkTag(version) {
56
+ if (typeof version !== 'string') return null
57
+ const trimmed = version.trim()
58
+ if (!trimmed) return null
59
+ const tag = trimmed.startsWith('v') ? trimmed : `v${trimmed}`
60
+ return RPK_RELEASE_TAG_RE.test(tag) ? tag : null
61
+ }
62
+
63
+ /**
64
+ * Release asset name for a platform.
65
+ * @param {Object} [opts]
66
+ * @param {string} [opts.platform=process.platform]
67
+ * @param {string} [opts.arch=process.arch]
68
+ * @returns {string|null} e.g. "rpk-linux-amd64.zip", or null when unsupported
69
+ */
70
+ function rpkAssetName({ platform = process.platform, arch = process.arch } = {}) {
71
+ const osName = RPK_OS_BY_PLATFORM[PLATFORM_ALIASES[platform] || platform]
72
+ const archName = RPK_ARCH_BY_NODE_ARCH[ARCH_ALIASES[arch] || arch]
73
+ if (!osName || !archName) return null
74
+ return `rpk-${osName}-${archName}.zip`
75
+ }
76
+
77
+ /**
78
+ * goreleaser checksums file name for a tag.
79
+ * @param {string} tag - e.g. "v26.2.2"
80
+ * @returns {string} e.g. "rpk_26.2.2_checksums.txt"
81
+ */
82
+ function rpkChecksumsName(tag) {
83
+ return `rpk_${tag.replace(/^v/, '')}_checksums.txt`
84
+ }
85
+
86
+ /**
87
+ * Build a CDN URL.
88
+ * @param {string} prefix - "v26.2.2" or "latest"
89
+ * @param {string} fileName
90
+ */
91
+ function rpkCdnUrl(prefix, fileName) {
92
+ return `${RPK_CDN_BASE}/${prefix}/${fileName}`
93
+ }
94
+
95
+ /**
96
+ * All URLs for a tag on the current (or given) platform.
97
+ * @param {string} tag - normalised tag
98
+ * @param {Object} [opts] - platform/arch overrides (see rpkAssetName)
99
+ * @returns {{ zip: string, checksums: string, assetName: string, checksumsName: string }|null}
100
+ */
101
+ function rpkCdnUrls(tag, opts = {}) {
102
+ const assetName = rpkAssetName(opts)
103
+ if (!assetName) return null
104
+ const checksumsName = rpkChecksumsName(tag)
105
+ return {
106
+ assetName,
107
+ checksumsName,
108
+ zip: rpkCdnUrl(tag, assetName),
109
+ checksums: rpkCdnUrl(tag, checksumsName)
110
+ }
111
+ }
112
+
113
+ function isNotPublished(httpCode) {
114
+ return NOT_PUBLISHED_HTTP_CODES.has(httpCode)
115
+ }
116
+
117
+ function parseHttpCode(stdout) {
118
+ const trimmed = String(stdout || '').trim()
119
+ return parseInt(trimmed.slice(-3), 10) || 0
120
+ }
121
+
122
+ /**
123
+ * Download a URL to a file with curl.
124
+ *
125
+ * The HTTP status travels through -w '%{http_code}' rather than curl's exit
126
+ * code: with -f a 403 exits 22 on most builds but 56 on others, so callers
127
+ * gate on the status. --retry alone only retries transient errors (timeouts,
128
+ * 5xx, 429); --retry-all-errors is deliberately absent so a deterministic
129
+ * 403 for an unpublished version is not retried for half a minute.
130
+ *
131
+ * @param {string} url
132
+ * @param {string} dest - Output path; removed again on failure
133
+ * @param {Object} [opts]
134
+ * @param {number} [opts.maxTime=300] - curl --max-time in seconds
135
+ * @param {number} [opts.retries=3]
136
+ * @returns {{ ok: boolean, httpCode: number, status: number|null, stderr: string }}
137
+ */
138
+ function curlToFile(url, dest, { maxTime = 300, retries = 3 } = {}) {
139
+ const result = spawnSync('curl', [
140
+ '-sSfL', '--retry', String(retries), '--retry-max-time', String(maxTime),
141
+ '--connect-timeout', '30', '--max-time', String(maxTime),
142
+ '-o', dest, '-w', '%{http_code}', url
143
+ ], { encoding: 'utf8', timeout: (maxTime + 60) * 1000 })
144
+
145
+ const httpCode = parseHttpCode(result.stdout)
146
+ const ok = result.status === 0 && httpCode === 200
147
+ if (!ok) {
148
+ // A mid-transfer failure can leave a partial file behind even with -f
149
+ fs.rmSync(dest, { force: true })
150
+ }
151
+ return { ok, httpCode, status: result.status, stderr: (result.stderr || '').trim() }
152
+ }
153
+
154
+ /**
155
+ * HEAD a URL and return its HTTP status (0 when curl itself failed).
156
+ * @param {string} url
157
+ * @returns {number}
158
+ */
159
+ function curlHead(url) {
160
+ const result = spawnSync('curl', [
161
+ '-sfIL', '--retry', '3', '--retry-max-time', '30',
162
+ '--connect-timeout', '15', '--max-time', '30',
163
+ '-o', os.devNull, '-w', '%{http_code}', url
164
+ ], { encoding: 'utf8', timeout: 90000 })
165
+ return parseHttpCode(result.stdout)
166
+ }
167
+
168
+ /**
169
+ * Find the expected sha256 for an asset in a sha256sum-format checksums file.
170
+ * @param {string} text - checksums file content
171
+ * @param {string} assetName
172
+ * @returns {string|null} lowercase hex digest, or null when the asset is not listed
173
+ */
174
+ function parseChecksums(text, assetName) {
175
+ const line = String(text || '')
176
+ .split(/\r?\n/)
177
+ .map(l => l.trim())
178
+ .find(l => l.endsWith(assetName))
179
+ if (!line) return null
180
+ const [digest] = line.split(/\s+/)
181
+ return digest ? digest.toLowerCase() : null
182
+ }
183
+
184
+ function sha256File(filePath) {
185
+ return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex')
186
+ }
187
+
188
+ /**
189
+ * Download and verify the rpk release binary for a tag.
190
+ *
191
+ * Returns null whenever the CDN has no complete build for the tag (HTTP
192
+ * 403/404 on the checksums file or the zip, network failure) so callers can
193
+ * fall back to a source build. Throws only for a published build that is
194
+ * wrong: a checksums file without this asset, a digest mismatch, or an
195
+ * archive that does not extract to an rpk binary.
196
+ *
197
+ * @param {string} version - Release tag or bare version (GA or RC)
198
+ * @param {string} destDir - Directory to download and extract into
199
+ * @param {Object} [opts]
200
+ * @param {string} [opts.platform=process.platform]
201
+ * @param {string} [opts.arch=process.arch]
202
+ * @param {{ log: Function, warn: Function }} [opts.log=console]
203
+ * @returns {string|null} Path to the extracted binary, or null when not published
204
+ */
205
+ function downloadRpkBinary(version, destDir, { platform = process.platform, arch = process.arch, log = console } = {}) {
206
+ const tag = normalizeRpkTag(version)
207
+ if (!tag) {
208
+ log.warn(`'${version}' is not a release tag (expected vX.Y.Z or vX.Y.Z-rcN); no rpk build to download`)
209
+ return null
210
+ }
211
+
212
+ const urls = rpkCdnUrls(tag, { platform, arch })
213
+ if (!urls) {
214
+ log.warn(`No rpk release asset for platform ${platform}/${arch}`)
215
+ return null
216
+ }
217
+
218
+ // The checksums file first: it is small, it is uploaded together with the
219
+ // zips, and its absence is the cheapest "not published" signal.
220
+ const checksumsPath = path.join(destDir, urls.checksumsName)
221
+ const checksumsResult = curlToFile(urls.checksums, checksumsPath, { maxTime: 60 })
222
+ if (!checksumsResult.ok) {
223
+ if (isNotPublished(checksumsResult.httpCode)) {
224
+ log.warn(`rpk ${tag} is not published on ${RPK_CDN_BASE} (HTTP ${checksumsResult.httpCode} for ${urls.checksums})`)
225
+ return null
226
+ }
227
+ // Anything other than 403/404 is the CDN or the network failing, not a
228
+ // tag with no build. Returning null here would send the caller into a
229
+ // private source build over a registry blip, and the installer would exit
230
+ // 0 having installed something other than what was asked for.
231
+ throw new Error(`Could not fetch ${urls.checksums} (HTTP ${checksumsResult.httpCode}, curl exit ${checksumsResult.status}${checksumsResult.stderr ? `: ${checksumsResult.stderr}` : ''})`)
232
+ }
233
+
234
+ log.log(`Downloading ${urls.assetName} for ${tag} from ${RPK_CDN_BASE}...`)
235
+ const zipPath = path.join(destDir, urls.assetName)
236
+ const zipResult = curlToFile(urls.zip, zipPath, { maxTime: 300 })
237
+ if (!zipResult.ok) {
238
+ if (isNotPublished(zipResult.httpCode)) {
239
+ // Checksums present but zip missing: an upload in progress or a
240
+ // platform the release skipped. Fall back rather than fail.
241
+ log.warn(`rpk ${tag} has a checksums file but no ${urls.assetName} on ${RPK_CDN_BASE} (HTTP ${zipResult.httpCode} for ${urls.zip})`)
242
+ return null
243
+ }
244
+ throw new Error(`Could not download ${urls.zip} (HTTP ${zipResult.httpCode}, curl exit ${zipResult.status}${zipResult.stderr ? `: ${zipResult.stderr}` : ''})`)
245
+ }
246
+
247
+ const expected = parseChecksums(fs.readFileSync(checksumsPath, 'utf8'), urls.assetName)
248
+ if (!expected) {
249
+ throw new Error(`${urls.checksumsName} for ${tag} lists no entry for ${urls.assetName}; refusing to install an unverified binary`)
250
+ }
251
+ const actual = sha256File(zipPath)
252
+ if (expected !== actual) {
253
+ throw new Error(
254
+ `Checksum mismatch for ${urls.assetName} (${tag}):\n` +
255
+ ` expected ${expected}\n actual ${actual}`
256
+ )
257
+ }
258
+ log.log('Checksum verified')
259
+
260
+ const unzipResult = spawnSync('unzip', ['-o', zipPath, '-d', destDir], {
261
+ encoding: 'utf8',
262
+ timeout: 60000
263
+ })
264
+ if (unzipResult.error) {
265
+ // spawnSync could not start unzip at all (typically ENOENT). That is a
266
+ // gap in this environment, not a bad archive, so leave the caller its
267
+ // source-build fallback and say why.
268
+ log.warn(`Could not run unzip to extract ${urls.assetName}: ${unzipResult.error.message}`)
269
+ return null
270
+ }
271
+ if (unzipResult.status !== 0) {
272
+ throw new Error(`Failed to extract ${urls.assetName}: ${unzipResult.stderr}`)
273
+ }
274
+
275
+ const binPath = path.join(destDir, 'rpk')
276
+ if (!fs.existsSync(binPath)) {
277
+ throw new Error(`Extracted archive did not contain an rpk binary: ${zipPath}`)
278
+ }
279
+ fs.chmodSync(binPath, 0o755)
280
+ return binPath
281
+ }
282
+
283
+ /**
284
+ * Newest GA tag on streaming-enterprise that has a build on the CDN.
285
+ * Tags, not releases: RC tags never had a GitHub Release, and GA releases
286
+ * stopped being published with the S3 move.
287
+ */
288
+ async function newestPublishedGaTagFromGitHub(octokit, probe, { platform, arch, log }) {
289
+ const semver = require('semver')
290
+ const refs = await octokit.paginate(octokit.rest.git.listMatchingRefs, {
291
+ owner: TAGS_OWNER,
292
+ repo: TAGS_REPO,
293
+ ref: 'tags/v',
294
+ per_page: 100
295
+ })
296
+ const gaTags = refs
297
+ .map(r => r.ref.replace(/^refs\/tags\//, ''))
298
+ .filter(t => RPK_GA_TAG_RE.test(t))
299
+ const versions = semver.rsort(gaTags.map(t => t.slice(1)))
300
+ // A tag can exist a few minutes before its build lands, so walk down a
301
+ // handful of candidates instead of trusting the newest tag blindly.
302
+ for (const v of versions.slice(0, 5)) {
303
+ const tag = `v${v}`
304
+ const urls = rpkCdnUrls(tag, { platform, arch })
305
+ const code = probe(urls.checksums)
306
+ if (code === 200) return tag
307
+ log.warn(`Newest tag ${tag} has no build on ${RPK_CDN_BASE} yet (HTTP ${code}); trying the previous GA`)
308
+ }
309
+ return null
310
+ }
311
+
312
+ /**
313
+ * Identify the version behind latest/ by downloading it and asking the
314
+ * binary. The CDN has no listing and no version marker, so this is the only
315
+ * tokenless way to learn what latest/ holds. Once the reported version names
316
+ * a tag, the already-downloaded zip is hashed against that tag's own
317
+ * checksums file: latest/ is supposed to be a byte copy of v<tag>/, so a
318
+ * mismatch means the reported version does not describe these bytes, and the
319
+ * tag is not trusted. The caller still re-downloads and re-verifies through
320
+ * downloadRpkBinary before installing anything, so this only gates whether
321
+ * the resolved tag is trustworthy, not the final install.
322
+ */
323
+ function versionBehindLatest(probe, { platform, arch, log }) {
324
+ const assetName = rpkAssetName({ platform, arch })
325
+ if (!assetName) return null
326
+ const latestZipUrl = rpkCdnUrl(RPK_CDN_LATEST_PREFIX, assetName)
327
+ const code = probe(latestZipUrl)
328
+ if (code !== 200) {
329
+ log.warn(`${RPK_CDN_LATEST_PREFIX}/ is not populated on ${RPK_CDN_BASE} (HTTP ${code} for ${latestZipUrl})`)
330
+ return null
331
+ }
332
+
333
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rpk-latest-'))
334
+ try {
335
+ const zipPath = path.join(tmpDir, assetName)
336
+ const dl = curlToFile(latestZipUrl, zipPath, { maxTime: 300 })
337
+ if (!dl.ok) {
338
+ log.warn(`Could not download ${latestZipUrl} (HTTP ${dl.httpCode})`)
339
+ return null
340
+ }
341
+ const unzip = spawnSync('unzip', ['-o', zipPath, '-d', tmpDir], { encoding: 'utf8', timeout: 60000 })
342
+ const binPath = path.join(tmpDir, 'rpk')
343
+ if (unzip.status !== 0 || !fs.existsSync(binPath)) {
344
+ log.warn(`Could not extract ${assetName} from ${RPK_CDN_LATEST_PREFIX}/`)
345
+ return null
346
+ }
347
+ fs.chmodSync(binPath, 0o755)
348
+ const ver = spawnSync(binPath, ['--version'], { encoding: 'utf8', timeout: 30000 })
349
+ const match = `${ver.stdout || ''}\n${ver.stderr || ''}`.match(/v\d+\.\d+\.\d+(-rc\d+)?/)
350
+ if (!match) {
351
+ log.warn(`Could not read a version from the ${RPK_CDN_LATEST_PREFIX}/ rpk binary`)
352
+ return null
353
+ }
354
+ const tag = match[0]
355
+
356
+ const checksumsName = rpkChecksumsName(tag)
357
+ const checksumsPath = path.join(tmpDir, checksumsName)
358
+ const checksumsResult = curlToFile(rpkCdnUrl(tag, checksumsName), checksumsPath, { maxTime: 60 })
359
+ if (!checksumsResult.ok) {
360
+ log.warn(`Could not verify ${RPK_CDN_LATEST_PREFIX}/ against ${tag}'s checksums (HTTP ${checksumsResult.httpCode}); not trusting it`)
361
+ return null
362
+ }
363
+ const expected = parseChecksums(fs.readFileSync(checksumsPath, 'utf8'), assetName)
364
+ const actual = sha256File(zipPath)
365
+ if (!expected || expected !== actual) {
366
+ log.warn(`${RPK_CDN_LATEST_PREFIX}/ does not match ${tag}'s published checksum for ${assetName}; not trusting it`)
367
+ return null
368
+ }
369
+
370
+ return tag
371
+ } finally {
372
+ fs.rmSync(tmpDir, { recursive: true, force: true })
373
+ }
374
+ }
375
+
376
+ /**
377
+ * Resolve the tag to install when the caller wants "the latest rpk".
378
+ *
379
+ * Order:
380
+ * 1. RPK_VERSION in the environment (pin; invalid shapes throw)
381
+ * 2. With a GitHub token: newest GA tag on streaming-enterprise whose
382
+ * build exists on the CDN (verified before anything runs)
383
+ * 3. latest/ on the CDN, identified by running the binary once
384
+ * 4. throw with a message that names the RPK_VERSION escape hatch
385
+ *
386
+ * @param {Object} [opts]
387
+ * @param {Object} [opts.env=process.env]
388
+ * @param {Object} [opts.octokit] - Octokit instance; defaults to the shared client when a token exists
389
+ * @param {Function} [opts.probe=curlHead] - url -> http status, injectable for tests
390
+ * @param {string} [opts.platform], [opts.arch]
391
+ * @param {{ log: Function, warn: Function }} [opts.log=console]
392
+ * @returns {Promise<string>} normalised tag
393
+ */
394
+ async function resolveLatestRpkTag({
395
+ env = process.env,
396
+ octokit,
397
+ probe = curlHead,
398
+ platform = process.platform,
399
+ arch = process.arch,
400
+ log = console
401
+ } = {}) {
402
+ if (env.RPK_VERSION) {
403
+ const pinned = normalizeRpkTag(env.RPK_VERSION)
404
+ if (!pinned) {
405
+ throw new Error(`RPK_VERSION='${env.RPK_VERSION}' is not a release tag; expected vX.Y.Z or vX.Y.Z-rcN`)
406
+ }
407
+ return pinned
408
+ }
409
+
410
+ const { getGitHubApiToken } = require('./github-token')
411
+ if (getGitHubApiToken()) {
412
+ const client = octokit || require('./octokit-client')
413
+ try {
414
+ const tag = await newestPublishedGaTagFromGitHub(client, probe, { platform, arch, log })
415
+ if (tag) return tag
416
+ } catch (err) {
417
+ log.warn(`Could not list ${TAGS_OWNER}/${TAGS_REPO} tags: ${err.message}`)
418
+ }
419
+ }
420
+
421
+ const fromLatest = versionBehindLatest(probe, { platform, arch, log })
422
+ if (fromLatest) return fromLatest
423
+
424
+ throw new Error(
425
+ `Could not resolve the latest rpk release: ${RPK_CDN_LATEST_PREFIX}/ is empty on ${RPK_CDN_BASE} ` +
426
+ `and no GitHub token is available to list ${TAGS_OWNER}/${TAGS_REPO} tags. ` +
427
+ 'Set RPK_VERSION=vX.Y.Z or provide a token (GH_TOKEN, GITHUB_TOKEN, or REDPANDA_GITHUB_TOKEN).'
428
+ )
429
+ }
430
+
431
+ // --- CLI ----------------------------------------------------------------------
432
+ // stdout carries only the result so shell callers can capture it; everything
433
+ // else goes to stderr. Exit 0 ok, 2 not published / unresolvable, 1 unexpected.
434
+
435
+ const EXIT_NOT_PUBLISHED = 2
436
+
437
+ function usage() {
438
+ return [
439
+ 'Usage:',
440
+ ' node rpk-cdn.js install --dest <dir> [--version <tag>] [--platform <p>] [--arch <a>]',
441
+ ' Download and verify rpk; prints the binary path. Without --version, resolves the',
442
+ ' latest release (RPK_VERSION, then GitHub tags with a token, then latest/).',
443
+ ' node rpk-cdn.js resolve-latest [--platform <p>] [--arch <a>]',
444
+ ' Prints the tag "install" would use.',
445
+ ' node rpk-cdn.js url --tag <tag> [--platform <p>] [--arch <a>]',
446
+ ' Prints the zip and checksums URLs for a tag.',
447
+ '',
448
+ '--platform accepts win32/darwin/linux (or windows). --arch accepts x64/arm64 (or amd64, x86_64, aarch64).'
449
+ ].join('\n')
450
+ }
451
+
452
+ function parseArgs(argv) {
453
+ const [command, ...rest] = argv
454
+ const opts = {}
455
+ for (let i = 0; i < rest.length; i++) {
456
+ const arg = rest[i]
457
+ if (!arg.startsWith('--')) throw new Error(`Unexpected argument '${arg}'\n${usage()}`)
458
+ const key = arg.slice(2)
459
+ const value = rest[i + 1]
460
+ if (value === undefined || value.startsWith('--')) throw new Error(`--${key} needs a value\n${usage()}`)
461
+ opts[key] = value
462
+ i++
463
+ }
464
+ return { command, opts }
465
+ }
466
+
467
+ async function main(argv) {
468
+ const stderrLog = { log: (...a) => console.error(...a), warn: (...a) => console.error(...a) }
469
+ const { command, opts } = parseArgs(argv)
470
+ const platformOpts = {
471
+ platform: opts.platform || process.platform,
472
+ arch: opts.arch || process.arch,
473
+ log: stderrLog
474
+ }
475
+
476
+ switch (command) {
477
+ case 'url': {
478
+ const tag = normalizeRpkTag(opts.tag || '')
479
+ if (!tag) {
480
+ console.error(`'${opts.tag}' is not a release tag; expected vX.Y.Z or vX.Y.Z-rcN`)
481
+ return EXIT_NOT_PUBLISHED
482
+ }
483
+ const urls = rpkCdnUrls(tag, platformOpts)
484
+ if (!urls) {
485
+ console.error(`No rpk release asset for platform ${platformOpts.platform}/${platformOpts.arch}`)
486
+ return EXIT_NOT_PUBLISHED
487
+ }
488
+ process.stdout.write(`${urls.zip}\n${urls.checksums}\n`)
489
+ return 0
490
+ }
491
+ case 'resolve-latest': {
492
+ const tag = await resolveLatestRpkTag(platformOpts)
493
+ process.stdout.write(`${tag}\n`)
494
+ return 0
495
+ }
496
+ case 'install': {
497
+ if (!opts.dest) throw new Error(`install requires --dest <dir>\n${usage()}`)
498
+ const tag = opts.version
499
+ ? normalizeRpkTag(opts.version)
500
+ : await resolveLatestRpkTag(platformOpts)
501
+ if (!tag) {
502
+ console.error(`'${opts.version}' is not a release tag; expected vX.Y.Z or vX.Y.Z-rcN`)
503
+ return EXIT_NOT_PUBLISHED
504
+ }
505
+ fs.mkdirSync(opts.dest, { recursive: true })
506
+ const binPath = downloadRpkBinary(tag, opts.dest, platformOpts)
507
+ if (!binPath) return EXIT_NOT_PUBLISHED
508
+ process.stdout.write(`${binPath}\n`)
509
+ return 0
510
+ }
511
+ default:
512
+ throw new Error(`Unknown command '${command || ''}'\n${usage()}`)
513
+ }
514
+ }
515
+
516
+ if (require.main === module) {
517
+ // process.exitCode rather than process.exit(): install-test-dependencies.sh
518
+ // captures the binary path from stdout through a pipe, and exit() can end
519
+ // the process before that write drains, handing the shell a truncated path.
520
+ main(process.argv.slice(2)).then(
521
+ code => { process.exitCode = code },
522
+ err => {
523
+ console.error(err.message)
524
+ // Unresolvable "latest" is a not-published condition, not a bug
525
+ process.exitCode = /Could not resolve the latest rpk release/.test(err.message) ? EXIT_NOT_PUBLISHED : 1
526
+ }
527
+ )
528
+ }
529
+
530
+ module.exports = {
531
+ RPK_CDN_BASE,
532
+ RPK_CDN_LATEST_PREFIX,
533
+ RPK_RELEASE_TAG_RE,
534
+ RPK_GA_TAG_RE,
535
+ RPK_OS_BY_PLATFORM,
536
+ RPK_ARCH_BY_NODE_ARCH,
537
+ normalizeRpkTag,
538
+ rpkAssetName,
539
+ rpkChecksumsName,
540
+ rpkCdnUrl,
541
+ rpkCdnUrls,
542
+ isNotPublished,
543
+ curlToFile,
544
+ curlHead,
545
+ parseChecksums,
546
+ sha256File,
547
+ downloadRpkBinary,
548
+ resolveLatestRpkTag
549
+ }
@@ -47,16 +47,16 @@ The build validates the name against the RC data. A typo gets a warning naming t
47
47
 
48
48
  === If you forget the property data
49
49
 
50
- You get told, once per component version, with the command to run:
50
+ You get told, once per component version, with the command to run. Every excerpt below is what actually lands in the build log, through Antora's own logger rather than the macro printing its own text -- `HH:MM:SS.mmm` stands in for the real timestamp, which varies on every build:
51
51
 
52
52
  [literal]
53
53
  ----
54
- prop macro: streaming@26.3 publishes no redpanda-properties-v26.3.*.json attachment
55
- in its reference module (found for other series: v26.2.1). prop: targets on 26.3
56
- pages are therefore not validated and their tooltips carry no data, rather than
57
- being checked against another release's properties. Generate it with
58
- 'npx doc-tools generate property-docs --tag v26.3.<patch>' on that branch, or
59
- leave prop: out of 26.3 content.
54
+ [HH:MM:SS.mmm] WARN (prop-macro): prop macro: streaming@26.3 publishes no
55
+ redpanda-properties-v26.3.*.json attachment in its reference module (found for
56
+ other series: v26.2.1). prop: targets on 26.3 pages are therefore not validated
57
+ and their tooltips carry no data, rather than being checked against another
58
+ release's properties. Generate it with 'npx doc-tools generate property-docs
59
+ --tag v26.3.<patch>' on that branch, or leave prop: out of 26.3 content.
60
60
  ----
61
61
 
62
62
  Mentions render as plain code with no tooltip until the file exists. That is deliberate: validating 26.3 content against 26.2 data would accept properties that release never had and flag removed ones as typos.
@@ -116,6 +116,34 @@ Enterprise features are declared in the registry (`enterprise-features.yml` in t
116
116
 
117
117
  Add `status-tooltip` to override the hover text on an `unreleased` badge, or `beta-tooltip` on a `beta` badge.
118
118
 
119
+ === A shipped feature that only exists as of a version: `since`
120
+
121
+ `status` answers "is this public yet," which is a question about a *branch* (prerelease or not). A different question comes up once a feature has fully shipped: an ordinarily-GA feature -- no `status` key at all -- still has a *first* version, and older released versions predate it. Without a check, a feature that ships in 26.3 would render as fully available on a 24.1 page that predates it by two years, because `status` alone has nothing to say about that.
122
+
123
+ `since` closes that gap:
124
+
125
+ [,yaml]
126
+ ----
127
+ - name: My Shipped Feature
128
+ scope: redpanda
129
+ since: '26.3'
130
+ description: What it does.
131
+ expiration: What happens when the licence expires.
132
+ ----
133
+
134
+ The check is against the *page's own version*, not the newest release: a mention on a 26.3 (or later) page renders normally, with no badge, because as of that page's version the feature has shipped. The identical mention on a 26.2 page renders as plain text with no styling, tooltip, or link, and a warning -- the same failure mode as an unreleased feature, but for a version reason rather than a branch reason:
135
+
136
+ [literal]
137
+ ----
138
+ [HH:MM:SS.mmm] WARN (enterprise-macro): enterprise:My Shipped Feature[] in
139
+ modules/manage/pages/example.adoc: 'My Shipped Feature' is marked since: 26.3 in
140
+ the enterprise features registry, but this page is version 26.2, which predates
141
+ it. Move the mention to a page whose version is at or after the since version, or
142
+ remove since if the feature covers this version too.
143
+ ----
144
+
145
+ `since` and `status: unreleased` are independent and can combine: a feature still in an RC carries `status: unreleased` while it is not public at all, then drops that key once it ships and (optionally) gains `since` set to the version it shipped in, so older released versions stop claiming it. Most shipped features need neither key.
146
+
119
147
  === Writing the mention
120
148
 
121
149
  Unchanged:
@@ -129,12 +157,12 @@ On the prerelease branch this renders with the `unreleased` badge. On released d
129
157
 
130
158
  [literal]
131
159
  ----
132
- enterprise:My Upcoming Feature[] in modules/manage/pages/example.adoc:
133
- 'My Upcoming Feature' is marked status: unreleased in the enterprise features
134
- registry, so it is only documented for an upcoming release. This page belongs to
135
- a released version, so the mention renders as plain text with no enterprise
136
- styling, tooltip, or link. Move the mention to the prerelease (beta) branch, or
137
- change the status once the feature ships.
160
+ [HH:MM:SS.mmm] WARN (enterprise-macro): enterprise:My Upcoming Feature[] in
161
+ modules/manage/pages/example.adoc: 'My Upcoming Feature' is marked status:
162
+ unreleased in the enterprise features registry, so it is only documented for an
163
+ upcoming release. This page belongs to a released version, so the mention renders
164
+ as plain text with no enterprise styling, tooltip, or link. Move the mention to
165
+ the prerelease (beta) branch, or change the status once the feature ships.
138
166
  ----
139
167
 
140
168
  The `unreleased` badge cannot be suppressed with `enterprise-beta-badge=false`. "Not in a release yet" is information a reader needs, not decoration.
@@ -167,19 +195,22 @@ Before opening a PR that documents something unreleased:
167
195
  * [ ] The mention is on the prerelease branch, not on `main` or a released `v/*` branch.
168
196
  * [ ] For a property: the RC property JSON is committed on that branch, generated from the RC tag.
169
197
  * [ ] For an enterprise feature: the registry entry has `status: unreleased`, not `beta: true`, unless it is genuinely a public beta.
198
+ * [ ] For a shipped feature gated to a specific version: the registry entry has `since` set to the version it ships in, not left unset.
170
199
  * [ ] The build log has no `prop macro:` or `enterprise:` warnings for your additions.
171
200
  * [ ] No mention of an unreleased property or feature on a Cloud page.
172
201
 
173
202
  == Seeing it work
174
203
 
175
- The preview site in this repository has fixtures for every case above, including a prerelease component version and a Cloud component:
204
+ The preview site in this repository has fixtures for every case above, including a prerelease component version and a Cloud component. The fast, fully local way to build just those fixtures, with no GitHub token and no real docs/cloud-docs content:
176
205
 
177
206
  [,bash]
178
207
  ----
179
- ./scripts/macro-test.sh --serve
208
+ ./scripts/macro-showcase.sh --serve
180
209
  ----
181
210
 
182
- Open `/preview/macros-showcase/`, which shows each macro call, what it renders, and why. `/preview/release-status-test/` and `/macro-fixtures/26.3/` show the same unreleased feature gated on a released version and badged on a prerelease one.
211
+ That builds `local-macro-showcase-playbook.yml` in seconds. Run `./scripts/macro-test.sh --serve` instead when you also want the fixtures proven against real production content (needs a token; pulls the full docs, cloud-docs, redpanda-labs, and rp-connect-docs repos).
212
+
213
+ Either way, open `/preview/macros-course/` first -- it's a hands-on course, not just a reference: it has you mark a property and register an enterprise feature, then edit the actual fixture data (a property JSON, the enterprise registry) yourself and rebuild to watch the macros react, including reproducing the `status: unreleased` gate on purpose. It links to `/preview/macros-showcase/`, which shows every other macro call, what it renders, and why, as pure reference. `/macro-fixtures/26.2/` and `/macro-fixtures/26.3/` are two versions of the SAME component -- one released, one prerelease (`prerelease: true` in its `antora.yml`) -- so flipping the version selector between them shows the same enterprise: mentions rendering differently with no edits: the unreleased feature badged on 26.3 and gated on 26.2, the since-gated feature (shipped `since: '26.3'`) gated on 26.2 and shipped plain on 26.3, and the beta feature badged on both. `/preview/release-status-test/` shows the unreleased/beta contrast again from a third, versionless component. `/macro-fixtures-cloud/` shows the Cloud availability rules.
183
214
 
184
215
  == Why the build enforces this rather than trusting reviewers
185
216
 
@@ -68,7 +68,7 @@ asciidoc:
68
68
 
69
69
  This inline macro marks a feature as an enterprise feature in prose. The feature name renders as a uniquely styled term with a tooltip explaining that the feature requires an Enterprise Edition license, and links to the feature's documentation or to the licensing page.
70
70
 
71
- The macro validates its target against the enterprise features registry: `modules/ROOT/partials/enterprise-features.yml` in the `shared` component (the `shared/` folder of the docs repository). The registry is the canonical list of enterprise features and their approved external names. Targets are matched case-insensitively, including aliases, and the canonical name, feature page link, and tooltip come from the matching entry. A target that matches no entry is reported according to `enterprise-validate`. To mark a new feature as enterprise, add it to the registry first.
71
+ The macro validates its target against the enterprise features registry: `modules/ROOT/partials/enterprise-features.yml` in the `shared` component (the `shared/` folder of the docs repository). The registry is the canonical list of enterprise features and their approved external names. Targets are matched case-insensitively, including aliases, and the canonical name, feature page link, and tooltip come from the matching entry. A target that matches no entry is reported according to `enterprise-validate` and renders as plain text -- no styling, tooltip, or link, since there is nothing verified to style it with. To mark a new feature as enterprise, add it to the registry first.
72
72
 
73
73
  === Usage
74
74
 
@@ -639,22 +639,27 @@ function enterpriseInlineMacro (config) {
639
639
  let registry
640
640
  if (config && config.contentCatalog) {
641
641
  registry = loadRegistry(config)
642
- if (!registry) warnNoRegistry(config && config.contentCatalog)
642
+ if (!registry) warnNoRegistry(config.contentCatalog)
643
+ }
644
+ if (!registry) {
645
+ // Nothing to validate against, so nothing should render as if it
646
+ // were. Mirrors prop:'s plain fallback for the same reason: a styled
647
+ // term this build cannot check is worse than an unstyled one.
648
+ return self.createInline(parent, 'quoted', attributes.text || target)
643
649
  }
644
650
  let feature = target
645
- let entry
646
- if (registry) {
647
- entry = registry.lookup.get(target.trim().toLowerCase())
648
- if (entry) {
649
- feature = entry.name
650
- } else {
651
- reportUnknownFeature({
652
- feature: target,
653
- mode,
654
- registry,
655
- filePath,
656
- })
657
- }
651
+ const entry = registry.lookup.get(target.trim().toLowerCase())
652
+ if (entry) {
653
+ feature = entry.name
654
+ } else {
655
+ reportUnknownFeature({ feature: target, mode, registry, filePath })
656
+ // An unrecognized name has nothing behind it to style -- no xref, no
657
+ // tooltip, no licence terms to point at. Falling through to the same
658
+ // styled output a real feature gets, just with a generic fallback
659
+ // tooltip, let a typo pass for a real feature with nothing on the
660
+ // page to show a reader it was wrong. Mirrors prop:'s plain fallback
661
+ // for an unrecognized property name, for the same reason.
662
+ return self.createInline(parent, 'quoted', attributes.text || feature)
658
663
  }
659
664
  const status = entryStatus(entry, { mode, filePath, contentCatalog: config && config.contentCatalog })
660
665
  if (status === STATUS_UNRELEASED && !isPrereleasePage(config, document)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redpanda-data/docs-extensions-and-macros",
3
- "version": "5.38.10",
3
+ "version": "5.42.0",
4
4
  "description": "Antora extensions and macros developed for Redpanda documentation.",
5
5
  "keywords": [
6
6
  "antora",
@@ -1,7 +1,6 @@
1
1
  'use strict'
2
2
 
3
3
  const { spawnSync } = require('child_process')
4
- const crypto = require('crypto')
5
4
  const path = require('path')
6
5
  const fs = require('fs')
7
6
  const os = require('os')
@@ -9,6 +8,7 @@ const semver = require('semver')
9
8
  const { findRepoRoot } = require('../../cli-utils/doc-tools-utils')
10
9
  const { generateRpkDocs, applyOverridesToTree, resolveReferences, shouldExcludeCommand, shouldUsePartialDir, derivePartialsDir } = require('./generate-rpk-docs')
11
10
  const { detectLinuxOnlyFromSource, warnIfDetectionLooksBroken } = require('./detect-platform-commands')
11
+ const { downloadRpkBinary, RPK_RELEASE_TAG_RE, RPK_CDN_BASE } = require('../../cli-utils/rpk-cdn')
12
12
  const { generateRpkDiff, printDiffReport, generateWhatsNewSection, flattenToMap } = require('./report-delta')
13
13
  const { loadAndValidateOverrides, ValidationResult } = require('./validate-overrides')
14
14
  const { validateDirectory, formatResults } = require('./validate-output')
@@ -1306,138 +1306,26 @@ function buildRpkBinary(sourcePath, outPath) {
1306
1306
  }
1307
1307
 
1308
1308
  /**
1309
- * Download an official rpk release binary for the current platform.
1310
- * @param {string} tag - Release tag (e.g., v26.1.12)
1309
+ * Download the official rpk release binary for the current platform from the
1310
+ * rpk distribution CDN (https://rpk.redpanda.com). GA and RC tags are both
1311
+ * published there; no token is needed. See cli-utils/rpk-cdn.js for the
1312
+ * layout and the verification rules.
1313
+ * @param {string} tag - Release tag (e.g., v26.1.12, v26.2.1-rc2)
1311
1314
  * @param {string} destDir - Directory to download and extract into
1312
- * @returns {string|null} Path to the extracted binary, or null if the
1313
- * release asset is unavailable (caller falls back to a source build)
1315
+ * @returns {string|null} Path to the extracted binary, or null if the CDN has
1316
+ * no build for the tag (HTTP 403/404; caller falls back to a source build).
1317
+ * Throws on a checksum mismatch or an archive with no rpk binary.
1314
1318
  */
1315
1319
  function downloadRpkRelease(tag, destDir) {
1316
- const osName = { darwin: 'darwin', linux: 'linux', win32: 'windows' }[process.platform]
1317
- const archName = { arm64: 'arm64', x64: 'amd64' }[process.arch]
1318
- if (!osName || !archName) {
1319
- console.warn(`No rpk release asset for platform ${process.platform}/${process.arch}`)
1320
- return null
1321
- }
1322
-
1323
- // Releases now publish to streaming-enterprise (private), not the public
1324
- // redpanda-data/redpanda. This function already has a fallback (the
1325
- // caller builds from source instead), so a missing token warns and
1326
- // returns null here rather than throwing the way the clone-only paths do.
1327
- const { getGitHubApiToken } = require('../../cli-utils/github-token')
1328
- const token = getGitHubApiToken()
1329
- if (!token) {
1330
- console.warn('No GitHub token available for the private streaming-enterprise release download; falling back to a source build.')
1331
- return null
1332
- }
1333
-
1334
- const assetName = `rpk-${osName}-${archName}.zip`
1335
- const checksumAsset = `rpk_${tag.replace(/^v/, '')}_checksums.txt`
1336
- const zipPath = path.join(destDir, assetName)
1337
- // The Authorization header reaches curl through a config file on stdin
1338
- // (--config -), never as an -H argument: an argv header is readable by
1339
- // any process on the host (ps, /proc) for as long as the transfer runs.
1340
- const curlAuthConfig = `header = "Authorization: token ${token}"`
1341
-
1342
- // streaming-enterprise is private, so the release's browser download URL
1343
- // (releases/download/<tag>/<asset>) 404s even with a valid token: it
1344
- // redirects to a signed storage URL that requires an authenticated
1345
- // browser session, not a bearer token. Only the API's per-asset url
1346
- // (asset.url, not asset.browser_download_url) accepts this Authorization
1347
- // header, so resolve assets through the release-by-tag API first.
1348
- const releaseResult = spawnSync('curl', [
1349
- '-fsSL', '--retry', '5', '--retry-all-errors',
1350
- '--connect-timeout', '30', '--max-time', '60',
1351
- '--config', '-',
1352
- '-H', 'Accept: application/vnd.github+json',
1353
- `https://api.github.com/repos/redpanda-data/streaming-enterprise/releases/tags/${tag}`
1354
- ], { encoding: 'utf8', input: curlAuthConfig, timeout: 90000 })
1355
-
1356
- if (releaseResult.status !== 0) {
1357
- console.warn(`Could not look up release ${tag} from streaming-enterprise (draft or missing release)`)
1358
- return null
1359
- }
1360
-
1361
- let release
1362
- try {
1363
- release = JSON.parse(releaseResult.stdout)
1364
- } catch {
1365
- console.warn(`Could not parse release metadata for ${tag}`)
1366
- return null
1367
- }
1368
-
1369
- const assetUrl = (release.assets || []).find(a => a.name === assetName)?.url
1370
- if (!assetUrl) {
1371
- console.warn(`Release ${tag} has no asset named ${assetName} (draft or missing release asset)`)
1372
- return null
1373
- }
1374
- const checksumUrl = (release.assets || []).find(a => a.name === checksumAsset)?.url
1375
-
1376
- console.log(`Downloading ${assetName} for ${tag}...`)
1377
- // Accept: application/octet-stream is required on the API asset url to
1378
- // receive the binary itself instead of its JSON metadata.
1379
- const curlResult = spawnSync('curl', [
1380
- '-fL', '--retry', '5', '--retry-all-errors',
1381
- '--connect-timeout', '30', '--max-time', '300',
1382
- '--config', '-',
1383
- '-H', 'Accept: application/octet-stream',
1384
- '-o', zipPath, assetUrl
1385
- ], { encoding: 'utf8', input: curlAuthConfig, timeout: 360000 })
1386
-
1387
- if (curlResult.status !== 0) {
1388
- console.warn(`Could not download rpk release for ${tag} (draft or missing release asset)`)
1389
- return null
1390
- }
1391
-
1392
- // Verify against the release checksum file when it exists
1393
- const checksumPath = path.join(destDir, checksumAsset)
1394
- const checksumResult = checksumUrl ? spawnSync('curl', [
1395
- '-fsSL', '--retry', '3', '--connect-timeout', '30', '--max-time', '60',
1396
- '--config', '-',
1397
- '-H', 'Accept: application/octet-stream',
1398
- '-o', checksumPath, checksumUrl
1399
- ], { encoding: 'utf8', input: curlAuthConfig, timeout: 90000 }) : { status: 1 }
1400
-
1401
- if (checksumResult.status === 0) {
1402
- const expectedLine = fs.readFileSync(checksumPath, 'utf8')
1403
- .split('\n')
1404
- .find(line => line.trim().endsWith(assetName))
1405
- if (expectedLine) {
1406
- const expected = expectedLine.trim().split(/\s+/)[0]
1407
- const actual = crypto.createHash('sha256').update(fs.readFileSync(zipPath)).digest('hex')
1408
- if (expected !== actual) {
1409
- throw new Error(
1410
- `Checksum mismatch for ${assetName} (${tag}):\n` +
1411
- ` expected ${expected}\n actual ${actual}`
1412
- )
1413
- }
1414
- console.log('Checksum verified')
1415
- }
1416
- } else {
1417
- console.warn('No checksum file published for this release; skipping verification')
1418
- }
1419
-
1420
- const unzipResult = spawnSync('unzip', ['-o', zipPath, '-d', destDir], {
1421
- encoding: 'utf8',
1422
- timeout: 60000
1423
- })
1424
- if (unzipResult.status !== 0) {
1425
- throw new Error(`Failed to extract ${assetName}: ${unzipResult.stderr}`)
1426
- }
1427
-
1428
- const binPath = path.join(destDir, 'rpk')
1429
- if (!fs.existsSync(binPath)) {
1430
- throw new Error(`Extracted archive did not contain an rpk binary: ${zipPath}`)
1431
- }
1432
- fs.chmodSync(binPath, 0o755)
1433
- return binPath
1320
+ return downloadRpkBinary(tag, destDir)
1434
1321
  }
1435
1322
 
1436
1323
  /**
1437
1324
  * Get an rpk binary matching the given version.
1438
- * Prefers the official release download (published stable releases only;
1439
- * RC releases are drafts, so their assets are not publicly downloadable).
1440
- * Falls back to building from source at the tag.
1325
+ * Prefers the official release download: GA and RC tags are published to
1326
+ * https://rpk.redpanda.com/v<tag>/ with no token needed. Falls back to
1327
+ * building from source at the tag (private clone, token required) when the
1328
+ * CDN has no build; vdev always builds from source.
1441
1329
  * @param {string} rpkVersion - Version tag from the snapshot (e.g., v26.1.12, v26.2.1-rc2)
1442
1330
  * @param {Object} [options]
1443
1331
  * @param {string} [options.rpkBin] - Existing binary to use, skipping download/build
@@ -1456,25 +1344,26 @@ function acquireRpkBinary(rpkVersion, options = {}) {
1456
1344
  }
1457
1345
 
1458
1346
  const tag = rpkVersion.startsWith('v') ? rpkVersion : `v${rpkVersion}`
1347
+
1348
+ if (!RPK_RELEASE_TAG_RE.test(tag) && tag !== 'vdev') {
1349
+ throw new Error(
1350
+ `Cannot acquire an rpk binary for version '${rpkVersion}'.\n` +
1351
+ 'The snapshot\'s rpk_version is not a release tag. ' +
1352
+ 'Pass --rpk-bin <path> to use a local rpk binary instead.'
1353
+ )
1354
+ }
1355
+
1459
1356
  const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rpk-bin-'))
1460
1357
 
1461
- // Only published (non-draft) releases have downloadable assets
1462
- if (/^v\d+\.\d+\.\d+$/.test(tag)) {
1358
+ // GA and RC tags both have a build on the CDN; only branches do not
1359
+ if (RPK_RELEASE_TAG_RE.test(tag)) {
1463
1360
  const downloaded = downloadRpkRelease(tag, workDir)
1464
1361
  if (downloaded) {
1465
1362
  return downloaded
1466
1363
  }
1467
1364
  console.log('Falling back to building rpk from source...')
1468
1365
  } else {
1469
- console.log(`No published release binary for ${tag}; building from source...`)
1470
- }
1471
-
1472
- if (!/^v\d+\.\d+\.\d+(-rc\d+)?$/.test(tag) && tag !== 'vdev') {
1473
- throw new Error(
1474
- `Cannot acquire an rpk binary for version '${rpkVersion}'.\n` +
1475
- 'The snapshot\'s rpk_version is not a release tag. ' +
1476
- 'Pass --rpk-bin <path> to use a local rpk binary instead.'
1477
- )
1366
+ console.log(`${tag} has no build on ${RPK_CDN_BASE} (branches build from source)...`)
1478
1367
  }
1479
1368
 
1480
1369
  const sourceRef = tag === 'vdev' ? 'dev' : tag