diff --git a/.github/scripts/validate-footers.js b/.github/scripts/validate-footers.js index 457df7874..5b2985eb6 100755 --- a/.github/scripts/validate-footers.js +++ b/.github/scripts/validate-footers.js @@ -20,6 +20,7 @@ import fs from "fs"; import path from "path"; +import { execSync } from "child_process"; import yaml from "js-yaml"; import { fileURLToPath } from "url"; @@ -51,15 +52,164 @@ const reportFile = args .find((arg) => arg.startsWith("--report=")) ?.split("=")[1]; const verbose = args.includes("--verbose"); +const changedOnly = args.includes("--changed-only"); // Track violations const violations = { duplicateFooters: [], multipleFooersPerDoc: [], invalidFooterId: [], - missingCategory: [], + missingFooters: [], }; +const DEFAULT_FOOTER_SIGNATURES = [ + "_Maintained with", + "_Built by", + "_Have questions?", + "_This page brought to you by", + "_Docs signed by", + "Made with ๐Ÿ’š by LightSpeedWP", +]; + +function extractFooterSignatures(config) { + const canonicalFooters = Object.values(config?.footers || {}) + .map((footer) => footer?.template) + .filter((template) => typeof template === "string") + .map((template) => + template + .trim() + .split("\n") + .map((line) => line.trim()) + .find((line) => line && line !== "---"), + ) + .filter(Boolean); + + return [...new Set([...DEFAULT_FOOTER_SIGNATURES, ...canonicalFooters])]; +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function extractFooterTail(content) { + const lines = content.split("\n"); + const separators = []; + + lines.forEach((line, idx) => { + if (line.trim() === "---") { + separators.push(idx); + } + }); + + if (separators.length === 0) { + return ""; + } + + const tailStart = separators[separators.length - 1] + 1; + return lines.slice(tailStart).join("\n").trim(); +} + +function countFooterSignatureMatches(content, config) { + const tail = extractFooterTail(content); + const signatures = extractFooterSignatures(config); + + return signatures.map((signature) => { + const escaped = escapeRegExp(signature); + const matches = tail.match(new RegExp(escaped, "g")) || []; + return { signature, count: matches.length }; + }); +} + +function getDefaultFooterForCategory(category) { + const categoryConfig = footerConfig.categories?.[category]; + const footerId = categoryConfig?.default_footer; + const footerTemplate = footerId && footerConfig.footers?.[footerId]?.template; + + if (typeof footerTemplate === "string") { + return footerTemplate.trimEnd(); + } + + return null; +} + +function replaceFooterTail(content, footerTemplate) { + const footerBlock = `\n---\n\n${footerTemplate.trimEnd()}\n`; + const separators = []; + const lines = content.split("\n"); + + lines.forEach((line, idx) => { + if (line.trim() === "---") { + separators.push(idx); + } + }); + + if (separators.length === 0) { + if (content.endsWith("\n")) { + return `${content}${footerBlock.trimStart()}`; + } + + return `${content}\n${footerBlock.trimStart()}`; + } + + const lastSeparatorIdx = separators[separators.length - 1]; + const contentWithoutTail = lines + .slice(0, lastSeparatorIdx) + .join("\n") + .replace(/\s+$/, ""); + return `${contentWithoutTail}${footerBlock}`; +} + +function inferCategory(filePath, frontmatter) { + if ( + frontmatter?.category && + footerConfig.categories?.[frontmatter.category] + ) { + return frontmatter.category; + } + + const normalizedPath = filePath.replace(/\\/g, "/"); + const pathPatterns = [ + { + pattern: /^\.github\/ISSUE_TEMPLATE\/.*\.md$/i, + category: "issue-template", + }, + { + pattern: /^\.github\/PULL_REQUEST_TEMPLATE\/.*\.md$/i, + category: "pull-request-template", + }, + { pattern: /^agents\/.*\.(?:md|agent\.md)$/i, category: "agents" }, + { + pattern: /^instructions\/.*\.md$|.*\.instructions\.md$/i, + category: "instructions", + }, + { pattern: /^schema\/.*\.md$|.*\.schema\.md$/i, category: "schema" }, + { + pattern: /^\.github\/reports\/.*\.md$|.*audit.*\.md$/i, + category: "audit", + }, + { pattern: /.*research.*\.md$/i, category: "research" }, + { + pattern: /^scripts\/.*\.md$|^utils\/.*\.md$|.*utility.*\.md$/i, + category: "utility", + }, + { + pattern: /^docs\/.*governance.*\.md$|^governance\/.*\.md$/i, + category: "governance", + }, + { + pattern: /^docs\/.*(?:automation|ai-ops).*\.md$/i, + category: "ai-ops", + }, + { pattern: /^docs\/.*\.md$/i, category: "docs" }, + { pattern: /^(?:.*\/)?README\.md$/i, category: "readme" }, + ]; + + const match = pathPatterns.find(({ pattern }) => + pattern.test(normalizedPath), + ); + return match?.category || ""; +} + /** * Find all Markdown files in the repository */ @@ -83,56 +233,62 @@ function findMarkdownFiles(dir = ".") { return files; } -/** - * Extract YAML frontmatter from a Markdown file - */ -function extractFrontmatter(content) { - const fmRegex = /^---\n([\s\S]*?)\n---/; - const match = content.match(fmRegex); +function readGitEventContext() { + const eventPath = process.env.GITHUB_EVENT_PATH; - if (!match) return null; + if (!eventPath || !fs.existsSync(eventPath)) { + return null; + } try { - return yaml.load(match[1]); + return JSON.parse(fs.readFileSync(eventPath, "utf8")); } catch { return null; } } -/** - * Extract all footer blocks from content - * A footer block is content after the last "---" separator - */ -function extractFooters(content) { - const separators = []; - const lines = content.split("\n"); +function getChangedMarkdownFiles() { + let baseRef = ""; + let headRef = ""; + const event = readGitEventContext(); + const eventName = process.env.GITHUB_EVENT_NAME || ""; + + if (eventName === "pull_request" || eventName === "pull_request_target") { + baseRef = event?.pull_request?.base?.sha || ""; + headRef = event?.pull_request?.head?.sha || ""; + } else if (eventName === "push") { + baseRef = event?.before || ""; + headRef = event?.after || ""; + } - // Find all "---" separators - lines.forEach((line, idx) => { - if (line.trim() === "---") { - separators.push(idx); - } - }); + const diffRange = + baseRef && headRef ? `${baseRef} ${headRef}` : "HEAD~1 HEAD"; - // If less than 2 separators, no footer - if (separators.length < 2) return []; + try { + const output = execSync(`git diff --name-only ${diffRange} -- '*.md'`, { + encoding: "utf8", + }).trim(); - // Content after the last separator is the footer - const lastSeparatorIdx = separators[separators.length - 1]; - const footerContent = lines - .slice(lastSeparatorIdx + 1) - .join("\n") - .trim(); + return output ? output.split("\n").filter(Boolean) : []; + } catch { + return findMarkdownFiles(); + } +} - if (!footerContent) return []; +/** + * Extract YAML frontmatter from a Markdown file + */ +function extractFrontmatter(content) { + const fmRegex = /^---\n([\s\S]*?)\n---/; + const match = content.match(fmRegex); - // Split footer into blocks (separated by blank lines) - const footerBlocks = footerContent - .split("\n\n") - .map((block) => block.trim()) - .filter((block) => block.length > 0); + if (!match) return null; - return footerBlocks; + try { + return yaml.load(match[1]); + } catch { + return null; + } } /** @@ -141,49 +297,51 @@ function extractFooters(content) { function validateFile(filePath) { const content = fs.readFileSync(filePath, "utf8"); const frontmatter = extractFrontmatter(content); - const footers = extractFooters(content); + const category = inferCategory(filePath, frontmatter); + const footerMatches = countFooterSignatureMatches(content, footerConfig); + const footerHitCounts = footerMatches.filter(({ count }) => count > 0); const fileViolations = []; - // Check category requirement - if ( - footerConfig.validation_rules.require_category_in_frontmatter && - !frontmatter?.category - ) { + if (!category) { + return fileViolations; + } + + // Check for duplicate footers + const duplicateMatch = footerMatches.find(({ count }) => count > 1); + if (duplicateMatch) { fileViolations.push({ - type: "missingCategory", + type: "duplicateFooters", file: filePath, - message: 'Document missing "category" field in frontmatter', + message: `Found repeated footer signature: ${duplicateMatch.signature}`, + count: duplicateMatch.count, }); } - // Check for duplicate footers - if (footers.length > 1) { - const seen = new Set(); - for (const footer of footers) { - if (seen.has(footer)) { - fileViolations.push({ - type: "duplicateFooters", - file: filePath, - message: `Found ${footers.length} footer blocks; ${footers.filter((f) => f === footer).length} are duplicates`, - count: footers.length, - }); - break; - } - seen.add(footer); - } + // Check for missing footer + if ( + footerConfig.validation_rules.require_footer_in_document && + category && + footerHitCounts.length === 0 + ) { + fileViolations.push({ + type: "missingFooters", + file: filePath, + message: `Document is missing a branded footer for category '${category}'`, + category, + }); } // Check for multiple footers per document if ( !footerConfig.validation_rules.allow_multiple_footers_per_document && - footers.length > 1 + footerHitCounts.length > 1 ) { fileViolations.push({ type: "multipleFootersPerDoc", file: filePath, - message: `Document has ${footers.length} footers; only 1 allowed`, - count: footers.length, + message: `Document has ${footerHitCounts.length} footer signatures; only 1 allowed`, + count: footerHitCounts.length, }); } @@ -217,8 +375,10 @@ function removeDuplicateFooters(content) { function main() { console.log("๐Ÿ” Scanning for Markdown files...\n"); - const files = findMarkdownFiles(); - console.log(`๐Ÿ“„ Found ${files.length} Markdown files\n`); + const files = changedOnly ? getChangedMarkdownFiles() : findMarkdownFiles(); + console.log( + `๐Ÿ“„ Found ${files.length} ${changedOnly ? "changed " : ""}Markdown files\n`, + ); let totalViolations = 0; @@ -241,8 +401,8 @@ function main() { violations.duplicateFooters.push({ file, ...v }); } else if (v.type === "multipleFootersPerDoc") { violations.multipleFooersPerDoc.push({ file, ...v }); - } else if (v.type === "missingCategory") { - violations.missingCategory.push({ file, ...v }); + } else if (v.type === "missingFooters") { + violations.missingFooters.push({ file, ...v }); } }); } @@ -253,7 +413,7 @@ function main() { console.log( `Multiple footers: ${violations.multipleFooersPerDoc.length}`, ); - console.log(`Missing category: ${violations.missingCategory.length}`); + console.log(`Missing footers: ${violations.missingFooters.length}`); console.log(`Total violations: ${totalViolations}\n`); // Report to file if requested @@ -269,13 +429,24 @@ function main() { const filesToFix = [ ...violations.duplicateFooters, ...violations.multipleFooersPerDoc, + ...violations.missingFooters, ].map((v) => v.file); const uniqueFiles = [...new Set(filesToFix)]; for (const file of uniqueFiles) { const content = fs.readFileSync(file, "utf8"); - const fixed = removeDuplicateFooters(content); + let fixed = removeDuplicateFooters(content); + + const frontmatter = extractFrontmatter(fixed); + const footerCategory = inferCategory(file, frontmatter); + const footerTemplate = footerCategory + ? getDefaultFooterForCategory(footerCategory) + : null; + + if (footerTemplate) { + fixed = replaceFooterTail(fixed, footerTemplate); + } // Create backup const backupFile = `${file}.backup`; diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 02990e750..befc9e6b7 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -62,6 +62,7 @@ jobs: - run: npm run validate:json - run: npm run validate:issue-fields - run: npm run validate:retired-doc-links + - run: npm run validate:footers -- --changed-only - run: npm run validate:frontmatter:changed -- --base ${{ github.event.merge_group.base_sha || github.event.pull_request.base.sha || github.event.before }} --head ${{ github.event.merge_group.head_sha || github.event.pull_request.head.sha || github.sha }} # Composite status check: ensures all checks pass before merge diff --git a/CHANGELOG.md b/CHANGELOG.md index 539f29ff8..238740a5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **Automation governance for Dependabot PRs and branding footers** โ€” Stopped the metadata sync flow from auto-creating a milestone for each Dependabot PR, switched footer detection to the canonical branding config with tail-aware matching, and backfilled branded markdown footers across the repository. Updated the related CI, workflow, and documentation surfaces to keep the behaviour durable. ([#1010](https://github.com/lightspeedwp/.github/issues/1010), [#1013](https://github.com/lightspeedwp/.github/pull/1013)) + - **Metadata governance automation for issues and pull requests** โ€” Added and hardened the GitHub automation that assigns project items, milestones, assignees, issue/PR relationships, project field values, and labelling behaviour for new issues and pull requests. Also updated the related docs, workflow guards, and test coverage to match the current codebase. ([#974](https://github.com/lightspeedwp/.github/pull/974)) - **Community health audit โ€” PR templates, governance docs, and README alignment** โ€” Completed a comprehensive audit of all community health files: updated WCAG version references from 2.1 to 2.2 AA in `pr_bug.md`, `pr_chore.md`, `pr_ci.md`, and `pr_dep_update.md`; added 15 missing branch-prefix rows to the default `pull_request_template.md` quick-selector table to align with `PULL_REQUEST_TEMPLATE/config.yml`; expanded `AGENTS.md` issue template list from 10 to 23 entries and added Saved Replies section; expanded `CLAUDE.md` issue template list to match; fixed template count, range, and parity note in `.github/custom-instructions.md`; completely rewrote `.github/workflows/README.md` with an accurate inventory of all 27 real workflows (removed 4 phantom workflow references); updated `.github/README.md` version date; added 20 missing files to `.github/SAVED_REPLIES/README.md`; added template index table to `.github/ISSUE_TEMPLATE/README.md`; replaced generic category list with the 9 actual YAML file inventory in `.github/DISCUSSION_TEMPLATE/README.md`; updated `docs/ISSUE_CREATION_GUIDE.md` to 25-template parity and corrected label values. ([#966](https://github.com/lightspeedwp/.github/pull/966)) diff --git a/config/footers.config.yaml b/config/footers.config.yaml index ef06a691d..532b0b5ad 100644 --- a/config/footers.config.yaml +++ b/config/footers.config.yaml @@ -338,4 +338,5 @@ validation_rules: max_footer_lines: 5 max_duplicates_allowed: 0 # Zero duplicates allowed require_category_in_frontmatter: true + require_footer_in_document: true allow_multiple_footers_per_document: false # ONE footer per document maximum diff --git a/docs/AUTOMATION.md b/docs/AUTOMATION.md index 30de06fd8..4dfefa679 100644 --- a/docs/AUTOMATION.md +++ b/docs/AUTOMATION.md @@ -2,7 +2,7 @@ file_type: "documentation" title: "Automation & Workflows" description: "Strategy, governance, and workflow documentation for GitHub automation in LightSpeed repositories." -version: "v1.0.5" +version: "v1.0.6" last_updated: "2026-06-19" owners: ["LightSpeedWP Team"] tags: ["automation", "workflows", "governance", "agents"] @@ -72,7 +72,8 @@ If your project allows hotfixes directly to `main`, ensure validation workflows | --- | --- | --- | --- | | **labeling.yml** | develop | Unified labelling, status/priority, and type automation | labeling.agent.js | | **changelog-validate.yml** | develop | Enforce changelog requirements and PR labelling standards | changelog validation | -| **metadata-governance.yml** | issues / pull_request_target | Apply assignee, milestone, and relationship metadata | issue-pr-metadata.cjs | +| **metadata-governance.yml** | issues / pull_request_target | Apply assignee and relationship metadata; inherit milestones only when explicitly linked | issue-pr-metadata.cjs | +| **validate-footers** | validation step | Enforce branded footers on changed Markdown and catch missing footer drift | `.github/scripts/validate-footers.js` | | **planner.yml** | develop | Post merge-readiness checklists and exit criteria to PRs | planner.agent.js | | **reviewer.yml** | develop | Automated PR review and quality feedback | reviewer.agent.js | | **project-meta-sync.yml** | issues / pull_request | Sync project board fields from labels, title/body fallbacks, and kickoff metadata | derive-project-fields.cjs | @@ -156,7 +157,8 @@ Issue types are defined once in `.github/issue-types.yml` and used by both: ### Metadata Governance - Issues and PRs are assigned to the repository project automatically on create. -- New issues and PRs should receive an assignee, milestone, and relationship metadata where relevant. +- New issues and PRs should receive an assignee and relationship metadata where relevant. +- Milestones are only applied when explicitly inherited from linked work or already present; the automation no longer invents a milestone per issue or PR. - `Start date` and `Target date` remain empty until the item is explicitly marked `status:ready` or `status:in-progress`. - Template enforcement must flag incomplete issues, apply `status:needs-more-info`, and keep the item open. @@ -264,3 +266,7 @@ All configuration files are validated: - [Agent Specifications](../.github/agents/) --- + +*Maintained by the ๐Ÿค– LightSpeedWP Automation Team* + +[๐Ÿ“‹ AI Governance](https://github.com/lightspeedwp/.github/blob/develop/docs/AUTOMATION.md) ยท [๐Ÿง  Agents](https://github.com/lightspeedwp/.github/blob/develop/AGENTS.md) ยท [๐Ÿ“ž Contact](https://lightspeedwp.agency/contact) diff --git a/docs/BRANCHING_STRATEGY.md b/docs/BRANCHING_STRATEGY.md index a01ffcb52..57105e11e 100644 --- a/docs/BRANCHING_STRATEGY.md +++ b/docs/BRANCHING_STRATEGY.md @@ -2,10 +2,10 @@ file_type: documentation title: Org-wide Git Branching Strategy description: Canonical branch naming, protection, merge discipline, and automation rules for LightSpeedWP repositories. -last_updated: '2026-06-09' +last_updated: '2026-06-19' owners: - LightSpeed Team -version: v1.5 +version: v1.5.1 status: active stability: stable domain: governance @@ -389,3 +389,7 @@ reused so automation stays predictable. - Add cheat sheets and workflow diagrams to internal wiki. --- + +*Built by ๐Ÿงฑ LightSpeedWP with โ˜•, ๐Ÿš€, and open-source spirit!* + +[๐Ÿ”— Website](https://lightspeedwp.agency) ยท [๐Ÿ“ง Contact](https://lightspeedwp.agency/contact) ยท [๐Ÿ‘ฅ Contributors](https://github.com/lightspeedwp/.github/graphs/contributors) diff --git a/docs/BRANDING_AGENT_USAGE.md b/docs/BRANDING_AGENT_USAGE.md index 1047b1e56..e09d8a707 100644 --- a/docs/BRANDING_AGENT_USAGE.md +++ b/docs/BRANDING_AGENT_USAGE.md @@ -2,17 +2,17 @@ title: "Unified Branding Agent โ€” Usage Guide" description: "Complete guide for using the unified branding agent to apply category-aware branding to documents" file_type: "documentation" -version: "1.0.0" +version: "1.0.1" created_date: "2026-05-29" -last_updated: "2026-06-03" +last_updated: "2026-06-19" category: "docs" owners: ["LightSpeedWP Automation Team"] --- # Unified Branding Agent โ€” Usage Guide -**Document Version**: 1.0.0 -**Last Updated**: 2026-05-29 +**Document Version**: 1.0.1 +**Last Updated**: 2026-06-19 **Related Issues**: #555 (Wave 4E Implementation) --- @@ -21,7 +21,7 @@ owners: ["LightSpeedWP Automation Team"] The **Unified Branding Agent** automates the application of category-aware branding (headers, footers, and badges) to Markdown documents across the repository. -It reads from the Wave 4D configuration (`config/footers.config.yaml` and `.schemas/branding-schema.json`) and applies consistent branding rules based on: +It reads from the canonical branding configuration (`config/footers.config.yaml` and `.schemas/branding-schema.json`) with a legacy fallback for older automation paths, and applies consistent branding rules based on: - **Document category** (explicitly in frontmatter or inferred from file path) - **Predefined footer templates** per category @@ -284,6 +284,16 @@ footers: *Built by ๐Ÿงฑ LightSpeedWP* ``` +### Validation + +The repository validator now treats missing branded footers in changed Markdown as a failure and can backfill them from the category default via `npm run validate:footers -- --fix`. + +Run the validator after bulk edits or agent changes to make sure changed docs are not left unbranded: + +```bash +npm run validate:footers +``` + ### `.schemas/branding-schema.json` Comprehensive JSON Schema for validation and IDE autocomplete. diff --git a/docs/BRANDING_CONFIG_SPEC.md b/docs/BRANDING_CONFIG_SPEC.md index a11d71e99..f3dfda4c4 100644 --- a/docs/BRANDING_CONFIG_SPEC.md +++ b/docs/BRANDING_CONFIG_SPEC.md @@ -2,17 +2,17 @@ title: "Branding Configuration Specification" description: "Complete specification for category-aware branding, frontmatter validation, and header/footer management" file_type: "documentation" -version: "1.0.0" +version: "1.0.1" created_date: "2026-05-29" -last_updated: "2026-06-03" +last_updated: "2026-06-19" category: "governance" owners: ["LightSpeedWP Automation Team"] --- # Branding Configuration Specification -**Document Version**: 1.0.0 -**Last Updated**: 2026-05-29 +**Document Version**: 1.0.1 +**Last Updated**: 2026-06-19 **Related Issues**: #33 (Parent Spec), #554 (Schema Implementation), #555 (Agent Implementation), #556 (Remediation) --- @@ -413,6 +413,8 @@ The configuration is used in: - CodeRabbit configuration - Linting and validation scripts +Missing branded footers are now treated as a validation failure via `npm run validate:footers`, which can also backfill the category default when run with `--fix`. + ### 9.3 For Documentation Generation Category-aware tools use configuration to: @@ -529,6 +531,6 @@ governance-footer: --- -*Built by ๐Ÿงฑ LightSpeedWP with โ˜•, ๐Ÿš€, and open-source spirit!* +โš–๏ธ *Governance policy maintained by LightSpeedWP* -[๐Ÿ”— Website](https://lightspeedwp.agency) ยท [๐Ÿ“ง Contact](https://lightspeedwp.agency/contact) ยท [๐Ÿ‘ฅ Contributors](https://github.com/lightspeedwp/.github/graphs/contributors) +[๐Ÿ“‹ Full Governance Docs](https://github.com/lightspeedwp/.github/blob/develop/AGENTS.md) ยท [๐Ÿ”’ Security](https://github.com/lightspeedwp/.github/blob/develop/SECURITY.md) diff --git a/docs/FOOTER_REMEDIATION_GUIDE.md b/docs/FOOTER_REMEDIATION_GUIDE.md index d20ca11a1..455775216 100644 --- a/docs/FOOTER_REMEDIATION_GUIDE.md +++ b/docs/FOOTER_REMEDIATION_GUIDE.md @@ -1,7 +1,7 @@ --- title: "Footer Remediation Guide" description: "How to identify, fix, and prevent duplicate footers in Markdown files" -version: "v1.0.1" +version: "v1.0.2" created_date: "2026-05-28" type: "guide" category: "governance" @@ -54,13 +54,13 @@ Currently, footers are: 1. **`schema/footer-config.schema.json`** โ€” JSON Schema defining valid footer structure 2. **`config/footers.config.yaml`** โ€” Predefined footer library with 13 category-specific templates -3. **`.github/scripts/validate-footers.js`** โ€” Validation script to detect and fix violations +3. **`.github/scripts/validate-footers.js`** โ€” Validation script to detect, fix, and backfill violations ### Key Principles โœ… **One footer per document** โ€” Validation enforces this โœ… **Predefined templates** โ€” Choose from validated, category-specific footers -โœ… **Schema validation** โ€” Prevent duplicates and invalid footers +โœ… **Schema validation** โ€” Prevent duplicates, missing footers, and invalid footers โœ… **Automation-ready** โ€” Footer insertion can be automated via agent --- @@ -342,7 +342,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - - run: node .github/scripts/validate-footers.js + - run: npm run validate:footers -- --changed-only ``` This ensures every PR is validated before merge. @@ -453,10 +453,10 @@ Action: Replace and note in commit 4. โฌœ **Validate changes** โ€” Confirm no violations remain 5. โฌœ **Commit & push** โ€” Create PR with fixes 6. โฌœ **Set up CI validation** โ€” Add footer-validation.yml workflow -7. โฌœ **Plan automation** โ€” Implement branding meta agent (issue #33) +7. โฌœ **Plan automation** โ€” Implement branding meta agent and footer validation hardening (issue #33) --- -**Created**: 2026-05-28 -**Status**: Active guidance for footer remediation -**Related issues**: #33 (branding meta agent), #46 (templates), #49 (schema) +โš–๏ธ *Governance policy maintained by LightSpeedWP* + +[๐Ÿ“‹ Full Governance Docs](https://github.com/lightspeedwp/.github/blob/develop/AGENTS.md) ยท [๐Ÿ”’ Security](https://github.com/lightspeedwp/.github/blob/develop/SECURITY.md) diff --git a/docs/ISSUE_CREATION_GUIDE.md b/docs/ISSUE_CREATION_GUIDE.md index 4a74e3c8f..b8a433270 100644 --- a/docs/ISSUE_CREATION_GUIDE.md +++ b/docs/ISSUE_CREATION_GUIDE.md @@ -2,7 +2,7 @@ title: GitHub Issue Creation Guide description: How to create well-formed issues, select templates, and trigger automation file_type: documentation -version: "1.0.6" +version: "1.0.7" created_date: "2026-05-31" last_updated: "2026-06-19" author: Claude Code @@ -128,7 +128,8 @@ Click **Submit new issue**. Your issue is now visible to the team and ready for - Issue outcomes are still driven by the body content and canonical labels, so keep the template complete and specific. - Incomplete templates are flagged and labelled for correction rather than closed. -- Metadata governance now handles the project item, assignee, milestone, and relationship metadata automatically when it can infer them safely. +- Metadata governance now handles the project item, assignee, and relationship metadata automatically when it can infer them safely. +- Milestones are no longer created per issue or PR by default; use a shared milestone deliberately when batching related work. ### AI / Automation Issue Creation @@ -222,3 +223,7 @@ When creating an issue: - [Issue Types](./ISSUE_TYPES.md) --- + +*Built by ๐Ÿงฑ LightSpeedWP with โ˜•, ๐Ÿš€, and open-source spirit!* + +[๐Ÿ”— Website](https://lightspeedwp.agency) ยท [๐Ÿ“ง Contact](https://lightspeedwp.agency/contact) ยท [๐Ÿ‘ฅ Contributors](https://github.com/lightspeedwp/.github/graphs/contributors) diff --git a/package.json b/package.json index b5823d45e..2e832e981 100644 --- a/package.json +++ b/package.json @@ -98,6 +98,7 @@ "validate:frontmatter:changed": "node scripts/validation/validate-frontmatter-freshness.js", "validate:branch-name": "node scripts/validation/validate-branch-name.js", "validate:workflows": "node scripts/validation/validate-workflows.js", + "validate:footers": "node .github/scripts/validate-footers.js", "validate:labeling-configs": "node scripts/validation/validate-labeling-configs.cjs", "validate:retired-doc-links": "node scripts/validation/validate-retired-doc-links.cjs", "validate:issue-fields": "node scripts/validation/validate-issue-fields.cjs", @@ -110,7 +111,7 @@ "validate:readme-links": "node scripts/validation/validate-readme-links.js", "validate:wceu:phase1": "node scripts/verify-wceu-readiness.js", "validate:wceu:phase2": "node scripts/validate-phase2-completion.js", - "validate:all": "npm run validate:branch-name && npm run validate:structure && npm run validate:skill-manifests && npm run validate:plugins && npm run validate:links && npm run validate:frontmatter && npm run validate:agents && npm run validate:issue-fields && npm run validate:workflows && npm run validate:memory && npm run validate:mermaid && npm run validate:json:all", + "validate:all": "npm run validate:branch-name && npm run validate:structure && npm run validate:skill-manifests && npm run validate:plugins && npm run validate:links && npm run validate:frontmatter && npm run validate:agents && npm run validate:issue-fields && npm run validate:workflows && npm run validate:footers && npm run validate:memory && npm run validate:mermaid && npm run validate:json:all", "eslint:delta:wave-1": "node scripts/compute-eslint-delta-wave-1.js", "sync-version": "node scripts/sync-version.js", "metrics:run": "node metrics/frontmatter-metrics.js", diff --git a/scripts/agents/includes/__tests__/header-footer.test.js b/scripts/agents/includes/__tests__/header-footer.test.js new file mode 100644 index 000000000..c59250287 --- /dev/null +++ b/scripts/agents/includes/__tests__/header-footer.test.js @@ -0,0 +1,59 @@ +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +describe("header-footer", () => { + test("ensureFooter appends the canonical docs footer", async () => { + const { ensureFooter } = await import("../header-footer.js"); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "branding-footer-")); + const filePath = path.join(tmpDir, "branching-strategy.md"); + + fs.writeFileSync( + filePath, + [ + "---", + "title: Org-wide Git Branching Strategy", + "description: Canonical branch naming and merge discipline.", + "file_type: documentation", + "---", + "", + "# Org-wide Git Branching Strategy", + "", + "Primary operations reference.", + "", + ].join("\n"), + ); + + expect(ensureFooter(filePath, { category: "docs" })).toBe(true); + + const output = fs.readFileSync(filePath, "utf8"); + expect(output).toContain("Built by ๐Ÿงฑ LightSpeedWP with โ˜•, ๐Ÿš€, and open-source spirit!"); + expect(output).toContain("https://lightspeedwp.agency/contact"); + }); + + test("ensureFooter ignores footer text mentioned in the body", async () => { + const { ensureFooter } = await import("../header-footer.js"); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "branding-footer-body-")); + const filePath = path.join(tmpDir, "branding-note.md"); + + fs.writeFileSync( + filePath, + [ + "---", + "title: Branding note", + "description: Body text mentions the footer phrase.", + "file_type: documentation", + "---", + "", + "This note mentions Built by ๐Ÿงฑ LightSpeedWP with โ˜•, ๐Ÿš€, and open-source spirit! in the body.", + "", + ].join("\n"), + ); + + expect(ensureFooter(filePath, { category: "docs" })).toBe(true); + + const output = fs.readFileSync(filePath, "utf8"); + const footerMatches = output.match(/Built by ๐Ÿงฑ LightSpeedWP with โ˜•, ๐Ÿš€, and open-source spirit!/g) || []; + expect(footerMatches).toHaveLength(2); + }); +}); diff --git a/scripts/agents/includes/__tests__/issue-pr-metadata.test.js b/scripts/agents/includes/__tests__/issue-pr-metadata.test.js index 3306f281a..1e975ddbc 100644 --- a/scripts/agents/includes/__tests__/issue-pr-metadata.test.js +++ b/scripts/agents/includes/__tests__/issue-pr-metadata.test.js @@ -1,5 +1,4 @@ const { - deriveMilestoneTitle, extractIssueRefs, formatRelationshipComment, getItemFromEvent, @@ -8,19 +7,6 @@ const { } = require("../issue-pr-metadata.cjs"); describe("issue-pr-metadata helpers", () => { - test("derives an assignee-ready milestone title from a release issue", () => { - expect( - deriveMilestoneTitle( - "Release v0.6.0 โ€” Community Health, Governance Docs, and Meta Agent Foundations", - ), - ).toBe( - "Release v0.6.0 โ€” Community Health, Governance Docs, and Meta Agent Foundations".slice( - 0, - 80, - ), - ); - }); - test("extracts linked issue references from PR wording", () => { expect(extractIssueRefs("Fixes #965\nRelated to #42")).toEqual([965, 42]); }); @@ -109,8 +95,7 @@ describe("issue-pr-metadata helpers", () => { expect(extractIssueRefs(item.body)).toEqual([965]); }); - test("syncs issue metadata with requester assignee and milestone fallback", async () => { - const createdMilestones = []; + test("syncs issue metadata with requester assignee and leaves milestone unset when no grouped milestone exists", async () => { const updatedIssues = []; const addedAssignees = []; const createdComments = []; @@ -127,11 +112,6 @@ describe("issue-pr-metadata helpers", () => { rest: { issues: { listMilestones: jest.fn(), - createMilestone: jest.fn().mockImplementation(async ({ title }) => { - const milestone = { number: 77, title }; - createdMilestones.push(milestone); - return { data: milestone }; - }), addAssignees: jest.fn().mockImplementation(async (args) => { addedAssignees.push(args); }), @@ -176,22 +156,78 @@ describe("issue-pr-metadata helpers", () => { expect(result).toMatchObject({ assignee: "ashleyshaw", - milestone: "Release v0.6.0 โ€” Community Health, Governance Docs, and Meta Agent Foundations".slice( - 0, - 80, - ), + milestone: "", }); expect(addedAssignees).toHaveLength(1); expect(addedAssignees[0]).toMatchObject({ issue_number: 968, assignees: ["ashleyshaw"], }); - expect(createdMilestones).toHaveLength(1); - expect(updatedIssues).toHaveLength(1); + expect(updatedIssues).toHaveLength(0); expect(createdComments).toHaveLength(1); expect(createdComments[0].body).toContain("Linked issues/PRs: #965"); }); + test("skips milestone assignment for Dependabot pull requests", async () => { + const updatedIssues = []; + + const github = { + paginate: jest.fn().mockResolvedValue([]), + graphql: jest.fn().mockResolvedValue({ + repository: { + issue: { + id: "ISSUE-ID", + }, + }, + }), + rest: { + issues: { + listMilestones: jest.fn(), + addAssignees: jest.fn(), + update: jest.fn().mockImplementation(async (args) => { + updatedIssues.push(args); + }), + listComments: jest.fn().mockResolvedValue([]), + createComment: jest.fn(), + updateComment: jest.fn(), + get: jest.fn().mockResolvedValue({ + data: { milestone: null }, + }), + }, + }, + }; + + const result = await syncItemMetadata({ + github, + owner: "lightspeedwp", + repo: ".github", + event: { + pull_request: { + number: 101, + node_id: "MDExOlB1bGxSZXF1ZXN0", + title: "Bump lodash from 4.17.20 to 4.17.21", + body: "", + labels: [], + milestone: null, + user: { login: "dependabot[bot]" }, + }, + }, + config: { + defaults: { + issue: { + assignee: "ashleyshaw", + }, + }, + }, + }); + + expect(result).toMatchObject({ + assignee: "dependabot[bot]", + milestone: "", + }); + expect(updatedIssues).toHaveLength(0); + }); + test("syncs pull request metadata and inherits milestone from linked issue", async () => { const updatedIssues = []; const createdComments = []; diff --git a/scripts/agents/includes/header-footer.js b/scripts/agents/includes/header-footer.js index 9f2a28246..78474c501 100644 --- a/scripts/agents/includes/header-footer.js +++ b/scripts/agents/includes/header-footer.js @@ -11,13 +11,29 @@ import path from "path"; import yaml from "js-yaml"; /** - * Load footer configuration from footers.yml + * Resolve the canonical footer configuration path. + */ +function resolveFooterConfigPath() { + const explicitPath = process.env.BRANDING_FOOTER_CONFIG?.trim(); + const projectRoot = process.cwd(); + const candidatePaths = [ + explicitPath ? path.resolve(explicitPath) : null, + path.join(projectRoot, "config/footers.config.yaml"), + path.join(projectRoot, ".github/automation/footers.yml"), + ].filter(Boolean); + + return candidatePaths.find((candidatePath) => fs.existsSync(candidatePath)) || null; +} + +/** + * Load footer configuration from the canonical branding config. */ function loadFooterConfig() { - const configPath = path.join(process.cwd(), ".github/automation/footers.yml"); - if (!fs.existsSync(configPath)) { + const configPath = resolveFooterConfigPath(); + if (!configPath) { return null; } + const content = fs.readFileSync(configPath, "utf-8"); return yaml.load(content); } @@ -33,6 +49,61 @@ const DEFAULT_FOOTERS = [ "_Docs signed by ๐Ÿค– Copilot for LightSpeedWP โ€“ always fresh!_", ]; +const DEFAULT_FOOTER_SIGNATURES = [ + "_Maintained with", + "_Built by", + "_Have questions?", + "_This page brought to you by", + "_Docs signed by", + "Made with ๐Ÿ’š by LightSpeedWP", +]; + +function getCanonicalFooterTemplates() { + const config = loadFooterConfig(); + if (!config?.categories || !config?.footers) { + return []; + } + + return Object.entries(config.categories) + .map(([, categoryConfig]) => config.footers[categoryConfig?.default_footer]) + .filter((footer) => typeof footer?.template === "string") + .map((footer) => footer.template.trimEnd()); +} + +function getFooterSignatures() { + const canonicalTemplates = getCanonicalFooterTemplates(); + const canonicalSignatures = canonicalTemplates + .map((template) => template.split("\n").map((line) => line.trim()).find(Boolean)) + .filter(Boolean); + + return [...new Set([...DEFAULT_FOOTER_SIGNATURES, ...canonicalSignatures])]; +} + +function stripFrontmatter(content) { + return content.replace(/^---\n[\s\S]*?\n---\n?/, ""); +} + +function extractFooterTail(content) { + const stripped = stripFrontmatter(content); + const lastSeparatorIndex = stripped.lastIndexOf("\n---\n"); + + if (lastSeparatorIndex === -1) { + return ""; + } + + return stripped.slice(lastSeparatorIndex + 1).trim(); +} + +function buildFooterBlock(footerText) { + return `\n---\n\n${footerText.trimEnd()}\n`; +} + +function hasKnownFooter(content) { + const tail = extractFooterTail(content); + const signatures = getFooterSignatures(); + return signatures.some((signature) => tail.includes(signature)); +} + /** * Get footer phrases for a given category * @param {string} category - Category from front matter or 'default' @@ -40,18 +111,28 @@ const DEFAULT_FOOTERS = [ */ function getFooterPhrases(category = "default") { const config = loadFooterConfig(); - if (!config || !config.categories) { + if (!config || !config.categories || !config.footers) { return DEFAULT_FOOTERS; } - // Try to get category-specific footers - if (config.categories[category] && config.categories[category].phrases) { - return config.categories[category].phrases; + const categoryConfig = + config.categories[category] || + config.categories.docs || + config.categories.readme || + null; + + const footerId = categoryConfig?.default_footer; + const footerTemplate = footerId && config.footers[footerId]?.template; + if (typeof footerTemplate === "string") { + return [footerTemplate.trimEnd()]; } - // Fall back to default category - if (config.categories.default && config.categories.default.phrases) { - return config.categories.default.phrases; + // Fall back to a known default if the config is partial or missing the category mapping. + if (categoryConfig?.allowed_footers?.length) { + const fallbackFooter = config.footers[categoryConfig.allowed_footers[0]]?.template; + if (typeof fallbackFooter === "string") { + return [fallbackFooter.trimEnd()]; + } } return DEFAULT_FOOTERS; @@ -156,17 +237,26 @@ function ensureFooter(file, options = {}) { let content = fs.readFileSync(file, "utf-8"); const nextFooter = getRandomFooter(category, seed); + const footerBlock = buildFooterBlock(nextFooter); - if (FOOTER_REGEX.test(content)) { - content = content.replace(FOOTER_REGEX, nextFooter); - fs.writeFileSync(file, content); - return true; - } + if (hasKnownFooter(content)) { + const frontmatterStripped = stripFrontmatter(content); + const lastSeparatorIndex = frontmatterStripped.lastIndexOf("\n---\n"); - if (!content.endsWith("\n")) { - content += "\n"; + if (lastSeparatorIndex !== -1) { + const prefix = content.slice(0, content.length - frontmatterStripped.length); + const bodyWithoutFooter = frontmatterStripped.slice(0, lastSeparatorIndex); + content = `${prefix}${bodyWithoutFooter.replace(/\s+$/, "")}${footerBlock}`; + } else { + content = `${content.replace(/\s+$/, "")}${footerBlock}`; + } + } else { + if (!content.endsWith("\n")) { + content += "\n"; + } + content += footerBlock.trimStart(); } - content += "\n" + nextFooter + "\n"; + fs.writeFileSync(file, content); return true; } diff --git a/scripts/agents/includes/issue-pr-metadata.cjs b/scripts/agents/includes/issue-pr-metadata.cjs index 0da6ec551..08b89dfff 100644 --- a/scripts/agents/includes/issue-pr-metadata.cjs +++ b/scripts/agents/includes/issue-pr-metadata.cjs @@ -25,11 +25,6 @@ function normaliseTitle(title) { .trim(); } -function deriveMilestoneTitle(title) { - const cleaned = normaliseTitle(title); - return cleaned.slice(0, 80) || "Untriaged"; -} - function getActorLogin(event) { return ( event?.sender?.login || @@ -192,33 +187,6 @@ function buildAssigneeCandidates(item, defaultAssignee) { return candidates; } -async function findOpenOrRecentMilestone(github, owner, repo, title) { - const milestones = await github.paginate(github.rest.issues.listMilestones, { - owner, - repo, - state: "all", - per_page: 100, - }); - - return milestones.find((milestone) => milestone.title === title) || null; -} - -async function ensureMilestone(github, owner, repo, title) { - const existing = await findOpenOrRecentMilestone(github, owner, repo, title); - if (existing) { - return existing; - } - - const created = await github.rest.issues.createMilestone({ - owner, - repo, - title, - state: "open", - }); - - return created.data; -} - async function assignIssue(github, owner, repo, number, candidates) { for (const candidate of candidates) { if (!candidate) continue; @@ -250,6 +218,13 @@ async function updateIssueMilestone(github, owner, repo, number, milestoneNumber }); } +function isDependabotPullRequest(item) { + return ( + item.kind === "pull_request" && + /^(dependabot\[bot\]|app\/dependabot)$/.test(item.author || "") + ); +} + async function resolveLinkedIssueMilestone(github, owner, repo, references) { for (const ref of references) { try { @@ -362,19 +337,19 @@ async function syncItemMetadata({ github, owner, repo, event, config }) { let milestoneSummary = item.milestone?.title || ""; if (!milestoneSummary) { - const linkedMilestone = - item.kind === "pull_request" - ? await resolveLinkedIssueMilestone( - github, - owner, - repo, - [...new Set([...hints.linkedRefs, ...extractIssueRefs(item.body)])], - ) - : null; - const milestoneTitle = linkedMilestone?.title || deriveMilestoneTitle(item.title); - const milestone = linkedMilestone || (await ensureMilestone(github, owner, repo, milestoneTitle)); - await updateIssueMilestone(github, owner, repo, item.number, milestone.number); - milestoneSummary = milestone.title; + const linkedMilestone = await resolveLinkedIssueMilestone( + github, + owner, + repo, + [...new Set([...hints.linkedRefs, ...extractIssueRefs(item.body)])], + ); + + if (linkedMilestone) { + await updateIssueMilestone(github, owner, repo, item.number, linkedMilestone.number); + milestoneSummary = linkedMilestone.title; + } else if (isDependabotPullRequest(item)) { + milestoneSummary = ""; + } } const hasRelationshipMetadata = @@ -472,7 +447,6 @@ module.exports = { addSubIssueRelationship, assignIssue, buildAssigneeCandidates, - deriveMilestoneTitle, extractIssueRefs, formatRelationshipComment, getActorLogin,