Skip to content

Cron – Check Broken Markdown Links #6

Cron – Check Broken Markdown Links

Cron – Check Broken Markdown Links #6

name: Cron – Check Broken Markdown Links
on:
schedule:
- cron: "0 0 * * 0"
workflow_dispatch:
inputs:
dry_run:
description: "Run without creating issues? (true/false)"
required: true
default: true
type: boolean
permissions:
contents: read
issues: write
jobs:
cron-check-broken-links:
runs-on: hl-web-lin-md
steps:
- name: Harden runner (audit outbound calls)
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Persists .lycheecache between runs. Note that --max-cache-age below is
# deliberately shorter than the weekly cron: every scheduled run re-checks
# every URL from scratch, so a link that rots mid-week is still caught.
# The cache exists to make same-day repeats cheap -- manual
# workflow_dispatch runs and re-runs of a failed job -- rather than to
# skip work on the weekly pass.
- name: Restore Lychee cache
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: .lycheecache
key: lychee-cache-${{ github.run_id }}
restore-keys: |
lychee-cache-
- name: Check Markdown links (Lychee)
id: lychee
uses: lycheeverse/lychee-action@e7477775783ea5526144ba13e8db5eec57747ce8 # v2.9.0
continue-on-error: true
with:
# --root-dir is required for root-relative links such as
# /images/foo.png to resolve against public/ instead of the
# filesystem root; without it every such link is reported broken.
# Concurrency, retries, timeout and accepted status codes come from
# lychee.toml; skipped hosts come from .lycheeignore.
args: >-
--verbose
--no-progress
--root-dir "${{ github.workspace }}/public"
--cache
--max-cache-age 1d
'./**/*.md'
output: ./lychee/out.md
fail: true
token: ${{ secrets.GITHUB_TOKEN }}
- name: Report Broken Links (Idempotent Issue Management)
if: steps.lychee.outcome == 'failure'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const fs = require('fs');
// Determine if this is a dry run
const isManual = context.eventName === 'workflow_dispatch';
const dryRun = isManual ? String(context.payload.inputs.dry_run).toLowerCase() === 'true' : false;
// Labels configuration
const targetLabels = ['broken-markdown-links', 'automated'];
const issueTitle = "Scheduled Markdown Link Check Found Broken Links";
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
console.log(`Event: ${context.eventName}, Dry Run: ${dryRun}`);
// Pull in Lychee's own markdown report so the issue names the
// offending URLs and their source files. Without this the report
// only ever existed in the run logs, which is why this issue
// accumulated timestamps with nothing actionable in them.
//
// GitHub rejects bodies over 65536 characters, so leave headroom
// for the surrounding text and say so explicitly when truncating.
const REPORT_LIMIT = 60000;
const reportPath = './lychee/out.md';
function readReport() {
try {
const raw = fs.readFileSync(reportPath, 'utf8').trim();
if (!raw) {
console.log(`${reportPath} is empty; falling back to the log pointer.`);
return null;
}
if (raw.length <= REPORT_LIMIT) return raw;
return raw.slice(0, REPORT_LIMIT) +
`\n\n> **Report truncated** at ${REPORT_LIMIT} characters. ` +
`See the [full run logs](${runUrl}) for the complete list.`;
} catch (error) {
// A missing artefact must never fail the reporting step.
console.log(`Could not read ${reportPath}: ${error.message}`);
return null;
}
}
const report = readReport();
const details = report
? `<details open>\n<summary><strong>Lychee report</strong></summary>\n\n${report}\n\n</details>`
: `> **Note:** The Lychee report could not be read for this run. ` +
`Please check the "Check Markdown links (Lychee)" step in the logs ` +
`to see the specific URLs that failed.`;
// Shared by both the create and the update path, so recurring
// comments carry the current failure list rather than a timestamp.
function buildBody(heading) {
return `### 🔗 ${heading}\n\n` +
`**Run Details:**\n` +
`- **Timestamp:** ${new Date().toISOString()}\n` +
`- **Workflow Run:** [View Logs](${runUrl})\n\n` +
`${details}\n\n` +
`> Link checking uses [Lychee](https://github.com/lycheeverse/lychee). ` +
`Hosts that cannot be verified by a headless checker are listed, with reasons, ` +
`in \`.lycheeignore\`.`;
}
const body = buildBody('Broken Links Detected');
if (dryRun) {
console.log("DRY RUN: Would have created or updated an issue.");
return;
}
// Search for existing issue and Update/Create
try {
const { data: issues } = await github.rest.issues.listForRepo({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
labels: targetLabels.join(','),
per_page: 100
});
const existingIssue = issues.find(issue => issue.title === issueTitle);
if (existingIssue) {
console.log(`Updating existing issue #${existingIssue.number}`);
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: existingIssue.number,
body: buildBody('Still Finding Broken Links')
});
} else {
console.log("Creating a new issue...");
await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: issueTitle,
body: body,
labels: targetLabels
});
}
} catch (error) {
console.error('Failed to manage broken link issue:', error);
core.setFailed(`Failed to create or update issue: ${error.message}`);
}