Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions components/lychee-online/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
2 changes: 1 addition & 1 deletion components/lychee-online/targets/claude-code-plugins.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
108 changes: 64 additions & 44 deletions components/lychee/lychee.test.sh
Original file line number Diff line number Diff line change
@@ -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)"
Expand Down Expand Up @@ -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()

Expand All @@ -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()
Expand Down Expand Up @@ -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 '<http://127.0.0.1:%s/%s/throttled>\n' "$http_port" "$status_probe" \
>"$http_fixture_root/Throttled.md"
printf '<http://127.0.0.1:%s/%s/live>\n' "$http_port" "$status_probe" \
>"$http_fixture_root/Live.md"
for code in 200 429 500 503 599 404 410 hang; do
printf '<http://127.0.0.1:%s/%s/%s>\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
6 changes: 6 additions & 0 deletions components/runner-policy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions components/runner-policy/policy.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
17 changes: 17 additions & 0 deletions components/runner-policy/runner-policy.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
Expand Down Expand Up @@ -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
Expand Down
18 changes: 12 additions & 6 deletions lychee.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading