Skip to main content

fix-broken-links

Fix broken links and stale redirect hops reported by the daily link checker. Reads .broken-links.json, triages each entry to a fix strategy (alias, S3 redirect, source edit, exclusion, or out-of-scope issue), and opens an auditable PR. Invoked by the check-links workflow; not user-invocable.

Source facts

Repository
pulumi/docs
Last source activity
September 4, 2026 at 02:59
Detected SKILL.md language
English
Stars
179
Forks
270

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
name
fix-broken-links
description
Fix broken links and stale redirect hops reported by the daily link checker. Reads .broken-links.json, triages each entry to a fix strategy (alias, S3 redirect, source edit, exclusion, or out-of-scope issue), and opens an auditable PR. Invoked by the check-links workflow; not user-invocable.
user-invocable
false
# Fix Broken Links You are fixing the broken links and stale redirect hops found by the daily link checker (`scripts/link-checker/check-links.js`, run against `https://www.pulumi.com`). Work through every reported entry, pick the right strategy for each, open one auditable PR, and file issues for anything that can't be fixed in this repo. ## Input Read `.broken-links.json` from the repo root. Shape: ```json { "generated": "2026-06-02T15:00:00.000Z", "internal": [ { "source": "...", "destination": "...", "reason": "HTTP_404", "hits": 210 }, { "source": "(server logs)", "destination": "...", "reason": "REAL_404", "hits": 94 } ], "external": [{ "source": "...", "destination": "...", "reason": "HTTP_404" }], "redirectHops": [ { "source": "...", "destination": "https://www.pulumi.com/docs/concepts/stacks/", "redirectsTo": "https://www.pulumi.com/docs/iac/concepts/stacks/", "reason": "REDIRECT_HOP" } ] } ``` - `source` — the live page the entry was found **on** (`(server logs)` for real-404 entries — see below). - `destination` — for `internal`/`external`, the URL that failed; for `redirectHops`, the resolved (absolute) URL the stale link points at — the link as authored in the source file may be relative or root-relative, so match on the path (e.g. `/docs/concepts/stacks/`), not the full string. - `reason` — BLC reason code (`HTTP_404`, `HTTP_410`, `ERRNO_ENOTFOUND`, …), `REAL_404` for entries merged from server logs, or `REDIRECT_HOP` for a working-but-stale internal link. - `internal` — `destination` is on `pulumi.com`; `external` — third-party. - `redirectHops` — internal links into a retired path that resolve fine (nothing here is actually broken) but only by way of a redirect to its replacement. Two known-bad classes today: `/docs/concepts/*` → `/docs/iac/concepts/*`, and `/docs/iac/concepts/options/*` → `/docs/iac/concepts/resources/options/*`. Each entry's `redirectsTo` is where it actually lands — that's the correct link to swap in. `destination` carries any URL fragment (`#anchor`) the original link had, but `redirectsTo` does not — the redirect's `Location` header has no fragment — so **re-append `destination`'s fragment to `redirectsTo` when you swap**. Because `destination` differs per anchor, two links into the same page with different anchors show up as separate entries — that's intentional, not a dedupe gap. This list is scoped narrowly to those known-bad hop classes, not general internal redirects (most internal redirects are intentional and not worth flagging). - `hits` — optional, `internal` only: real 404 requests on that destination from server logs over the export window (merged in by `scripts/link-checker/merge-404-signal.py`). The list is sorted by `hits` descending — **work highest-hits first**; entries without `hits` follow. If `internal`, `external`, and `redirectHops` are all empty, do nothing (the workflow won't invoke you in that case, but be defensive). ### Redirect hops (`reason: "REDIRECT_HOP"`) These aren't broken — the crawler followed the redirect and the page loaded fine — so they skip the false-positive verification and deduplication-by-brokenness logic below in one respect: there's no "is it really 404" question to ask. But do still check for an in-flight duplicate fix (an open PR already swapping the same link) before touching it. Fix strategy is always the same: **edit at source**, swapping the link for `redirectsTo` plus `destination`'s `#fragment`, if it had one (strip the `https://www.pulumi.com` origin from both `destination` and `redirectsTo` to get root-relative paths — the source file's own link may already be relative rather than root-relative, so match on the path, not the full `destination` string, when locating it). Skip the "confirm genuinely broken" step for these; go straight to the mapping-to-source-file step below. ### Real-404 signal (`reason: "REAL_404"`) These entries come from **server logs**, not the crawler: URLs readers actually requested that returned 404 — external referrers, bookmarks, stale search results. That means: - `source` is `(server logs)` — there is no on-site page to edit; the fix is almost always a **Hugo alias** or **S3 redirect** to the obvious successor page, or the nearest section index when none exists. - Verify the URL still 404s before fixing (the export is up to a week old; it may have been fixed since). - Residual noise the export's bot filter missed (crawler probes, exploit scans) goes under **False positives / not actioned** — never give junk paths a redirect. ## Verify each link before fixing it **The checker produces false positives — never fix a link without confirming it's actually broken.** The crawler hits live URLs with a bot user agent, so it routinely mis-flags links that are fine in a browser: bot-protected sites, transient `5xx`/timeouts, rate-limiting, auth walls, and pages that block automated clients. `excludeAcceptable()` already filters the common transient reasons, but plenty slip through — especially in the `external` list. For **every** reported link, confirm it's genuinely broken before touching anything: 1. **Re-check the destination** — `WebFetch` (or `curl -sIL`) the URL. If it resolves, it's a false positive. 2. **Weigh the reason code.** Internal `HTTP_404`/`HTTP_410` on a `pulumi.com` path is almost always real. External failures, and codes like `HTTP_403`, `HTTP_401`, `HTTP_429`, `HTTP_503`, or connection/timeout errors, are frequently bot protection or transient — treat as suspect until confirmed. 3. **If it's a false positive, do not edit, redirect, or exclude it.** Leave the link as-is and record it in the PR audit trail under a **False positives / not actioned** section, with the URL, the reason code, and one line on why it's fine (e.g. "resolves in browser; 403 is Cloudflare bot protection"). Optionally suggest it as a future exclusion-list candidate, but don't add it unless it's a recurring offender. Only links you've confirmed broken proceed to triage below. ## Skip links already being handled (deduplication) Broken links persist for days, so the same ones surface in consecutive runs while a fix is already in flight. **Before actioning a confirmed-broken link, check whether it's already covered** — never open a second PR for a fix that's pending, or file a duplicate issue. For each confirmed-broken link, search for prior work: 1. **Open PRs in `pulumi/docs`.** List candidates with `gh pr list --state open --search "broken link" --json number,title,url,headRefName` (prior runs use the `fix/broken-links-*` branch pattern). For a likely match, confirm with `gh pr view <n>` / `gh pr diff <n>` that it actually covers this link (same destination URL, source file, redirect, or exclusion entry). 2. **Existing issues, before filing a new one.** For an out-of-scope item that would become an issue in another repo, search that repo first — `gh issue list --repo pulumi/<repo> --state open --search "<url-or-path>"` (or `gh search issues`). If an open issue already tracks it, treat it as a duplicate and reuse that issue's link; do **not** file another. Classify each confirmed-broken link as **actionable** (no existing PR/issue covers it) or **duplicate** (one does — capture the PR/issue URL). Then: - **All duplicates** (every confirmed-broken link is already covered): do **not** create a branch, PR, or any issue. Write a Slack summary to `.broken-links-pr.txt` saying so and listing each link with its existing PR/issue link, e.g. `:link: 5 broken links — all already tracked (no new PR): <url> → #19470, …`. Stop here. - **Some actionable, some duplicates:** action only the non-duplicates (open the PR, file new issues). **Omit duplicates from the fixes**, but list them in a short **Already tracked** section in the PR description (one line each, linking the existing PR/issue). Don't re-file or re-fix them. ## Mapping a live URL back to its source file `source` and `destination` are live `https://www.pulumi.com/...` URLs. To edit or redirect them you must find the file that produces them: 1. Strip the origin to get the path, e.g. `/docs/iac/concepts/stacks/`. 2. Hugo content lives under `content/`. A path like `/docs/iac/concepts/stacks/` is produced by `content/docs/iac/concepts/stacks/_index.md` or `.../stacks.md`. Use **Glob**/**Grep** under `content/` to locate it. 3. To find *which* file emits a broken **destination** link, Grep for the link text under `content/`, `data/`, `layouts/`, and `static/` — it may come from a data file, a shortcode, or a template, not prose. 4. Registry, API-docs, and other generated paths have **no** source file in this repo (see "Out of scope" below). ## Per-link triage Apply the first row that matches. | Situation | Strategy | Mechanism | |---|---|---| | `redirectHops` entry (link works, but only via a redirect) | **Edit at source** | Swap the link for `redirectsTo`, root-relative, re-appending `destination`'s `#fragment` | | Internal link points at a moved/renamed path, but the destination page still exists at a new path | **Hugo alias** | Add the old path to `aliases:` on the destination page's frontmatter | | Internal link to a page that was deleted/restructured, or a non-Hugo path (registry, generated docs) | **S3 redirect** | Add a redirect line to the topic-appropriate file in `scripts/redirects/` | | Broken link living in **editable** content (`content/docs`, `content/product`) | **Edit at source** | Fix the link in place; use the full root-relative path (`/docs/...`), never `../` | | Broken link in a **blog** post **and** an equivalent replacement exists (same content, or close enough that the post's meaning is unchanged) | **Edit at source + stamp `lastmod`** | Swap the link; add/update `lastmod:` in that post's frontmatter (leave `date:` alone) | | Broken link in a **blog** post with **no** equivalent replacement | **Route around the prose** | Add an alias/redirect so it resolves; if it's a dead external link, add it to the exclusion list. **Do not reword blog prose** | | Dead / transient / bot-protected **external** link | **Exclusion list** | Add the URL to `getDefaultExcludedKeywords()` in `check-links.js` with an inline `//` comment naming the reason + blog post/issue. If the page mattered, prefer swapping to a Wayback snapshot | | Redirect **loop** or malformed alias YAML | **Repair** | Remove the stale redirect / fix the alias indentation | | Breakage owned by **another repo** (e.g. registry `logoUrl`, generated SDK docs) or otherwise unfixable here | **Out of scope** | File a GitHub issue and document it in the PR description — do not hack around it | ### Mechanics **Hugo alias.** Add the old path under `aliases:` in the destination page's frontmatter (root-relative, trailing slash): ```yaml aliases: - /docs/old/path/ ``` Full mechanics and edge cases: `move-doc:references:alias-injection`. **S3 redirect.** Append one line to the topic-appropriate file in `scripts/redirects/` (e.g. `cicd-redirects.txt`, `iac-cli-redirects.txt`, `general-broken-links-redirects.txt` when nothing more specific fits). Format is `source-path|destination-url`, one per line. The source is the path **without** a leading slash and usually ends in `/index.html`; the destination is root-relative (`/docs/.../`) or a full URL for off-site targets: ``` docs/old/path/index.html|/docs/iac/new/path/ ``` **Edit at source.** Replace the broken link with the correct one. Internal docs links use the full canonical root-relative path (`/docs/iac/concepts/stacks/`); never `../`. This is the only strategy that touches prose, and for blog posts it's limited to equivalent-content link swaps. **Blog `lastmod`.** Any blog edit must add or update `lastmod:` (ISO date, today) in that post's frontmatter so the change is reflected, while the original `date:`/publish date stays put so post ordering doesn't shift. **Exclusion list.** Add the URL to the array returned by `getDefaultExcludedKeywords()` in `scripts/link-checker/check-links.js`, grouped with similar entries, with an inline comment naming the reason and the post/issue, matching the existing style: ```js "https://example.com/gone", // blog/<post>: 404, no replacement (#<issue>) ``` ## Editing guardrails - **Tutorials are editable** like docs — fix their links at the source. - **Blog prose is editable only** to substitute a broken link for an equivalent-content replacement, and every such edit stamps `lastmod`. Blog links with no equivalent replacement are routed around with an alias/redirect/exclusion, never reworded. - Internal links use full root-relative paths, never `../`. - `make lint` and `make build` must both pass before you open the PR. ## Output If **every** confirmed-broken link and every `redirectHops` entry is a duplicate (see deduplication above), skip all of the below: open no branch, PR, or issue, write the "all already tracked" Slack summary to `.broken-links-pr.txt`, and stop. Otherwise, for the actionable (non-duplicate) entries: 1. Create a branch `fix/broken-links-<date>` (date from the workflow, e.g. `fix/broken-links-2026-06-02`). 2. Make the fixes, grouping related changes into clear commits. 3. File a GitHub issue (`gh issue create`) for every **new** out-of-scope item (reuse the existing issue for duplicates). 4. Run `make lint` and `make build`; fix anything they surface. 5. Open a **ready** (non-draft) PR to `master`. 6. Write the final PR URL plus a one-line summary to `.broken-links-pr.txt` for the workflow's Slack step, e.g.: `:link: Fixed 7 broken links — <PR URL> (2 aliases, 1 redirect, 3 source edits, 1 issue filed)` ## PR description contract (auditability) The reviewer must be able to audit every decision without re-deriving it. Model the description on PR #19469. Include: - **A table or list of every broken link and redirect hop** → the strategy applied → one line of non-obvious reasoning (why a redirect vs. an alias, why excluded, etc.). - A **Verification** section: confirm `make lint` and `make build` passed, and note that each link was re-checked before fixing. - A **False positives / not actioned** section listing every reported link you confirmed was actually fine, with its reason code and why (so the reviewer knows it was checked, not missed). - An **Already tracked** section, when any link was skipped as a duplicate: one succinct line per link linking the existing PR or issue that covers it. - An **Out of scope / filed issues** section linking every issue you created for breakage owned by other repos or otherwise unfixable here.
View on GitHub