From 004b9dc71f670c6f20c78bada70e744970fb627f Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:53:35 -0400 Subject: [PATCH 1/2] fix(lychee): accept 429, 5xx and timeouts in the online link check A throttled or erroring host, or a timeout, says nothing about whether a link is dead, so it no longer fails the online run. 404, 410 and every other 4xx still fail. accept restates the default 100..=103 and 200..=299 because setting it replaces the default. lychee 0.24.2 never retries a rejected 5xx; revisit when lycheeverse/lychee#2324 ships. The status-code test now asserts that rule: 429, 500, 503, 599 and a timeout pass, an accepted 429 is requested once, 404 and 410 fail, and a 200 still passes. 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 --- components/lychee-online/README.md | 8 +-- components/lychee/lychee.test.sh | 108 +++++++++++++++++------------ lychee.toml | 18 +++-- 3 files changed, 80 insertions(+), 54 deletions(-) diff --git a/components/lychee-online/README.md b/components/lychee-online/README.md index 0cae22bd..b3e71d63 100644 --- a/components/lychee-online/README.md +++ b/components/lychee-online/README.md @@ -5,8 +5,9 @@ reusable `.github/workflows/maintenance-check-links-online.yml` (ci-workflows#739). The reusable runs lychee with the network on over the target's Markdown with its managed [`lychee.toml`](../lychee/README.md) and its own `.lycheeignore`, writes the report to the job summary, fails red on a -broken link, and holds only the `GITHUB_TOKEN`. It opens no issue and posts -no comment, and it is not part of any `ci-status`. +broken link, and holds only the `GITHUB_TOKEN`. A 429, a 5xx or a timeout does +not fail the run (the `accept` comment in the managed `lychee.toml` says why). +It opens no issue and posts no comment, and it is not part of any `ci-status`. ## Opt-in @@ -70,5 +71,4 @@ ignores `melodic-software/ci-workflows/*`, so the pin moves by hand: Exact-URL `.lycheeignore` entries in the target, each with a dated reason, only for pages proven alive that block the checker; shared ones go in the managed `lychee.toml`. Per-host pacing (standards#775, removed in #776) did not -stop runner 429s, and a single red run on a github.com blob page 503 is not -proof of rot. +stop runner 429s, so the managed config accepts 429 and 5xx instead. diff --git a/components/lychee/lychee.test.sh b/components/lychee/lychee.test.sh index 2c0a8624..378f5dc0 100755 --- a/components/lychee/lychee.test.sh +++ b/components/lychee/lychee.test.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash # Tests the lychee component: the good fixture's local links and anchors resolve # on disk, the bad fixture's broken references are flagged, JavaScript-rendered -# fragments are remapped away, and a manual online run retries a 429 and then -# fails it instead of accepting it. +# fragments are remapped away, and an online run passes a 429, a 5xx or a +# timeout while a 404 or 410 still fails it. # Skips cleanly when the engine or the fixture server's runtime is absent. set -uo pipefail root="$(git rev-parse --show-toplevel)" @@ -261,24 +261,28 @@ assert_contains 'bad fixture flags the missing fragment' "$out" 'Cannot find fra assert_contains 'bad fixture flags the missing file' "$out" 'does-not-exist.md' # Status handling only takes effect against a real response, so nothing above can -# reach it. A local server answers 429 on one path and 200 on a control path, -# and records every request to the throttled path: the config sets no `accept`, -# so a 429 must be retried and then fail, while the 200 control proves the -# server and the default accepted range work. -python3 - "$http_fixture_root/port" "$http_fixture_root/throttled-hits" \ +# reach it. A local server answers the status code named by the path's last +# segment (`hang` sleeps past the client timeout) and records every request +# path. The expected outcomes are the owner's rule the config comment states: a +# 429, a 5xx or a timeout passes the run, a 404 or 410 fails it, and a 200 still +# passes because the default 2xx range is restated. +python3 - "$http_fixture_root/port" "$http_fixture_root/hits" \ <<'PY' >/dev/null 2>"$http_fixture_root/server.err" & import socketserver import sys +import time from http.server import BaseHTTPRequestHandler class Handler(BaseHTTPRequestHandler): def do_GET(self): - throttled = "throttled" in self.path - if throttled: - with open(sys.argv[2], "a", encoding="utf-8") as hits: - hits.write(self.command + "\n") - self.send_response(429 if throttled else 200) + with open(sys.argv[2], "a", encoding="utf-8") as hits: + hits.write(self.path + "\n") + last = self.path.rstrip("/").rsplit("/", 1)[-1] + if last == "hang": + time.sleep(5) + last = "200" + self.send_response(int(last)) self.send_header("Content-Length", "0") self.end_headers() @@ -288,10 +292,12 @@ class Handler(BaseHTTPRequestHandler): pass -# Port 0 takes an ephemeral port, and the port file is written only after the -# bind succeeds — that ordering is what makes the caller's readiness poll mean -# "the server is listening" rather than "the process started". -server = socketserver.TCPServer(("127.0.0.1", 0), Handler) +# Threaded, so the hanging path does not hold up the others. Port 0 takes an +# ephemeral port, and the port file is written only after the bind succeeds — +# that ordering is what makes the caller's readiness poll mean "the server is +# listening" rather than "the process started". +socketserver.ThreadingTCPServer.daemon_threads = True +server = socketserver.ThreadingTCPServer(("127.0.0.1", 0), Handler) with open(sys.argv[1], "w", encoding="utf-8") as handle: handle.write(str(server.server_address[1])) server.serve_forever() @@ -320,40 +326,54 @@ else # bare token because MSYS/git-bash rewrites backslashes inside an argument # containing `://`, which would silently mangle an anchored URL regex. status_probe='lychee-status-probe' - printf '\n' "$http_port" "$status_probe" \ - >"$http_fixture_root/Throttled.md" - printf '\n' "$http_port" "$status_probe" \ - >"$http_fixture_root/Live.md" + for code in 200 429 500 503 599 404 410 hang; do + printf '\n' "$http_port" "$status_probe" "$code" \ + >"$http_fixture_root/Status$code.md" + done status_dump="$(lychee --dump --config "$config" --include "$status_probe" \ - "$http_fixture_root/Throttled.md" 2>&1)" - assert_contains 'rate-limited fixture URL is checked, not silently excluded' \ - "$status_dump" "http://127.0.0.1:$http_port/$status_probe/throttled" + "$http_fixture_root/Status429.md" 2>&1)" + assert_contains 'status fixture URL is checked, not silently excluded' \ + "$status_dump" "http://127.0.0.1:$http_port/$status_probe/429" - live_out="$(lychee --no-progress --config "$config" --include "$status_probe" \ - "$http_fixture_root/Live.md" 2>&1)" - rc=$? - assert_exit '200 control exits 0' 0 "$rc" # Exit 0 alone is also what checking nothing looks like; the OK count proves a - # request was made and its 200 was accepted. - assert_contains '200 control is counted as a checked OK link' "$live_out" '1 OK' + # request was made and its status was accepted. + for code in 200 429 500 503 599; do + status_out="$(lychee --no-progress --config "$config" --include "$status_probe" \ + "$http_fixture_root/Status$code.md" 2>&1)" + rc=$? + assert_exit "$code passes the run" 0 "$rc" + assert_contains "$code is counted as a checked OK link" "$status_out" '1 OK' + done + # The cost the config comment names: an accepted 429 is final on the first + # reply, so lychee does not retry it. + assert_row_count 'accepted 429 is requested once, not retried' \ + "$(<"$http_fixture_root/hits")" 1 "/$status_probe/429\$" + + for code in 404 410; do + status_out="$(lychee --no-progress --config "$config" --include "$status_probe" \ + "$http_fixture_root/Status$code.md" 2>&1)" + rc=$? + assert_nonzero "dead link $code fails the run" "$rc" + # An unreachable server also exits non-zero; naming the status keeps the + # case from passing for the wrong reason. + assert_contains "dead link $code fails on the status code itself" \ + "$status_out" "Rejected status code: $code" + done - throttled_out="$(lychee --no-progress --config "$config" --include "$status_probe" \ - "$http_fixture_root/Throttled.md" 2>&1)" + # A short timeout and no retries keep the case fast; the timeout is still + # reported, it just does not fail the run. + status_out="$(lychee --no-progress --config "$config" --include "$status_probe" \ + --timeout 1 --max-retries 0 "$http_fixture_root/Statushang.md" 2>&1)" rc=$? - assert_nonzero 'persistent 429 fails the run' "$rc" - # A dead or unreachable server also exits non-zero; naming the status keeps the - # case from passing for the wrong reason. - assert_contains 'persistent 429 fails on the status code itself' \ - "$throttled_out" 'Rejected status code: 429' - # One request would mean the 429 was taken as final; more than one proves - # lychee retried it, the behavior an accepted 429 would skip. - throttled_hits="$(wc -l <"$http_fixture_root/throttled-hits" 2>/dev/null || printf '0')" - if ((throttled_hits > 1)); then - pass "persistent 429 is retried before failing: $throttled_hits requests" - else - fail 'persistent 429 is retried before failing' "requests: $throttled_hits" - fi + assert_exit 'timeout passes the run' 0 "$rc" + assert_contains 'timeout is still reported' "$status_out" '1 Timeouts' + # Control: the same request without accept_timeouts fails, so the pass above + # comes from the config key. + lychee --no-progress --config "$config" --include "$status_probe" --accept-timeouts=false \ + --timeout 1 --max-retries 0 "$http_fixture_root/Statushang.md" >/dev/null 2>&1 + rc=$? + assert_nonzero 'without accept_timeouts a timeout fails the run' "$rc" fi [[ $FAILED -eq 0 ]] || exit 1 diff --git a/lychee.toml b/lychee.toml index 17434b96..60e82477 100644 --- a/lychee.toml +++ b/lychee.toml @@ -15,12 +15,18 @@ include_fragments = "full" # whole trees of markdown, are excluded by path below. hidden = true -# No `accept` key: lychee's default status set applies, so a 429 fails the link. -# Accepting 429 would count a throttled response as OK on the first reply, which -# skips lychee's retry with per-host backoff (`max_retries`, default 3) and -# never checks the page's fragment. lychee's rate-limit docs name accepting 429 -# a last resort. A host that still throttles after the retries is a per-host -# pacing problem, not a reason to stop checking every link on every host. +# Transient failures do not fail an online run: a throttled (429) or erroring +# (5xx) host, or a timeout, says nothing about whether the link is dead. A dead +# link (404, 410 and every other 4xx) still fails. Setting `accept` replaces +# lychee's default set, so the default 100..=103 and 200..=299 are restated. +# Cost: an accepted 429 or 5xx counts as OK on the first reply, so it is not +# retried and its #fragment is not checked. The online run's job summary lists +# accepted 429 and 5xx links and timeouts so a host that stays down is noticed. +# lychee 0.24.2 never retries a rejected 5xx; revisit accepting 5xx when +# https://github.com/lycheeverse/lychee/pull/2324 ships in a release. +# The offline lane makes no HTTP requests, so neither key changes it. +accept = ["100..=103", "200..=299", "429", "500..=599"] +accept_timeouts = true # Rewrite JavaScript-rendered fragments to the same URL without the fragment. # These anchors exist only after the page's scripts run, so lychee, which reads From 1a3af3ceaee0ef027cce06cce28daf58b85c87a2 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:50:29 -0400 Subject: [PATCH 2/2] feat(lychee-online): repin the claude-code-plugins caller to ci-workflows v0.59.0 v0.59.0 (4aff993) carries ci-workflows#844, the report step that lists timeouts and accepted 429 and 5xx links in the job summary, so the caller moves in the same merge as the accept config it reports on. The runner-policy contract at 4aff993 is a verbatim copy of the v0.55.0 entry: the reusable's workflow_call inputs, secrets, outputs, top-level and job permissions and runs-on are structurally identical between 3a2c748 and 4aff993. The gate cannot auto-approve it because the GITHUB_TOKEN-bearing lychee-action step changed (format, output, args, an id), so the copy is registered by hand. Refs: melodic-software/claude-code-plugins#8730 Refs: melodic-software/ci-workflows#739 Co-Authored-By: Claude Opus 5.5 --- .../targets/claude-code-plugins.yml | 2 +- components/runner-policy/README.md | 6 ++++++ components/runner-policy/policy.json | 17 +++++++++++++++++ components/runner-policy/runner-policy.test.mjs | 17 +++++++++++++++++ 4 files changed, 41 insertions(+), 1 deletion(-) diff --git a/components/lychee-online/targets/claude-code-plugins.yml b/components/lychee-online/targets/claude-code-plugins.yml index db8444e4..74c7d322 100644 --- a/components/lychee-online/targets/claude-code-plugins.yml +++ b/components/lychee-online/targets/claude-code-plugins.yml @@ -29,7 +29,7 @@ jobs: check-links: permissions: contents: read - uses: melodic-software/ci-workflows/.github/workflows/maintenance-check-links-online.yml@3a2c748fdef9cfb9ceec5a66289e21842189689f # v0.55.0 + uses: melodic-software/ci-workflows/.github/workflows/maintenance-check-links-online.yml@4aff99320ef484a7beac35ee3a7b7b854ec1e33f # v0.59.0 with: runner: ubuntu-24.04 # Test data, not links to check: intentionally broken fixtures. diff --git a/components/runner-policy/README.md b/components/runner-policy/README.md index efd6b534..9b04b336 100644 --- a/components/runner-policy/README.md +++ b/components/runner-policy/README.md @@ -1347,6 +1347,12 @@ also runs when the API refuses the key with status 401 or 402, a 403 stops the run, and the CLI default moves from 2.1.304 to `latest` (ci-workflows#840, ci-workflows#841). No input, secret, caller permission, or routing changes. +At the v0.59.0 tag `4aff99320ef484a7beac35ee3a7b7b854ec1e33f`, signed off by +hand on 2026-10-21, `maintenance-check-links-online` is a verbatim copy of its +v0.55.0 entry. Its `workflow_call` inputs, secrets, top-level and job +permissions and `runs-on` are unchanged; a new report step lists timeouts and +accepted 429 and 5xx links in the job summary (ci-workflows#844). No input, +secret, caller permission, or routing changes. The Zizmor contract at `de50a08b6093d231519ee7a4c9371db76c0a7e1e` uses its reviewed `runner` input and checksum-verified native Linux binary, so enrolled consumers may route that advisory lane onto the managed fleet diff --git a/components/runner-policy/policy.json b/components/runner-policy/policy.json index 3e97f06e..bc86a4a8 100644 --- a/components/runner-policy/policy.json +++ b/components/runner-policy/policy.json @@ -826,6 +826,23 @@ "minimumCallerPermissions": { "contents": "read" } + }, + "melodic-software/ci-workflows/.github/workflows/maintenance-check-links-online.yml@4aff99320ef484a7beac35ee3a7b7b854ec1e33f": { + "routing": "runner-input", + "runnerInput": "runner", + "allowedInputs": [ + "runner", + "timeout-minutes", + "opt-in-property", + "config", + "extra-args", + "globs", + "lychee-version" + ], + "allowedSecrets": {}, + "minimumCallerPermissions": { + "contents": "read" + } } }, "approvedHostedRunnerLabels": ["ubuntu-24.04", "windows-2025"], diff --git a/components/runner-policy/runner-policy.test.mjs b/components/runner-policy/runner-policy.test.mjs index 3688034b..427eab16 100644 --- a/components/runner-policy/runner-policy.test.mjs +++ b/components/runner-policy/runner-policy.test.mjs @@ -52,6 +52,7 @@ const REPINE_LANE_SHA_V0_54_0 = "7b362428c76085a160e28656cd2c75c3147a69ff"; const REPINE_LANE_SHA_V0_55_0 = "3a2c748fdef9cfb9ceec5a66289e21842189689f"; const REPINE_LANE_SHA_V0_56_0 = "1bb8953644d16f21d10718962c6702f01ea3308a"; const REPINE_LANE_SHA_V0_58_0 = "5e18f3d6cebd6fa5e66b0941ee0f7ed6ba288d6c"; +const REPINE_LANE_SHA_V0_59_0 = "4aff99320ef484a7beac35ee3a7b7b854ec1e33f"; const STANDARDS_SYNC_SHA = "35f2684ac953794b854bac1959df00e74eeca1d9"; const REUSABLE_PATH = "melodic-software/ci-workflows/.github/workflows/osv-scanner.yml"; const REUSABLE_REFERENCE = `${REUSABLE_PATH}@${SHA}`; @@ -9600,6 +9601,22 @@ test("both intake contracts at the v0.58.0 tag copy their v0.56.0 entries forwar ); }); +// v0.59.0 keeps the online link check's interface: inputs, secrets and +// permissions are unchanged and only steps move (ci-workflows#844), so its +// v0.55.0 entry carries forward verbatim. +test("the online link-check contract at the v0.59.0 tag copies its v0.55.0 entry forward verbatim", () => { + const contracts = BASE_POLICY.approvedReusableWorkflowContracts; + const workflowPath = + "melodic-software/ci-workflows/.github/workflows/maintenance-check-links-online.yml"; + const previous = contracts[`${workflowPath}@${REPINE_LANE_SHA_V0_55_0}`]; + assert.ok(previous, "expected a v0.55.0 contract for maintenance-check-links-online"); + assert.deepEqual(contracts[`${workflowPath}@${REPINE_LANE_SHA_V0_59_0}`], previous); + assert.deepEqual( + Object.keys(contracts).filter((key) => key.endsWith(`@${REPINE_LANE_SHA_V0_59_0}`)), + [`${workflowPath}@${REPINE_LANE_SHA_V0_59_0}`], + ); +}); + // Convergence as a property, never as a hardcoded SHA. The daily // claude-lanes-repin lane rewrites these components on every ci-workflows // release, and a test naming one revision would go red by construction on