Skip to content

Broken Internal Links #1368

Broken Internal Links

Broken Internal Links #1368

Workflow file for this run

name: Broken Internal Links
# On PRs, only crawl when a change could actually move an internal link:
# content (blog/course/pages), templates, menu/baseURL config, the data
# records rendered into links, or the checker config itself. A CSS/test/
# workflow-only PR does not trigger this workflow at all. The non-PR events
# carry no paths filter, so they always run and keep master fully covered.
on:
pull_request:
paths:
- 'content/**'
- 'themes/**/layouts/**'
# root-level template overrides move links just like theme layouts
- 'layouts/**'
- 'config/**'
- 'data/**'
- 'lychee.toml'
- 'Rakefile'
# Build/runtime inputs: the crawl runs against a Hugo-built site using
# bundled tooling, so a change here can move generated links too.
- '.github/actions/setup-hugo/**'
- 'Gemfile'
- 'Gemfile.lock'
push:
branches: [ master ]
schedule:
- cron: '0 0 * * *'
workflow_dispatch:
workflow_run:
workflows: ["Sync articles"]
types:
- completed
permissions:
contents: read
concurrency:
group: link-check-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event.pull_request.number != '' }}
defaults:
run:
shell: bash
env:
# Stalled-fetch guard (actions/checkout#2441): abort a fetch that has moved
# <1 KB/s for 30s, so checkout's built-in 3x retry can recover instead of
# the step hanging silently until the job cap kills it. Measurements and
# full rationale live in .github/workflows/_hugo.yml. GIT_CONFIG_* env
# rather than `git config --global`, because checkout overrides HOME before
# it runs git - a global config from an earlier step never reaches it.
GIT_CONFIG_COUNT: '2'
GIT_CONFIG_KEY_0: http.lowSpeedLimit
GIT_CONFIG_VALUE_0: '1000'
GIT_CONFIG_KEY_1: http.lowSpeedTime
GIT_CONFIG_VALUE_1: '30'
jobs:
link_check:
name: Broken Internal Links
# Sync fan-out gate: "Sync articles" completes every 10 min during the
# day, but most runs commit nothing. For workflow_run events, only crawl
# when the sync actually pushed (master head moved past the sha the sync
# ran on). github.sha for workflow_run = current default-branch head.
if: ${{ github.event_name != 'workflow_run' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_sha != github.sha) }}
runs-on: ubuntu-latest
# 15, not 10: html-proofer's image/fragment pass over the full page set
# runs after lychee inside the same job.
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: ruby/setup-ruby@v1
with:
ruby-version: '4.0'
bundler-cache: true
# setup only - rake test:links runs its own production build via
# bin/hugo-build; the composite's default build made this job build
# the whole site TWICE and blow its 10-minute timeout on cold caches.
- uses: ./.github/actions/setup-hugo
with:
build: 'false'
- uses: taiki-e/install-action@v2.86.3
with:
tool: lychee
# html-proofer catches what lychee does not: image src files that are
# referenced but missing from the build, and malformed hash fragments.
# The task existed in the Rakefile but was wired into no workflow,
# hook, or script - dead code reading as coverage (2026-08-07 audit).
# It is non-blocking by its own design; test:links stays blocking.
#
# ONE rake invocation, not two steps: build_for_linkcheck memoizes per
# process, so the pair shares a single production build. Two steps
# would build the whole site twice and blow the job timeout.
- run: bundle exec rake test:links test:html_proofer
# test:links_external existed in the Rakefile and was wired into no
# workflow - the same dead-code-reading-as-coverage defect the comment
# above describes for html_proofer. It is what would have caught the dead
# LangChain cookbook link, the dead langchainai Twitter link and the 404
# blog.langchain.dev source, all of which shipped and were only found by
# hand on 2026-08-28.
#
# Scheduled and manual runs only. External URLs rate-limit and flap, so
# running this per-PR would train everyone to ignore a red check; the
# daily cron catches rot without blocking anyone. Non-blocking by design.
- name: External links (scheduled only, non-blocking)
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
continue-on-error: true
run: bundle exec rake test:links_external