-
Notifications
You must be signed in to change notification settings - Fork 153
chore: Add GitHub Actions workflow file for enforcing metadata in docs #2099
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 12 commits
Commits
Show all changes
14 commits
Select commit
Hold shift + click to select a range
c497bb7
chore: Add GitHub Actions workflow file for enforcing metadata in docs
crazyuploader f473792
chore: Add tags
crazyuploader 94e3fd2
fix: Update GitHub Actions workflow file for checking metadata
crazyuploader d0e1ebf
docs: Update tags
crazyuploader 652f0eb
Update check-metadata-docs.yml
crazyuploader 980d61e
Update check-metadata-docs.yml
crazyuploader e062929
Update tags
crazyuploader f6d5a02
Merge branch 'main' into chore/add-github-metadata-checker
crazyuploader 67567f0
Switch to Husky pre-commit for metadata check
crazyuploader b642c01
Update CONTRIBUTING.md
crazyuploader ce67552
Update check-docs-metadata.js
crazyuploader 13822e4
Chore: Fix tests
crazyuploader bbe30d4
Chore: Add description requirement for docs
crazyuploader 53728ad
Update CONTRIBUTING.md
crazyuploader File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| name: Docs Metadata Guard | ||
|
|
||
| on: | ||
| pull_request: | ||
| paths: | ||
| - "data/docs/**" | ||
| - "next.config.js" | ||
| - "scripts/check-docs-metadata.js" | ||
| - "tests/docs-metadata.test.js" | ||
| - "tests/fixtures/**" | ||
| - "package.json" | ||
| - ".github/workflows/docs-metadata-guard.yml" | ||
|
|
||
| jobs: | ||
| check-metadata: | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - name: Checkout repository | ||
| uses: actions/checkout@v5 | ||
| with: | ||
| fetch-depth: 0 | ||
|
|
||
| - name: Set up Node.js | ||
| uses: actions/setup-node@v6 | ||
| with: | ||
| node-version: "20" | ||
|
|
||
| - name: Install dependencies | ||
| run: yarn install --frozen-lockfile --non-interactive | ||
|
|
||
| - name: Run docs metadata tests | ||
| run: yarn test:docs-metadata | ||
|
|
||
| - name: Validate docs metadata | ||
| run: yarn check:docs-metadata |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,15 +1,23 @@ | ||
| #!/usr/bin/env sh | ||
|
|
||
| set -e | ||
|
|
||
| echo 'husky (pre-commit): running lint-staged' | ||
| yarn lint-staged | ||
|
|
||
| STAGED_FILES=$(git diff --cached --name-only) | ||
|
|
||
| # Check for docs redirect changes | ||
| if printf '%s\n' "$STAGED_FILES" | grep -E '^(data/docs/.*\.mdx|next\.config\.js|scripts/check-doc-redirects\.js)$' >/dev/null; then | ||
| echo 'husky (pre-commit): verifying docs redirects' | ||
| yarn check:doc-redirects | ||
| else | ||
| echo 'husky (pre-commit): skipping docs redirect check (no relevant changes staged)' | ||
| fi | ||
|
|
||
| # Check for docs metadata | ||
| if printf '%s\n' "$STAGED_FILES" | grep -E '^data/docs/.*\.mdx$' >/dev/null; then | ||
| echo 'husky (pre-commit): validating docs metadata' | ||
| HUSKY_PRE_COMMIT=true yarn check:docs-metadata | ||
| else | ||
| echo 'husky (pre-commit): skipping docs metadata check (no documentation changes staged)' | ||
| fi |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,301 @@ | ||
| #!/usr/bin/env node | ||
|
|
||
| const { execSync } = require('child_process') | ||
| const fs = require('fs') | ||
|
|
||
| function run(command) { | ||
| try { | ||
| return execSync(command, { encoding: 'utf8' }).trim() | ||
| } catch (error) { | ||
| console.error(`Failed to execute: ${command}`) | ||
| console.error(error.message) | ||
| process.exit(1) | ||
| } | ||
| } | ||
|
|
||
| function getChangedDocFiles(baseRef) { | ||
| let mergeBase | ||
| try { | ||
| mergeBase = run(`git merge-base HEAD ${baseRef}`) | ||
| } catch (error) { | ||
| if (baseRef !== 'origin/main') { | ||
| mergeBase = run('git merge-base HEAD origin/main') | ||
| } else { | ||
| throw error | ||
| } | ||
| } | ||
|
|
||
| const docPattern = /^data\/docs\/.*\.mdx$/ | ||
| const changedFiles = new Set() | ||
|
|
||
| // Get committed changes | ||
| try { | ||
| const committedDiff = execSync(`git diff --name-only --diff-filter=ACMR ${mergeBase} HEAD`, { | ||
| encoding: 'utf8', | ||
| }) | ||
| committedDiff | ||
| .split('\n') | ||
| .filter((file) => docPattern.test(file)) | ||
| .forEach((file) => changedFiles.add(file)) | ||
| } catch (error) { | ||
| console.error('Unable to read git diff for docs changes.') | ||
| console.error(error.message) | ||
| process.exit(1) | ||
| } | ||
|
|
||
| // Get working tree changes | ||
| try { | ||
| const workingDiff = execSync('git diff --name-only --diff-filter=ACMR HEAD', { | ||
| encoding: 'utf8', | ||
| }) | ||
| workingDiff | ||
| .split('\n') | ||
| .filter((file) => docPattern.test(file)) | ||
| .forEach((file) => changedFiles.add(file)) | ||
| } catch (error) { | ||
| console.error('Unable to read local git diff for docs changes.') | ||
| console.error(error.message) | ||
| process.exit(1) | ||
| } | ||
|
|
||
| return Array.from(changedFiles).filter(Boolean) | ||
| } | ||
|
|
||
| function getGitAuthorDate(filePath) { | ||
| try { | ||
| const dateString = execSync(`git log -2 --pretty=format:%as -- ${filePath}`, { | ||
| encoding: 'utf8', | ||
| }).trim() | ||
| return dateString || null | ||
| } catch (error) { | ||
| return null | ||
| } | ||
| } | ||
|
|
||
| function getStagedDocFiles() { | ||
| try { | ||
| const stagedFiles = execSync('git diff --cached --name-only --diff-filter=ACMR', { | ||
| encoding: 'utf8', | ||
| }) | ||
| const docPattern = /^data\/docs\/.*\.mdx$/ | ||
| return stagedFiles | ||
| .split('\n') | ||
| .filter((file) => docPattern.test(file)) | ||
| .filter(Boolean) | ||
| } catch (error) { | ||
| console.error('Unable to read staged files.') | ||
| console.error(error.message) | ||
| process.exit(1) | ||
| } | ||
| } | ||
|
|
||
| function extractFrontmatter(filePath) { | ||
| try { | ||
| const content = fs.readFileSync(filePath, 'utf8') | ||
| const lines = content.split('\n') | ||
| let inFrontmatter = false | ||
| let frontmatterLines = [] | ||
| let delimiterCount = 0 | ||
|
|
||
| for (const line of lines) { | ||
| if (line.trim() === '---') { | ||
| delimiterCount++ | ||
| if (delimiterCount === 1) { | ||
| inFrontmatter = true | ||
| continue | ||
| } | ||
| if (delimiterCount === 2) { | ||
| break | ||
| } | ||
| } | ||
| if (inFrontmatter && delimiterCount === 1) { | ||
| frontmatterLines.push(line) | ||
| } | ||
| } | ||
|
|
||
| return frontmatterLines.join('\n') | ||
| } catch (error) { | ||
| return null | ||
| } | ||
| } | ||
|
|
||
| function validateMetadata(filePath) { | ||
| const errors = [] | ||
| const warnings = [] | ||
|
|
||
| // Check if file exists | ||
| if (!fs.existsSync(filePath)) { | ||
| errors.push('file not found') | ||
| return { errors, warnings } | ||
| } | ||
|
|
||
| // Extract frontmatter | ||
| const frontmatter = extractFrontmatter(filePath) | ||
| if (frontmatter === null) { | ||
| errors.push('cannot read file') | ||
| return { errors, warnings } | ||
| } | ||
|
|
||
| const lines = frontmatter.split('\n') | ||
| const fieldMap = new Map() | ||
|
|
||
| // Parse frontmatter fields | ||
| for (const line of lines) { | ||
| const match = line.match(/^(\w+):\s*(.*)$/) | ||
| if (match) { | ||
| fieldMap.set(match[1], match[2].trim()) | ||
| } | ||
| } | ||
|
|
||
| // Validate tags field (warning only) | ||
| if (!fieldMap.has('tags')) { | ||
| warnings.push('missing tags') | ||
| } else { | ||
| const tagsValue = fieldMap.get('tags') | ||
| if (!tagsValue.includes('[')) { | ||
| warnings.push('tags must be an array') | ||
| } else if (/^\[\s*\]$/.test(tagsValue)) { | ||
| warnings.push('tags array cannot be empty') | ||
| } | ||
| } | ||
|
|
||
| // Validate date field (required) | ||
| if (!fieldMap.has('date')) { | ||
| errors.push('missing date') | ||
| } else { | ||
| const dateValue = fieldMap.get('date').replace(/['"]/g, '').trim() | ||
| const datePattern = /^\d{4}-\d{2}-\d{2}$/ | ||
| if (!datePattern.test(dateValue)) { | ||
| errors.push('invalid date format - use YYYY-MM-DD') | ||
| } else { | ||
| // Check if date is valid | ||
| const date = new Date(dateValue) | ||
| if (isNaN(date.getTime())) { | ||
| errors.push('invalid date value') | ||
| } else { | ||
| // Allow dates up to 7 days in the future | ||
| const today = new Date() | ||
| today.setHours(0, 0, 0, 0) | ||
|
|
||
| const maxFutureDate = new Date(today) | ||
| maxFutureDate.setDate(maxFutureDate.getDate() + 7) | ||
|
|
||
| if (date > maxFutureDate) { | ||
| errors.push('date cannot be more than 7 days in the future') | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // New validation: Compare frontmatter date with git commit date | ||
| if (fieldMap.has('date')) { | ||
| const frontmatterDate = fieldMap.get('date').replace(/['"]/g, '').trim() | ||
| const gitDate = getGitAuthorDate(filePath) | ||
|
|
||
| if (gitDate) { | ||
| const frontDate = new Date(frontmatterDate) | ||
| const commitDate = new Date(gitDate) | ||
|
|
||
| if (frontDate < commitDate) { | ||
| warnings.push( | ||
| `frontmatter date (${frontmatterDate}) is before git commit date (${gitDate})` | ||
| ) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // Validate title field (required) | ||
| if (!fieldMap.has('title')) { | ||
| errors.push('missing title') | ||
| } else { | ||
| const titleValue = fieldMap.get('title').trim() | ||
| if (!titleValue || titleValue === '""' || titleValue === "''") { | ||
| errors.push('title cannot be empty') | ||
| } | ||
| } | ||
|
|
||
| return { errors, warnings } | ||
| } | ||
|
|
||
| function main() { | ||
| const isPreCommit = process.env.HUSKY_PRE_COMMIT === 'true' | ||
| const baseBranch = process.env.GITHUB_BASE_REF | ||
| ? `origin/${process.env.GITHUB_BASE_REF}` | ||
| : process.env.DEFAULT_BRANCH || 'origin/main' | ||
|
|
||
| // Get changed files | ||
| const changedFiles = isPreCommit ? getStagedDocFiles() : getChangedDocFiles(baseBranch) | ||
|
|
||
| if (changedFiles.length === 0) { | ||
| console.log('No documentation files to check') | ||
| return | ||
| } | ||
|
|
||
| console.log(`Checking ${changedFiles.length} documentation file(s) for required metadata...\n`) | ||
|
|
||
| const invalidFiles = [] | ||
| const warningFiles = [] | ||
| let allValid = true | ||
|
|
||
| for (const file of changedFiles) { | ||
| const { errors, warnings } = validateMetadata(file) | ||
|
|
||
| if (errors.length > 0) { | ||
| console.error(`❌ ${file}: ${errors.join('; ')}`) | ||
| invalidFiles.push({ file, issues: errors }) | ||
| allValid = false | ||
| } | ||
|
|
||
| if (warnings.length > 0) { | ||
| console.warn(`⚠️ ${file}: ${warnings.join('; ')}`) | ||
| warningFiles.push({ file, issues: warnings }) | ||
| } | ||
|
|
||
| if (errors.length === 0 && warnings.length === 0) { | ||
| console.log(`✅ ${file}`) | ||
| } | ||
| } | ||
|
|
||
| console.log('') | ||
|
|
||
| // Display summary | ||
| if (warningFiles.length > 0) { | ||
| console.warn('Documentation metadata warnings:') | ||
| warningFiles.forEach(({ file, issues }) => { | ||
| console.warn(` • ${file}: ${issues.join('; ')}`) | ||
| }) | ||
| console.warn('\nConsider adding tags to improve documentation discoverability.\n') | ||
| } | ||
|
|
||
| if (!allValid) { | ||
| console.error('Documentation metadata validation failed:') | ||
| invalidFiles.forEach(({ file, issues }) => { | ||
| console.error(` • ${file}: ${issues.join('; ')}`) | ||
| }) | ||
| console.error('\nRequired fields:') | ||
| console.error(' - date: Date in YYYY-MM-DD format') | ||
| console.error(' - title: Non-empty title field') | ||
| console.error(' - tags: Array of tags (recommended)') | ||
| console.error('\nExample:') | ||
| console.error('---') | ||
| console.error('title: My Documentation Page') | ||
| console.error(`date: ${new Date().toISOString().split('T')[0]}`) | ||
| console.error('tags: ["SigNoz Cloud", "Self-Host"]') | ||
| console.error('---\n') | ||
| process.exit(1) | ||
| } | ||
|
|
||
| console.log('✅ All documentation files have valid metadata\n') | ||
| } | ||
|
|
||
| module.exports = { | ||
| getChangedDocFiles, | ||
| getStagedDocFiles, | ||
| extractFrontmatter, | ||
| validateMetadata, | ||
| main, | ||
| } | ||
|
|
||
| if (require.main === module) { | ||
| main() | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.