diff --git a/.github/scripts/resolve-shared-content.sh b/.github/scripts/resolve-shared-content.sh
new file mode 100755
index 0000000000..db3a23f129
--- /dev/null
+++ b/.github/scripts/resolve-shared-content.sh
@@ -0,0 +1,45 @@
+#!/bin/bash
+# Resolve shared content files to their consuming product pages.
+#
+# Usage:
+# echo "content/shared/foo.md" | ./resolve-shared-content.sh
+# ./resolve-shared-content.sh < changed_files.txt
+# ./resolve-shared-content.sh changed_files.txt
+#
+# For shared files (content/shared/*), finds all pages with matching
+# `source:` frontmatter and outputs those instead. Non-shared files
+# pass through unchanged.
+
+set -euo pipefail
+
+# Read input from file argument or stdin
+if [[ $# -gt 0 && -f "$1" ]]; then
+ INPUT=$(cat "$1")
+else
+ INPUT=$(cat)
+fi
+
+# Process each file
+while IFS= read -r file; do
+ [[ -z "$file" ]] && continue
+
+ if [[ "$file" == content/shared/* ]]; then
+ # Extract the shared path portion (e.g., /shared/influxdb3-cli/foo.md)
+ SHARED_PATH="${file#content}"
+
+ # Find all files that source this shared content
+ # The source frontmatter looks like: source: /shared/path/to/file.md
+ CONSUMERS=$(grep -rl "^source: ${SHARED_PATH}$" content/ 2>/dev/null | grep -v '^content/shared/' || true)
+
+ if [[ -n "$CONSUMERS" ]]; then
+ echo "$CONSUMERS"
+ else
+ # No consumers found - output the shared file itself
+ # (Vale can still lint it with default config)
+ echo "$file"
+ fi
+ else
+ # Non-shared file - pass through
+ echo "$file"
+ fi
+done <<< "$INPUT" | sort -u
diff --git a/.github/scripts/vale-check.sh b/.github/scripts/vale-check.sh
new file mode 100755
index 0000000000..d24f3b611f
--- /dev/null
+++ b/.github/scripts/vale-check.sh
@@ -0,0 +1,113 @@
+#!/bin/bash
+# Run Vale on files with appropriate product-specific configs.
+#
+# Usage:
+# ./vale-check.sh file1.md file2.md ...
+# ./vale-check.sh < files.txt
+# ./vale-check.sh --files files.txt
+#
+# Groups files by their Vale config, runs Vale once per group,
+# and outputs combined JSON results.
+#
+# Exit codes:
+# 0 - No errors (warnings/suggestions allowed)
+# 1 - One or more errors found
+
+set -euo pipefail
+
+# Parse arguments
+FILES=()
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --files)
+ shift
+ while IFS= read -r f || [[ -n "$f" ]]; do
+ [[ -n "$f" ]] && FILES+=("$f")
+ done < "$1"
+ shift
+ ;;
+ *)
+ FILES+=("$1")
+ shift
+ ;;
+ esac
+done
+
+# Read from stdin if no files provided
+if [[ ${#FILES[@]} -eq 0 ]]; then
+ while IFS= read -r f || [[ -n "$f" ]]; do
+ [[ -n "$f" ]] && FILES+=("$f")
+ done
+fi
+
+if [[ ${#FILES[@]} -eq 0 ]]; then
+ echo "No files to check" >&2
+ exit 0
+fi
+
+# Map file to Vale config
+get_vale_config() {
+ local file="$1"
+ case "$file" in
+ content/influxdb3/cloud-dedicated/*) echo "content/influxdb3/cloud-dedicated/.vale.ini" ;;
+ content/influxdb3/cloud-serverless/*) echo "content/influxdb3/cloud-serverless/.vale.ini" ;;
+ content/influxdb/v2/*) echo "content/influxdb/v2/.vale.ini" ;;
+ content/*) echo ".vale.ini" ;;
+ *) echo ".vale-instructions.ini" ;;
+ esac
+}
+
+# Group files by config
+declare -A CONFIG_GROUPS
+for file in "${FILES[@]}"; do
+ config=$(get_vale_config "$file")
+ CONFIG_GROUPS["$config"]+="$file"$'\n'
+done
+
+# Run Vale for each config group and collect results
+ALL_RESULTS='{"files": {}, "errors": 0, "warnings": 0, "suggestions": 0}'
+TOTAL_ERRORS=0
+
+for config in "${!CONFIG_GROUPS[@]}"; do
+ files="${CONFIG_GROUPS[$config]}"
+ # Remove trailing newline and convert to array
+ mapfile -t file_array <<< "${files%$'\n'}"
+
+ echo "Running Vale with config: $config (${#file_array[@]} files)" >&2
+
+ # Run Vale via Docker
+ RESULT=$(docker run --rm \
+ -v "$(pwd)":/workdir \
+ -w /workdir \
+ jdkato/vale:latest \
+ --config="$config" \
+ --output=JSON \
+ --minAlertLevel=suggestion \
+ "${file_array[@]}" 2>/dev/null || true)
+
+ if [[ -n "$RESULT" && "$RESULT" != "null" ]]; then
+ # Merge results - Vale outputs {filepath: [alerts...]}
+ # Count errors in this batch
+ BATCH_ERRORS=$(echo "$RESULT" | jq '[.[][].Severity] | map(select(. == "error")) | length')
+ BATCH_WARNINGS=$(echo "$RESULT" | jq '[.[][].Severity] | map(select(. == "warning")) | length')
+ BATCH_SUGGESTIONS=$(echo "$RESULT" | jq '[.[][].Severity] | map(select(. == "suggestion")) | length')
+
+ TOTAL_ERRORS=$((TOTAL_ERRORS + BATCH_ERRORS))
+
+ # Merge file results into ALL_RESULTS
+ ALL_RESULTS=$(echo "$ALL_RESULTS" | jq --argjson new "$RESULT" \
+ --argjson e "$BATCH_ERRORS" \
+ --argjson w "$BATCH_WARNINGS" \
+ --argjson s "$BATCH_SUGGESTIONS" \
+ '.files += $new | .errors += $e | .warnings += $w | .suggestions += $s')
+ fi
+done
+
+# Output combined results
+echo "$ALL_RESULTS"
+
+# Exit with error if any errors found
+if [[ $TOTAL_ERRORS -gt 0 ]]; then
+ exit 1
+fi
+exit 0
diff --git a/.github/workflows/pr-vale-check.yml b/.github/workflows/pr-vale-check.yml
new file mode 100644
index 0000000000..de4c2a19d0
--- /dev/null
+++ b/.github/workflows/pr-vale-check.yml
@@ -0,0 +1,280 @@
+name: Vale Style Check
+
+on:
+ pull_request:
+ paths:
+ - 'content/**/*.md'
+ - 'README.md'
+ - 'DOCS-*.md'
+ - '**/AGENTS.md'
+ - '**/CLAUDE.md'
+ - '.github/**/*.md'
+ - '.claude/**/*.md'
+ types: [opened, synchronize, reopened]
+
+jobs:
+ vale-check:
+ name: Vale style check
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ pull-requests: write
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Detect changed files
+ id: detect
+ run: |
+ echo "🔍 Detecting changed files in PR #${{ github.event.number }}"
+
+ # Get changed files via GitHub API
+ curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
+ "https://api.github.com/repos/${{ github.repository }}/pulls/${{ github.event.number }}/files" \
+ | jq -r '.[].filename' > all_changed_files.txt
+
+ # Filter for markdown files in scope
+ grep -E '^(content/.*\.md|README\.md|DOCS-.*\.md|.*/AGENTS\.md|.*/CLAUDE\.md|\.github/.*\.md|\.claude/.*\.md)$' \
+ all_changed_files.txt > files_to_check.txt || true
+
+ if [[ -s files_to_check.txt ]]; then
+ FILE_COUNT=$(wc -l < files_to_check.txt | tr -d ' ')
+ echo "✅ Found $FILE_COUNT file(s) to check"
+ echo "has-files=true" >> $GITHUB_OUTPUT
+ cat files_to_check.txt
+ else
+ echo "❌ No matching files to check"
+ echo "has-files=false" >> $GITHUB_OUTPUT
+ fi
+
+ - name: Skip if no files to check
+ if: steps.detect.outputs.has-files == 'false'
+ run: |
+ echo "No markdown files in scope - skipping Vale check"
+ echo "✅ **No files to check** - Vale check skipped" >> $GITHUB_STEP_SUMMARY
+
+ - name: Resolve shared content
+ if: steps.detect.outputs.has-files == 'true'
+ run: |
+ chmod +x .github/scripts/resolve-shared-content.sh
+ .github/scripts/resolve-shared-content.sh files_to_check.txt > resolved_files.txt
+
+ echo "📁 Resolved files:"
+ cat resolved_files.txt
+
+ RESOLVED_COUNT=$(wc -l < resolved_files.txt | tr -d ' ')
+ echo "resolved-count=$RESOLVED_COUNT" >> $GITHUB_OUTPUT
+
+ - name: Run Vale
+ if: steps.detect.outputs.has-files == 'true'
+ id: vale
+ run: |
+ chmod +x .github/scripts/vale-check.sh
+
+ set +e # Don't exit on error
+ .github/scripts/vale-check.sh --files resolved_files.txt > vale_results.json 2>vale_stderr.txt
+ EXIT_CODE=$?
+ set -e
+
+ # Log stderr for debugging
+ if [[ -s vale_stderr.txt ]]; then
+ echo "Vale output:"
+ cat vale_stderr.txt
+ fi
+
+ # Parse results
+ if [[ -s vale_results.json ]]; then
+ ERRORS=$(jq -r '.errors // 0' vale_results.json)
+ WARNINGS=$(jq -r '.warnings // 0' vale_results.json)
+ SUGGESTIONS=$(jq -r '.suggestions // 0' vale_results.json)
+
+ echo "error-count=$ERRORS" >> $GITHUB_OUTPUT
+ echo "warning-count=$WARNINGS" >> $GITHUB_OUTPUT
+ echo "suggestion-count=$SUGGESTIONS" >> $GITHUB_OUTPUT
+
+ if [[ $ERRORS -gt 0 ]]; then
+ echo "check-result=failed" >> $GITHUB_OUTPUT
+ else
+ echo "check-result=passed" >> $GITHUB_OUTPUT
+ fi
+ else
+ echo "check-result=error" >> $GITHUB_OUTPUT
+ echo "error-count=0" >> $GITHUB_OUTPUT
+ echo "warning-count=0" >> $GITHUB_OUTPUT
+ echo "suggestion-count=0" >> $GITHUB_OUTPUT
+ fi
+
+ exit $EXIT_CODE
+
+ - name: Generate annotations
+ if: always() && steps.detect.outputs.has-files == 'true' && steps.vale.outputs.check-result != ''
+ run: |
+ if [[ ! -s vale_results.json ]]; then
+ echo "No results to process"
+ exit 0
+ fi
+
+ # Process each file's alerts
+ jq -r '.files | to_entries[] | .key as $file | .value[] | "\($file)|\(.Line)|\(.Severity)|\(.Check)|\(.Message)"' \
+ vale_results.json 2>/dev/null | while IFS='|' read -r file line severity check message; do
+
+ case "$severity" in
+ error)
+ echo "::error file=${file},line=${line},title=${check}::${message}"
+ ;;
+ warning)
+ echo "::warning file=${file},line=${line},title=${check}::${message}"
+ ;;
+ suggestion)
+ echo "::notice file=${file},line=${line},title=${check}::${message}"
+ ;;
+ esac
+ done
+
+ - name: Post PR comment
+ if: always() && steps.detect.outputs.has-files == 'true'
+ uses: actions/github-script@v7
+ with:
+ script: |
+ const fs = require('fs');
+
+ // Read results
+ let results = { files: {}, errors: 0, warnings: 0, suggestions: 0 };
+ try {
+ results = JSON.parse(fs.readFileSync('vale_results.json', 'utf8'));
+ } catch (e) {
+ console.log('Could not read results:', e.message);
+ }
+
+ const errors = results.errors || 0;
+ const warnings = results.warnings || 0;
+ const suggestions = results.suggestions || 0;
+ const checkResult = '${{ steps.vale.outputs.check-result }}';
+
+ // Build comment body
+ let body = '## Vale Style Check Results\n\n';
+ body += '| Metric | Count |\n';
+ body += '|--------|-------|\n';
+ body += `| Errors | ${errors} |\n`;
+ body += `| Warnings | ${warnings} |\n`;
+ body += `| Suggestions | ${suggestions} |\n\n`;
+
+ // List errors (blocking)
+ if (errors > 0) {
+ body += '### Errors (blocking)\n\n';
+ body += '| File | Line | Rule | Message |\n';
+ body += '|------|------|------|--------|\n';
+
+ for (const [file, alerts] of Object.entries(results.files || {})) {
+ for (const alert of alerts) {
+ if (alert.Severity === 'error') {
+ const msg = alert.Message.replace(/\|/g, '\\|').substring(0, 100);
+ body += `| \`${file}\` | ${alert.Line} | ${alert.Check} | ${msg} |\n`;
+ }
+ }
+ }
+ body += '\n';
+ }
+
+ // Collapsible warnings
+ if (warnings > 0) {
+ body += '\n';
+ body += `Warnings (${warnings})
\n\n`;
+ body += '| File | Line | Rule | Message |\n';
+ body += '|------|------|------|--------|\n';
+
+ let count = 0;
+ for (const [file, alerts] of Object.entries(results.files || {})) {
+ for (const alert of alerts) {
+ if (alert.Severity === 'warning' && count < 20) {
+ const msg = alert.Message.replace(/\|/g, '\\|').substring(0, 100);
+ body += `| \`${file}\` | ${alert.Line} | ${alert.Check} | ${msg} |\n`;
+ count++;
+ }
+ }
+ }
+ if (warnings > 20) {
+ body += `\n_Showing first 20 of ${warnings} warnings._\n`;
+ }
+ body += '\n \n\n';
+ }
+
+ // Status line
+ body += '---\n';
+ if (checkResult === 'failed') {
+ body += `❌ **Check failed** — fix ${errors} error(s) before merging.\n`;
+ } else if (checkResult === 'passed') {
+ body += '✅ **Check passed**\n';
+ } else {
+ body += '⚠️ **Check could not complete**\n';
+ }
+
+ // Find and update existing comment or create new
+ const { data: comments } = await github.rest.issues.listComments({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: context.issue.number,
+ });
+
+ const botComment = comments.find(c =>
+ c.user.type === 'Bot' && c.body.includes('## Vale Style Check Results')
+ );
+
+ if (botComment) {
+ await github.rest.issues.updateComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ comment_id: botComment.id,
+ body: body
+ });
+ } else {
+ await github.rest.issues.createComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: context.issue.number,
+ body: body
+ });
+ }
+
+ - name: Write step summary
+ if: always() && steps.detect.outputs.has-files == 'true'
+ run: |
+ ERRORS="${{ steps.vale.outputs.error-count }}"
+ WARNINGS="${{ steps.vale.outputs.warning-count }}"
+ SUGGESTIONS="${{ steps.vale.outputs.suggestion-count }}"
+ RESULT="${{ steps.vale.outputs.check-result }}"
+
+ echo "## Vale Style Check Results" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "| Metric | Count |" >> $GITHUB_STEP_SUMMARY
+ echo "|--------|-------|" >> $GITHUB_STEP_SUMMARY
+ echo "| Errors | ${ERRORS:-0} |" >> $GITHUB_STEP_SUMMARY
+ echo "| Warnings | ${WARNINGS:-0} |" >> $GITHUB_STEP_SUMMARY
+ echo "| Suggestions | ${SUGGESTIONS:-0} |" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+
+ if [[ "$RESULT" == "failed" ]]; then
+ echo "❌ **Check failed** — fix ${ERRORS} error(s) before merging." >> $GITHUB_STEP_SUMMARY
+ elif [[ "$RESULT" == "passed" ]]; then
+ echo "✅ **Check passed**" >> $GITHUB_STEP_SUMMARY
+ else
+ echo "⚠️ **Check could not complete**" >> $GITHUB_STEP_SUMMARY
+ fi
+
+ - name: Upload results
+ if: always() && steps.detect.outputs.has-files == 'true'
+ uses: actions/upload-artifact@v4
+ with:
+ name: vale-results
+ path: |
+ vale_results.json
+ resolved_files.txt
+ files_to_check.txt
+ retention-days: 30
+
+ - name: Fail on errors
+ if: steps.vale.outputs.check-result == 'failed'
+ run: |
+ echo "Vale found ${{ steps.vale.outputs.error-count }} error(s)"
+ exit 1
diff --git a/DOCS-TESTING.md b/DOCS-TESTING.md
index 17506fc045..589c845ebf 100644
--- a/DOCS-TESTING.md
+++ b/DOCS-TESTING.md
@@ -622,6 +622,28 @@ Vale can raise different alert levels:
For more configuration details, see [Vale configuration](https://vale.sh/docs/topics/config).
+### CI Integration
+
+Vale runs automatically on pull requests that modify markdown files. The workflow:
+
+1. Detects changed markdown files (content, README, instruction files)
+2. Resolves shared content to consuming product pages
+3. Maps files to appropriate Vale configs (matching local Lefthook behavior)
+4. Runs Vale via Docker (`jdkato/vale:latest`)
+5. Reports results as inline annotations and a PR summary comment
+
+**Alert levels:**
+- **Errors** block merging
+- **Warnings** and **suggestions** are informational only
+
+**Files checked:**
+- `content/**/*.md`
+- `README.md`, `DOCS-*.md`
+- `**/AGENTS.md`, `**/CLAUDE.md`
+- `.github/**/*.md`, `.claude/**/*.md`
+
+The CI check uses the same product-specific configs as local development, ensuring consistency between local and CI linting.
+
## Pre-commit Hooks
docs-v2 uses [Lefthook](https://github.com/evilmartians/lefthook) to manage Git hooks that run automatically during pre-commit and pre-push.
diff --git a/docs/plans/2026-02-11-vale-ci-design.md b/docs/plans/2026-02-11-vale-ci-design.md
new file mode 100644
index 0000000000..a2886960d3
--- /dev/null
+++ b/docs/plans/2026-02-11-vale-ci-design.md
@@ -0,0 +1,190 @@
+# Vale CI Integration Design
+
+## Overview
+
+Add Vale style linting to GitHub Actions CI to enforce documentation quality standards on pull requests. The workflow blocks merging on style errors and provides actionable feedback through inline annotations and PR summary comments.
+
+## Goals
+
+1. **Block merging on style errors** - PRs with Vale errors cannot be merged
+2. **Provide informational feedback** - Warnings and suggestions shown but don't block
+3. **Support humans and AI agents** - Structured output that both can act on autonomously
+
+## Design Decisions
+
+| Decision | Choice | Rationale |
+|----------|--------|-----------|
+| Scope | Content + instruction files | Match local Lefthook behavior |
+| Config handling | Smart detection per file | Respect product-specific branding rules |
+| Reporting | Inline annotations + PR comment | Context on lines + overview for agents |
+| Filter mode | Added lines only | Avoid noise from pre-existing issues |
+| Alert level | Errors block, warnings don't | Match current local behavior |
+| Vale installation | Docker (`jdkato/vale:latest`) | Always current, matches local setup |
+
+## Files to Create
+
+```
+.github/
+├── scripts/
+│ ├── resolve-shared-content.sh # Shared content resolution
+│ └── vale-check.sh # Vale runner with config mapping
+└── workflows/
+ └── pr-vale-check.yml # Main workflow
+```
+
+## Workflow Architecture
+
+```
+pr-vale-check.yml
+├── Trigger: pull_request on markdown files
+├── Steps:
+│ ├── 1. Checkout repository
+│ ├── 2. Detect changed files (GitHub API)
+│ ├── 3. Skip if no files to check
+│ ├── 4. Resolve shared content → product pages
+│ ├── 5. Run Vale via Docker (grouped by config)
+│ ├── 6. Process results & generate annotations
+│ ├── 7. Post PR comment summary
+│ ├── 8. Upload results artifact
+│ └── 9. Fail if errors found
+```
+
+## Trigger Paths
+
+```yaml
+on:
+ pull_request:
+ paths:
+ - 'content/**/*.md'
+ - 'README.md'
+ - 'DOCS-*.md'
+ - '**/AGENTS.md'
+ - '**/CLAUDE.md'
+ - '.github/**/*.md'
+ - '.claude/**/*.md'
+ types: [opened, synchronize, reopened]
+```
+
+## Shared Content Resolution
+
+Files in `content/shared/` are sourced by product-specific pages. When a shared file changes, Vale must lint the consuming pages (which have product-specific configs).
+
+**Script**: `.github/scripts/resolve-shared-content.sh`
+
+**Logic**:
+1. For each changed file:
+ - If `content/shared/*`: find all pages with `source:` frontmatter pointing to it
+ - Else: pass through unchanged
+2. Output resolved file list
+
+**Example**:
+```
+Input:
+ content/shared/influxdb3-cli/admin/database/create.md
+
+Output:
+ content/influxdb3/core/reference/cli/influxdb3/admin/database/create.md
+ content/influxdb3/enterprise/reference/cli/influxdb3/admin/database/create.md
+```
+
+**Reusability**: This script can be used by other workflows (link-checker, tests) that also need shared content resolution.
+
+## Vale Config Mapping
+
+**Script**: `.github/scripts/vale-check.sh`
+
+Maps files to their Vale config based on path:
+
+| File Pattern | Vale Config |
+|--------------|-------------|
+| `content/influxdb3/cloud-dedicated/**` | `content/influxdb3/cloud-dedicated/.vale.ini` |
+| `content/influxdb3/cloud-serverless/**` | `content/influxdb3/cloud-serverless/.vale.ini` |
+| `content/influxdb/v2/**` | `content/influxdb/v2/.vale.ini` |
+| `content/**/*.md` (other) | `.vale.ini` |
+| Non-content files | `.vale-instructions.ini` |
+
+Files are grouped by config, then Vale runs once per group to minimize Docker invocations.
+
+## Vale Execution
+
+Uses Docker to match local development:
+
+```bash
+docker run --rm \
+ -v $(pwd):/workdir \
+ -w /workdir \
+ jdkato/vale:latest \
+ --config=$CONFIG \
+ --output=JSON \
+ $FILES
+```
+
+## Output Processing
+
+### GitHub Annotations
+
+```bash
+# Errors (visible in Files Changed, block merge)
+echo "::error file=${FILE},line=${LINE},title=${CHECK}::${MESSAGE}"
+
+# Warnings (visible, don't block)
+echo "::warning file=${FILE},line=${LINE},title=${CHECK}::${MESSAGE}"
+
+# Suggestions (visible, don't block)
+echo "::notice file=${FILE},line=${LINE},title=${CHECK}::${MESSAGE}"
+```
+
+### PR Summary Comment
+
+Posted via `github-script` action:
+
+```markdown
+## Vale Style Check Results
+
+| Metric | Count |
+|--------|-------|
+| Files checked | 12 |
+| Errors | 3 |
+| Warnings | 7 |
+| Suggestions | 2 |
+
+### Errors (blocking)
+
+| File | Line | Rule | Message |
+|------|------|------|---------|
+| `content/influxdb3/core/get-started.md` | 42 | InfluxDataDocs.Branding | Use 'InfluxDB' instead of 'influxdb' |
+
+
+Warnings (7)
+
+| File | Line | Rule | Message |
+|------|------|------|---------|
+| ... |
+
+
+
+---
+❌ **Check failed** — fix 3 error(s) before merging.
+```
+
+## Exit Behavior
+
+- **Exit 0**: No errors (warnings/suggestions allowed)
+- **Exit 1**: One or more errors (blocks merge)
+- Summary comment posted in both cases
+
+## Implementation Tasks
+
+1. [ ] Create `.github/scripts/resolve-shared-content.sh`
+2. [ ] Create `.github/scripts/vale-check.sh`
+3. [ ] Create `.github/workflows/pr-vale-check.yml`
+4. [ ] Test on a PR with intentional Vale errors
+5. [ ] Update `pr-link-check.yml` to use shared resolution script
+6. [ ] Document in DOCS-TESTING.md
+
+## Future Considerations
+
+- **Caching**: Cache Docker image pull across runs
+- **Parallel execution**: Run Vale config groups in parallel if performance becomes an issue
+- **Shared workflow**: Consider `workflow_call` if other repos need the same check
+- **Remove npm Vale**: Once CI uses Docker, `@vvago/vale` can be removed from devDependencies
diff --git a/docs/plans/2026-02-11-vale-ci-implementation.md b/docs/plans/2026-02-11-vale-ci-implementation.md
new file mode 100644
index 0000000000..d8ed0a4eaa
--- /dev/null
+++ b/docs/plans/2026-02-11-vale-ci-implementation.md
@@ -0,0 +1,685 @@
+# Vale CI Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Add Vale style linting to GitHub Actions that blocks PRs on errors and provides actionable feedback.
+
+**Architecture:** Single workflow with two supporting scripts. The shared content resolution script expands `content/shared/` files to their consuming product pages. The vale-check script maps files to product-specific Vale configs and runs Vale via Docker.
+
+**Tech Stack:** Bash scripts, GitHub Actions YAML, Docker (jdkato/vale), jq for JSON processing
+
+---
+
+## Task 1: Create Shared Content Resolution Script
+
+**Files:**
+- Create: `.github/scripts/resolve-shared-content.sh`
+
+**Step 1: Create the script with basic structure**
+
+```bash
+#!/bin/bash
+# Resolve shared content files to their consuming product pages.
+#
+# Usage:
+# echo "content/shared/foo.md" | ./resolve-shared-content.sh
+# ./resolve-shared-content.sh < changed_files.txt
+# ./resolve-shared-content.sh changed_files.txt
+#
+# For shared files (content/shared/*), finds all pages with matching
+# `source:` frontmatter and outputs those instead. Non-shared files
+# pass through unchanged.
+
+set -euo pipefail
+
+# Read input from file argument or stdin
+if [[ $# -gt 0 && -f "$1" ]]; then
+ INPUT=$(cat "$1")
+else
+ INPUT=$(cat)
+fi
+
+# Process each file
+while IFS= read -r file; do
+ [[ -z "$file" ]] && continue
+
+ if [[ "$file" == content/shared/* ]]; then
+ # Extract the shared path portion (e.g., /shared/influxdb3-cli/foo.md)
+ SHARED_PATH="${file#content}"
+
+ # Find all files that source this shared content
+ # The source frontmatter looks like: source: /shared/path/to/file.md
+ CONSUMERS=$(grep -rl "^source: ${SHARED_PATH}$" content/ 2>/dev/null | grep -v '^content/shared/' || true)
+
+ if [[ -n "$CONSUMERS" ]]; then
+ echo "$CONSUMERS"
+ else
+ # No consumers found - output the shared file itself
+ # (Vale can still lint it with default config)
+ echo "$file"
+ fi
+ else
+ # Non-shared file - pass through
+ echo "$file"
+ fi
+done <<< "$INPUT" | sort -u
+```
+
+**Step 2: Make script executable and commit**
+
+Run:
+```bash
+chmod +x .github/scripts/resolve-shared-content.sh
+git add .github/scripts/resolve-shared-content.sh
+git commit -m "feat(ci): add shared content resolution script
+
+Resolves content/shared/* files to their consuming product pages
+by searching for matching source: frontmatter. Reusable by Vale,
+link-checker, and other workflows that need shared content expansion."
+```
+
+**Step 3: Test the script locally**
+
+Run:
+```bash
+# Test with a known shared file
+echo "content/shared/influxdb3-cli/admin/database/create.md" | .github/scripts/resolve-shared-content.sh
+
+# Test with mixed input
+printf "content/shared/identify-version.md\ncontent/influxdb3/core/get-started.md" | .github/scripts/resolve-shared-content.sh
+```
+
+Expected: Shared files expand to product pages, non-shared files pass through.
+
+---
+
+## Task 2: Create Vale Check Script
+
+**Files:**
+- Create: `.github/scripts/vale-check.sh`
+
+**Step 1: Create the script**
+
+```bash
+#!/bin/bash
+# Run Vale on files with appropriate product-specific configs.
+#
+# Usage:
+# ./vale-check.sh file1.md file2.md ...
+# ./vale-check.sh < files.txt
+# ./vale-check.sh --files files.txt
+#
+# Groups files by their Vale config, runs Vale once per group,
+# and outputs combined JSON results.
+#
+# Exit codes:
+# 0 - No errors (warnings/suggestions allowed)
+# 1 - One or more errors found
+
+set -euo pipefail
+
+# Parse arguments
+FILES=()
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --files)
+ shift
+ while IFS= read -r f; do
+ [[ -n "$f" ]] && FILES+=("$f")
+ done < "$1"
+ shift
+ ;;
+ *)
+ FILES+=("$1")
+ shift
+ ;;
+ esac
+done
+
+# Read from stdin if no files provided
+if [[ ${#FILES[@]} -eq 0 ]]; then
+ while IFS= read -r f; do
+ [[ -n "$f" ]] && FILES+=("$f")
+ done
+fi
+
+if [[ ${#FILES[@]} -eq 0 ]]; then
+ echo "No files to check" >&2
+ exit 0
+fi
+
+# Map file to Vale config
+get_vale_config() {
+ local file="$1"
+ case "$file" in
+ content/influxdb3/cloud-dedicated/*) echo "content/influxdb3/cloud-dedicated/.vale.ini" ;;
+ content/influxdb3/cloud-serverless/*) echo "content/influxdb3/cloud-serverless/.vale.ini" ;;
+ content/influxdb/v2/*) echo "content/influxdb/v2/.vale.ini" ;;
+ content/*) echo ".vale.ini" ;;
+ *) echo ".vale-instructions.ini" ;;
+ esac
+}
+
+# Group files by config
+declare -A CONFIG_GROUPS
+for file in "${FILES[@]}"; do
+ config=$(get_vale_config "$file")
+ CONFIG_GROUPS["$config"]+="$file"$'\n'
+done
+
+# Run Vale for each config group and collect results
+ALL_RESULTS='{"files": {}, "errors": 0, "warnings": 0, "suggestions": 0}'
+TOTAL_ERRORS=0
+
+for config in "${!CONFIG_GROUPS[@]}"; do
+ files="${CONFIG_GROUPS[$config]}"
+ # Remove trailing newline and convert to array
+ mapfile -t file_array <<< "${files%$'\n'}"
+
+ echo "Running Vale with config: $config (${#file_array[@]} files)" >&2
+
+ # Run Vale via Docker
+ RESULT=$(docker run --rm \
+ -v "$(pwd)":/workdir \
+ -w /workdir \
+ jdkato/vale:latest \
+ --config="$config" \
+ --output=JSON \
+ --minAlertLevel=suggestion \
+ "${file_array[@]}" 2>/dev/null || true)
+
+ if [[ -n "$RESULT" && "$RESULT" != "null" ]]; then
+ # Merge results - Vale outputs {filepath: [alerts...]}
+ # Count errors in this batch
+ BATCH_ERRORS=$(echo "$RESULT" | jq '[.[][].Severity] | map(select(. == "error")) | length')
+ BATCH_WARNINGS=$(echo "$RESULT" | jq '[.[][].Severity] | map(select(. == "warning")) | length')
+ BATCH_SUGGESTIONS=$(echo "$RESULT" | jq '[.[][].Severity] | map(select(. == "suggestion")) | length')
+
+ TOTAL_ERRORS=$((TOTAL_ERRORS + BATCH_ERRORS))
+
+ # Merge file results into ALL_RESULTS
+ ALL_RESULTS=$(echo "$ALL_RESULTS" | jq --argjson new "$RESULT" \
+ --argjson e "$BATCH_ERRORS" \
+ --argjson w "$BATCH_WARNINGS" \
+ --argjson s "$BATCH_SUGGESTIONS" \
+ '.files += $new | .errors += $e | .warnings += $w | .suggestions += $s')
+ fi
+done
+
+# Output combined results
+echo "$ALL_RESULTS"
+
+# Exit with error if any errors found
+if [[ $TOTAL_ERRORS -gt 0 ]]; then
+ exit 1
+fi
+exit 0
+```
+
+**Step 2: Make script executable and commit**
+
+Run:
+```bash
+chmod +x .github/scripts/vale-check.sh
+git add .github/scripts/vale-check.sh
+git commit -m "feat(ci): add Vale check script with config mapping
+
+Maps files to product-specific Vale configs matching lefthook.yml:
+- cloud-dedicated, cloud-serverless, v2 use product configs
+- Other content uses root .vale.ini
+- Non-content uses .vale-instructions.ini
+
+Runs Vale via Docker for version consistency with local dev."
+```
+
+**Step 3: Test the script locally**
+
+Run:
+```bash
+# Test with a content file
+echo "content/influxdb3/core/get-started/_index.md" | .github/scripts/vale-check.sh
+
+# Test with instruction file
+echo "README.md" | .github/scripts/vale-check.sh
+```
+
+Expected: Vale runs with appropriate config, outputs JSON.
+
+---
+
+## Task 3: Create Vale CI Workflow
+
+**Files:**
+- Create: `.github/workflows/pr-vale-check.yml`
+
+**Step 1: Create the workflow file**
+
+```yaml
+name: Vale Style Check
+
+on:
+ pull_request:
+ paths:
+ - 'content/**/*.md'
+ - 'README.md'
+ - 'DOCS-*.md'
+ - '**/AGENTS.md'
+ - '**/CLAUDE.md'
+ - '.github/**/*.md'
+ - '.claude/**/*.md'
+ types: [opened, synchronize, reopened]
+
+jobs:
+ vale-check:
+ name: Vale style check
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ pull-requests: write
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Detect changed files
+ id: detect
+ run: |
+ echo "🔍 Detecting changed files in PR #${{ github.event.number }}"
+
+ # Get changed files via GitHub API
+ curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
+ "https://api.github.com/repos/${{ github.repository }}/pulls/${{ github.event.number }}/files" \
+ | jq -r '.[].filename' > all_changed_files.txt
+
+ # Filter for markdown files in scope
+ grep -E '^(content/.*\.md|README\.md|DOCS-.*\.md|.*/AGENTS\.md|.*/CLAUDE\.md|\.github/.*\.md|\.claude/.*\.md)$' \
+ all_changed_files.txt > files_to_check.txt || true
+
+ if [[ -s files_to_check.txt ]]; then
+ FILE_COUNT=$(wc -l < files_to_check.txt | tr -d ' ')
+ echo "✅ Found $FILE_COUNT file(s) to check"
+ echo "has-files=true" >> $GITHUB_OUTPUT
+ cat files_to_check.txt
+ else
+ echo "❌ No matching files to check"
+ echo "has-files=false" >> $GITHUB_OUTPUT
+ fi
+
+ - name: Skip if no files to check
+ if: steps.detect.outputs.has-files == 'false'
+ run: |
+ echo "No markdown files in scope - skipping Vale check"
+ echo "✅ **No files to check** - Vale check skipped" >> $GITHUB_STEP_SUMMARY
+
+ - name: Resolve shared content
+ if: steps.detect.outputs.has-files == 'true'
+ run: |
+ chmod +x .github/scripts/resolve-shared-content.sh
+ .github/scripts/resolve-shared-content.sh files_to_check.txt > resolved_files.txt
+
+ echo "📁 Resolved files:"
+ cat resolved_files.txt
+
+ RESOLVED_COUNT=$(wc -l < resolved_files.txt | tr -d ' ')
+ echo "resolved-count=$RESOLVED_COUNT" >> $GITHUB_OUTPUT
+
+ - name: Run Vale
+ if: steps.detect.outputs.has-files == 'true'
+ id: vale
+ run: |
+ chmod +x .github/scripts/vale-check.sh
+
+ set +e # Don't exit on error
+ .github/scripts/vale-check.sh --files resolved_files.txt > vale_results.json 2>vale_stderr.txt
+ EXIT_CODE=$?
+ set -e
+
+ # Log stderr for debugging
+ if [[ -s vale_stderr.txt ]]; then
+ echo "Vale output:"
+ cat vale_stderr.txt
+ fi
+
+ # Parse results
+ if [[ -s vale_results.json ]]; then
+ ERRORS=$(jq -r '.errors // 0' vale_results.json)
+ WARNINGS=$(jq -r '.warnings // 0' vale_results.json)
+ SUGGESTIONS=$(jq -r '.suggestions // 0' vale_results.json)
+
+ echo "error-count=$ERRORS" >> $GITHUB_OUTPUT
+ echo "warning-count=$WARNINGS" >> $GITHUB_OUTPUT
+ echo "suggestion-count=$SUGGESTIONS" >> $GITHUB_OUTPUT
+
+ if [[ $ERRORS -gt 0 ]]; then
+ echo "check-result=failed" >> $GITHUB_OUTPUT
+ else
+ echo "check-result=passed" >> $GITHUB_OUTPUT
+ fi
+ else
+ echo "check-result=error" >> $GITHUB_OUTPUT
+ echo "error-count=0" >> $GITHUB_OUTPUT
+ echo "warning-count=0" >> $GITHUB_OUTPUT
+ echo "suggestion-count=0" >> $GITHUB_OUTPUT
+ fi
+
+ exit $EXIT_CODE
+
+ - name: Generate annotations
+ if: always() && steps.detect.outputs.has-files == 'true' && steps.vale.outputs.check-result != ''
+ run: |
+ if [[ ! -s vale_results.json ]]; then
+ echo "No results to process"
+ exit 0
+ fi
+
+ # Process each file's alerts
+ jq -r '.files | to_entries[] | .key as $file | .value[] | "\($file)|\(.Line)|\(.Severity)|\(.Check)|\(.Message)"' \
+ vale_results.json 2>/dev/null | while IFS='|' read -r file line severity check message; do
+
+ case "$severity" in
+ error)
+ echo "::error file=${file},line=${line},title=${check}::${message}"
+ ;;
+ warning)
+ echo "::warning file=${file},line=${line},title=${check}::${message}"
+ ;;
+ suggestion)
+ echo "::notice file=${file},line=${line},title=${check}::${message}"
+ ;;
+ esac
+ done
+
+ - name: Post PR comment
+ if: always() && steps.detect.outputs.has-files == 'true'
+ uses: actions/github-script@v7
+ with:
+ script: |
+ const fs = require('fs');
+
+ // Read results
+ let results = { files: {}, errors: 0, warnings: 0, suggestions: 0 };
+ try {
+ results = JSON.parse(fs.readFileSync('vale_results.json', 'utf8'));
+ } catch (e) {
+ console.log('Could not read results:', e.message);
+ }
+
+ const errors = results.errors || 0;
+ const warnings = results.warnings || 0;
+ const suggestions = results.suggestions || 0;
+ const checkResult = '${{ steps.vale.outputs.check-result }}';
+
+ // Build comment body
+ let body = '## Vale Style Check Results\n\n';
+ body += '| Metric | Count |\n';
+ body += '|--------|-------|\n';
+ body += `| Errors | ${errors} |\n`;
+ body += `| Warnings | ${warnings} |\n`;
+ body += `| Suggestions | ${suggestions} |\n\n`;
+
+ // List errors (blocking)
+ if (errors > 0) {
+ body += '### Errors (blocking)\n\n';
+ body += '| File | Line | Rule | Message |\n';
+ body += '|------|------|------|--------|\n';
+
+ for (const [file, alerts] of Object.entries(results.files || {})) {
+ for (const alert of alerts) {
+ if (alert.Severity === 'error') {
+ const msg = alert.Message.replace(/\|/g, '\\|').substring(0, 100);
+ body += `| \`${file}\` | ${alert.Line} | ${alert.Check} | ${msg} |\n`;
+ }
+ }
+ }
+ body += '\n';
+ }
+
+ // Collapsible warnings
+ if (warnings > 0) {
+ body += '\n';
+ body += `Warnings (${warnings})
\n\n`;
+ body += '| File | Line | Rule | Message |\n';
+ body += '|------|------|------|--------|\n';
+
+ let count = 0;
+ for (const [file, alerts] of Object.entries(results.files || {})) {
+ for (const alert of alerts) {
+ if (alert.Severity === 'warning' && count < 20) {
+ const msg = alert.Message.replace(/\|/g, '\\|').substring(0, 100);
+ body += `| \`${file}\` | ${alert.Line} | ${alert.Check} | ${msg} |\n`;
+ count++;
+ }
+ }
+ }
+ if (warnings > 20) {
+ body += `\n_Showing first 20 of ${warnings} warnings._\n`;
+ }
+ body += '\n \n\n';
+ }
+
+ // Status line
+ body += '---\n';
+ if (checkResult === 'failed') {
+ body += `❌ **Check failed** — fix ${errors} error(s) before merging.\n`;
+ } else if (checkResult === 'passed') {
+ body += '✅ **Check passed**\n';
+ } else {
+ body += '⚠️ **Check could not complete**\n';
+ }
+
+ // Find and update existing comment or create new
+ const { data: comments } = await github.rest.issues.listComments({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: context.issue.number,
+ });
+
+ const botComment = comments.find(c =>
+ c.user.type === 'Bot' && c.body.includes('## Vale Style Check Results')
+ );
+
+ if (botComment) {
+ await github.rest.issues.updateComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ comment_id: botComment.id,
+ body: body
+ });
+ } else {
+ await github.rest.issues.createComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: context.issue.number,
+ body: body
+ });
+ }
+
+ - name: Write step summary
+ if: always() && steps.detect.outputs.has-files == 'true'
+ run: |
+ ERRORS="${{ steps.vale.outputs.error-count }}"
+ WARNINGS="${{ steps.vale.outputs.warning-count }}"
+ SUGGESTIONS="${{ steps.vale.outputs.suggestion-count }}"
+ RESULT="${{ steps.vale.outputs.check-result }}"
+
+ echo "## Vale Style Check Results" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "| Metric | Count |" >> $GITHUB_STEP_SUMMARY
+ echo "|--------|-------|" >> $GITHUB_STEP_SUMMARY
+ echo "| Errors | ${ERRORS:-0} |" >> $GITHUB_STEP_SUMMARY
+ echo "| Warnings | ${WARNINGS:-0} |" >> $GITHUB_STEP_SUMMARY
+ echo "| Suggestions | ${SUGGESTIONS:-0} |" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+
+ if [[ "$RESULT" == "failed" ]]; then
+ echo "❌ **Check failed** — fix ${ERRORS} error(s) before merging." >> $GITHUB_STEP_SUMMARY
+ elif [[ "$RESULT" == "passed" ]]; then
+ echo "✅ **Check passed**" >> $GITHUB_STEP_SUMMARY
+ else
+ echo "⚠️ **Check could not complete**" >> $GITHUB_STEP_SUMMARY
+ fi
+
+ - name: Upload results
+ if: always() && steps.detect.outputs.has-files == 'true'
+ uses: actions/upload-artifact@v4
+ with:
+ name: vale-results
+ path: |
+ vale_results.json
+ resolved_files.txt
+ files_to_check.txt
+ retention-days: 30
+
+ - name: Fail on errors
+ if: steps.vale.outputs.check-result == 'failed'
+ run: |
+ echo "Vale found ${{ steps.vale.outputs.error-count }} error(s)"
+ exit 1
+```
+
+**Step 2: Commit the workflow**
+
+Run:
+```bash
+git add .github/workflows/pr-vale-check.yml
+git commit -m "feat(ci): add Vale style check workflow
+
+Runs Vale on PR changes with product-specific configs:
+- Blocks merging on errors
+- Shows warnings/suggestions as annotations
+- Posts summary comment for humans and AI agents
+- Uses Docker for Vale version consistency"
+```
+
+---
+
+## Task 4: Test the Workflow
+
+**Step 1: Create a test file with intentional errors**
+
+Create `content/influxdb3/core/test-vale-errors.md`:
+```markdown
+---
+title: Test Vale Errors
+description: Temporary test file for Vale CI.
+---
+
+This file tests the vale CI workflow.
+
+Here is an error: influxdb should be InfluxDB.
+
+Here is a branding issue: influx-db is wrong.
+```
+
+**Step 2: Commit and push to trigger workflow**
+
+Run:
+```bash
+git add content/influxdb3/core/test-vale-errors.md
+git commit -m "test(ci): add temporary file to test Vale workflow"
+git push origin docs-v2-jts-vale-ci
+```
+
+**Step 3: Open a PR and verify**
+
+1. Open PR from `docs-v2-jts-vale-ci` to `master`
+2. Verify Vale check runs
+3. Verify annotations appear on changed lines
+4. Verify PR comment is posted
+5. Verify check fails due to errors
+
+**Step 4: Clean up test file**
+
+Run:
+```bash
+git rm content/influxdb3/core/test-vale-errors.md
+git commit -m "test(ci): remove Vale test file"
+git push origin docs-v2-jts-vale-ci
+```
+
+---
+
+## Task 5: Update Documentation
+
+**Files:**
+- Modify: `DOCS-TESTING.md`
+
+**Step 1: Add Vale CI section to DOCS-TESTING.md**
+
+Find the "Style Linting (Vale)" section and add after the existing content:
+
+```markdown
+### CI Integration
+
+Vale runs automatically on pull requests that modify markdown files. The workflow:
+
+1. Detects changed markdown files (content, README, instruction files)
+2. Resolves shared content to consuming product pages
+3. Maps files to appropriate Vale configs (matching local Lefthook behavior)
+4. Runs Vale via Docker (`jdkato/vale:latest`)
+5. Reports results as inline annotations and a PR summary comment
+
+**Alert levels:**
+- **Errors** block merging
+- **Warnings** and **suggestions** are informational only
+
+**Files checked:**
+- `content/**/*.md`
+- `README.md`, `DOCS-*.md`
+- `**/AGENTS.md`, `**/CLAUDE.md`
+- `.github/**/*.md`, `.claude/**/*.md`
+
+The CI check uses the same product-specific configs as local development, ensuring consistency between local and CI linting.
+```
+
+**Step 2: Commit documentation update**
+
+Run:
+```bash
+git add DOCS-TESTING.md
+git commit -m "docs: add Vale CI section to DOCS-TESTING.md"
+```
+
+---
+
+## Task 6: Final Verification
+
+**Step 1: Verify all scripts are executable**
+
+Run:
+```bash
+ls -la .github/scripts/resolve-shared-content.sh
+ls -la .github/scripts/vale-check.sh
+```
+
+Expected: Both show `-rwxr-xr-x` permissions.
+
+**Step 2: Verify workflow syntax**
+
+Run:
+```bash
+# Use actionlint if available, or just check YAML validity
+python3 -c "import yaml; yaml.safe_load(open('.github/workflows/pr-vale-check.yml'))"
+```
+
+Expected: No errors.
+
+**Step 3: Push final changes**
+
+Run:
+```bash
+git push origin docs-v2-jts-vale-ci
+```
+
+---
+
+## Summary
+
+After completing all tasks, you will have:
+
+1. `.github/scripts/resolve-shared-content.sh` - Reusable script for expanding shared content
+2. `.github/scripts/vale-check.sh` - Vale runner with config mapping
+3. `.github/workflows/pr-vale-check.yml` - CI workflow with annotations and PR comments
+4. Updated `DOCS-TESTING.md` with CI documentation
+
+The workflow mirrors local Lefthook behavior, using Docker-based Vale and product-specific configs.