@redpanda-data/docs-extensions-and-macros 5.44.0 → 5.45.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 +49 -0
- package/cli-utils/git-credential-env.js +84 -0
- package/extensions/git-full-clone.js +10 -15
- package/package.json +1 -1
- package/tools/bundle-openapi.js +11 -18
- package/tools/lint-screenshots/index.js +383 -0
- package/tools/property-extractor/rp-util-fetch.js +1 -43
- package/tools/rpk-docs/generate-plugin-stubs.js +21 -5
- package/tools/rpk-docs/rpk-docs-handler.js +8 -17
package/bin/doc-tools.js
CHANGED
|
@@ -2748,6 +2748,55 @@ programCli
|
|
|
2748
2748
|
runCli(options)
|
|
2749
2749
|
})
|
|
2750
2750
|
|
|
2751
|
+
/**
|
|
2752
|
+
* lint-screenshots
|
|
2753
|
+
*
|
|
2754
|
+
* @description
|
|
2755
|
+
* Deterministic checks from the docs screenshot standard, run against the
|
|
2756
|
+
* .adoc pages under modules/ in a docs repo: every image macro (block and
|
|
2757
|
+
* inline) has alt text, the alt text is at most 125 characters, does not
|
|
2758
|
+
* start with "screenshot of" / "image of" / "picture of", and has no
|
|
2759
|
+
* unquoted comma (Asciidoctor would split it into width/height); the
|
|
2760
|
+
* referenced image file exists and is at most 100KB. Those are errors and
|
|
2761
|
+
* exit 1. Warnings (exit 0 unless --warnings-as-errors) cover the rest of
|
|
2762
|
+
* the standard: images above the 50KB target, JPEG/WebP where PNG or SVG
|
|
2763
|
+
* belongs, captures wider than 1920px, and - in --files mode - changed
|
|
2764
|
+
* images nothing references. Commented-out macros are skipped.
|
|
2765
|
+
*
|
|
2766
|
+
* @why
|
|
2767
|
+
* cloud-docs enforced these rules on every PR and its daily screenshot cron
|
|
2768
|
+
* with a repo-local script; the other docs repos had nothing. One command
|
|
2769
|
+
* in doc-tools lets every docs repo and the shared PR review pipeline run
|
|
2770
|
+
* the same checks, so no two jobs disagree about the same 100KB limit.
|
|
2771
|
+
*
|
|
2772
|
+
* @example
|
|
2773
|
+
* # Whole repo (from the docs repo root): what the cloud-docs PR check runs
|
|
2774
|
+
* npx doc-tools lint-screenshots
|
|
2775
|
+
*
|
|
2776
|
+
* # PR mode: only pages changed in the PR, plus pages that use a changed image
|
|
2777
|
+
* gh pr diff 123 --name-only > changed.txt
|
|
2778
|
+
* npx doc-tools lint-screenshots --files changed.txt --format json --output review-output/screenshot-lint.json
|
|
2779
|
+
*
|
|
2780
|
+
* # Adopt the stricter items as blocking
|
|
2781
|
+
* npx doc-tools lint-screenshots --warnings-as-errors
|
|
2782
|
+
*/
|
|
2783
|
+
programCli
|
|
2784
|
+
.command('lint-screenshots')
|
|
2785
|
+
.description('Lint image macros and image files in a docs repo against the screenshot standard (alt text, size, format, width)')
|
|
2786
|
+
.option('--root <path>', 'Docs repo root, the directory that holds modules/ (default: current directory)')
|
|
2787
|
+
.option('--files <path>', 'Changed-files list (one repo-relative path per line, for example from `gh pr diff --name-only`): lint only listed .adoc files and pages that reference a listed image')
|
|
2788
|
+
.option('--format <format>', 'Output format: human or json', 'human')
|
|
2789
|
+
.option('--output <path>', 'Also write the JSON result to this file')
|
|
2790
|
+
.option('--max-alt-length <n>', 'Alt text character limit', String(require('../tools/lint-screenshots').DEFAULTS.maxAltLength))
|
|
2791
|
+
.option('--max-bytes <n>', 'Image size ceiling in bytes (error above this)', String(require('../tools/lint-screenshots').DEFAULTS.maxBytes))
|
|
2792
|
+
.option('--target-bytes <n>', 'Image size target in bytes (warning above this)', String(require('../tools/lint-screenshots').DEFAULTS.targetBytes))
|
|
2793
|
+
.option('--max-width <n>', 'Intrinsic image width limit in pixels (warning above this)', String(require('../tools/lint-screenshots').DEFAULTS.maxWidth))
|
|
2794
|
+
.option('--warnings-as-errors', 'Exit 1 on warnings too')
|
|
2795
|
+
.action((options) => {
|
|
2796
|
+
const { runCli } = require('../tools/lint-screenshots')
|
|
2797
|
+
runCli(options)
|
|
2798
|
+
})
|
|
2799
|
+
|
|
2751
2800
|
/**
|
|
2752
2801
|
* preview-string
|
|
2753
2802
|
*
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Git credential helper environment
|
|
5
|
+
*
|
|
6
|
+
* Every doc-tools code path that clones or fetches a private Redpanda repo
|
|
7
|
+
* with the git CLI needs the same thing: hand git a GitHub token without the
|
|
8
|
+
* token ever becoming visible outside the process. This module is the single
|
|
9
|
+
* implementation of that.
|
|
10
|
+
*
|
|
11
|
+
* The token travels through a per-invocation credential helper registered
|
|
12
|
+
* with GIT_CONFIG_* environment variables, so:
|
|
13
|
+
*
|
|
14
|
+
* - It is never a `git -c` argument or part of the remote URL, so it cannot
|
|
15
|
+
* be read out of argv (`ps`, /proc/<pid>/cmdline) for the length of a
|
|
16
|
+
* multi-minute clone, and git cannot echo it back in an error message.
|
|
17
|
+
* - It is never written into the clone's .git/config, where it would sit
|
|
18
|
+
* readable on disk for every later step that reads the checkout.
|
|
19
|
+
* - Nothing goes through a shell the caller has to quote for.
|
|
20
|
+
*
|
|
21
|
+
* Use getGitHubToken() from ./github-token to resolve the token, then pass
|
|
22
|
+
* the result here. Pair it with redactCredentials() on any git stderr that
|
|
23
|
+
* reaches an Error message or a CI log.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
// The variable the helper reads the token from at callback time. Only the
|
|
27
|
+
// name appears in the (static, secret-free) helper command string.
|
|
28
|
+
const TOKEN_ENV_VAR = 'DOC_TOOLS_GIT_CREDENTIAL_TOKEN'
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Environment for a git subprocess that must authenticate to github.com.
|
|
32
|
+
*
|
|
33
|
+
* With no token, `baseEnv` is returned unchanged rather than half-configured:
|
|
34
|
+
* git then falls back to the host's own credential helpers, which is what a
|
|
35
|
+
* local run relies on and all a public repo needs. Callers that require a
|
|
36
|
+
* token should check for one and fail with their own message first.
|
|
37
|
+
*
|
|
38
|
+
* The helper is scoped to `credential.https://github.com.helper`, so no other
|
|
39
|
+
* host is ever offered the token, and entry 0 clears inherited helpers so a
|
|
40
|
+
* system credential manager cannot intercept (or prompt) before ours answers.
|
|
41
|
+
* Any GIT_CONFIG_* entries already in `baseEnv` are replaced.
|
|
42
|
+
*
|
|
43
|
+
* @param {string|null|undefined} token - GitHub token, or falsy for no auth
|
|
44
|
+
* @param {NodeJS.ProcessEnv} [baseEnv=process.env] - Environment to extend
|
|
45
|
+
* @returns {NodeJS.ProcessEnv} Environment to pass as a spawn/exec `env`
|
|
46
|
+
*/
|
|
47
|
+
function gitAuthEnv(token, baseEnv = process.env) {
|
|
48
|
+
if (!token) return baseEnv
|
|
49
|
+
return {
|
|
50
|
+
...baseEnv,
|
|
51
|
+
[TOKEN_ENV_VAR]: token,
|
|
52
|
+
GIT_CONFIG_COUNT: '2',
|
|
53
|
+
GIT_CONFIG_KEY_0: 'credential.helper',
|
|
54
|
+
GIT_CONFIG_VALUE_0: '',
|
|
55
|
+
GIT_CONFIG_KEY_1: 'credential.https://github.com.helper',
|
|
56
|
+
GIT_CONFIG_VALUE_1: `!f() { echo "username=x-access-token"; echo "password=$${TOKEN_ENV_VAR}"; }; f`
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Strip anything credential-shaped out of text before it is surfaced
|
|
62
|
+
* anywhere that gets logged -- an Error message ends up in plain CI logs.
|
|
63
|
+
*
|
|
64
|
+
* Covers a credential in a remote URL's userinfo (https://<token>@github.com/...),
|
|
65
|
+
* which git echoes back verbatim on common failures (a private repo it cannot
|
|
66
|
+
* see, a bad ref), and a Basic-auth header in case git ever echoes failing
|
|
67
|
+
* config back. Defense in depth: gitAuthEnv() already keeps the token out of
|
|
68
|
+
* argv and URLs, so this covers credentials that came from somewhere else,
|
|
69
|
+
* such as a URL the caller was handed.
|
|
70
|
+
*
|
|
71
|
+
* @param {string} text - Text to scrub (nullish becomes '')
|
|
72
|
+
* @returns {string} Text with credentials replaced by ***
|
|
73
|
+
*/
|
|
74
|
+
function redactCredentials(text) {
|
|
75
|
+
return String(text || '')
|
|
76
|
+
.replace(/\/\/[^/@\s]+@/g, '//***@')
|
|
77
|
+
.replace(/(authorization:\s*basic\s+)\S+/gi, '$1***')
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = {
|
|
81
|
+
gitAuthEnv,
|
|
82
|
+
redactCredentials,
|
|
83
|
+
TOKEN_ENV_VAR
|
|
84
|
+
}
|
|
@@ -6,6 +6,7 @@ const { execSync } = require('child_process')
|
|
|
6
6
|
const fs = require('fs')
|
|
7
7
|
const path = require('path')
|
|
8
8
|
const { getGitHubToken } = require('../cli-utils/github-token')
|
|
9
|
+
const { gitAuthEnv, redactCredentials } = require('../cli-utils/git-credential-env')
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Configure Antora to use full git clones instead of shallow clones.
|
|
@@ -72,27 +73,21 @@ module.exports.register = function ({ config, playbook }) {
|
|
|
72
73
|
|
|
73
74
|
// Private repos need credentials: the git CLI does not share Antora's
|
|
74
75
|
// built-in credential manager, so resolve a token (GIT_CREDENTIALS first,
|
|
75
|
-
// then the common token env vars) and hand it to git through
|
|
76
|
-
// helper. Both the config and the token travel via environment
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
// helpers; entry 1 registers a helper scoped to github.com so no other
|
|
81
|
-
// host is ever offered the token.
|
|
76
|
+
// then the common token env vars) and hand it to git through gitAuthEnv's
|
|
77
|
+
// credential helper. Both the config and the token travel via environment
|
|
78
|
+
// variables, so nothing touches the command line, the remote URL, or the
|
|
79
|
+
// cached .git/config, and there is no shell quoting to break on other
|
|
80
|
+
// platforms.
|
|
82
81
|
const token = getGitHubToken()
|
|
83
82
|
const gitCommand = 'git fetch --unshallow'
|
|
84
|
-
const gitEnv = { ...process.env, GIT_TERMINAL_PROMPT: '0' }
|
|
85
83
|
if (token) {
|
|
86
84
|
logger.info(' → Using GitHub token for unshallow fetches')
|
|
87
|
-
gitEnv.GIT_FULL_CLONE_TOKEN = token
|
|
88
|
-
gitEnv.GIT_CONFIG_COUNT = '2'
|
|
89
|
-
gitEnv.GIT_CONFIG_KEY_0 = 'credential.helper'
|
|
90
|
-
gitEnv.GIT_CONFIG_VALUE_0 = ''
|
|
91
|
-
gitEnv.GIT_CONFIG_KEY_1 = 'credential.https://github.com.helper'
|
|
92
|
-
gitEnv.GIT_CONFIG_VALUE_1 = '!f() { echo "username=x-access-token"; echo "password=$GIT_FULL_CLONE_TOKEN"; }; f'
|
|
93
85
|
} else {
|
|
94
86
|
logger.info(' → No GitHub token found; unshallow may fail for private repos')
|
|
95
87
|
}
|
|
88
|
+
// GIT_TERMINAL_PROMPT=0 in both cases: a prompt in a build has no one to
|
|
89
|
+
// answer it, so fail fast instead of hanging.
|
|
90
|
+
const gitEnv = gitAuthEnv(token, { ...process.env, GIT_TERMINAL_PROMPT: '0' })
|
|
96
91
|
|
|
97
92
|
for (const aggregate of contentAggregate) {
|
|
98
93
|
for (const origin of aggregate.origins || []) {
|
|
@@ -123,7 +118,7 @@ module.exports.register = function ({ config, playbook }) {
|
|
|
123
118
|
if (err.killed) {
|
|
124
119
|
logger.warn(` ✗ Unshallow timeout after ${duration}ms for ${path.basename(gitdir)} (increase unshallowTimeout if needed)`)
|
|
125
120
|
} else {
|
|
126
|
-
logger.warn(` ✗ Failed to unshallow ${path.basename(gitdir)}: ${err.message}`)
|
|
121
|
+
logger.warn(` ✗ Failed to unshallow ${path.basename(gitdir)}: ${redactCredentials(err.message)}`)
|
|
127
122
|
}
|
|
128
123
|
logger.warn(` ⚠️ Git dates may be inaccurate for this repo - consider using a pre-cloned cache`)
|
|
129
124
|
}
|
package/package.json
CHANGED
package/tools/bundle-openapi.js
CHANGED
|
@@ -5,6 +5,7 @@ const path = require('path');
|
|
|
5
5
|
const { execSync, execFileSync, spawnSync } = require('child_process');
|
|
6
6
|
const yaml = require('yaml');
|
|
7
7
|
const { getMajorMinor } = require('../cli-utils/version');
|
|
8
|
+
const { gitAuthEnv, redactCredentials } = require('../cli-utils/git-credential-env');
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Normalize a Git tag into a semantic version string.
|
|
@@ -662,24 +663,16 @@ async function bundleOpenAPI(options) {
|
|
|
662
663
|
throw new Error('redpanda-data/streaming-enterprise is a private repository: set GH_TOKEN, GITHUB_TOKEN, or REDPANDA_GITHUB_TOKEN so the clone can authenticate.');
|
|
663
664
|
}
|
|
664
665
|
|
|
665
|
-
//
|
|
666
|
-
//
|
|
667
|
-
//
|
|
668
|
-
//
|
|
669
|
-
//
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
if (!quiet) {
|
|
674
|
-
console.log('🔑 Using authenticated clone (token provided)');
|
|
675
|
-
}
|
|
676
|
-
gitEnv.BUNDLE_OPENAPI_CLONE_TOKEN = token;
|
|
677
|
-
gitEnv.GIT_CONFIG_COUNT = '2';
|
|
678
|
-
gitEnv.GIT_CONFIG_KEY_0 = 'credential.helper';
|
|
679
|
-
gitEnv.GIT_CONFIG_VALUE_0 = '';
|
|
680
|
-
gitEnv.GIT_CONFIG_KEY_1 = 'credential.https://github.com.helper';
|
|
681
|
-
gitEnv.GIT_CONFIG_VALUE_1 = '!f() { echo "username=x-access-token"; echo "password=$BUNDLE_OPENAPI_CLONE_TOKEN"; }; f';
|
|
666
|
+
// gitAuthEnv hands the token to git through a credential helper carried
|
|
667
|
+
// in the subprocess environment, so it cannot appear in this process's
|
|
668
|
+
// argv (visible to anything that can list processes) or in an exec
|
|
669
|
+
// error's command line, and is never embedded in the remote URL or
|
|
670
|
+
// persisted in the clone's .git/config.
|
|
671
|
+
const useToken = Boolean(token) && repositoryUrl.includes('github.com');
|
|
672
|
+
if (useToken && !quiet) {
|
|
673
|
+
console.log('🔑 Using authenticated clone (token provided)');
|
|
682
674
|
}
|
|
675
|
+
const gitEnv = gitAuthEnv(useToken ? token : null, { ...process.env });
|
|
683
676
|
|
|
684
677
|
try {
|
|
685
678
|
execFileSync('git', ['clone', '--depth', '1', '--branch', tag, repositoryUrl, 'redpanda'], {
|
|
@@ -689,7 +682,7 @@ async function bundleOpenAPI(options) {
|
|
|
689
682
|
timeout: 60000 // 1 minute timeout
|
|
690
683
|
});
|
|
691
684
|
} catch (cloneError) {
|
|
692
|
-
throw new Error(`Failed to clone repository: ${cloneError.message}`);
|
|
685
|
+
throw new Error(`Failed to clone repository: ${redactCredentials(cloneError.message)}`);
|
|
693
686
|
}
|
|
694
687
|
|
|
695
688
|
const repoDir = path.join(tempDir, 'redpanda');
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const fs = require('fs')
|
|
4
|
+
const path = require('path')
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* doc-tools lint-screenshots: deterministic checks from the docs screenshot
|
|
8
|
+
* standard that need no browser and no model - alt text and image macro
|
|
9
|
+
* correctness, plus the image file facts (existence, size, format, capture
|
|
10
|
+
* width) that a PR reviewer would otherwise measure by hand.
|
|
11
|
+
*
|
|
12
|
+
* Promoted from cloud-docs' tests/doc-detective/lint-screenshots.js so every
|
|
13
|
+
* docs repo runs ONE implementation. The error-severity rules are exactly the
|
|
14
|
+
* rules that script enforced (its findings were already blocking cloud-docs
|
|
15
|
+
* PRs); the stricter items from the screenshot standard that no repo enforced
|
|
16
|
+
* before are warnings, so adopting the command never turns a previously green
|
|
17
|
+
* repo red on day one.
|
|
18
|
+
*
|
|
19
|
+
* This does NOT judge the "purpose test" (whether an image should exist at
|
|
20
|
+
* all), annotation style, PII overlays, or alt-text accuracy. Those need a
|
|
21
|
+
* human or a vision pass.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const DEFAULTS = Object.freeze({
|
|
25
|
+
maxAltLength: 125,
|
|
26
|
+
maxBytes: 100 * 1024,
|
|
27
|
+
targetBytes: 50 * 1024,
|
|
28
|
+
maxWidth: 1920, // 2x capture of a <=960px display width
|
|
29
|
+
bannedAltPrefixes: ['screenshot of', 'image of', 'picture of']
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
const IMAGE_EXT_RE = /\.(png|svg|jpe?g|gif|webp|avif)$/i
|
|
33
|
+
|
|
34
|
+
// Block macro: image::target[attrs]. Same shape the cloud-docs linter
|
|
35
|
+
// matched, so its existing findings carry over unchanged.
|
|
36
|
+
const BLOCK_MACRO_RE = /image::([^\[\]]+)\[([^\]]*)\]/g
|
|
37
|
+
// Inline macro: image:target[attrs] - single colon, not preceded by a word
|
|
38
|
+
// character (that would be another macro name) or a colon (the block form).
|
|
39
|
+
const INLINE_MACRO_RE = /(?<![\w:])image:(?!:)([^\[\]\s]+)\[([^\]]*)\]/g
|
|
40
|
+
|
|
41
|
+
// A comma after unquoted alt text is only legitimate when every segment that
|
|
42
|
+
// follows is itself a recognized image attribute (width=100, role=foo, or a
|
|
43
|
+
// bare numeric width/height). Any other trailing segment means the comma split
|
|
44
|
+
// real alt-text content, which Asciidoctor then reads as width/height.
|
|
45
|
+
const NAMED_ATTR_RE = /^[a-zA-Z_:][a-zA-Z0-9_-]*\s*=/
|
|
46
|
+
const BARE_DIMENSION_RE = /^\d+$/
|
|
47
|
+
|
|
48
|
+
function parseAltText (attrs) {
|
|
49
|
+
const trimmed = attrs.trim()
|
|
50
|
+
if (trimmed.startsWith('"')) {
|
|
51
|
+
const closingIdx = trimmed.indexOf('"', 1)
|
|
52
|
+
if (closingIdx !== -1) {
|
|
53
|
+
return { altText: trimmed.slice(1, closingIdx), hasUnquotedComma: false }
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const segments = trimmed.split(',').map((s) => s.trim())
|
|
57
|
+
const [altText, ...rest] = segments
|
|
58
|
+
const allRestAreAttrs = rest.every((s) => NAMED_ATTR_RE.test(s) || BARE_DIMENSION_RE.test(s))
|
|
59
|
+
return { altText, hasUnquotedComma: rest.length > 0 && !allRestAreAttrs }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function findAdocFiles (dir, out = []) {
|
|
63
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
64
|
+
if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue
|
|
65
|
+
const full = path.join(dir, entry.name)
|
|
66
|
+
if (entry.isDirectory()) findAdocFiles(full, out)
|
|
67
|
+
else if (entry.name.endsWith('.adoc')) out.push(full)
|
|
68
|
+
}
|
|
69
|
+
return out
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Antora module a page belongs to, from its path under modules/. */
|
|
73
|
+
function moduleOf (modulesDir, filePath) {
|
|
74
|
+
const rel = path.relative(modulesDir, filePath)
|
|
75
|
+
if (rel.startsWith('..')) return null
|
|
76
|
+
return rel.split(path.sep)[0]
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Resolve an image macro target to a file under modules/, the way Antora
|
|
81
|
+
* does for the two forms that resolve inside one component:
|
|
82
|
+
* "shared:file.png" -> modules/shared/images/file.png
|
|
83
|
+
* "file.png" -> modules/<page's module>/images/file.png
|
|
84
|
+
* Returns null when the target cannot be checked locally: remote URLs,
|
|
85
|
+
* attribute references ({imagesdir}/x.png), and component-qualified IDs
|
|
86
|
+
* (other-component:module:file.png).
|
|
87
|
+
*/
|
|
88
|
+
function resolveImage (target, adocFile, modulesDir) {
|
|
89
|
+
const ref = target.trim()
|
|
90
|
+
if (/^https?:\/\//.test(ref) || ref.includes('{')) return null
|
|
91
|
+
const parts = ref.split(':')
|
|
92
|
+
if (parts.length > 2) return null
|
|
93
|
+
if (parts.length === 2 && /^[a-zA-Z0-9_.-]+$/.test(parts[0])) {
|
|
94
|
+
return path.join(modulesDir, parts[0], 'images', parts[1])
|
|
95
|
+
}
|
|
96
|
+
const mod = moduleOf(modulesDir, adocFile)
|
|
97
|
+
if (!mod) return null
|
|
98
|
+
return path.join(modulesDir, mod, 'images', ref)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Intrinsic pixel width from the file header; null when unknown. */
|
|
102
|
+
function imageWidth (filePath) {
|
|
103
|
+
let fd
|
|
104
|
+
try {
|
|
105
|
+
fd = fs.openSync(filePath, 'r')
|
|
106
|
+
const buf = Buffer.alloc(32)
|
|
107
|
+
const read = fs.readSync(fd, buf, 0, 32, 0)
|
|
108
|
+
if (read >= 24 && buf.readUInt32BE(0) === 0x89504e47 && buf.toString('ascii', 12, 16) === 'IHDR') {
|
|
109
|
+
return buf.readUInt32BE(16)
|
|
110
|
+
}
|
|
111
|
+
if (read >= 10 && buf.toString('ascii', 0, 4) === 'GIF8') {
|
|
112
|
+
return buf.readUInt16LE(6)
|
|
113
|
+
}
|
|
114
|
+
return null
|
|
115
|
+
} catch (err) {
|
|
116
|
+
return null
|
|
117
|
+
} finally {
|
|
118
|
+
if (fd !== undefined) fs.closeSync(fd)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function kb (bytes) {
|
|
123
|
+
return `${Math.round(bytes / 1024)}KB`
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Collect every image macro reference from one page. Comment lines and
|
|
128
|
+
* comment blocks are skipped: a commented-out macro is not published.
|
|
129
|
+
*/
|
|
130
|
+
function collectReferences (adocFile, modulesDir, root) {
|
|
131
|
+
const content = fs.readFileSync(adocFile, 'utf8')
|
|
132
|
+
const relPath = path.relative(root, adocFile)
|
|
133
|
+
const refs = []
|
|
134
|
+
let inCommentBlock = false
|
|
135
|
+
const lines = content.split('\n')
|
|
136
|
+
for (let i = 0; i < lines.length; i++) {
|
|
137
|
+
const line = lines[i]
|
|
138
|
+
if (line.trim() === '////') { inCommentBlock = !inCommentBlock; continue }
|
|
139
|
+
if (inCommentBlock || line.trimStart().startsWith('//')) continue
|
|
140
|
+
for (const [re, form] of [[BLOCK_MACRO_RE, 'block'], [INLINE_MACRO_RE, 'inline']]) {
|
|
141
|
+
re.lastIndex = 0
|
|
142
|
+
let m
|
|
143
|
+
while ((m = re.exec(line)) !== null) {
|
|
144
|
+
const [, target, attrs] = m
|
|
145
|
+
refs.push({
|
|
146
|
+
adocFile,
|
|
147
|
+
relPath,
|
|
148
|
+
line: i + 1,
|
|
149
|
+
form,
|
|
150
|
+
target: target.trim(),
|
|
151
|
+
attrs,
|
|
152
|
+
resolved: resolveImage(target, adocFile, modulesDir)
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return refs
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Run the linter.
|
|
162
|
+
*
|
|
163
|
+
* @param {Object} options
|
|
164
|
+
* @param {string} options.root - Docs repo root (the directory holding modules/)
|
|
165
|
+
* @param {string[]} [options.files] - Changed-files scope (repo-relative paths, for
|
|
166
|
+
* example from `gh pr diff --name-only`). When set, only .adoc files in the
|
|
167
|
+
* list and .adoc files that reference an image in the list are linted, and
|
|
168
|
+
* listed images nothing references are reported. When omitted, every .adoc
|
|
169
|
+
* under modules/ is linted.
|
|
170
|
+
* @param {number} [options.maxAltLength]
|
|
171
|
+
* @param {number} [options.maxBytes]
|
|
172
|
+
* @param {number} [options.targetBytes]
|
|
173
|
+
* @param {number} [options.maxWidth]
|
|
174
|
+
* @param {string[]} [options.bannedAltPrefixes]
|
|
175
|
+
* @returns {Object} { findings, summary, scope }
|
|
176
|
+
*/
|
|
177
|
+
function lintScreenshots (options) {
|
|
178
|
+
// Explicit undefined wins a spread, and intOption() returns undefined for
|
|
179
|
+
// an option nobody passed, so a plain `{ ...DEFAULTS, ...options }` lets a
|
|
180
|
+
// programmatic caller silently erase a threshold: every `size > undefined`
|
|
181
|
+
// is then false and the size rules vanish without a word. Drop undefined
|
|
182
|
+
// keys so an omitted threshold means "the default", not "no limit".
|
|
183
|
+
const provided = Object.fromEntries(
|
|
184
|
+
Object.entries(options || {}).filter(([, value]) => value !== undefined))
|
|
185
|
+
const opts = { ...DEFAULTS, ...provided }
|
|
186
|
+
if (!opts.root) throw new Error('lint-screenshots requires a root directory')
|
|
187
|
+
const root = path.resolve(opts.root)
|
|
188
|
+
const modulesDir = path.join(root, 'modules')
|
|
189
|
+
if (!fs.existsSync(modulesDir)) {
|
|
190
|
+
throw new Error(`No modules/ directory under ${root} - point --root at an Antora docs repo`)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const scoped = Array.isArray(opts.files)
|
|
194
|
+
const changedAdoc = new Set()
|
|
195
|
+
const changedImages = new Set()
|
|
196
|
+
// Deleted images stay in scope. Skipping a path that no longer exists would
|
|
197
|
+
// drop the pages that still reference it out of the scoped pass, so a PR
|
|
198
|
+
// that deletes a screenshot still referenced by a page would report nothing
|
|
199
|
+
// in --files mode while a whole-repo run reports image-missing. They are
|
|
200
|
+
// tracked separately so the orphaned-image rule can skip them: a file that
|
|
201
|
+
// is gone cannot be orphaned.
|
|
202
|
+
const deletedImages = new Set()
|
|
203
|
+
if (scoped) {
|
|
204
|
+
for (const f of opts.files) {
|
|
205
|
+
const abs = path.resolve(root, f)
|
|
206
|
+
const exists = fs.existsSync(abs)
|
|
207
|
+
if (f.endsWith('.adoc')) {
|
|
208
|
+
if (exists) changedAdoc.add(abs)
|
|
209
|
+
} else if (IMAGE_EXT_RE.test(f)) {
|
|
210
|
+
changedImages.add(abs)
|
|
211
|
+
if (!exists) deletedImages.add(abs)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const adocFiles = findAdocFiles(modulesDir)
|
|
217
|
+
const allRefs = adocFiles.flatMap((f) => collectReferences(f, modulesDir, root))
|
|
218
|
+
const refs = scoped
|
|
219
|
+
? allRefs.filter((r) => changedAdoc.has(r.adocFile) || (r.resolved && changedImages.has(r.resolved)))
|
|
220
|
+
: allRefs
|
|
221
|
+
|
|
222
|
+
const findings = []
|
|
223
|
+
const add = (rule, severity, ref, message, file) => {
|
|
224
|
+
findings.push({
|
|
225
|
+
rule,
|
|
226
|
+
severity,
|
|
227
|
+
file: file || ref.relPath,
|
|
228
|
+
line: ref ? ref.line : null,
|
|
229
|
+
image: ref ? ref.target : null,
|
|
230
|
+
message
|
|
231
|
+
})
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const seenImages = new Set()
|
|
235
|
+
for (const ref of refs) {
|
|
236
|
+
const { altText, hasUnquotedComma } = parseAltText(ref.attrs)
|
|
237
|
+
|
|
238
|
+
if (!altText) {
|
|
239
|
+
add('alt-missing', 'error', ref, 'Missing alt text')
|
|
240
|
+
} else {
|
|
241
|
+
if (hasUnquotedComma) {
|
|
242
|
+
add('alt-unquoted-comma', 'error', ref,
|
|
243
|
+
`Unquoted alt text contains a comma, which Asciidoctor splits into separate width/height attributes at render time. Wrap the alt text in double quotes or remove the comma: "${altText}"`)
|
|
244
|
+
}
|
|
245
|
+
if (altText.length > opts.maxAltLength) {
|
|
246
|
+
add('alt-too-long', 'error', ref, `Alt text is ${altText.length} chars (max ${opts.maxAltLength}): "${altText}"`)
|
|
247
|
+
}
|
|
248
|
+
const lowerAlt = altText.toLowerCase()
|
|
249
|
+
if (opts.bannedAltPrefixes.some((p) => lowerAlt.startsWith(p))) {
|
|
250
|
+
add('alt-banned-prefix', 'error', ref, `Alt text starts with a banned prefix: "${altText}"`)
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (!ref.resolved) continue
|
|
255
|
+
if (!fs.existsSync(ref.resolved)) {
|
|
256
|
+
add('image-missing', 'error', ref, `Referenced image not found: ${path.relative(root, ref.resolved)}`)
|
|
257
|
+
continue
|
|
258
|
+
}
|
|
259
|
+
// Image-file facts are reported once per image, on its first reference,
|
|
260
|
+
// so a shared image on five pages is one finding, not five.
|
|
261
|
+
if (seenImages.has(ref.resolved)) continue
|
|
262
|
+
seenImages.add(ref.resolved)
|
|
263
|
+
|
|
264
|
+
const size = fs.statSync(ref.resolved).size
|
|
265
|
+
if (size > opts.maxBytes) {
|
|
266
|
+
add('image-too-large', 'error', ref, `Image is ${kb(size)}, over the ${kb(opts.maxBytes)} ceiling`)
|
|
267
|
+
} else if (size > opts.targetBytes) {
|
|
268
|
+
add('image-above-target', 'warning', ref, `Image is ${kb(size)}, above the ${kb(opts.targetBytes)} target (${kb(opts.maxBytes)} ceiling)`)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const ext = path.extname(ref.resolved).toLowerCase()
|
|
272
|
+
if (['.jpg', '.jpeg', '.webp', '.avif'].includes(ext)) {
|
|
273
|
+
add('image-format', 'warning', ref, `Use PNG for screenshots or SVG for diagrams (got ${ext})`)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const width = imageWidth(ref.resolved)
|
|
277
|
+
if (width !== null && width > opts.maxWidth) {
|
|
278
|
+
add('image-too-wide', 'warning', ref, `Image is ${width}px wide, over the ${opts.maxWidth}px capture width (2x of a ${opts.maxWidth / 2}px display)`)
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (scoped) {
|
|
283
|
+
const referenced = new Set(allRefs.map((r) => r.resolved).filter(Boolean))
|
|
284
|
+
for (const img of changedImages) {
|
|
285
|
+
if (deletedImages.has(img)) continue // gone, so not orphaned
|
|
286
|
+
if (!referenced.has(img)) {
|
|
287
|
+
add('orphaned-image', 'warning', null,
|
|
288
|
+
'No image macro under modules/ references this file - if it is used through an attribute or from another repo, ignore this; otherwise remove it or add the macro',
|
|
289
|
+
path.relative(root, img))
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const severityRank = { error: 0, warning: 1 }
|
|
295
|
+
findings.sort((a, b) => severityRank[a.severity] - severityRank[b.severity] ||
|
|
296
|
+
a.file.localeCompare(b.file) || (a.line || 0) - (b.line || 0))
|
|
297
|
+
|
|
298
|
+
const lintedFiles = new Set(refs.map((r) => r.relPath))
|
|
299
|
+
return {
|
|
300
|
+
scope: scoped ? 'changed' : 'repo',
|
|
301
|
+
findings,
|
|
302
|
+
summary: {
|
|
303
|
+
adoc_files_scanned: adocFiles.length,
|
|
304
|
+
adoc_files_linted: scoped ? lintedFiles.size : adocFiles.length,
|
|
305
|
+
references: refs.length,
|
|
306
|
+
images: seenImages.size,
|
|
307
|
+
errors: findings.filter((f) => f.severity === 'error').length,
|
|
308
|
+
warnings: findings.filter((f) => f.severity === 'warning').length
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function formatHuman (result) {
|
|
314
|
+
const { summary, findings } = result
|
|
315
|
+
const scopeNote = result.scope === 'changed'
|
|
316
|
+
? `${summary.adoc_files_linted} changed .adoc file(s) (${summary.references} image reference(s))`
|
|
317
|
+
: `${summary.adoc_files_scanned} .adoc files (${summary.references} image reference(s))`
|
|
318
|
+
if (findings.length === 0) {
|
|
319
|
+
return `Checked ${scopeNote}. No screenshot standard violations found.`
|
|
320
|
+
}
|
|
321
|
+
const lines = [`Checked ${scopeNote}. Found ${summary.errors} error(s), ${summary.warnings} warning(s):`, '']
|
|
322
|
+
const fmt = (f) => `${f.file}${f.line ? `:${f.line}` : ''}${f.image ? ` [${f.image}]` : ''} ${f.message}`
|
|
323
|
+
const errors = findings.filter((f) => f.severity === 'error')
|
|
324
|
+
const warnings = findings.filter((f) => f.severity === 'warning')
|
|
325
|
+
if (errors.length) {
|
|
326
|
+
lines.push('Errors:')
|
|
327
|
+
for (const f of errors) lines.push(` ${fmt(f)}`)
|
|
328
|
+
}
|
|
329
|
+
if (warnings.length) {
|
|
330
|
+
if (errors.length) lines.push('')
|
|
331
|
+
lines.push('Warnings (not blocking):')
|
|
332
|
+
for (const f of warnings) lines.push(` ${fmt(f)}`)
|
|
333
|
+
}
|
|
334
|
+
return lines.join('\n')
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function readFileList (listPath) {
|
|
338
|
+
const text = fs.readFileSync(path.resolve(listPath), 'utf8')
|
|
339
|
+
return text.split('\n').map((l) => l.trim()).filter(Boolean)
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function intOption (value, name) {
|
|
343
|
+
if (value === undefined || value === null) return undefined
|
|
344
|
+
const n = Number(value)
|
|
345
|
+
if (!Number.isInteger(n) || n <= 0) throw new Error(`${name} must be a positive integer (got ${value})`)
|
|
346
|
+
return n
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function runCli (options) {
|
|
350
|
+
let result
|
|
351
|
+
try {
|
|
352
|
+
result = lintScreenshots({
|
|
353
|
+
root: options.root || process.cwd(),
|
|
354
|
+
files: options.files ? readFileList(options.files) : undefined,
|
|
355
|
+
maxAltLength: intOption(options.maxAltLength, '--max-alt-length'),
|
|
356
|
+
maxBytes: intOption(options.maxBytes, '--max-bytes'),
|
|
357
|
+
targetBytes: intOption(options.targetBytes, '--target-bytes'),
|
|
358
|
+
maxWidth: intOption(options.maxWidth, '--max-width')
|
|
359
|
+
})
|
|
360
|
+
} catch (err) {
|
|
361
|
+
console.error(`Error: ${err.message}`)
|
|
362
|
+
process.exit(2)
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (options.output) {
|
|
366
|
+
fs.mkdirSync(path.dirname(path.resolve(options.output)), { recursive: true })
|
|
367
|
+
fs.writeFileSync(path.resolve(options.output), JSON.stringify(result, null, 2) + '\n')
|
|
368
|
+
}
|
|
369
|
+
console.log(options.format === 'json' ? JSON.stringify(result, null, 2) : formatHuman(result))
|
|
370
|
+
|
|
371
|
+
const blocking = result.summary.errors + (options.warningsAsErrors ? result.summary.warnings : 0)
|
|
372
|
+
process.exit(blocking > 0 ? 1 : 0)
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
module.exports = {
|
|
376
|
+
DEFAULTS,
|
|
377
|
+
lintScreenshots,
|
|
378
|
+
formatHuman,
|
|
379
|
+
runCli,
|
|
380
|
+
parseAltText,
|
|
381
|
+
resolveImage,
|
|
382
|
+
imageWidth
|
|
383
|
+
}
|
|
@@ -5,6 +5,7 @@ const fs = require('fs')
|
|
|
5
5
|
const os = require('os')
|
|
6
6
|
const path = require('path')
|
|
7
7
|
const { getGitHubToken, getGitHubApiToken } = require('../../cli-utils/github-token')
|
|
8
|
+
const { gitAuthEnv, redactCredentials } = require('../../cli-utils/git-credential-env')
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* rp_util's config schema JSON, for any streaming-enterprise ref (tag,
|
|
@@ -58,49 +59,6 @@ const SCHEMA_FLAGS = [
|
|
|
58
59
|
* @param {string} ref - Branch, tag, or commit SHA
|
|
59
60
|
* @param {string} destDir - Destination directory (must not exist yet)
|
|
60
61
|
*/
|
|
61
|
-
/**
|
|
62
|
-
* Strip a credential out of text before it's surfaced anywhere that gets
|
|
63
|
-
* logged -- an Error message ends up in plain CI logs. Covers a credential
|
|
64
|
-
* embedded in a Git remote URL's userinfo (e.g. https://<token>@github.com/...),
|
|
65
|
-
* which Git echoes back verbatim in common failures (a private repo it can't
|
|
66
|
-
* access, a bad ref, ...), and a Basic-auth header in case Git ever echoes
|
|
67
|
-
* failing config back -- defense in depth: this module keeps the token out
|
|
68
|
-
* of argv and URLs entirely (see gitAuthEnv).
|
|
69
|
-
*/
|
|
70
|
-
function redactCredentials(text) {
|
|
71
|
-
return String(text || '')
|
|
72
|
-
.replace(/\/\/[^/@\s]+@/g, '//***@')
|
|
73
|
-
.replace(/(authorization:\s*basic\s+)\S+/gi, '$1***')
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* Build the environment that authenticates git's github.com requests via a
|
|
78
|
-
* per-invocation credential helper, injected through GIT_CONFIG_* env vars.
|
|
79
|
-
* No token byte ever appears in argv (readable by any local process via
|
|
80
|
-
* `ps`/`/proc/<pid>/cmdline` for the full multi-minute clone) or in a URL
|
|
81
|
-
* (echoed verbatim by git on common failures), and nothing is written into
|
|
82
|
-
* the resulting clone's .git/config, where it would otherwise sit readable
|
|
83
|
-
* on disk for the entire downstream Bazel/Docker build. Same env-only
|
|
84
|
-
* pattern the property-docs Makefile migration uses.
|
|
85
|
-
* @param {string} token
|
|
86
|
-
* @returns {object} env for spawnSync
|
|
87
|
-
*/
|
|
88
|
-
function gitAuthEnv(token) {
|
|
89
|
-
return {
|
|
90
|
-
...process.env,
|
|
91
|
-
RP_UTIL_FETCH_GIT_TOKEN: token,
|
|
92
|
-
GIT_CONFIG_COUNT: '2',
|
|
93
|
-
// Clear any inherited helpers first so a system credential manager
|
|
94
|
-
// can't intercept (or prompt) before ours answers.
|
|
95
|
-
GIT_CONFIG_KEY_0: 'credential.helper',
|
|
96
|
-
GIT_CONFIG_VALUE_0: '',
|
|
97
|
-
GIT_CONFIG_KEY_1: 'credential.https://github.com.helper',
|
|
98
|
-
// The helper reads the token from its own environment at callback time;
|
|
99
|
-
// argv carries only this static, secret-free string.
|
|
100
|
-
GIT_CONFIG_VALUE_1: '!f() { echo "username=x-access-token"; echo "password=$RP_UTIL_FETCH_GIT_TOKEN"; }; f'
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
62
|
function cloneStreamingEnterprise(ref, destDir) {
|
|
105
63
|
const token = getGitHubToken()
|
|
106
64
|
if (!token) {
|
|
@@ -17,6 +17,8 @@ const fs = require('fs')
|
|
|
17
17
|
const path = require('path')
|
|
18
18
|
const os = require('os')
|
|
19
19
|
const { spawnSync } = require('child_process')
|
|
20
|
+
const { getGitHubToken } = require('../../cli-utils/github-token')
|
|
21
|
+
const { gitAuthEnv, redactCredentials } = require('../../cli-utils/git-credential-env')
|
|
20
22
|
|
|
21
23
|
/**
|
|
22
24
|
* Read command titles from generated partials. The title line is
|
|
@@ -78,21 +80,35 @@ function fetchPartialsDir({ docsRepo, docsRef, plugin, sourcePath }) {
|
|
|
78
80
|
// both carry the single-source tag, so either family can be stubbed
|
|
79
81
|
const sparsePath = sourcePath || `modules/reference/partials/rpk-${plugin}`
|
|
80
82
|
|
|
81
|
-
|
|
83
|
+
// The docs repo is private, so authenticate when the environment offers a
|
|
84
|
+
// token (GIT_CREDENTIALS in Antora builds; ACTIONS_BOT_TOKEN, GITHUB_TOKEN,
|
|
85
|
+
// and friends in workflows). Clone anonymously otherwise, and say so, so a
|
|
86
|
+
// failed run in CI names the missing piece instead of a bare git prompt.
|
|
87
|
+
const token = getGitHubToken()
|
|
88
|
+
// gitAuthEnv keeps the token in the subprocess environment only: never in
|
|
89
|
+
// argv, never in the remote URL, never in the clone's .git/config.
|
|
90
|
+
const env = gitAuthEnv(token)
|
|
91
|
+
const authNote = token ? '' : ' (no GitHub token found; cloning anonymously)'
|
|
92
|
+
console.log(`Fetching ${sparsePath} from ${docsRepo}@${docsRef}${authNote}...`)
|
|
82
93
|
const cloneResult = spawnSync('git', [
|
|
83
94
|
'clone', '--depth', '1', '--filter=blob:none', '--sparse',
|
|
84
95
|
'--branch', docsRef,
|
|
85
96
|
`https://github.com/${docsRepo}.git`, repoDir
|
|
86
|
-
], { encoding: 'utf8', timeout: 180000 })
|
|
97
|
+
], { encoding: 'utf8', timeout: 180000, env })
|
|
87
98
|
if (cloneResult.status !== 0) {
|
|
88
|
-
|
|
99
|
+
const hint = token
|
|
100
|
+
? ''
|
|
101
|
+
: `\nIf ${docsRepo} is private, set ACTIONS_BOT_TOKEN, GITHUB_TOKEN, REDPANDA_GITHUB_TOKEN, or GIT_CREDENTIALS so the clone can authenticate.`
|
|
102
|
+
throw new Error(`Failed to clone ${docsRepo}@${docsRef}: ${redactCredentials(cloneResult.stderr)}${hint}`)
|
|
89
103
|
}
|
|
90
104
|
|
|
105
|
+
// --filter=blob:none deferred the blobs, so the sparse checkout pulls them
|
|
106
|
+
// now and needs the same credential helper as the clone.
|
|
91
107
|
const sparseResult = spawnSync('git', ['sparse-checkout', 'set', sparsePath], {
|
|
92
|
-
cwd: repoDir, encoding: 'utf8', timeout: 60000
|
|
108
|
+
cwd: repoDir, encoding: 'utf8', timeout: 60000, env
|
|
93
109
|
})
|
|
94
110
|
if (sparseResult.status !== 0) {
|
|
95
|
-
throw new Error(`Failed sparse checkout of ${sparsePath}: ${sparseResult.stderr}`)
|
|
111
|
+
throw new Error(`Failed sparse checkout of ${sparsePath}: ${redactCredentials(sparseResult.stderr)}`)
|
|
96
112
|
}
|
|
97
113
|
|
|
98
114
|
const partialsDir = path.join(repoDir, sparsePath)
|
|
@@ -12,6 +12,7 @@ const { downloadRpkBinary, RPK_RELEASE_TAG_RE, RPK_CDN_BASE } = require('../../c
|
|
|
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')
|
|
15
|
+
const { gitAuthEnv, redactCredentials } = require('../../cli-utils/git-credential-env')
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* Known rpk plugins that are managed separately (have install/uninstall commands)
|
|
@@ -241,12 +242,10 @@ function prepareSourceFromRef(sourceRef, sourcePath = null) {
|
|
|
241
242
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rpk-source-'))
|
|
242
243
|
const repoDir = path.join(tmpDir, 'redpanda')
|
|
243
244
|
|
|
244
|
-
// streaming-enterprise is private, so a token is required.
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
// never embedded in the remote URL, so no token is persisted in the
|
|
249
|
-
// clone's .git/config either.
|
|
245
|
+
// streaming-enterprise is private, so a token is required. gitAuthEnv
|
|
246
|
+
// hands it to git through a credential helper carried in the subprocess
|
|
247
|
+
// environment, so it never reaches argv, the remote URL, or the clone's
|
|
248
|
+
// .git/config.
|
|
250
249
|
const { getGitHubToken } = require('../../cli-utils/github-token')
|
|
251
250
|
const token = getGitHubToken()
|
|
252
251
|
if (!token) {
|
|
@@ -255,15 +254,7 @@ function prepareSourceFromRef(sourceRef, sourcePath = null) {
|
|
|
255
254
|
'Set GH_TOKEN, GITHUB_TOKEN, or REDPANDA_GITHUB_TOKEN so the clone can authenticate.'
|
|
256
255
|
)
|
|
257
256
|
}
|
|
258
|
-
const gitEnv =
|
|
259
|
-
...process.env,
|
|
260
|
-
RPK_SOURCE_CLONE_TOKEN: token,
|
|
261
|
-
GIT_CONFIG_COUNT: '2',
|
|
262
|
-
GIT_CONFIG_KEY_0: 'credential.helper',
|
|
263
|
-
GIT_CONFIG_VALUE_0: '',
|
|
264
|
-
GIT_CONFIG_KEY_1: 'credential.https://github.com.helper',
|
|
265
|
-
GIT_CONFIG_VALUE_1: '!f() { echo "username=x-access-token"; echo "password=$RPK_SOURCE_CLONE_TOKEN"; }; f'
|
|
266
|
-
}
|
|
257
|
+
const gitEnv = gitAuthEnv(token)
|
|
267
258
|
|
|
268
259
|
console.log(`Sparse-cloning streaming-enterprise repo (ref: ${sourceRef}) to ${repoDir}...`)
|
|
269
260
|
|
|
@@ -287,7 +278,7 @@ function prepareSourceFromRef(sourceRef, sourcePath = null) {
|
|
|
287
278
|
throw new Error(
|
|
288
279
|
`Failed to clone streaming-enterprise repo with ref '${sourceRef}'.\n` +
|
|
289
280
|
`Make sure the branch or tag exists.\n` +
|
|
290
|
-
`Error: ${cloneResult.stderr}`
|
|
281
|
+
`Error: ${redactCredentials(cloneResult.stderr)}`
|
|
291
282
|
)
|
|
292
283
|
}
|
|
293
284
|
|
|
@@ -302,7 +293,7 @@ function prepareSourceFromRef(sourceRef, sourcePath = null) {
|
|
|
302
293
|
})
|
|
303
294
|
|
|
304
295
|
if (sparseResult.status !== 0) {
|
|
305
|
-
throw new Error(`Failed to set sparse checkout: ${sparseResult.stderr}`)
|
|
296
|
+
throw new Error(`Failed to set sparse checkout: ${redactCredentials(sparseResult.stderr)}`)
|
|
306
297
|
}
|
|
307
298
|
|
|
308
299
|
console.log(`Sparse checkout complete`)
|