From 5468b60ebc87827075041c29439e2d489b9a4189 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:55:45 -0400 Subject: [PATCH 1/2] feat(check-links-online): list timeouts and accepted 429 and 5xx links in the summary The standards lychee.toml now accepts 429 and 5xx and sets accept_timeouts, so those links no longer fail the online run. lychee's markdown report lists timeouts but never lists accepted responses; only its JSON report at --verbose carries them, in success_map. The reusable now asks lychee-action for that JSON and a report step writes the job summary: broken links (which fail the job), timeouts, and accepted 429 and 5xx responses (which do not). The step also keeps the no-links and unreadable-report failures. Refs: melodic-software/claude-code-plugins#8730 Refs: melodic-software/ci-workflows#739 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Tje1j8KEYaYSxYe8RgJ3Lw --- .../maintenance-check-links-online.test.cjs | 170 +++++++++++++++++- .../maintenance-check-links-online.yml | 62 ++++++- README.md | 4 +- 3 files changed, 222 insertions(+), 14 deletions(-) diff --git a/.github/scripts/maintenance-check-links-online.test.cjs b/.github/scripts/maintenance-check-links-online.test.cjs index 45dbcac2..3a07f9b5 100644 --- a/.github/scripts/maintenance-check-links-online.test.cjs +++ b/.github/scripts/maintenance-check-links-online.test.cjs @@ -4,13 +4,17 @@ // unless the caller repository's custom property is "true", a dispatched run // always checks, and a property read that errors fails red. The workflow also // holds its contract: workflow_call only, contents: read, the -// GITHUB_TOKEN only, and the report in the job summary. +// GITHUB_TOKEN only, and the report in the job summary: broken links fail the +// job, while timeouts and accepted 429 and 5xx responses are listed without +// failing it. const assert = require("node:assert/strict"); +const { spawnSync } = require("node:child_process"); const fs = require("node:fs"); const path = require("node:path"); const test = require("node:test"); +const { makeTemporaryDirectory } = require("./temporary-directories.cjs"); const { parseWorkflow } = require("./workflow-yaml.cjs"); const AsyncFunction = Object.getPrototypeOf(async () => {}).constructor; @@ -21,7 +25,7 @@ const source = fs.readFileSync( ); const workflow = parseWorkflow(source); const job = workflow.jobs["check-links"]; -const [gate, checkout, lychee] = job.steps; +const [gate, checkout, lychee, report] = job.steps; async function runGate({ eventName, properties }) { const original = process.env.OPT_IN_PROPERTY; @@ -69,14 +73,22 @@ test("the workflow is reusable only, read-only, and declares no secrets", () => assert.doesNotMatch(source, /\$\{\{\s*secrets\./u); }); -test("lychee uses the GITHUB_TOKEN, fails red, and writes the job summary", () => { +test("lychee uses the GITHUB_TOKEN and hands a verbose JSON report to the report step", () => { assert.match(lychee.uses, /^lycheeverse\/lychee-action@[0-9a-f]{40}$/u); // biome-ignore lint/suspicious/noTemplateCurlyInString: literal GitHub Actions expression assert.equal(lychee.with.token, "${{ github.token }}"); - assert.equal(lychee.with.fail, true); - assert.equal(lychee.with.jobSummary, true); - assert.equal(lychee.with.format, "markdown"); - assert.doesNotMatch(lychee.with.args, /--offline/u); + // lychee-action writes the job summary only for markdown and fails on + // lychee's exit code itself; the report step owns both instead. + assert.equal(lychee.with.format, "json"); + assert.equal(lychee.with.jobSummary, false); + assert.equal(lychee.with.fail, false); + assert.equal(lychee.with.failIfEmpty, false); + // Only -v and above fills success_map, where accepted responses appear. + assert.match(lychee.with.args, /(^|\s)--verbose(\s|$)/u); + assert.doesNotMatch(lychee.with.args, /--offline|--format|--output/u); + assert.equal(report.env.REPORT, lychee.with.output); + // biome-ignore lint/suspicious/noTemplateCurlyInString: literal GitHub Actions expression + assert.equal(report.env.LYCHEE_EXIT, "${{ steps.lychee.outputs.exit_code }}"); assert.equal(checkout.with["persist-credentials"], false); }); @@ -84,6 +96,150 @@ test("every step after the gate runs only when the gate says so", () => { for (const step of [checkout, lychee]) { assert.equal(step.if, "steps.gate.outputs.run == 'true'"); } + assert.equal(report.if, "steps.gate.outputs.run == 'true' && !cancelled()"); +}); + +// Recorded from lychee 0.24.2 `--format json --verbose` against a local status +// server, with accept = 100..=103, 200..=299, 429, 500..=599 and +// accept_timeouts; only the input path is renamed and durations dropped. +const recorded = { + total: 7, + unique: 7, + successful: 3, + unknown: 0, + unsupported: 0, + timeouts: 1, + redirects: 0, + remaps: 0, + excludes: 0, + errors: 3, + cached: 0, + success_map: { + "docs/links.md": [ + { + url: "http://127.0.0.1:18766/429/y", + status: { text: "429 Too Many Requests", code: 429 }, + span: { line: 1, column: 2 }, + }, + { + url: "http://127.0.0.1:18766/503/y", + status: { text: "503 Service Unavailable", code: 503 }, + span: { line: 2, column: 2 }, + }, + { + url: "http://127.0.0.1:18766/200/y", + status: { text: "200 OK", code: 200 }, + span: { line: 4, column: 2 }, + }, + ], + }, + error_map: { + "docs/links.md": [ + { + url: "http://127.0.0.1:18766/404/y", + status: { text: "Rejected status code: 404 Not Found", code: 404 }, + span: { line: 5, column: 2 }, + }, + { + url: "http://127.0.0.1:18766/410/y", + status: { text: "Rejected status code: 410 Gone", code: 410 }, + span: { line: 6, column: 2 }, + }, + { + url: "http://127.0.0.1:18766/403/y", + status: { text: "Rejected status code: 403 Forbidden", code: 403 }, + span: { line: 7, column: 2 }, + }, + ], + }, + timeout_map: { + "docs/links.md": [ + { + url: "http://127.0.0.1:18766/hang/y", + status: { text: "Timeout", details: "Request timed out" }, + span: { line: 3, column: 2 }, + }, + ], + }, + suggestion_map: {}, + redirect_map: {}, + excluded_map: {}, + detailed_stats: true, +}; + +function runReport(reportBody, lycheeExit) { + const directory = makeTemporaryDirectory("check-links-online-"); + const reportPath = path.join(directory, "report.json"); + const summaryPath = path.join(directory, "summary.md"); + fs.writeFileSync(reportPath, reportBody); + fs.writeFileSync(summaryPath, ""); + const result = spawnSync("bash", ["-c", report.run], { + encoding: "utf8", + env: { + ...process.env, + REPORT: reportPath, + LYCHEE_EXIT: lycheeExit, + GITHUB_STEP_SUMMARY: summaryPath, + }, + }); + return { + status: result.status, + log: `${result.stdout}${result.stderr}`, + summary: fs.readFileSync(summaryPath, "utf8"), + }; +} + +function section(summary, title) { + const start = summary.indexOf(`## ${title}`); + if (start === -1) return ""; + const next = summary.indexOf("\n## ", start + 1); + return summary.slice(start, next === -1 ? undefined : next); +} + +test("broken links fail the job and every list reaches the summary", () => { + const { status, summary } = runReport(JSON.stringify(recorded), "2"); + assert.equal(status, 1); + const broken = section(summary, "Broken links"); + assert.match( + broken, + / in `docs\/links\.md` at 5:2/u, + ); + assert.match(broken, /\/410\/y>/u); + assert.match(broken, /\/403\/y>/u); + assert.match(section(summary, "Timeouts"), /\/hang\/y>/u); + const accepted = section(summary, "Accepted 429 and 5xx"); + assert.match(accepted, /^\* \[429\] /mu); + assert.match(accepted, /^\* \[503\] /mu); + assert.doesNotMatch(accepted, /\/200\/y>/u); +}); + +test("timeouts and accepted 429 and 5xx responses are listed without failing the job", () => { + const { error_map: _broken, ...rest } = recorded; + const passing = { ...rest, errors: 0, error_map: {} }; + const { status, summary } = runReport(JSON.stringify(passing), "0"); + assert.equal(status, 0); + assert.equal(section(summary, "Broken links"), ""); + assert.match(section(summary, "Timeouts"), /\/hang\/y>/u); + assert.match( + section(summary, "Accepted 429 and 5xx"), + /\/429\/y>[\s\S]*\/503\/y>/u, + ); +}); + +test("a run that found no links or left no JSON report fails the job", () => { + const empty = { + ...recorded, + total: 0, + successful: 0, + errors: 0, + timeouts: 0, + success_map: {}, + error_map: {}, + timeout_map: {}, + }; + assert.equal(runReport(JSON.stringify(empty), "0").status, 1); + assert.equal(runReport("", "101").status, 1); + assert.notEqual(runReport("thread 'main' panicked", "101").status, 0); }); test("a dispatched run checks links without reading the property", async () => { diff --git a/.github/workflows/maintenance-check-links-online.yml b/.github/workflows/maintenance-check-links-online.yml index b2caebc3..b83ed41b 100644 --- a/.github/workflows/maintenance-check-links-online.yml +++ b/.github/workflows/maintenance-check-links-online.yml @@ -4,7 +4,10 @@ name: maintenance-check-links-online # Markdown, with the caller's lychee.toml (the standards-synced config the # offline lane also reads) and the .lycheeignore lychee picks up from the # working directory. The report goes to the job summary and any broken link -# fails the job red. It never opens an issue or posts a comment. +# fails the job red. Links the caller's lychee.toml accepts as transient (a +# 429 or 5xx in `accept`, a timeout under `accept_timeouts`) do not fail it but +# are listed in the summary, so a host that stays down is still noticed. It +# never opens an issue or posts a comment. # # GITHUB_TOKEN only: it lifts the github.com rate limit, and it cannot read # another private repository, so lychee never reports a link into one as OK. @@ -112,19 +115,27 @@ jobs: # runner IP, so each host gets fewer parallel requests, spaced out, with # longer retries and timeout. Pacing did not stop every 429 # (standards#775, #776); a page proven alive that still blocks the - # checker gets an exact-URL .lycheeignore entry with a dated reason. A - # single red run on a github.com blob page 503 is not proof of rot. + # checker gets an exact-URL .lycheeignore entry with a dated reason. + # + # JSON with -v, because only that output lists accepted responses + # (lychee 0.24.2 fills `success_map` at -v and above; the markdown report + # never lists them). The next step writes the summary and decides the + # job's result, so this step neither fails nor writes a summary. - name: Check links (online) + id: lychee if: steps.gate.outputs.run == 'true' uses: lycheeverse/lychee-action@e7477775783ea5526144ba13e8db5eec57747ce8 # v2.9.0 with: lycheeVersion: ${{ inputs.lychee-version }} token: ${{ github.token }} - fail: true - jobSummary: true - format: markdown + fail: false + failIfEmpty: false + jobSummary: false + format: json + output: ${{ runner.temp }}/lychee/report.json args: >- --no-progress + --verbose --config '${{ inputs.config }}' --host-concurrency 4 --host-request-interval 250ms @@ -133,3 +144,42 @@ jobs: --timeout 40 ${{ inputs.extra-args }} ${{ inputs.globs }} + + # Broken links fail the job; timeouts and accepted 429 and 5xx responses + # are listed but do not. A report jq cannot parse (lychee panicked or + # never ran) fails the job too. + - name: Write the report + if: steps.gate.outputs.run == 'true' && !cancelled() + env: + REPORT: ${{ runner.temp }}/lychee/report.json + LYCHEE_EXIT: ${{ steps.lychee.outputs.exit_code }} + run: | + set -euo pipefail + jq -r ' + def rows($map): + [$map | to_entries[] | .key as $input | .value[] + | "* [\(.status.code // .status.text)] <\(.url)> in `\($input)`" + + (if .span then " at \(.span.line):\(.span.column)" else "" end) + + " | \(.status.details // .status.text)"]; + def section($title; $map): + rows($map) | if length > 0 then "\n## \($title)\n\n" + join("\n") else empty end; + "# Online link check\n", + "| Total | OK | Broken | Timeouts | Excluded |", + "| --- | --- | --- | --- | --- |", + "| \(.total) | \(.successful) | \(.errors) | \(.timeouts) | \(.excludes) |", + section("Broken links (fail the run)"; .error_map), + section("Timeouts (do not fail the run)"; .timeout_map), + section("Accepted 429 and 5xx responses (do not fail the run)"; + .success_map + | map_values(map(select(.status.code == 429 + or (.status.code >= 500 and .status.code <= 599)))) + | with_entries(select(.value | length > 0))) + ' "$REPORT" >>"$GITHUB_STEP_SUMMARY" + if ! jq -e '.total > 0' "$REPORT" >/dev/null; then + echo "::error::No links were found. This usually indicates a configuration error." + exit 1 + fi + if [ "$LYCHEE_EXIT" != 0 ]; then + echo "::error::lychee exited ${LYCHEE_EXIT}: see the broken links in the job summary." + exit 1 + fi diff --git a/README.md b/README.md index d0943f34..6f21937f 100644 --- a/README.md +++ b/README.md @@ -908,7 +908,9 @@ GitHub continues the normal weekly patching of each hosted image generation. link check: lychee with the network on over the caller's Markdown, reading its `lychee.toml` (the standards-synced config the offline lane uses) and `.lycheeignore`. The report goes to the job summary and a broken link fails - the job red; it opens no issue and posts no comment. It holds only the + the job red. Timeouts and the 429 and 5xx responses the config accepts are + listed in the summary without failing the job. It opens no issue and posts + no comment. It holds only the `GITHUB_TOKEN`, which cannot read another private repository, so lychee never reports a link into one as OK. **Opt-in by repository property**: a `schedule` run reads the caller's `opt-in-property` (default From c7842ef523a87bd0d60d09c1d40a71fd6298be39 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:31:28 -0400 Subject: [PATCH 2/2] fix(check-links-online): name timeout failures, catch an empty report, cap the summary - Timeouts fail the run when the caller's config does not set accept_timeouts, so the heading no longer says they do not, and a run failed only by timeouts says so. - lychee-action creates the report file before lychee runs, so an empty report now fails with a pointer to the lychee step log instead of "No links were found"; a non-JSON report gets its own annotation. - Rows omit a null column, fence input names that hold backticks, fold newlines in details, and sort by input and URL. - Each section is capped at 200 rows with a "+N more" line, under the 1 MiB per-step job summary limit. Refs: melodic-software/claude-code-plugins#8730 Refs: melodic-software/ci-workflows#739 Co-Authored-By: Claude Opus 5.5 --- .../maintenance-check-links-online.test.cjs | 99 ++++++++++++++++++- .../maintenance-check-links-online.yml | 49 ++++++--- README.md | 5 +- 3 files changed, 135 insertions(+), 18 deletions(-) diff --git a/.github/scripts/maintenance-check-links-online.test.cjs b/.github/scripts/maintenance-check-links-online.test.cjs index 3a07f9b5..9a388858 100644 --- a/.github/scripts/maintenance-check-links-online.test.cjs +++ b/.github/scripts/maintenance-check-links-online.test.cjs @@ -167,7 +167,7 @@ const recorded = { detailed_stats: true, }; -function runReport(reportBody, lycheeExit) { +function runReport(reportBody, lycheeExit, maxRows = report.env.MAX_ROWS) { const directory = makeTemporaryDirectory("check-links-online-"); const reportPath = path.join(directory, "report.json"); const summaryPath = path.join(directory, "summary.md"); @@ -179,6 +179,7 @@ function runReport(reportBody, lycheeExit) { ...process.env, REPORT: reportPath, LYCHEE_EXIT: lycheeExit, + MAX_ROWS: String(maxRows), GITHUB_STEP_SUMMARY: summaryPath, }, }); @@ -226,7 +227,7 @@ test("timeouts and accepted 429 and 5xx responses are listed without failing the ); }); -test("a run that found no links or left no JSON report fails the job", () => { +test("a run that found no links fails the job", () => { const empty = { ...recorded, total: 0, @@ -237,9 +238,97 @@ test("a run that found no links or left no JSON report fails the job", () => { error_map: {}, timeout_map: {}, }; - assert.equal(runReport(JSON.stringify(empty), "0").status, 1); - assert.equal(runReport("", "101").status, 1); - assert.notEqual(runReport("thread 'main' panicked", "101").status, 0); + const { status, log } = runReport(JSON.stringify(empty), "0"); + assert.equal(status, 1); + assert.match(log, /::error::No links were found\./u); +}); + +test("an empty report, as lychee-action leaves after a lychee error, fails with a pointer to the lychee step", () => { + const { status, log, summary } = runReport("", "1"); + assert.equal(status, 1); + assert.match( + log, + /::error::lychee exited 1 without writing a report: see the 'Check links \(online\)' step log\./u, + ); + assert.doesNotMatch(log, /No links were found/u); + assert.equal(summary, ""); +}); + +test("a report that is not JSON fails the job", () => { + const { status, log } = runReport("not json", "1"); + assert.equal(status, 1); + assert.match(log, /::error::lychee exited 1 and its report is not JSON/u); +}); + +test("timeouts that fail the run name the missing accept_timeouts", () => { + // lychee exits 2 for a timeout when accept_timeouts is unset (check.rs). + const onlyTimeouts = { + ...recorded, + errors: 0, + error_map: {}, + success_map: {}, + }; + const { status, log, summary } = runReport(JSON.stringify(onlyTimeouts), "2"); + assert.equal(status, 1); + assert.match( + log, + /::error::lychee exited 2: timeouts failed the run: the config does not set accept_timeouts\./u, + ); + assert.match(summary, /^## Timeouts$/mu); + assert.match(section(summary, "Timeouts"), /\/hang\/y>/u); +}); + +test("entries render without a code, without a column, and with odd input names", () => { + // Same status shape as the recorded timeout entry: details and no code. + const odd = { + ...recorded, + errors: 1, + error_map: { + "docs/a`b.md": [ + { + url: "http://127.0.0.1:18766/reset/y", + status: { + text: "Network error", + details: "connection reset\nby peer", + }, + span: { line: 9, column: null }, + }, + ], + }, + }; + const broken = section( + runReport(JSON.stringify(odd), "2").summary, + "Broken links", + ); + assert.equal( + broken.trim().split("\n").at(-1), + "* [Network error] in `` docs/a`b.md `` at 9 | connection reset by peer", + ); +}); + +test("sections are sorted by input and URL and capped with a remainder line", () => { + const entry = (n) => ({ + url: `http://127.0.0.1:18766/404/${n}`, + status: { text: "Rejected status code: 404 Not Found", code: 404 }, + span: { line: 1, column: 2 }, + }); + const many = { + ...recorded, + errors: 4, + error_map: { + "z.md": [entry("d"), entry("c")], + "a.md": [entry("b"), entry("a")], + }, + }; + const broken = section( + runReport(JSON.stringify(many), "2", 3).summary, + "Broken links", + ); + const rows = broken.trim().split("\n").slice(2); + assert.deepEqual( + rows.map((row) => row.match(/404\/(\w)>|^\* \+\d+ more$/u)[1] ?? row), + ["a", "b", "c", "* +1 more"], + ); }); test("a dispatched run checks links without reading the property", async () => { diff --git a/.github/workflows/maintenance-check-links-online.yml b/.github/workflows/maintenance-check-links-online.yml index b83ed41b..ed87540b 100644 --- a/.github/workflows/maintenance-check-links-online.yml +++ b/.github/workflows/maintenance-check-links-online.yml @@ -145,30 +145,53 @@ jobs: ${{ inputs.extra-args }} ${{ inputs.globs }} - # Broken links fail the job; timeouts and accepted 429 and 5xx responses - # are listed but do not. A report jq cannot parse (lychee panicked or - # never ran) fails the job too. + # Broken links fail the job; accepted 429 and 5xx responses are listed but + # do not. Timeouts are listed too, and fail the job only when the + # caller's config does not set accept_timeouts (lychee check.rs decides + # the exit code). lychee-action creates the report file before lychee + # runs, so a config error or a panic leaves it empty; that fails the job + # with a pointer to the lychee step's log. Each section is capped so the + # summary stays under the 1 MiB per-step limit + # (https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-commands#step-isolation-and-limits). - name: Write the report if: steps.gate.outputs.run == 'true' && !cancelled() env: REPORT: ${{ runner.temp }}/lychee/report.json LYCHEE_EXIT: ${{ steps.lychee.outputs.exit_code }} + MAX_ROWS: 200 run: | set -euo pipefail - jq -r ' + if [ ! -s "$REPORT" ]; then + echo "::error::lychee exited ${LYCHEE_EXIT} without writing a report: see the 'Check links (online)' step log." + exit 1 + fi + if ! jq -e 'type == "object"' "$REPORT" >/dev/null 2>&1; then + echo "::error::lychee exited ${LYCHEE_EXIT} and its report is not JSON: see the 'Check links (online)' step log." + exit 1 + fi + jq -r --argjson max "$MAX_ROWS" ' + # A code span whose fence is longer than any backtick run inside it. + def code: ([scan("`+") | length] | max // 0) as $n + | ("`" * ($n + 1)) as $fence + | if $n > 0 then "\($fence) \(.) \($fence)" else "\($fence)\(.)\($fence)" end; def rows($map): - [$map | to_entries[] | .key as $input | .value[] - | "* [\(.status.code // .status.text)] <\(.url)> in `\($input)`" - + (if .span then " at \(.span.line):\(.span.column)" else "" end) - + " | \(.status.details // .status.text)"]; + [$map | to_entries | sort_by(.key)[] | .key as $input + | .value | sort_by(.url)[] + | "* [\(.status.code // .status.text)] <\(.url)> in \($input | code)" + + (if .span then " at \(.span.line)" + (if .span.column then ":\(.span.column)" else "" end) else "" end) + + " | \(.status.details // .status.text | gsub("[\r\n]+"; " "))"]; def section($title; $map): - rows($map) | if length > 0 then "\n## \($title)\n\n" + join("\n") else empty end; + rows($map) + | if length == 0 then empty + else "\n## \($title)\n\n" + (.[:$max] | join("\n")) + + (if length > $max then "\n* +\(length - $max) more" else "" end) + end; "# Online link check\n", "| Total | OK | Broken | Timeouts | Excluded |", "| --- | --- | --- | --- | --- |", "| \(.total) | \(.successful) | \(.errors) | \(.timeouts) | \(.excludes) |", section("Broken links (fail the run)"; .error_map), - section("Timeouts (do not fail the run)"; .timeout_map), + section("Timeouts"; .timeout_map), section("Accepted 429 and 5xx responses (do not fail the run)"; .success_map | map_values(map(select(.status.code == 429 @@ -180,6 +203,10 @@ jobs: exit 1 fi if [ "$LYCHEE_EXIT" != 0 ]; then - echo "::error::lychee exited ${LYCHEE_EXIT}: see the broken links in the job summary." + if jq -e '.errors == 0 and .timeouts > 0' "$REPORT" >/dev/null; then + echo "::error::lychee exited ${LYCHEE_EXIT}: timeouts failed the run: the config does not set accept_timeouts." + else + echo "::error::lychee exited ${LYCHEE_EXIT}: see the broken links in the job summary." + fi exit 1 fi diff --git a/README.md b/README.md index 6f21937f..126a5b27 100644 --- a/README.md +++ b/README.md @@ -908,8 +908,9 @@ GitHub continues the normal weekly patching of each hosted image generation. link check: lychee with the network on over the caller's Markdown, reading its `lychee.toml` (the standards-synced config the offline lane uses) and `.lycheeignore`. The report goes to the job summary and a broken link fails - the job red. Timeouts and the 429 and 5xx responses the config accepts are - listed in the summary without failing the job. It opens no issue and posts + the job red. The 429 and 5xx responses the config accepts are listed in the + summary without failing the job, and so are timeouts when the config sets + `accept_timeouts` (otherwise a timeout fails it). It opens no issue and posts no comment. It holds only the `GITHUB_TOKEN`, which cannot read another private repository, so lychee never reports a link into one as OK. **Opt-in by repository property**: a