From 14ee88c13cc0e18aaa921d5d8c8eaef1502c69a4 Mon Sep 17 00:00:00 2001 From: Randy Fay Date: Mon, 3 Aug 2026 17:39:05 -0600 Subject: [PATCH 1/2] fix: replace two dead blog links, ignore Cloudflare-challenged hosts in linkspector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## The Issue The Link check workflow has failed on every push to main since 2026-07-31. Stack Overflow, tag1.com, and timeanddate.com return HTTP 403 to linkspector's headless Chrome. The 403 carries `cf-mitigated: challenge`, reproducible with plain curl, so it is a Cloudflare bot challenge rather than a link problem; Cloudflare's "Agent" category, offered to site owners on 2026-07-01, covers browser-driving agents. Auditing every URL the ignore list already suppressed turned up two genuinely dead links that the list was hiding. ## How This PR Solves The Issue Dead links fixed in the posts: - `news.extly.com` (February 2026 newsletter) resolves to 0.0.0.0. It was a syndicated copy; now points at the original Laravel News article. - A LinkedIn Pulse article (August 2025 newsletter) redirects to `article_not_found` with no Wayback snapshots. Repointed to the same tutorial on The Drop Times. - The timeanddate.com link to the past 2024-03-06 meeting is removed; the date remains as text. Ignore patterns added for hosts serving a Cloudflare challenge: stackoverflow.com, timeanddate.com (kept for future meeting announcements), pixabay.com. Widened the existing tag1consulting.com pattern to cover the newer tag1.com domain. ## Manual Testing Instructions Preview: https://20260803-rfay-linkspector-cl.ddev-com-front-end.pages.dev/ - /blog/ddev-february-2026-newsletter — "Read on Laravel News" resolves - /blog/ddev-august-2025-newsletter — Ollama item resolves to The Drop Times - /blog/2024-plans — meeting date reads as plain text - The Link check job passes ## Automated Testing Overview No new tests; the change is link-checker configuration and content. Verified the YAML parses, all 19 patterns compile, and they match the 12 failing URLs from run 30811952142 without matching the replacements. All 7 Stack Overflow URLs confirmed live via api.stackexchange.com. ## Release/Deployment Notes A Cloudflare challenge returns 403 whether or not the page exists, so the suppressed URLs on those hosts cannot be validated by CI and need occasional manual spot-checks. Cloudflare's new defaults land 2026-09-15, so more hosts may need this treatment. 🤖 Developed with assistance from [Claude Code](https://claude.ai/code) Co-authored-by: Claude Opus 5 --- .linkspector.yml | 18 +++++++++++++++++- src/content/blog/2024-plans.md | 2 +- .../blog/ddev-august-2025-newsletter.md | 2 +- .../blog/ddev-february-2026-newsletter.md | 2 +- 4 files changed, 20 insertions(+), 4 deletions(-) diff --git a/.linkspector.yml b/.linkspector.yml index 6b4ca019..ff9e6f38 100644 --- a/.linkspector.yml +++ b/.linkspector.yml @@ -11,7 +11,7 @@ ignorePatterns: # Known to block or reject linkspector's headless-browser / CI requests, # even though the pages are reachable from a normal browser. - pattern: "^https://(www\\.)?linkedin\\.com" - - pattern: "^https://(www\\.)?tag1consulting\\.com" + - pattern: "^https://(www\\.)?tag1(consulting)?\\.com" - pattern: "^https://(www\\.)?packtpub\\.com" - pattern: "^https://buildkite\\.com/ddev/" - pattern: "^https://(www\\.)?npmjs\\.com" @@ -32,6 +32,22 @@ ignorePatterns: - pattern: "^https://firesphere\\.dev/articles/ddev-elasticsearch-and-silverstripe$" # Blocks or is unreachable from CI runners, even though the site is up. - pattern: "^https://(www\\.)?rueegger\\.me" + # Serves a Cloudflare bot challenge ("cf-mitigated: challenge", HTTP 403) to + # anything that isn't an interactive browser, which is what linkspector's + # headless Chrome looks like. Cloudflare's "Agent" bot category, offered to + # site owners starting 2026-07-01, explicitly covers browser-driving agents: + # https://blog.cloudflare.com/content-independence-day-ai-options/ + # The linked pages are all live; verified out-of-band via api.stackexchange.com. + - pattern: "^https://stackoverflow\\.com" + # Same challenge. Kept even though no post currently links here: meeting + # announcements use timeanddate.com for timezone-aware times, so new links + # will appear. A stale link to a past meeting should be removed from the post + # rather than left for this pattern to hide. + - pattern: "^https://(www\\.)?timeanddate\\.com" + # Same challenge, but only intermittently: failed in the 2026-07-30 and + # 2026-07-31 runs, passed on 2026-08-03. Used only for image-credit + # attribution links. + - pattern: "^https://(www\\.)?pixabay\\.com" aliveStatusCodes: - 200 - 206 diff --git a/src/content/blog/2024-plans.md b/src/content/blog/2024-plans.md index e2451149..d9afc2e8 100644 --- a/src/content/blog/2024-plans.md +++ b/src/content/blog/2024-plans.md @@ -12,7 +12,7 @@ categories: - Community --- -At the DDEV Advisory Group's 2-hour [annual review/planning meeting on March 6, 2024](https://www.timeanddate.com/worldclock/fixedtime.html?msg=DDEV+Advisory+Group&iso=20240306T09&p1=75&ah=2), we'll talk about plans and priorities for 2024. These have been discussed previously in the [DDEV 2024 Priorities discussion](https://github.com/orgs/ddev/discussions/5720) but in getting ready for the meeting it seems like a good time to propose them in a more cohesive fashion. (Everyone is welcome to the meeting. The Zoom link is announced in [Discord](/s/discord) or email [support](mailto:support%40ddev.com) for a calendar invitation or the zoom link.) +At the DDEV Advisory Group's 2-hour annual review/planning meeting on March 6, 2024, we'll talk about plans and priorities for 2024. These have been discussed previously in the [DDEV 2024 Priorities discussion](https://github.com/orgs/ddev/discussions/5720) but in getting ready for the meeting it seems like a good time to propose them in a more cohesive fashion. (Everyone is welcome to the meeting. The Zoom link is announced in [Discord](/s/discord) or email [support](mailto:support%40ddev.com) for a calendar invitation or the zoom link.) ## Community diff --git a/src/content/blog/ddev-august-2025-newsletter.md b/src/content/blog/ddev-august-2025-newsletter.md index e0d7b26a..4e1eac89 100644 --- a/src/content/blog/ddev-august-2025-newsletter.md +++ b/src/content/blog/ddev-august-2025-newsletter.md @@ -40,7 +40,7 @@ This month: v1.24.7 release, Contributor Training starts up again, ddev-hostname - **Creating a Simple Donation Form in Drupal with Stripe & Webforms** Setup payments in minutes with DDEV and Webform. → [Read more↗](https://picozzi.com/notebook/2025/jul/creating-simple-donation-form-drupal-stripe-and-webforms) - **Free Local AI with Ollama & DDEV** - How to Set Up Ollama as a Free, Local AI Provider for Your local Drupal Development. → [Read more↗](https://www.linkedin.com/pulse/how-set-up-ollama-free-local-ai-provider-your-drupal-rakesh-james-gedje/) + How to Set Up Ollama as a Free, Local AI Provider for Your local Drupal Development. → [Read more↗](https://www.thedroptimes.com/50560/set-ollama-free-local-ai-provider-in-your-drupal-ddev-environment) ## DDEV Training Starting Up Again! diff --git a/src/content/blog/ddev-february-2026-newsletter.md b/src/content/blog/ddev-february-2026-newsletter.md index 9c921641..841276be 100644 --- a/src/content/blog/ddev-february-2026-newsletter.md +++ b/src/content/blog/ddev-february-2026-newsletter.md @@ -35,7 +35,7 @@ Then in March I'll be at [DrupalCon Chicago](https://events.drupal.org/chicago20 - **ddev-mngr** → A Go-based command-line tool with an interactive terminal UI for managing multiple DDEV projects at once — start, stop, check status, and open URLs across projects. With this add-on [Olivier Dobberkau](https://github.com/dkd-dobberkau) inspired a new TUI approach for DDEV core as well! [View on GitHub↗](https://github.com/dkd-dobberkau/ddev-mngr) - **TYPO3 DDEV Agent Skill** → Netresearch built an Agent Skill (compatible with Claude Code, Cursor, Windsurf, and GitHub Copilot) that automates DDEV environment setup for TYPO3 extension development, including multi-version testing environments for TYPO3 11.5, 12.4, and 13.4 LTS. [View on GitHub↗](https://github.com/netresearch/typo3-ddev-skill) - **Using Laravel Boost with DDEV** → Russell Jones explains how to integrate Laravel Boost (an official MCP server) with DDEV, giving AI coding agents contextual access to routes, database schema, logs, and configuration. [Read on Dev.to↗](https://dev.to/jonesrussell/using-laravel-boost-with-ddev-1kc6) -- **Laravel VS Code Extension v1.4.2** → Now includes Docker integration support and a fix for Pint functionality within DDEV environments. [Read more↗](https://news.extly.com/more-news/2030-dev-news/24693-docker-support-in-laravel-vs-code-extension-v1-4-2.html) +- **Laravel VS Code Extension v1.4.2** → Now includes Docker integration support and a fix for Pint functionality within DDEV environments. [Read on Laravel News↗](https://laravel-news.com/laravel-vscode-extension-v1-4-2) ## Community Tutorials from Around the World From 360c9999b1c5e2da82fc8ebd221ccdb9cf291912 Mon Sep 17 00:00:00 2001 From: Randy Fay Date: Mon, 3 Aug 2026 17:54:31 -0600 Subject: [PATCH 2/2] ci: check added links on PRs, sweep all links weekly into an issue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## The Issue Internal links are already validated by `astro-link-validator` during the Astro build, so this workflow only ever checked third-party URLs. Running the full sweep on every push to main meant any one of ~500 external links rotting turned the branch red: 9 of the last 20 pushes failed. A gate that is wrong half the time stops being read, and the ignore list grew an entry per newly-blocked host to keep it quiet. ## How This PR Solves The Issue Split the workflow into two jobs with the failure behavior each case deserves. The `pr` job keeps blocking with `filter_mode: added`, which is the case worth gating, since a typo'd URL in a new post fails while the author is still there to fix it. The `sweep` job takes over the full run, moves off push-to-main onto a weekly cron plus `workflow_dispatch`, and opens or updates a single tracking issue rather than failing; it closes that issue when every link resolves again. Link rot becomes a chore queue instead of a broken build. Also imported the hard-line-break guidance from ddev/ddev#8662 into `AGENTS.md`, extended to cover two cases this repo hits: commit bodies, which are reused verbatim as pull request descriptions here, and reports a workflow posts through `gh`. The sweep job's issue body is written one-line-per-paragraph for that reason. ## Manual Testing Instructions Preview: https://20260803-rfay-linkspector-cl.ddev-com-front-end.pages.dev/ - The blocking `Check blog links` job runs and passes on this PR - Run `Sweep all blog links` via workflow_dispatch on the branch; confirm it files an issue titled "Link check: broken external links in blog content" and that the job itself succeeds - Run it a second time and confirm the existing issue is updated rather than a duplicate created ## Automated Testing Overview No new tests. The workflow YAML parses and both job guards resolve as intended, every `run:` block is shellcheck-clean at warning level, and the report generation was tested end-to-end against real linkspector JSON output. The linkspector CLI contract the sweep depends on was verified directly: `-j` emits `{source, severity, diagnostics[]}` and exit status is non-zero when links fail. ## Release/Deployment Notes Link checking no longer runs on push to main, so merging will not surface link rot; the weekly sweep replaces it and first runs the following Monday. The sweep needs `issues: write`, granted at job scope only. If linkspector itself fails rather than merely finding bad links, the job fails loudly instead of silently reporting zero. 🤖 Developed with assistance from [Claude Code](https://claude.ai/code) Co-authored-by: Claude Opus 5 --- .github/workflows/link-check.yml | 112 +++++++++++++++++++++++++++++-- AGENTS.md | 10 +++ 2 files changed, 115 insertions(+), 7 deletions(-) diff --git a/.github/workflows/link-check.yml b/.github/workflows/link-check.yml index e0187751..b54d97c0 100644 --- a/.github/workflows/link-check.yml +++ b/.github/workflows/link-check.yml @@ -1,12 +1,15 @@ name: Link check on: - push: - branches: [main] pull_request: paths: - "src/content/blog/**" - ".linkspector.yml" - ".github/workflows/link-check.yml" + # The full sweep re-checks every external link in every post, including ones + # nobody touched. Third-party rot is a chore, not a reason to break main, so + # it runs weekly and files an issue instead of failing a required check. + schedule: + - cron: "17 6 * * 1" workflow_dispatch: concurrency: @@ -14,14 +17,19 @@ concurrency: cancel-in-progress: true permissions: - checks: write contents: read - pull-requests: read jobs: - linkspector: + # Blocking, and scoped to links the author actually added or changed. A typo'd + # URL in a new post fails here, where the author can still fix it. + pr: name: Check blog links + if: github.event_name == 'pull_request' runs-on: ubuntu-24.04 + permissions: + checks: write + contents: read + pull-requests: read steps: - uses: actions/checkout@v7 - name: Run linkspector @@ -30,6 +38,96 @@ jobs: github_token: ${{ secrets.GITHUB_TOKEN }} reporter: github-check fail_on_error: true - # Check links only in the added lines for PRs, but check everything otherwise - filter_mode: ${{ github.event_name == 'pull_request' && 'added' || 'nofilter' }} + filter_mode: added config_file: ".linkspector.yml" + + # Non-blocking. Reports into a single reusable issue so link rot accumulates in + # one place instead of turning main red. + sweep: + name: Sweep all blog links + if: github.event_name != 'pull_request' + runs-on: ubuntu-24.04 + permissions: + contents: read + issues: write + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ISSUE_TITLE: "Link check: broken external links in blog content" + NODE_VERSION: 24 + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-node@v7 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Run linkspector + id: check + run: | + # A non-zero exit means broken links were found, which is not a job + # failure here. Unparseable output means the tool itself broke, which is. + npx --yes @umbrelladocs/linkspector@0.5.6 check \ + -c .linkspector.yml -j > result.json || true + if ! jq -e 'has("diagnostics")' result.json >/dev/null 2>&1; then + echo "::error::linkspector produced no usable JSON" + head -c 2000 result.json || true + exit 1 + fi + echo "broken=$(jq '.diagnostics | length' result.json)" >> "$GITHUB_OUTPUT" + + - name: Build report + if: steps.check.outputs.broken != '0' + run: | + { + # Each paragraph is one long line on purpose: GitHub renders issue + # bodies with GFM hard line breaks, so a wrapped paragraph would come + # out ragged. See "Avoiding Hard Line Breaks" in AGENTS.md. + echo "${{ steps.check.outputs.broken }} link(s) in \`src/content/blog\` did not resolve." + echo + echo "A Cloudflare bot challenge returns 403 whether or not the page exists, so confirm by hand before editing a post. If a link is genuinely dead, fix the post; only add an entry to \`.linkspector.yml\` when the page is fine and the checker is being blocked." + echo + jq -r ' + .diagnostics + | map(((.message | capture("Cannot reach (?.+?) Status: (?.*)$")?) + // {url: .message, st: "?"}) as $m + | {url: $m.url, st: $m.st, path: .location.path, + line: .location.range.start.line}) + | group_by(.url | sub("^https?://"; "") | sub("/.*$"; "")) + | map("### \(.[0].url | sub("^https?://"; "") | sub("/.*$"; "")) (\(length))\n" + + (map("- [\(.path):\(.line)](\(env.BLOB)/\(.path)#L\(.line)) — `\(.st)`\n \(.url)") + | join("\n"))) + | join("\n\n") + ' result.json + echo + echo "---" + echo "[Sweep run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})" + } > report.md + env: + BLOB: ${{ github.server_url }}/${{ github.repository }}/blob/${{ github.sha }} + + - name: Open or update the tracking issue + if: steps.check.outputs.broken != '0' + run: | + existing=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open \ + --search "\"$ISSUE_TITLE\" in:title" --json number,title \ + | jq -r --arg t "$ISSUE_TITLE" \ + '[.[] | select(.title == $t)] | first | .number // empty') + if [ -n "$existing" ]; then + gh issue edit "$existing" --repo "$GITHUB_REPOSITORY" --body-file report.md + echo "Updated issue #$existing" + else + gh issue create --repo "$GITHUB_REPOSITORY" \ + --title "$ISSUE_TITLE" --body-file report.md + fi + + - name: Close the tracking issue when everything resolves + if: steps.check.outputs.broken == '0' + run: | + existing=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open \ + --search "\"$ISSUE_TITLE\" in:title" --json number,title \ + | jq -r --arg t "$ISSUE_TITLE" \ + '[.[] | select(.title == $t)] | first | .number // empty') + if [ -n "$existing" ]; then + gh issue close "$existing" --repo "$GITHUB_REPOSITORY" \ + --comment "All blog links resolved in the latest sweep." + fi diff --git a/AGENTS.md b/AGENTS.md index 2be51403..96c23474 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -68,6 +68,16 @@ Only commit when explicitly requested by the user. **Never run `git push` (or any command that pushes to a remote), under any circumstances, even if explicitly asked.** The user always pushes their own branches/commits themselves. +### Avoiding Hard Line Breaks in Issue/PR/Comment Bodies + +GitHub renders issue, PR, and comment bodies (`gh issue create`, `gh pr create`, `gh pr comment`, `gh issue comment`, etc.) with GFM's hard-line-break behavior: a single `\n` inside a paragraph becomes an actual `
`. This is different from how GitHub renders committed Markdown files (this file, docs, READMEs), which follow standard CommonMark, where a lone `\n` is just whitespace and the paragraph reflows to the container width. + +Hand-wrapping prose to a fixed column width — normal, good practice for a text file — produces a ragged, too-short-lined paragraph when posted as an issue/PR/comment body, because each wrapped line becomes its own forced line instead of reflowing. + +When writing a `--body-file` for any of these commands, write each paragraph as one continuous line with no embedded newlines. Only use actual blank lines to separate paragraphs, headings, and list items. This does not apply to code blocks, tables, or files meant to be read as source. + +Because a commit body here is reused verbatim as the pull request description, write commit bodies the same way: one continuous line per paragraph, rather than wrapping to a fixed column width as git convention would otherwise suggest. The same applies to any report a workflow generates and posts through `gh`. + ## Working with Claude Code ### Branch Naming