From 7e7552ce42d8b65d10678d8782642e3efb1bb8cb Mon Sep 17 00:00:00 2001 From: Marc LeBlanc <7050295+marcleblanc2@users.noreply.github.com> Date: Fri, 11 Sep 2026 05:13:33 -0600 Subject: [PATCH 1/3] ci: fail PRs that break redirects, with suggested fixes dev/check-redirects.mjs checks every entry in src/data/redirects.ts: source shadows a page, source has a #fragment, duplicate source, /docs prefix, chained redirect, missing destination page or heading. The workflow compares against the merge base, so only redirects a PR breaks are reported, grouped by problem with the fix explained under each heading, and posts one suggested change per fixable entry the PR added (deleted again once the finding is gone). Not part of `npm run check`: main has hundreds of pre-existing findings. Squash of the check-redirects branch rebased onto main; the check-links commits it carried are already on main. Amp-Thread-ID: https://ampcode.com/threads/T-01a08fee-74b4-76dc-aaf9-d1245d68fdc9 Co-authored-by: Amp --- .github/workflows/check-redirects.yml | 133 +++++++ AGENTS.md | 1 + dev/check-redirects.mjs | 492 ++++++++++++++++++++++++++ 3 files changed, 626 insertions(+) create mode 100644 .github/workflows/check-redirects.yml create mode 100644 dev/check-redirects.mjs diff --git a/.github/workflows/check-redirects.yml b/.github/workflows/check-redirects.yml new file mode 100644 index 000000000..634678aa0 --- /dev/null +++ b/.github/workflows/check-redirects.yml @@ -0,0 +1,133 @@ +name: Check redirects + +# Reports redirects in src/data/redirects.ts that this PR breaks, compared with +# the merge base: destinations that no longer exist, #fragments whose heading +# was renamed, and new redirects that shadow an existing page. Pre-existing +# broken redirects on the base branch are ignored. + +on: + pull_request: + +# A new push supersedes the run for the previous one +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: write + +jobs: + check-redirects: + name: Broken redirects introduced by this PR + runs-on: ubuntu-latest + steps: + - name: Check out pull request head + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + + - name: Install github-slugger, the only dependency of dev/check-redirects.mjs + # Into a scratch prefix, not the repo: `npm install ` next to + # package.json would install every dependency of the site + run: | + npm install --prefix "$RUNNER_TEMP/deps" --no-package-lock --no-audit --no-fund \ + "github-slugger@$(node -p 'require("./package.json").dependencies["github-slugger"]')" + ln -s "$RUNNER_TEMP/deps/node_modules" node_modules + + - name: Check out merge base + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + merge_base=$(git merge-base "$BASE_SHA" HEAD) + git worktree add "$RUNNER_TEMP/base" "$merge_base" + git diff -U0 "$merge_base" HEAD -- src/data/redirects.ts > "$RUNNER_TEMP/changes.diff" + + - name: Record broken redirects already present on the base branch + # Exit 1 means findings, which is expected here + run: | + node dev/check-redirects.mjs --format json \ + --root "$RUNNER_TEMP/base" > "$RUNNER_TEMP/base-redirects.json" \ + || [ $? -eq 1 ] + + - name: Find redirects broken by this PR + id: check + env: + # Line links in the report open redirects.ts on the PR branch + LINK_BASE: ${{ github.event.pull_request.head.repo.html_url }}/blob/${{ github.event.pull_request.head.ref }} + run: | + if node dev/check-redirects.mjs --format markdown \ + --baseline "$RUNNER_TEMP/base-redirects.json" \ + --diff "$RUNNER_TEMP/changes.diff" \ + --review "$RUNNER_TEMP/review.json" \ + --link-base "$LINK_BASE" > "$RUNNER_TEMP/report.md"; then + echo "broken=false" >> "$GITHUB_OUTPUT" + else + echo "broken=true" >> "$GITHUB_OUTPUT" + fi + cat "$RUNNER_TEMP/report.md" + + - name: Comment on the pull request + # Fork PRs get a read-only token; the report is still in the job log + if: github.event.pull_request.head.repo.full_name == github.repository + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + BROKEN: ${{ steps.check.outputs.broken }} + run: | + marker='' + existing_comment=$(gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" \ + --paginate --jq ".[] | select(.body | startswith(\"$marker\")) | .id" | head -n 1) + + # Comment only when there is something to report, or an earlier report to resolve + if [ "$BROKEN" = true ]; then + { echo "$marker"; cat "$RUNNER_TEMP/report.md"; } > "$RUNNER_TEMP/comment.md" + elif [ -n "$existing_comment" ]; then + printf '%s\n### ✅ The redirects an earlier revision of this PR broke are fixed\n' \ + "$marker" > "$RUNNER_TEMP/comment.md" + else + exit 0 + fi + + if [ -n "$existing_comment" ]; then + gh api --method PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$existing_comment" \ + --field body=@"$RUNNER_TEMP/comment.md" + else + gh pr comment "$PR_NUMBER" --body-file "$RUNNER_TEMP/comment.md" + fi + + - name: Suggest fixes as review comments + # One suggested change per fixable entry this PR added. Suggestions + # already on the PR (same lines and entry) are not posted again; + # suggestions for findings that are gone are deleted. GitHub sets line + # to null on comments it could not carry to the new revision, so those + # are deleted too. + if: github.event.pull_request.head.repo.full_name == github.repository + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + key='(.path + ":" + (.line | tostring) + ":" + (.body | split("\n")[0]))' + gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/comments" --paginate \ + --jq ".[] | select(.body | startswith(\"`, + `Problem: ${problem}`, + `Fix: ${describeFix(finding)}`, + '````suggestion', + ...entry, + '````' + ]; + return { + path: REDIRECTS_PATH, + ...(startLine !== endLine && {start_line: startLine, start_side: 'RIGHT'}), + line: endLine, + side: 'RIGHT', + body: body.join('\n') + }; + }); + return {event: 'COMMENT', body: '', comments}; +} + +const FORMATTERS = { + text: formatText, + json: findings => JSON.stringify(findings, null, '\t') + '\n', + markdown: formatMarkdown +}; + +function main() { + const format = FORMATTERS[FORMAT]; + if (!format) { + throw new Error( + `Unknown --format "${FORMAT}"; use text, json, or markdown` + ); + } + + let findings = findBrokenRedirects(loadRedirects(), buildHeadingsByRoute()); + if (BASELINE_FILE) { + findings = withoutBaseline(findings, BASELINE_FILE); + } + + if (REVIEW_FILE) { + const added = DIFF_FILE ? addedLines(DIFF_FILE) : new Set(); + fs.writeFileSync( + REVIEW_FILE, + JSON.stringify(reviewRequest(findings, added), null, '\t') + '\n' + ); + } + + // Not process.exit(): that can truncate stdout when it is a pipe + process.stdout.write(format(findings)); + process.exitCode = findings.length === 0 ? 0 : 1; +} + +main(); From befb8a8d70a4426113e1d3de138b1b1595e51af4 Mon Sep 17 00:00:00 2001 From: Marc LeBlanc <7050295+marcleblanc2@users.noreply.github.com> Date: Fri, 11 Sep 2026 05:26:15 -0600 Subject: [PATCH 2/3] check-redirects: don't repeat the final destination the Fix line already names Amp-Thread-ID: https://ampcode.com/threads/T-01a08fee-74b4-76dc-aaf9-d1245d68fdc9 Co-authored-by: Amp --- dev/check-redirects.mjs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/dev/check-redirects.mjs b/dev/check-redirects.mjs index 6317eacc8..f13831db8 100644 --- a/dev/check-redirects.mjs +++ b/dev/check-redirects.mjs @@ -232,11 +232,15 @@ function findBrokenRedirects(redirects, headingsByRoute) { const destination = splitUrl(redirect.destination); if (isRedirect(destination.pathname)) { + // The fix names the final destination; only a loop needs `detail` const final = finalDestination(destination.pathname); - report(redirect, PROBLEM.chained, { - detail: final ?? 'none, redirect loop', - fix: final && {source: redirect.source, destination: final} - }); + report( + redirect, + PROBLEM.chained, + final + ? {fix: {source: redirect.source, destination: final}} + : {detail: 'none, redirect loop'} + ); continue; } const headings = headingsByRoute.get(destination.pathname); From 26dd4a733379d7c3345aa71b58c55fe1375b4fe0 Mon Sep 17 00:00:00 2001 From: Marc LeBlanc <7050295+marcleblanc2@users.noreply.github.com> Date: Fri, 11 Sep 2026 09:06:08 -0600 Subject: [PATCH 3/3] check-redirects: sync review comments with dev/sync-review-comments.sh (from the check-links PR) Amp-Thread-ID: https://ampcode.com/threads/T-01a08fee-74b4-76dc-aaf9-d1245d68fdc9 Co-authored-by: Amp --- .github/workflows/check-redirects.yml | 28 +++------------------------ 1 file changed, 3 insertions(+), 25 deletions(-) diff --git a/.github/workflows/check-redirects.yml b/.github/workflows/check-redirects.yml index 634678aa0..706bd254e 100644 --- a/.github/workflows/check-redirects.yml +++ b/.github/workflows/check-redirects.yml @@ -98,35 +98,13 @@ jobs: fi - name: Suggest fixes as review comments - # One suggested change per fixable entry this PR added. Suggestions - # already on the PR (same lines and entry) are not posted again; - # suggestions for findings that are gone are deleted. GitHub sets line - # to null on comments it could not carry to the new revision, so those - # are deleted too. + # One suggested change per fixable entry this PR added, kept in sync + # with the findings; see dev/sync-review-comments.sh if: github.event.pull_request.head.repo.full_name == github.repository env: GH_TOKEN: ${{ github.token }} PR_NUMBER: ${{ github.event.pull_request.number }} - run: | - key='(.path + ":" + (.line | tostring) + ":" + (.body | split("\n")[0]))' - gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/comments" --paginate \ - --jq ".[] | select(.body | startswith(\"