Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 134 additions & 3 deletions .github/workflows/link_check.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,16 @@ name: link_check
# checks them with lychee (.lychee.toml), and opens/updates a tracking issue
# when any are broken. Run it on demand from the Actions tab via "Run workflow".
#
# The work is split across two jobs so that the third-party lychee action only
# ever receives a read-only token: link_check runs with `contents: read`, and
# only the separate report_broken_links job is granted `issues: write`.
# It also runs a second, report-only lychee pass over just the fragment-bearing
# URLs (`--include-fragments`), reporting candidate dead anchors to a separate
# tracking issue. That pass never gates: lychee cannot distinguish a dead anchor
# from a dead page by exit code, and anchor output needs too much triage to block
# a build on.
#
# The work is split across three jobs so that the third-party lychee action only
# ever receives a read-only token: link_check runs with `contents: read`, and only
# the separate report_broken_links and report_dead_fragments jobs are granted
# `issues: write`.

on:
schedule:
Expand All @@ -29,6 +36,7 @@ jobs:
contents: read
outputs:
links_broken: ${{ steps.flag.outputs.broken }}
fragments_flagged: ${{ steps.fragflag.outputs.flagged }}
steps:
- name: Free up disk space
run: |
Expand Down Expand Up @@ -102,6 +110,80 @@ jobs:
echo "::error::Broken external links found; see the link-check tracking issue."
exit 1

# --- Fragment check: report-only, and deliberately last ---
#
# Ordering is load-bearing. These steps run *after* the gating upload and the
# failing step, because a step that dies here (an action setup error, which
# `fail: false` does not cover) would otherwise skip the gating upload via the
# implicit success() on later steps -- and report_broken_links, which runs on
# always(), would then fail downloading an artifact that was never produced,
# silently suppressing the broken-link tracking issue. Hence every step below
# carries always(): they must survive the exit 1 above without being able to
# interfere with it.
#
# lychee returns exit 2 for a missing fragment, exactly as it does for a dead
# page, so a fragment failure is indistinguishable from a broken link in one
# run. Measured on this corpus, roughly a third of first-run fragment output
# needs triage, which is too noisy to gate a build. Hence a separate run whose
# exit code is never consulted, reporting to its own tracking issue.
#
# Only fragment-bearing URLs are checked (287 of 14,607 at time of writing), so
# this costs a fraction of the gating run rather than doubling it. `#/...` is
# dropped because those are single-page-app hash routes, not anchors, and
# anchor-only mode ignores `#:~:text=` scroll-to-text fragments by design.
- name: Extract fragment-bearing URLs
id: fragurls
if: always()
run: |
# The build may have failed before producing this; report nothing rather
# than erroring, so a failed build can't masquerade as a fragment problem.
if [ ! -f external-urls.txt ]; then
echo "count=0" >> "$GITHUB_OUTPUT"; exit 0
fi
grep '#' external-urls.txt | grep -v '#/' > fragment-urls.txt || true
count=$(wc -l < fragment-urls.txt)
echo "count=$count" >> "$GITHUB_OUTPUT"
echo "Checking fragments on $count of $(wc -l < external-urls.txt) URLs" >&2

- name: Check fragments (report-only)
id: fragments
if: always() && steps.fragurls.outputs.count != '0'
uses: lycheeverse/lychee-action@8646ba30535128ac92d33dfc9133794bfdd9b411
with:
lycheeVersion: v0.24.2
# Layered on .lychee.toml rather than duplicating it, so throttling, the
# user agent and the accepted status codes cannot drift between the two runs.
args: >-
--config .lychee.toml
--include-fragments=anchor-only
--exclude-file .lychee-fragment-excludes.txt
fragment-urls.txt
output: lychee-fragment-report.md
fail: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Flag whether fragments were found
id: fragflag
if: always()
run: |
# Note this reads the *fragments* step, never the gating one. A dead anchor
# must not be able to fail this workflow.
if [ "${{ steps.fragments.outputs.exit_code }}" != "0" ] \
&& [ -s lychee-fragment-report.md ]; then
echo "flagged=true" >> "$GITHUB_OUTPUT"
else
echo "flagged=false" >> "$GITHUB_OUTPUT"
fi

- name: Upload fragment report
if: always() && steps.fragflag.outputs.flagged == 'true'
uses: actions/upload-artifact@v4
with:
name: lychee-fragment-report
path: lychee-fragment-report.md
if-no-files-found: error

Comment thread
cursor[bot] marked this conversation as resolved.
report_broken_links:
name: Open or update tracking issue
needs: link_check
Expand Down Expand Up @@ -135,3 +217,52 @@ jobs:
else
gh issue create --title "$title" --label link-check --body-file lychee-report.md
fi

report_dead_fragments:
name: Open or update fragment tracking issue
needs: link_check
# Independent of links_broken on purpose. A dead anchor is reported whether or not
# the gating check also failed, and it never blocks anything -- this job's own
# success is not a gate either. Kept as a separate issue from the broken-link one
# so a triage backlog of anchors can't bury an actually-dead page.
if: ${{ always() && needs.link_check.outputs.fragments_flagged == 'true' }}
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
steps:
- name: Download fragment report
uses: actions/download-artifact@v4
with:
name: lychee-fragment-report
path: .

- name: Open or update fragment tracking issue
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
run: |
gh label create link-check-fragments --color FEF2C0 \
--description "Automated anchor/fragment check (report-only)" 2>/dev/null || true

# Expect to triage these rather than treat them as a pass/fail list: a
# missing anchor can mean the anchor moved, the page was restructured, or
# lychee simply cannot see it. Confirm against the served HTML before editing
# a link, and see .lychee-fragment-excludes.txt for the hosts already ruled out.
{
echo "Report-only. These are **candidate** dead anchors, not confirmed defects —"
echo "roughly a third of a first run needs triage. Verify one against the served"
echo "HTML before editing any link."
echo
cat lychee-fragment-report.md
} > fragment-issue-body.md

title="Dead anchors in docs links (report-only)"
existing=$(gh issue list --label link-check-fragments --state open --json number --jq '.[0].number // empty')

if [ -n "$existing" ]; then
gh issue comment "$existing" --body-file fragment-issue-body.md
echo "Updated existing issue #$existing"
else
gh issue create --title "$title" --label link-check-fragments --body-file fragment-issue-body.md
fi
28 changes: 28 additions & 0 deletions .lychee-fragment-excludes.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Hosts excluded from the *fragment* check only (see .github/workflows/link_check.yaml).
#
# These URLs are still checked normally by the gating link check via .lychee.toml —
# this file only suppresses "Cannot find fragment" errors. lychee has no
# fragment-specific exclude (verified, lychee 0.24.2: --exclude drops the URL
# entirely), so the fragment run is a separate invocation that layers this file on
# top of .lychee.toml.
#
# Entries are regexes, one per line. Lines starting with # are comments (verified:
# a "# ..." line is not applied as a regex).
#
# Bar for adding a host: fetch one failing URL and confirm the anchor IS in the
# served HTML. If an independent fetch *also* can't find the anchor, the failure may
# be real -- leave it in the report rather than hiding it.

# Anchor verified present as id="concepts-availability-zones" in the served HTML,
# alongside other server-rendered content ids, yet lychee reports it missing.
# So these are false positives -- but NOT because the docs are JS-rendered, which
# was the original assumption. The actual mechanism is unestablished; if someone
# works it out, narrow or drop this entry.
^https?://docs\.aws\.amazon\.com/

# Same pattern: id="overview-of-cloud-billing-roles-in-cloud-iam" is present in the
# served HTML and lychee still reports it missing. Mechanism unestablished.
# Separately, this host silently moved -- cloud.google.com/... 301s to
# docs.cloud.google.com/... -- so our 16 anchored links here should be repointed at
# the new host, after which this entry may become unnecessary.
^https?://(www\.)?cloud\.google\.com/
10 changes: 10 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,16 @@ around a literal, rewrite the sentence.
- **Cross-references use the relref shortcode**, not markdown paths:
`{{< relref "/operate/rs/clusters/new-cluster-setup" >}}`. A broken relref fails the
build. Link text is descriptive — never "click here" or a bare URL.
**relref validates the page, never the heading** — a relref with a dead `#anchor`
builds clean, so check the anchor against the built page yourself.
- **Prefer an internal relref to an external anchor** where we document the same thing.
The relref is build-checked; an external anchor is not, and upstream restructures
without telling us.
- **Pin GitHub deep links to a commit SHA**, never a moving branch. Line anchors such as
`#L99-L130` drift silently as the file changes, so verify the lines at the SHA you
pin. A link to a repository or file as a whole may stay on the default branch.
- **Never link a scroll-to-text fragment** (`#:~:text=`). It is not a real anchor, it
breaks on any upstream rewording, and no checker can validate it.
- **Preserve shortcodes, frontmatter, and code fences verbatim.** Do not reformat them.
- **Frontmatter**: copy the shape from a sibling page in the same directory rather than
composing one. `title` and `linkTitle` are effectively universal; `description`,
Expand Down
Loading