Skip to content

CI: "Internal links" job flakes — linkinator@6 crashes with exit 13 ("unsettled top-level await") #58

Description

@jdevalk

Summary

The Internal links check in .github/workflows/links.yml (the internal job) intermittently fails with exit code 13. When it does, linkinator crawls only the homepage and then aborts — it never reports a broken link. This is a tooling crash, not a real broken-link finding, so it produces false-red runs that block PRs.

Seen on PR #56: failed twice on re-run with identical output, while the same content scans clean locally.

Evidence

Failing CI log (job tail):

🏊‍♂️ crawling http://localhost:4321
[200] http://localhost:4321/
Warning: Detected unsettled top-level await at .../linkinator/build/src/cli.js:318
await main();
^
##[error]Process completed with exit code 13.

The crawl dies ~5s after fetching /, before recursing into any other page.

Running the exact same command locally against the built site passes cleanly:

🤖 Successfully scanned 210 links in 0.155 seconds.
linkinator exit: 0

So there are zero broken internal links — the failure is the crawler crashing, not the content.

Root cause

Node exit code 13 = "unsettled top-level await": the event loop emptied while a top-level await was still pending. This is a known interaction between linkinator@6's CLI (await main() at top level) and newer Node runtimes. CI pins node-version: 22, but the job invokes npx --yes linkinator@6, which resolves to whatever latest 6.x is published at run time — so the behaviour can change underneath us without any repo change.

The same pattern is used in both jobs in links.yml:

  • internal job — .github/workflows/links.yml:48
  • external job — .github/workflows/links.yml:103

Possible fixes (pick one)

  1. Pin to a known-good exact version — replace linkinator@6 with a pinned linkinator@<x.y.z> (and bump deliberately), so an upstream patch can't silently reintroduce the crash. Lowest-risk.
  2. Retry the crawl step — wrap the linkinator invocation in a small retry loop (it's a hard crash, not a slow link), so a transient abort doesn't fail the job. Mirrors the existing external-link hardening.
  3. Switch checker — evaluate lychee (Rust, no Node top-level-await foot-gun) as a drop-in for the internal crawl.

Option 1 is the smallest change and most in keeping with the existing linkinator.config.json setup.

Impact

  • False-red Internal links check blocks PR merges until manually re-run (and re-runs don't reliably help, since it's deterministic per resolved linkinator version).
  • No correctness risk to the site — broken links are still caught when the crawler doesn't crash, and the daily external sweep is unaffected by this specific symptom.

🤖 Generated with Claude Code

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workinggithub_actionsPull requests that update GitHub Actions code

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions