From 9ddce375f2a44ebc420420df6063e5493195a08b Mon Sep 17 00:00:00 2001 From: Matt McKay Date: Mon, 3 Aug 2026 16:18:38 +1000 Subject: [PATCH 1/2] Harden linkcheck.yml, declare known false positives, pin to v1 Closes #566. Hardening, as filed ------------------- The three improvements from #566, already proven in lecture-python-intro #783: - `contents: read` in permissions. The block only granted `issues: write`, and because `permissions:` resets every unlisted scope to none, the `gh api .../releases/latest` call was running with `contents: none`. - `set -euo pipefail` and single-asset selection. The old jq emitted one line per matching asset with nothing to stop an empty or multi-line result flowing into curl; it now selects the first match and fails with a clear message when there is none. - `curl -fsSL` rather than `-sL`, so an HTTP error page is a download failure rather than a cryptic tar extraction error. Verified the new jq against this repo's current latest release: it returns the publish-2026aug03 tarball, same URL as the old form. Known false positives --------------------- action-link-checker v1.1.0 adds an `ignore-patterns` input (QuantEcon/action-link-checker#2, shipped in #3 there). This declares the same exemptions the repo already keeps in `lectures/_config.yml` under `linkcheck_ignore`, which Sphinx's built-in checker honours but this checker never sees -- it scans built HTML, not the Sphinx config. The regex syntax is identical, so the entries move across unchanged. FRED is the one that mattered: it throttles datacenter IP ranges, so it times out from a runner while returning 200 in under half a second from a normal network. Seventeen duplicate issues were filed for those links between April and July. Checked the patterns against the actual published HTML from the current release, using the shipped v1.1.0 code rather than a reimplementation: across 33 files and 955 external anchors, 7 anchors match, 6 distinct URLs, and no FRED link remains checked. The other 948 are unaffected. Pinning ------- Moves from `@main` to `@v1`. #566 recorded the decision to stay on `@main` on the grounds that it is a first-party action, and that holds, but v1.1.0 shipped a default-on behaviour change (a recurring finding now refreshes one issue instead of opening a new one weekly) which reached this repo the moment it merged. `@v1` still takes patches and features automatically; it just means a future change of that kind is something we adopt rather than receive. The other three consumers -- lecture-python-intro, lecture-python-advanced.myst and continuous_time_mcs -- should move to `@v1` too, so the series does not end up split across pinning schemes. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/linkcheck.yml | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/.github/workflows/linkcheck.yml b/.github/workflows/linkcheck.yml index 22022eb9..5eb558a4 100644 --- a/.github/workflows/linkcheck.yml +++ b/.github/workflows/linkcheck.yml @@ -9,6 +9,7 @@ jobs: name: QuantEcon AI link checking runs-on: "ubuntu-latest" permissions: + contents: read # read latest release assets via gh api issues: write # required for QuantEcon link-checker steps: # Download the latest release HTML archive (permanent, not subject to artifact expiry) @@ -17,19 +18,39 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | + set -euo pipefail ASSET_URL=$(gh api repos/${{ github.repository }}/releases/latest \ - --jq '.assets[] | select(.name | endswith(".tar.gz")) | .browser_download_url') - echo "asset-url=$ASSET_URL" >> $GITHUB_OUTPUT + --jq '[.assets[] | select(.name | endswith(".tar.gz")) | .browser_download_url] | first // ""') + if [ -z "$ASSET_URL" ]; then + echo "::error::No .tar.gz asset found on the latest release" + exit 1 + fi + echo "asset-url=$ASSET_URL" >> "$GITHUB_OUTPUT" - name: Download and extract release HTML run: | + set -euo pipefail mkdir -p _site - curl -sL "${{ steps.release.outputs.asset-url }}" | tar -xz -C _site + curl -fsSL "${{ steps.release.outputs.asset-url }}" | tar -xz -C _site - name: AI-Powered Link Checker - uses: QuantEcon/action-link-checker@main + uses: QuantEcon/action-link-checker@v1 with: html-path: '_site' fail-on-broken: 'false' silent-codes: '403,503' ai-suggestions: 'true' create-issue: 'true' - + # Known false positives, kept in step with the linkcheck_ignore list in + # lectures/_config.yml. Sphinx's built-in checker reads that list; this + # checker scans the built HTML and never sees it, so the exemptions have + # to be stated in both places. Same regex syntax, so entries move across + # unchanged -- update both when adding one. + ignore-patterns: | + https://github.com/matplotlib/matplotlib/blob/v3.6.2/lib/matplotlib/axes/_axes.py#L1417-L1669 + https://ieeexplore.ieee.org/document/8757088 + https://www.sciencedirect.com/science/article/pii/S1477388021000177 + https://keras.io/ + https://data.oecd.org/ + https://www.reddit.com/ + https://openai.com + https://chatgpt.com/ + https://fred.stlouisfed.org/.* From f8cc8d8c71cf5497ae6a36c7a06430a3d76924b0 Mon Sep 17 00:00:00 2001 From: Matt McKay Date: Mon, 3 Aug 2026 16:27:15 +1000 Subject: [PATCH 2/2] Note that Sphinx anchors these patterns and this checker does not Copilot review of #592. The comment said the entries move across "unchanged" because the regex syntax matches, which glossed over a real difference: Sphinx's linkcheck_ignore is applied with re.match, anchored at the start of the URL, while action-link-checker applies its ignore-patterns with re.search. A pattern copied from _config.yml can therefore match more here than it does there, never less. It makes no difference to the nine current entries -- each begins with https://, so matching mid-URL would need a URL with another URL embedded in it -- but a future maintainer adding a bare domain would get an entry that this checker honours and Sphinx silently ignores. Comment text only; the patterns and the behaviour are unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/linkcheck.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/linkcheck.yml b/.github/workflows/linkcheck.yml index 5eb558a4..5066bc58 100644 --- a/.github/workflows/linkcheck.yml +++ b/.github/workflows/linkcheck.yml @@ -43,7 +43,10 @@ jobs: # lectures/_config.yml. Sphinx's built-in checker reads that list; this # checker scans the built HTML and never sees it, so the exemptions have # to be stated in both places. Same regex syntax, so entries move across - # unchanged -- update both when adding one. + # directly -- but the matching differs: Sphinx anchors a pattern at the + # start of the URL, this checker matches anywhere in it. A pattern + # copied here can therefore match more than it does there, never less. + # Update both when adding one, and anchor deliberately if it matters. ignore-patterns: | https://github.com/matplotlib/matplotlib/blob/v3.6.2/lib/matplotlib/axes/_axes.py#L1417-L1669 https://ieeexplore.ieee.org/document/8757088