Skip to content

Commit 54f5ed7

Browse files
heiskrCopilot
andauthored
Make internal link checker blocking on PRs (#62268)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 031a122f-ce90-4f05-8641-f98155428219 Copilot-Session: 15ec74e5-6592-4b40-9482-74d62bd15d7c
1 parent f530144 commit 54f5ed7

6 files changed

Lines changed: 106 additions & 18 deletions

File tree

.github/workflows/link-check-on-pr.yml

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,14 +59,12 @@ jobs:
5959
6060
- name: Check links in changed files
6161
if: steps.changed-files.outputs.any_changed == 'true'
62-
# Work in progress: never fail the PR. The comment is informational only.
63-
continue-on-error: true
6462
env:
6563
FILES_CHANGED: ${{ steps.changed-files.outputs.all_changed_files }}
6664
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
6765
ACTION_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
6866
SHOULD_COMMENT: ${{ secrets.DOCS_BOT_APP_ID != '' }}
69-
FAIL_ON_FLAW: false
67+
FAIL_ON_FLAW: true
7068
ENABLED_LANGUAGES: en
7169
run: npm run check-links-pr
7270

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1 @@
1-
1. Create a new custom security configuration, or edit an existing one. See [AUTOTITLE](/code-security/how-tos/secure-at-scale/configure-organization-security/establish-complete-coverage/create-custom-configuration.
2-
1+
1. Create a new custom security configuration, or edit an existing one. See [AUTOTITLE](/code-security/how-tos/secure-at-scale/configure-organization-security/establish-complete-coverage/create-custom-configuration).

src/links/lib/extract-links.ts

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ import type { Context, Page } from '@/types'
1919
const logger = createLogger(import.meta.url)
2020

2121
// Link patterns for Markdown
22-
const INTERNAL_LINK_PATTERN = /\]\(\/[^)]+\)/g
23-
const AUTOTITLE_LINK_PATTERN = /\[AUTOTITLE\]\(([^)]+)\)/g
22+
const INTERNAL_LINK_PATTERN = /\]\((\/[^()\s]+(?:\([^()]*\)[^()\s]*)*)\)/g
23+
const AUTOTITLE_LINK_PATTERN = /\[AUTOTITLE\]\(([^)\s]+)\)/g
2424
// Handles one level of balanced parentheses in URLs (e.g., Wikipedia links).
2525
// Uses an unrolled loop to avoid catastrophic backtracking on malformed URLs.
2626
const EXTERNAL_LINK_PATTERN = /\]\((https?:\/\/[^()\s]*(?:\([^()]*\)[^()\s]*)*)\)/g
@@ -128,13 +128,27 @@ export function extractLinksFromMarkdown(content: string): LinkExtractionResult
128128

129129
// Strip fenced code blocks to avoid checking example/placeholder URLs
130130
// Replaces non-newline characters with spaces to preserve line numbers and positions
131-
const strippedContent = content.replace(
131+
const withoutFences = content.replace(
132132
/^ {0,3}(`{3,})[^\n]*\n[\s\S]*?^ {0,3}\1\s*$/gm,
133133
(match) => {
134134
return match.replace(/[^\n]/g, ' ')
135135
},
136136
)
137137

138+
// Strip inline code spans too, so example or placeholder links written inside
139+
// backticks (e.g. `[AUTOTITLE](/PATH/TO/PAGE)` in the style guide) aren't
140+
// treated as real links. Markdown never renders links inside inline code.
141+
//
142+
// Per CommonMark, a code span opens with a backtick run of length N and closes
143+
// with a run of exactly N backticks; both runs must be maximal, i.e. not
144+
// adjacent to another backtick. The lookarounds enforce that so mismatched
145+
// runs (e.g. `x`` or ``x```) stay literal instead of masking real text (and a
146+
// real link) between them, which would let a broken link evade the check.
147+
// The content is replaced with spaces to preserve line numbers and positions.
148+
const strippedContent = withoutFences.replace(/(?<!`)(`+)(?!`)[^\n]*?(?<!`)\1(?!`)/g, (match) => {
149+
return match.replace(/[^\n]/g, ' ')
150+
})
151+
138152
// Precompute line-start offsets once so every getLineAndColumn call is O(log L).
139153
const lineOffsets = buildLineOffsets(strippedContent)
140154

@@ -160,14 +174,14 @@ export function extractLinksFromMarkdown(content: string): LinkExtractionResult
160174
// Extract regular internal links
161175
while ((match = INTERNAL_LINK_PATTERN.exec(strippedContent)) !== null) {
162176
// Skip if this is an AUTOTITLE link (already captured)
163-
const fullMatch = match[0]
164177
if (strippedContent.substring(match.index - 10, match.index).includes('AUTOTITLE')) {
165178
continue
166179
}
167180

168181
const { line, column } = getLineAndColumn(lineOffsets, match.index)
169-
// Extract href from ](/path) format
170-
const href = fullMatch.substring(2, fullMatch.length - 1).split('#')[0]
182+
// Extract href from ](/path) format. The destination is captured in group 1,
183+
// which handles balanced parentheses (e.g. asset filenames like `(fr).pdf`).
184+
const href = match[1].split('#')[0]
171185
const text = extractLinkText(strippedContent, match.index)
172186

173187
internalLinks.push({

src/links/lib/link-report.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -151,9 +151,6 @@ ${errors
151151

152152
return `## 🔗 Link Check Results
153153
154-
> [!NOTE]
155-
> **This check is being actively worked on** and may produce false positives. If something looks wrong, you can safely ignore it for now.
156-
157154
${errorSection}${warningSection}${detailsLink}
158155
<!-- link-checker-pr-comment -->`
159156
},

src/links/scripts/check-links-pr.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,9 @@ function filterContentFiles(files: string[]): string[] {
150150
return files.filter((file) => {
151151
// Only check Markdown files in content/ or data/
152152
if (!file.endsWith('.md')) return false
153+
// Skip README.md files. They're developer docs, not published pages, and use
154+
// repo-relative paths (e.g. /src/...) that aren't valid site links.
155+
if (file === 'README.md' || file.endsWith('/README.md')) return false
153156
if (file.startsWith('content/') || file.startsWith('data/')) return true
154157
return false
155158
})
@@ -338,15 +341,24 @@ async function main() {
338341
}
339342
}
340343

341-
// Write artifact for debugging
344+
// Write artifact for debugging. Best-effort: a reporting/API failure must
345+
// never fail the build. Only broken links (below) should fail the PR.
342346
const allFlaws = [...allBrokenLinks, ...allRedirectLinks]
343-
await uploadArtifact('broken-links.json', JSON.stringify(groupBrokenLinks(allFlaws), null, 2))
347+
try {
348+
await uploadArtifact('broken-links.json', JSON.stringify(groupBrokenLinks(allFlaws), null, 2))
349+
} catch (err) {
350+
console.warn('Could not upload broken-links artifact:', err)
351+
}
344352

345-
// Post PR comment if configured
353+
// Post PR comment if configured. Best-effort for the same reason.
346354
const shouldComment = process.env.SHOULD_COMMENT === 'true'
347355
if (shouldComment) {
348356
const actionUrl = process.env.ACTION_RUN_URL
349-
await commentOnPR(allFlaws, actionUrl)
357+
try {
358+
await commentOnPR(allFlaws, actionUrl)
359+
} catch (err) {
360+
console.warn('Could not post PR comment:', err)
361+
}
350362
}
351363

352364
// Exit with error if broken links found

src/links/tests/extract-links.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,74 @@ And [another real link](https://real.example.com/page).
210210
expect(result.externalLinks[1].href).toBe('https://real.example.com/page')
211211
})
212212

213+
test('skips links inside inline code spans', () => {
214+
const content = `
215+
See [a real link](/real/path) for details.
216+
217+
* For links to other pages: \`See [AUTOTITLE](/PATH/TO/PAGE).\`
218+
* With a query: \`See [AUTOTITLE](/path/to/page?tool=TOOLNAME).\`
219+
220+
And [another real link](/another/real/path) here.
221+
`
222+
const result = extractLinksFromMarkdown(content)
223+
224+
expect(result.internalLinks).toHaveLength(2)
225+
expect(result.internalLinks.map((l) => l.href)).toEqual(['/real/path', '/another/real/path'])
226+
})
227+
228+
test('handles inline code and a real link on the same line', () => {
229+
const content = `Use \`[AUTOTITLE](/PLACEHOLDER)\` and then see [the guide](/real/guide).`
230+
const result = extractLinksFromMarkdown(content)
231+
232+
expect(result.internalLinks).toHaveLength(1)
233+
expect(result.internalLinks[0].href).toBe('/real/guide')
234+
})
235+
236+
test('does not mask links when backtick runs are mismatched', () => {
237+
// Per CommonMark, a code span needs equal-length, maximal backtick runs on
238+
// both ends. These lines have mismatched runs, so they are NOT code spans
239+
// and the links between the backticks are real and must be extracted.
240+
const content = [
241+
`A single-open, double-close: \`[one](/real/one)\`\``,
242+
`A double-open, triple-close: \`\`[two](/real/two)\`\`\``,
243+
].join('\n')
244+
const result = extractLinksFromMarkdown(content)
245+
246+
expect(result.internalLinks.map((l) => l.href)).toEqual(['/real/one', '/real/two'])
247+
})
248+
249+
test('still masks links inside valid multi-backtick code spans', () => {
250+
// A matched double-backtick run is a real code span, even when it wraps an
251+
// inner single backtick, so the link inside must be ignored.
252+
const content = `Example: \`\` \`[skip](/placeholder)\` \`\` and see [the guide](/real/guide).`
253+
const result = extractLinksFromMarkdown(content)
254+
255+
expect(result.internalLinks).toHaveLength(1)
256+
expect(result.internalLinks[0].href).toBe('/real/guide')
257+
})
258+
259+
test('captures internal links with balanced parentheses in the path', () => {
260+
const content = `See the [privacy statement (PDF)](/assets/images/help/site-policy/github-privacy-statement(07.22.20)(fr).pdf).`
261+
const result = extractLinksFromMarkdown(content)
262+
263+
expect(result.internalLinks).toHaveLength(1)
264+
expect(result.internalLinks[0].href).toBe(
265+
'/assets/images/help/site-policy/github-privacy-statement(07.22.20)(fr).pdf',
266+
)
267+
})
268+
269+
test('does not let an unclosed link destination span multiple lines', () => {
270+
const content = `
271+
Broken: [AUTOTITLE](/code-security/create-custom-configuration.
272+
1. A following list item with [a real link](/real/target).
273+
`
274+
const result = extractLinksFromMarkdown(content)
275+
276+
// The unclosed link is not extracted, and it does not swallow the real link
277+
// on the next line into a giant multi-line href.
278+
expect(result.internalLinks.map((l) => l.href)).toEqual(['/real/target'])
279+
})
280+
213281
test('handles complex nested brackets', () => {
214282
const content = `
215283
Use the [\`git clone\`](/repositories/cloning) command.

0 commit comments

Comments
 (0)