Skip to content

broken-links

broken-links #30

Workflow file for this run

name: broken-links
on:
schedule:
# Nightly at 09:00 UTC (around 03:00 MT / 04:00 CT).
- cron: '0 9 * * *'
workflow_dispatch: {}
permissions:
issues: write
contents: read
jobs:
crawl:
name: Crawl site for broken links
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Pick target URL
id: target
# We feed Lychee the sitemap rather than the homepage. Lychee's
# CLI doesn't support recursive crawling — it checks every link
# on every input. Pointing it at /sitemap.xml gives it the full
# URL list (33 static pages + 162 blog posts) and it then checks
# every link on every one of those pages. That's the "test all
# internal pages too" coverage the homepage-only run lacked.
run: |
if [ -n "${{ secrets.STAGING_URL }}" ]; then
base="${{ secrets.STAGING_URL }}"
else
base="https://beta.ontargetaba.com/"
fi
# Trim trailing slash, append sitemap.xml
base="${base%/}"
echo "url=${base}/sitemap.xml" >> "$GITHUB_OUTPUT"
- name: Run Lychee
id: lychee
uses: lycheeverse/lychee-action@v2
with:
args: >-
--verbose
--no-progress
--max-retries 2
--retry-wait-time 5
--timeout 20
--accept 200,206,429
--include-verbatim
--exclude '^https://ontargetaba\.com/'
--exclude '^https://static\.hotjar\.com/c/hotjar-$'
--exclude 'search_term_string'
--exclude '^https://(www\.)?linkedin\.com/'
--exclude '^https://(www\.)?instagram\.com/'
--exclude '^https://(www\.)?facebook\.com/'
${{ steps.target.outputs.url }}
fail: false
output: ./lychee-report.md
- name: Verify Lychee actually ran
# `fail: false` above lets the step continue when broken links
# are found, but it also masks the case where Lychee itself
# blew up (bad CLI args, sitemap unreachable, etc.) — in which
# case no report is produced and we'd silently open a misleading
# tracking issue. Treat "no report or empty report" as a hard
# workflow failure so red CI flags the real problem.
run: |
if [ ! -s ./lychee-report.md ]; then
echo "::error::Lychee produced no report — it likely failed to run. Check the 'Run Lychee' step logs above for CLI arg errors or network failures."
exit 1
fi
- name: Find existing tracking issue
id: existing
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
num=$(gh issue list --state open --label broken-links \
--search "Broken links found" \
--json number --jq '.[0].number // empty')
echo "number=$num" >> "$GITHUB_OUTPUT"
- name: Open or update tracking issue
if: steps.lychee.outputs.exit_code != '0'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TARGET_URL: ${{ steps.target.outputs.url }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
date_str=$(date -u +%Y-%m-%d)
title="Broken links found ${date_str}"
# Build the body in a file. We previously stitched it with
# bash $(...) + +=, but command substitution strips trailing
# newlines, so when lychee-report.md didn't end with one the
# closing ``` ended up smushed against the last content line.
# Writing through printf to a real file gives us deterministic
# line endings — closing fence is always on its own line.
{
printf '## Broken links\n\n'
printf 'Target: %s\n' "$TARGET_URL"
printf 'Run: %s\n\n' "$RUN_URL"
printf '```\n'
if [ -s ./lychee-report.md ]; then
cat ./lychee-report.md
else
printf 'See run logs for details.\n'
fi
# Force a final newline before the closing fence regardless
# of whether the report ended with one.
printf '\n```\n'
} > /tmp/issue-body.md
if [ -n "${{ steps.existing.outputs.number }}" ]; then
gh issue edit "${{ steps.existing.outputs.number }}" \
--title "$title" \
--body-file /tmp/issue-body.md
else
gh issue create \
--title "$title" \
--body-file /tmp/issue-body.md \
--label broken-links
fi
- name: Close tracking issue when clean
if: steps.lychee.outputs.exit_code == '0' && steps.existing.outputs.number != ''
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh issue close "${{ steps.existing.outputs.number }}" \
--comment "All previously broken links are now resolving. Closing."