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-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/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/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 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