| #!/usr/bin/env node |
|
|
| |
| |
| |
| |
| |
|
|
| import { execSync } from 'node:child_process'; |
| import { |
| mkdirSync, |
| rmSync, |
| readFileSync, |
| existsSync, |
| lstatSync, |
| } from 'node:fs'; |
| import { tmpdir } from 'node:os'; |
| import { join } from 'node:path'; |
|
|
| const ACTIONLINT_VERSION = '1.7.7'; |
| const SHELLCHECK_VERSION = '0.11.0'; |
| const YAMLLINT_VERSION = '1.35.1'; |
|
|
| const TEMP_DIR = |
| process.env.GEMINI_LINT_TEMP_DIR || join(tmpdir(), 'gemini-cli-linters'); |
|
|
| function getPlatformArch() { |
| const platform = process.platform; |
| const arch = process.arch; |
| if (platform === 'linux' && arch === 'x64') { |
| return { |
| actionlint: 'linux_amd64', |
| shellcheck: 'linux.x86_64', |
| }; |
| } |
| if (platform === 'darwin' && arch === 'x64') { |
| return { |
| actionlint: 'darwin_amd64', |
| shellcheck: 'darwin.x86_64', |
| }; |
| } |
| if (platform === 'darwin' && arch === 'arm64') { |
| return { |
| actionlint: 'darwin_arm64', |
| shellcheck: 'darwin.aarch64', |
| }; |
| } |
| if (platform === 'win32' && arch === 'x64') { |
| return { |
| actionlint: 'windows_amd64', |
| |
| |
| }; |
| } |
| throw new Error(`Unsupported platform/architecture: ${platform}/${arch}`); |
| } |
|
|
| const platformArch = getPlatformArch(); |
|
|
| const PYTHON_VENV_PATH = join(TEMP_DIR, 'python_venv'); |
|
|
| const pythonVenvPythonPath = join( |
| PYTHON_VENV_PATH, |
| process.platform === 'win32' ? 'Scripts' : 'bin', |
| process.platform === 'win32' ? 'python.exe' : 'python', |
| ); |
|
|
| const isWindows = process.platform === 'win32'; |
|
|
| const actionlintCheck = isWindows |
| ? `where actionlint 2>nul` |
| : 'command -v actionlint'; |
|
|
| const actionlintInstaller = isWindows |
| ? `powershell -Command "` + |
| `New-Item -ItemType Directory -Force -Path '${TEMP_DIR}/actionlint' | Out-Null; ` + |
| `Invoke-WebRequest -Uri 'https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_${platformArch.actionlint}.zip' -OutFile '${TEMP_DIR}/.actionlint.zip'; ` + |
| `Add-Type -AssemblyName System.IO.Compression.FileSystem; ` + |
| `[System.IO.Compression.ZipFile]::ExtractToDirectory('${TEMP_DIR}/.actionlint.zip', '${TEMP_DIR}/actionlint')"` |
| : ` |
| mkdir -p "${TEMP_DIR}/actionlint" |
| curl -sSLo "${TEMP_DIR}/.actionlint.tgz" "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_${platformArch.actionlint}.tar.gz" |
| tar -xzf "${TEMP_DIR}/.actionlint.tgz" -C "${TEMP_DIR}/actionlint" |
| `; |
|
|
| const shellcheckCheck = isWindows |
| ? `where shellcheck 2>nul` |
| : 'command -v shellcheck'; |
|
|
| const shellcheckInstaller = isWindows |
| ? `powershell -Command "` + |
| `Invoke-WebRequest -Uri 'https://github.com/koalaman/shellcheck/releases/download/v${SHELLCHECK_VERSION}/shellcheck-v${SHELLCHECK_VERSION}.zip' -OutFile '${TEMP_DIR}/.shellcheck.zip'; ` + |
| `Add-Type -AssemblyName System.IO.Compression.FileSystem; ` + |
| `[System.IO.Compression.ZipFile]::ExtractToDirectory('${TEMP_DIR}/.shellcheck.zip', '${TEMP_DIR}/shellcheck')"` |
| : ` |
| mkdir -p "${TEMP_DIR}/shellcheck" |
| curl -sSLo "${TEMP_DIR}/.shellcheck.txz" "https://github.com/koalaman/shellcheck/releases/download/v${SHELLCHECK_VERSION}/shellcheck-v${SHELLCHECK_VERSION}.${platformArch.shellcheck}.tar.xz" |
| tar -xf "${TEMP_DIR}/.shellcheck.txz" -C "${TEMP_DIR}/shellcheck" --strip-components=1 |
| `; |
|
|
| const yamllintCheck = isWindows |
| ? `if exist "${PYTHON_VENV_PATH}\\Scripts\\yamllint.exe" (exit 0) else (exit 1)` |
| : `test -x "${PYTHON_VENV_PATH}/bin/yamllint"`; |
|
|
| const yamllintInstaller = isWindows |
| ? `python -m venv "${PYTHON_VENV_PATH}" && ` + |
| `"${pythonVenvPythonPath}" -m pip install --upgrade pip && ` + |
| `"${pythonVenvPythonPath}" -m pip install "yamllint==${YAMLLINT_VERSION}" --index-url https://pypi.org/simple` |
| : ` |
| python3 -m venv "${PYTHON_VENV_PATH}" && \ |
| "${pythonVenvPythonPath}" -m pip install --upgrade pip && \ |
| "${pythonVenvPythonPath}" -m pip install "yamllint==${YAMLLINT_VERSION}" --index-url https://pypi.org/simple |
| `; |
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| const LINTERS = { |
| actionlint: { |
| check: actionlintCheck, |
| installer: actionlintInstaller, |
| run: ` |
| actionlint \ |
| -color \ |
| -ignore 'SC2002:' \ |
| -ignore 'SC2016:' \ |
| -ignore 'SC2129:' \ |
| -ignore 'label ".+" is unknown' |
| `, |
| }, |
| shellcheck: { |
| check: shellcheckCheck, |
| installer: shellcheckInstaller, |
| run: ` |
| git ls-files | grep -E '^([^.]+|.*\\.(sh|zsh|bash))' | xargs file --mime-type \ |
| | grep "text/x-shellscript" | awk '{ print substr($1, 1, length($1)-1) }' \ |
| | xargs shellcheck \ |
| --check-sourced \ |
| --enable=all \ |
| --exclude=SC2002,SC2129,SC2310 \ |
| --severity=style \ |
| --format=gcc \ |
| --color=never | sed -e 's/note:/warning:/g' -e 's/style:/warning:/g' |
| `, |
| }, |
| yamllint: { |
| check: yamllintCheck, |
| installer: yamllintInstaller, |
| run: "git ls-files | grep -E '\\.(yaml|yml)' | xargs yamllint --format github", |
| }, |
| }; |
|
|
| function runCommand(command, stdio = 'inherit') { |
| try { |
| const env = { ...process.env }; |
| const nodeBin = join(process.cwd(), 'node_modules', '.bin'); |
| const sep = isWindows ? ';' : ':'; |
| const pythonBin = isWindows |
| ? join(PYTHON_VENV_PATH, 'Scripts') |
| : join(PYTHON_VENV_PATH, 'bin'); |
| |
| const pathKey = 'Path' in env ? 'Path' : 'PATH'; |
| env[pathKey] = [ |
| nodeBin, |
| join(TEMP_DIR, 'actionlint'), |
| join(TEMP_DIR, 'shellcheck'), |
| pythonBin, |
| env[pathKey], |
| ].join(sep); |
| execSync(command, { stdio, env, shell: true }); |
| return true; |
| } catch { |
| return false; |
| } |
| } |
|
|
| export function setupLinters() { |
| console.log('Setting up linters...'); |
| if (!process.env.GEMINI_LINT_TEMP_DIR) { |
| rmSync(TEMP_DIR, { recursive: true, force: true }); |
| } |
| mkdirSync(TEMP_DIR, { recursive: true }); |
|
|
| for (const linter in LINTERS) { |
| const { check, installer } = LINTERS[linter]; |
| if (!runCommand(check, 'ignore')) { |
| console.log(`Installing ${linter}...`); |
| if (!runCommand(installer)) { |
| console.error( |
| `Failed to install ${linter}. Please install it manually.`, |
| ); |
| process.exit(1); |
| } |
| } |
| } |
| console.log('All required linters are available.'); |
| } |
|
|
| export function runESLint() { |
| console.log('\nRunning ESLint...'); |
| if (!runCommand('npm run lint')) { |
| process.exit(1); |
| } |
| } |
|
|
| export function runActionlint() { |
| console.log('\nRunning actionlint...'); |
| if (!runCommand(LINTERS.actionlint.run)) { |
| process.exit(1); |
| } |
| } |
|
|
| export function runShellcheck() { |
| console.log('\nRunning shellcheck...'); |
| if (!runCommand(LINTERS.shellcheck.run)) { |
| process.exit(1); |
| } |
| } |
|
|
| export function runYamllint() { |
| console.log('\nRunning yamllint...'); |
| if (!runCommand(LINTERS.yamllint.run)) { |
| process.exit(1); |
| } |
| } |
|
|
| export function runPrettier() { |
| console.log('\nRunning Prettier...'); |
| if (!runCommand('prettier --check .')) { |
| console.log( |
| 'Prettier check failed. Please run "npm run format" to fix formatting issues.', |
| ); |
| process.exit(1); |
| } |
| } |
|
|
| export function runSensitiveKeywordLinter() { |
| console.log('\nRunning sensitive keyword linter...'); |
| const SENSITIVE_PATTERN = /gemini-\d+(\.\d+)?/g; |
| const ALLOWED_KEYWORDS = new Set([ |
| 'gemini-3.1', |
| 'gemini-3', |
| 'gemini-3.0', |
| 'gemini-2.5', |
| 'gemini-2.0', |
| 'gemini-1.5', |
| 'gemini-1.0', |
| ]); |
|
|
| function getChangedFiles() { |
| const baseRef = process.env.GITHUB_BASE_REF || 'main'; |
| try { |
| execSync(`git fetch origin ${baseRef}`); |
| const mergeBase = execSync(`git merge-base HEAD origin/${baseRef}`) |
| .toString() |
| .trim(); |
| return execSync(`git diff --name-only ${mergeBase}..HEAD`) |
| .toString() |
| .trim() |
| .split('\n') |
| .filter(Boolean); |
| } catch { |
| console.error(`Could not get changed files against origin/${baseRef}.`); |
| try { |
| console.log('Falling back to diff against HEAD~1'); |
| return execSync(`git diff --name-only HEAD~1..HEAD`) |
| .toString() |
| .trim() |
| .split('\n') |
| .filter(Boolean); |
| } catch { |
| console.error('Could not get changed files against HEAD~1 either.'); |
| process.exit(1); |
| } |
| } |
| } |
|
|
| const changedFiles = getChangedFiles(); |
| let violationsFound = false; |
|
|
| for (const file of changedFiles) { |
| if (!existsSync(file) || lstatSync(file).isDirectory()) { |
| continue; |
| } |
| const content = readFileSync(file, 'utf-8'); |
| const lines = content.split('\n'); |
| let match; |
| while ((match = SENSITIVE_PATTERN.exec(content)) !== null) { |
| const keyword = match[0]; |
| if (!ALLOWED_KEYWORDS.has(keyword)) { |
| violationsFound = true; |
| const matchIndex = match.index; |
| let lineNum = 0; |
| let charCount = 0; |
| for (let i = 0; i < lines.length; i++) { |
| const line = lines[i]; |
| if (charCount + line.length + 1 > matchIndex) { |
| lineNum = i + 1; |
| const colNum = matchIndex - charCount + 1; |
| console.log( |
| `::warning file=${file},line=${lineNum},col=${colNum}::Found sensitive keyword "${keyword}". Please make sure this change is appropriate to submit.`, |
| ); |
| break; |
| } |
| charCount += line.length + 1; |
| } |
| } |
| } |
| } |
|
|
| if (!violationsFound) { |
| console.log('No sensitive keyword violations found.'); |
| } |
| } |
|
|
| function stripJSONComments(json) { |
| return json.replace( |
| /\\"|"(?:\\"|[^"])*"|(\/\/.*|\/\*[\s\S]*?\*\/)/g, |
| (m, g) => (g ? '' : m), |
| ); |
| } |
|
|
| export function runTSConfigLinter() { |
| console.log('\nRunning tsconfig linter...'); |
|
|
| let files = []; |
| try { |
| |
| files = execSync("git ls-files 'packages/**/tsconfig.json'") |
| .toString() |
| .trim() |
| .split('\n') |
| .filter(Boolean); |
| } catch (e) { |
| console.error('Error finding tsconfig.json files:', e.message); |
| process.exit(1); |
| } |
|
|
| let hasError = false; |
|
|
| for (const file of files) { |
| const tsconfigPath = join(process.cwd(), file); |
| if (!existsSync(tsconfigPath)) { |
| console.error(`Error: ${tsconfigPath} does not exist.`); |
| hasError = true; |
| continue; |
| } |
|
|
| try { |
| const content = readFileSync(tsconfigPath, 'utf-8'); |
| const config = JSON.parse(stripJSONComments(content)); |
|
|
| |
| if (config.exclude) { |
| if (!Array.isArray(config.exclude)) { |
| console.error( |
| `Error: ${file} "exclude" must be an array. Found: ${JSON.stringify( |
| config.exclude, |
| )}`, |
| ); |
| hasError = true; |
| } else { |
| const allowedExclude = new Set(['node_modules', 'dist']); |
| const invalidExcludes = config.exclude.filter( |
| (item) => !allowedExclude.has(item), |
| ); |
|
|
| if (invalidExcludes.length > 0) { |
| console.error( |
| `Error: ${file} "exclude" contains invalid items: ${JSON.stringify( |
| invalidExcludes, |
| )}. Only "node_modules" and "dist" are allowed.`, |
| ); |
| hasError = true; |
| } |
| } |
| } |
| } catch (error) { |
| console.error(`Error parsing ${tsconfigPath}: ${error.message}`); |
| hasError = true; |
| } |
| } |
|
|
| if (hasError) { |
| process.exit(1); |
| } |
| } |
|
|
| export function runGithubActionsPinningLinter() { |
| console.log('\nRunning GitHub Actions pinning linter...'); |
|
|
| let files = []; |
| try { |
| files = execSync( |
| "git ls-files '.github/workflows/*.yml' '.github/workflows/*.yaml' '.github/actions/**/*.yml' '.github/actions/**/*.yaml'", |
| ) |
| .toString() |
| .trim() |
| .split('\n') |
| .filter(Boolean); |
| } catch (e) { |
| console.error('Error finding GitHub Actions workflow files:', e.message); |
| process.exit(1); |
| } |
|
|
| let violationsFound = false; |
| |
| const USES_PATTERN = /uses:\s*['"]?([^@\s'"]+)@([^#\s'"]+)['"]?/; |
| const SHA_PATTERN = /^[0-9a-f]{40}$/i; |
|
|
| for (const file of files) { |
| if (!existsSync(file) || lstatSync(file).isDirectory()) { |
| continue; |
| } |
| const content = readFileSync(file, 'utf-8'); |
| const lines = content.split('\n'); |
|
|
| for (let i = 0; i < lines.length; i++) { |
| const line = lines[i]; |
| const match = line.match(USES_PATTERN); |
| if (match) { |
| const action = match[1]; |
| let ref = match[2]; |
|
|
| |
| ref = ref.replace(/['"]$/, ''); |
|
|
| |
| if ( |
| action.startsWith('./') || |
| action.startsWith('docker://') || |
| line.includes('# github-actions-pinning:ignore') |
| ) { |
| continue; |
| } |
|
|
| if (!SHA_PATTERN.test(ref)) { |
| violationsFound = true; |
| const lineNum = i + 1; |
| console.error( |
| `::error file=${file},line=${lineNum}::Action "${action}" uses "${ref}" instead of a 40-character SHA.`, |
| ); |
| } |
| } |
| } |
| } |
|
|
| if (violationsFound) { |
| console.error(` |
| GitHub Actions pinning violations found. Please use exact commit hashes. |
| |
| To automatically fix these, you can use the "ratchet" tool (https://github.com/sethvargo/ratchet): |
| - Mac/Linux (Homebrew): brew install ratchet && ratchet pin .github/workflows/*.yml .github/actions/**/*.yml |
| - Other platforms: Download from GitHub releases and run "ratchet pin .github/workflows/*.yml .github/actions/**/*.yml" |
| |
| If you must use a tag, you can ignore this check by adding a comment (discouraged): |
| uses: some-action@v1 # github-actions-pinning:ignore |
| `); |
| process.exit(1); |
| } else { |
| console.log('No GitHub Actions pinning violations found.'); |
| } |
| } |
|
|
| function main() { |
| const args = process.argv.slice(2); |
|
|
| if (args.includes('--setup')) { |
| setupLinters(); |
| } |
| if (args.includes('--eslint')) { |
| runESLint(); |
| } |
| if (args.includes('--actionlint')) { |
| runActionlint(); |
| } |
| if (args.includes('--shellcheck')) { |
| runShellcheck(); |
| } |
| if (args.includes('--yamllint')) { |
| runYamllint(); |
| } |
| if (args.includes('--prettier')) { |
| runPrettier(); |
| } |
| if (args.includes('--sensitive-keywords')) { |
| runSensitiveKeywordLinter(); |
| } |
| if (args.includes('--tsconfig')) { |
| runTSConfigLinter(); |
| } |
| if (args.includes('--check-github-actions-pinning')) { |
| runGithubActionsPinningLinter(); |
| } |
|
|
| if (args.length === 0) { |
| setupLinters(); |
| runESLint(); |
| runActionlint(); |
| runShellcheck(); |
| runYamllint(); |
| runPrettier(); |
| runSensitiveKeywordLinter(); |
| runTSConfigLinter(); |
| runGithubActionsPinningLinter(); |
| console.log('\nAll linting checks passed!'); |
| } |
| } |
|
|
| main(); |
|
|