Problem
markdown-link-check.yml validates the whole repository on every pull request. scripts/linting/Markdown-Link-Check.ps1 then spawns one markdown-link-check Node process per file, serially, across roughly 1,150 markdown files. Node cold-start cost dominates before a single link is checked.
Two independent inefficiencies compound:
- No changed-files scoping. The link check is the only lane in
pr-validation.yml without it. frontmatter-validation, yaml-lint, ps-script-analyzer, skill-validation, pester-tests, pytest-tests, and eval-validation all already accept changed-files-only and are called with true.
- Serial, single-file CLI invocations. Each file is an independent process, so the loop is trivially parallelizable but runs one at a time.
Evidence
scripts/linting/Markdown-Link-Check.ps1 invokes the CLI once per file inside foreach ($file in $filesToCheck), with the plugins/ tree and test fixtures already excluded from discovery.
.github/workflows/pr-validation.yml calls the reusable workflow with only soft-fail: true and no scoping inputs.
Issue #2650 reports a full local run covering 4,089 links across 1,122 files, which gives a sense of the surface being re-walked on every pull request.
Suggested approach
Changed-files scoping. Add -ChangedFilesOnly and -BaseBranch to Markdown-Link-Check.ps1, using the existing Get-ChangedFilesFromGit helper in scripts/linting/Modules/LintingHelpers.psm1, and intersect the result with current discovery filters so the plugins/ and fixture exclusions still apply. Add matching changed-files-only and base-branch inputs to the reusable workflow, following the skill-validation.yml and asset-docs-validation.yml pattern, and set fetch-depth: 0 so merge-base resolves.
To avoid losing coverage, weekly-validation.yml should run the same reusable workflow with changed-files-only: false. It does not call the link check today.
Bounded parallelism. Convert the per-file loop to ForEach-Object -Parallel with a configurable throttle. Result aggregation, console output, and Write-CIAnnotation should stay serial so output ordering and annotations are unchanged.
Behavior that must be preserved
- An empty changed set reports a clean run rather than failing; an unscoped scan that finds nothing still fails.
- Malformed junit XML still marks a file failed regardless of the CLI exit code.
- The results JSON schema, step summary, and broken-link annotations are unchanged.
npm run lint:md-links still performs a full local scan by default.
Interaction with #2647
#2647 argues that a green markdown link check carries no information today because the lane is soft-fail: true on pull requests. Scoping the lane to changed files narrows what it inspects per pull request, so the two should be considered together: the weekly full sweep is what preserves whole-repository coverage, and #2647's proposal to move relative-link enforcement into a non-soft-fail lane remains the stronger guarantee.
Further optimizations considered but not proposed here
- Batch multiple files per CLI invocation, removing the remaining process spawns.
- Deduplicate external URLs across files; hosts such as
learn.microsoft.com repeat across hundreds of files and are re-fetched each time.
- Tune
timeout (currently 20s) and add aliveStatusCodes for bot-hostile hosts.
Acceptance
- Pull request validation checks only markdown files changed against the base branch.
- A full-repository sweep runs on a schedule.
- Files are checked concurrently with a configurable, bounded throttle.
- Existing behavior above is preserved and covered by tests.
Problem
markdown-link-check.ymlvalidates the whole repository on every pull request.scripts/linting/Markdown-Link-Check.ps1then spawns onemarkdown-link-checkNode process per file, serially, across roughly 1,150 markdown files. Node cold-start cost dominates before a single link is checked.Two independent inefficiencies compound:
pr-validation.ymlwithout it.frontmatter-validation,yaml-lint,ps-script-analyzer,skill-validation,pester-tests,pytest-tests, andeval-validationall already acceptchanged-files-onlyand are called withtrue.Evidence
scripts/linting/Markdown-Link-Check.ps1invokes the CLI once per file insideforeach ($file in $filesToCheck), with theplugins/tree and test fixtures already excluded from discovery..github/workflows/pr-validation.ymlcalls the reusable workflow with onlysoft-fail: trueand no scoping inputs.Issue #2650 reports a full local run covering 4,089 links across 1,122 files, which gives a sense of the surface being re-walked on every pull request.
Suggested approach
Changed-files scoping. Add
-ChangedFilesOnlyand-BaseBranchtoMarkdown-Link-Check.ps1, using the existingGet-ChangedFilesFromGithelper inscripts/linting/Modules/LintingHelpers.psm1, and intersect the result with current discovery filters so theplugins/and fixture exclusions still apply. Add matchingchanged-files-onlyandbase-branchinputs to the reusable workflow, following theskill-validation.ymlandasset-docs-validation.ymlpattern, and setfetch-depth: 0somerge-baseresolves.To avoid losing coverage,
weekly-validation.ymlshould run the same reusable workflow withchanged-files-only: false. It does not call the link check today.Bounded parallelism. Convert the per-file loop to
ForEach-Object -Parallelwith a configurable throttle. Result aggregation, console output, andWrite-CIAnnotationshould stay serial so output ordering and annotations are unchanged.Behavior that must be preserved
npm run lint:md-linksstill performs a full local scan by default.Interaction with #2647
#2647 argues that a green markdown link check carries no information today because the lane is
soft-fail: trueon pull requests. Scoping the lane to changed files narrows what it inspects per pull request, so the two should be considered together: the weekly full sweep is what preserves whole-repository coverage, and #2647's proposal to move relative-link enforcement into a non-soft-fail lane remains the stronger guarantee.Further optimizations considered but not proposed here
learn.microsoft.comrepeat across hundreds of files and are re-fetched each time.timeout(currently20s) and addaliveStatusCodesfor bot-hostile hosts.Acceptance