diff --git a/.github/workflows/link_check.yaml b/.github/workflows/link_check.yaml index a7166e055e..e2b770773a 100644 --- a/.github/workflows/link_check.yaml +++ b/.github/workflows/link_check.yaml @@ -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: @@ -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: | @@ -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 + report_broken_links: name: Open or update tracking issue needs: link_check @@ -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 diff --git a/.lychee-fragment-excludes.txt b/.lychee-fragment-excludes.txt new file mode 100644 index 0000000000..edb9e869b1 --- /dev/null +++ b/.lychee-fragment-excludes.txt @@ -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/ diff --git a/AGENTS.md b/AGENTS.md index bbed14d07a..7cfa4dc388 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`,