Skip to content

Point old editions to archived pages from their own time - #528

Merged
JesperDramsch merged 4 commits into
mainfrom
ai-agent/gallant-dirac-yvp0kp
Nov 8, 2026
Merged

JesperDramsch merged 4 commits into
mainfrom
ai-agent/gallant-dirac-yvp0kp

Conversation

@JesperDramsch

Copy link
Copy Markdown
Owner

Why

Many series serve every edition from one URL, and that URL only shows the newest edition. Older editions in the data still point to it, so they show the wrong edition. PyCon Tanzania 2024's sponsor (pycon.or.tz/sponsor/) now shows 2026. The data has 255 such URLs, on superseded editions that are already in it. Nothing caught them: the old link checker treated a 200 as healthy, and only replaced links that failed.

What changes

utils/wayback.py is now the only code that talks to the Wayback Machine:

  • captures(url): one CDX request listing a page's successful captures. A failed lookup raises WaybackError instead of looking like "no captures".
  • pick_capture(stamps, end): the last capture on or before the edition's end plus 14 days. Applied to Tanzania's real CDX list, this picks 2024-10-04, not the October 2026 capture, which shows the next edition.
  • save(url): asks the archive to save a page and reports the real status.

Saving: archive-new-urls.yml → utils/archive_save.py. The triggers are the same as before. It saves URLs new since the last successful run, now from all five URL fields instead of only link. It also saves each edition's pages at fixed points, so a capture from the right time exists before the site moves on:

  • 14 days before the CfP closes, and the day it closes (cfp_ext when set)
  • 30 and 14 days before start, on start, and 14 days after it

A run covers every point since the last successful run and saves each URL once. The old loop reported every save as successful, because || true made the exit status 0.

Resolving: the new weekly archive-resolve.yml → utils/archive_resolve.py.

  • Superseded editions: an edition counts as superseded once it has ended and a later edition of the same conference is in the data. Each yearless live URL on it gets the chosen capture, written with its full 14-digit timestamp.
  • Broken links: a URL that is gone on an edition that has ended is handled the same way. Gone means a 404 or 410, a host that doesn't resolve, a refused connection, or a TLS failure. Timeouts, 403, 429 and 5xx responses don't count.
  • Kept URLs: a URL with no capture in the window stays as it is and goes into a new NEEDS:archive block of the rolling PR. The replacements are listed in the commit body, and COMMITTED:archive names the commit.
  • Left alone: year URLs (2024.pycon.de), editions that aren't superseded, and the 872 existing archive links.
  • Takes over redirect-scan.yml, which this PR removes: every live URL is requested once, and redirects to other sites are queued for the desk as before. Moved Mastodon accounts are followed.
  • Writes to auto/conference-updates with the same concurrency group, merge_data.py conflict handling and push retries as the other writers.

The sort never touches the network. Removed:

  • check_links and check_link_availability
  • --skip_links, and the pixi run links task
  • the no_archive.txt and archived_links.txt caches. Both were keyed by URL alone, so a miss recorded for one edition blocked the lookup for every other edition sharing that URL. They also had no dates, so they couldn't say which edition a capture showed.

Docs:

  • The enricher's compromise path (.claude/skills/enricher/SKILL.md) uses the same CDX query and rule.
  • AGENTS.md, REVIEW.md and DEVELOPMENT.md no longer mention --skip_links.
  • desk.md names the new archive writer's blocks.

Still to come in this PR

The one-time backfill of the 255 superseded URLs. It needs Wayback access, which the cloud session that wrote this PR didn't have. It will be a separate commit, run with archive_resolve.py --no-probe, so its diff can be reviewed by itself.

Tests

  • pytest tests/ (the uv command from REVIEW.md): 2,623 passed, 4 skipped, 3 xfailed, 2 xpassed.
  • New: tests/test_wayback.py, tests/test_archive_save.py, tests/test_archive_resolve.py. tests/test_link_redirects.py now runs the redirect cases through the weekly link check. The tests for the removed cache-based checker (tests/test_link_checking.py) are removed with it.
  • ruff@0.4.4, black@24.4.2, isort@5.13.2 and docsig@0.53.2, with the pre-commit arguments, are clean on the changed files.
  • python utils/sort_yaml.py on the current data succeeds. Its only diff is three editions moving to archive.yml by date, which isn't committed here.

The two workflows haven't run yet. archive-resolve.yml can only be dispatched once it's on main.


Generated by Claude Code

utils/wayback.py is now the only code that talks to the Wayback Machine:
captures() lists a page's captures through CDX, pick_capture() chooses the
last one on or before an edition's end plus 14 days, and save() asks the
archive to save a page and reports the real result.

archive-new-urls.yml moves its inline Python and curl loop into
utils/archive_save.py. It now covers all five URL fields, not only link,
and besides URLs new since the last successful run it saves each
edition's pages at fixed points: 14 days before the CfP closes and on
the day it closes (cfp_ext when set), 30 and 14 days before the start,
on the start, and 14 days after it. A run saves every point since the
last successful run, each URL once. The old loop reported every save as
successful, because `|| true` set the exit status to 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016a2XGgiRrtWm4EUP7ek9wt
A series that serves every edition from one URL shows only its newest
edition there, so older editions' links show the wrong one (PyCon
Tanzania 2024's sponsor page now shows 2026). utils/archive_resolve.py
finds the yearless URLs of superseded editions (ended, with a later
edition of the same conference in the data) and replaces each with the
CDX capture from that edition's time, written with its full 14-digit
timestamp. A URL with no such capture stays as it is and is listed in
the rolling PR's NEEDS:archive block.

The new weekly archive-resolve.yml also takes over the redirect scan:
it requests every live URL once, queues redirects to other sites for
the desk as before, archives links that are gone on editions that have
ended, and follows moved Mastodon accounts. It writes to the rolling
branch with the same merge and push handling as the other writers.

The sort no longer touches the network. check_links,
check_link_availability, the --skip_links flag, the `pixi run links`
task and the no_archive.txt and archived_links.txt caches are removed;
the caches were keyed by URL alone, so a miss for one edition hid
lookups for every other edition sharing that URL. The enricher's
compromise path uses the same CDX rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016a2XGgiRrtWm4EUP7ek9wt
@github-actions github-actions Bot added dependencies Pull requests that update a dependency file Documentation CI/CD Tests labels Nov 6, 2026

Copy link
Copy Markdown
Owner Author

Routine review

1. Request

No linked issue. The request is the PR body: point superseded editions' yearless URLs (link, cfp_link, sponsor, finaid, coc) to the Wayback capture from the edition's own time. This covers:

  • a shared utils/wayback.py;
  • archive-new-urls.yml → utils/archive_save.py: save new URLs from all five fields, plus save points around CfP and start;
  • a new weekly archive-resolve.yml → utils/archive_resolve.py: superseded links, broken links on ended editions, a NEEDS:archive/COMMITTED:archive writer, and the Mastodon follow that replaces redirect-scan.yml;
  • removing check_links, check_link_availability, --skip_links, pixi run links and both URL caches from the sort;
  • doc updates (enricher SKILL, AGENTS.md, REVIEW.md, DEVELOPMENT.md, desk.md).

The 255-URL backfill is announced as a later commit in this PR.

2. Checked against

  • Date: Fri Nov 6 19:01:51 UTC 2026.
  • unverified: that the CDX API (web.archive.org/cdx/search/cdx, filter=statuscode:200) returns what utils/wayback.py parses. Wayback was unreachable from the sandbox (proxy 403).
  • requests' exception hierarchy (a peer reset is raised as requests.ConnectionError; ConnectTimeout subclasses both ConnectionError and Timeout). I checked this by running requests 2.x locally, not from docs, so no URL is cited.

3. Needs your decision

  1. Whether captures need a lower bound. pick_capture (utils/wayback.py:168) takes the latest capture on or before end + 14 days and has no earliest date. Say a yearless URL was last captured years before the edition, while it still showed an older edition. The superseded edition then gets that older page, permanently, because archive links are never revisited. Should captures from before about start − 1 year count as "no capture" and go to NEEDS:archive? Recommendation: yes. It costs a few more NEEDS lines and removes a silent wrong-edition case.
  2. This PR changes REVIEW.md by dropping --skip_links from the Data check. I applied the base-branch version except for that one flag: the flag no longer exists on the head, so the sort ran without it. Recommendation: keep the change. It is needed once the flag goes.
  3. Scope. The PR folds in the weekly link check: redirect-scan.yml is deleted, and every live URL in all three files is probed (1,298 URLs today; the old check skipped links more than 5 years old). It also replaces broken links on any ended edition, not only superseded ones. Keep both in this PR, or split them out? Recommendation: keep them, but only with the finding below fixed, since broken-link replacement is the part that edits data unattended.

4. Findings

Important

  • utils/tidy_conf/links.py:447-448 with :415. Every requests.ConnectionError becomes error="connection", and Probe.broken treats that as gone. The docstring (:408-413) says only an unresolvable host, a refused connection or a TLS failure count, and that "only a lasting failure counts". But requests raises a mid-request reset as the same ConnectionError.
    • Evidence: a verification run against a local server that resets after reading the request printed Probe(url='http://127.0.0.1:43075/', …, status=None, error='connection') broken= True. The raw exception was ConnectionError(ProtocolError('Connection aborted.', ConnectionResetError(104, 'Connection reset by peer'))). A server that just closes gave RemoteDisconnected(...), also broken=True.
    • Sequence: in the weekly run, a site that is up resets one connection. check_links (utils/archive_resolve.py:266) makes it a broken candidate on any ended edition, including one that is not superseded or that has a year URL. resolve then overwrites the field with an archive link. There is no retry, and the change is never revisited.
    • Smallest fix: in probe, count "connection" only when the wrapped cause is a name-resolution failure, ConnectionRefusedError or requests.exceptions.SSLError, and map ProtocolError('Connection aborted.') to a transient error. Add a case for it to TestProbe.test_errors (tests/test_archive_resolve.py:217); responses can raise requests.ConnectionError(ProtocolError(...)).

Nits

  • .github/workflows/archive-resolve.yml:235. NEEDS:archive is written only when the run changed data and needs.md is non-empty. This matches enrich-tba-conferences.yml:283-286, but kept archive URLs recur every week and produce no data change. After the rolling PR is merged, a week with zero replacements leaves the kept list in the run log only. A week with an empty list never clears an earlier, stale block.
  • utils/archive_resolve.py:194: latest[entry["conference"]] assumes every entry with a start has conference. The schema guarantees it today; entry.get would match line 189.

5. Tests

Invariant coverage. The request's invariant is "a superseded yearless URL gets the capture from its own edition's time". The tests cover it on the Tanzania fixture (test_tanzania_2024_gets_the_capture_from_its_own_time, TestPickCapture) and on synthetic entries. None runs over the 255 real instances; that is expected once the backfill commit lands, so its diff is the evidence there. I loaded the real data and ran superseded_candidates(…, 2026-11-06): 255 candidates over 205 unique URLs, matching the PR body. Writing all three files back with write_conference_yaml and no replacements gives an empty git diff, so the resolve job's rewrite does not churn the data.

Base vs change. The new test files import modules that do not exist on base, so they fail there by construction. This proves nothing beyond that.

Change (head f9cdaaf):

  • pytest tests/ -q -p no:cacheprovider (REVIEW.md's uv line, without -x): 2623 passed, 4 skipped, 3 xfailed, 2 xpassed, 251 warnings in 138.09s (0:02:18)
  • uvx ruff@0.4.4 check utils tests: All checks passed!
  • python utils/sort_yaml.py (uv, REVIEW.md pins): exit 0. git diff --stat -- _data: _data/archive.yml | 53 +++, _data/conferences.yml | 53 ---. This is the date-driven move the post-merge sort will make, not a finding.
  • npm ci && npm test: Test Suites: 15 passed, 15 total, Tests: 423 passed, 423 total
  • pre-commit.ci - pr: success. The other check runs (Python Tests, Pre-commit Checks, Data Validation, CodeQL) also succeeded on f9cdaaf.

6. Bounds

  • Head commit. The webhook named 4f00f1e. The PR head is now f9cdaaf, a pre-commit.ci auto-fix on top of it that touches only tests/test_archive_resolve.py (+3/−1). I reviewed f9cdaaf.
  • Route. Cloned the repository, fetched refs/pull/528/head, and diffed against origin/main. REST reads worked.
  • Read in full: utils/wayback.py, utils/archive_resolve.py, utils/archive_save.py, both workflows, the links.py Probe/probe section, and the sort/main/merge_data/desk_pr_body/pixi/doc diffs.
  • Skimmed: the test diffs, beyond the cases cited.
  • Not run:
    • Any Wayback request (proxy 403).
    • Either workflow. archive-resolve.yml cannot be dispatched before merge.
    • Whether gh run list in archive-new-urls.yml works under permissions: contents: read. This is pre-existing: the base workflow made the same call with the same permissions.

Generated by Claude Code

requests raises a reset or a connection dropped mid-request as the same
ConnectionError as a host that does not resolve, so probe() counted a
site that is up but reset one connection as gone, and the weekly resolve
would have archived its link for good. probe() now walks the exception
chain: a failed TLS handshake, a name-resolution failure or a refused
connection is lasting; anything else is "reset" and leaves the link alone.

The resolve workflow now replaces NEEDS:archive on every run that finds
an open rolling PR, and empties it when nothing is kept, so a week with
no data change no longer leaves a stale list. superseded() reads
`conference` with .get on both passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016a2XGgiRrtWm4EUP7ek9wt

Copy link
Copy Markdown
Owner Author

Replies to the routine review. All three are addressed in 608fcdf.

Important: a reset counted as broken (fixed). I reproduced it: against a local socket that resets after reading the request, probe returned error='connection' and broken=True. The exception chains requests raises, as seen locally:

Case Chain
Reset ConnectionError → ProtocolError → ConnectionResetError
Refused ConnectionError → MaxRetryError → NewConnectionError → ConnectionRefusedError
DNS failure ConnectionError → MaxRetryError → NameResolutionError → gaierror

probe now walks the chain (is_lasting_connection_failure). Only SSLError, ConnectionRefusedError, NameResolutionError and gaierror count as "connection", and so as broken. Everything else becomes "reset" and leaves the link alone. TestProbe covers each of these chains, plus RemoteDisconnected. It also has two live-socket tests: one server that resets the connection, and one closed port that refuses it. The reset cases fail against the previous probe and pass now.

Nit: NEEDS:archive only written on a data change (fixed). The PR step now replaces the block on every run that finds an open rolling PR, and empties it when nothing is kept. A run with no change still doesn't open a PR just for that list.

Nit: latest[entry["conference"]] (fixed). Both passes of superseded() now use entry.get("conference").

On the decisions:

  • (2) The REVIEW.md flag change stays: --skip_links no longer exists.
  • (3) Folding the weekly link check and the broken-link rule into the resolve job was the maintainer's choice when we planned this. It stays, now with the reset fix.
  • (1) A lower bound on capture age is a change to the selection rule. I've put it to the maintainer and haven't changed it here.

Full suite: 2628 passed, 4 skipped, 3 xfailed, 2 xpassed. pre-commit is clean on the changed files.


Generated by Claude Code

@JesperDramsch JesperDramsch added the Review Ready Runs the PR review Routine; the Routine removes it after posting label Nov 6, 2026

Copy link
Copy Markdown
Owner Author

Routine review

Re-review of 608fcdf. The earlier review was of f9cdaaf.

1. Request

No linked issue. The request is the PR body: point superseded editions' yearless URLs (link, cfp_link, sponsor, finaid, coc) to the Wayback capture from the edition's own time. It covers:

  • utils/wayback.py;
  • archive-new-urls.yml → utils/archive_save.py;
  • the weekly archive-resolve.yml → utils/archive_resolve.py, covering superseded links, broken links on ended editions, NEEDS:archive/COMMITTED:archive, and the Mastodon follow that replaces redirect-scan.yml;
  • the removal of check_links, check_link_availability, --skip_links, pixi run links and both URL caches;
  • the doc updates.

This round's delta (608fcdf):

  • probe counts a reset as transient;
  • NEEDS:archive is now refreshed on every run;
  • superseded() uses entry.get("conference").

2. Checked against

  • Date: Fri Nov 6 21:50:16 UTC 2026.
  • The exception chains probe now walks: I ran the PR's live-socket tests locally. One server resets after reading the request; one port is closed, so the connection is refused. No URL is cited.
  • urllib3.exceptions.NameResolutionError, imported at module level in utils/tidy_conf/links.py:9: pixi.lock pins only urllib3 2.x (2.2.1, 2.3.0, 2.5.0). I checked locally that the probe, redirect and Mastodon tests pass with requests==2.31.0 and urllib3==2.2.1, the lowest pinned pair. No URL is cited.
  • unverified: that the CDX API returns what utils/wayback.py parses. Wayback was not exercised, as in the first review.

3. Needs your decision

  1. Lower bound on capture age (carried over). pick_capture (utils/wayback.py:150) still has no earliest date. The author left this to you. Recommendation unchanged: count captures from before about start − 1 year as "no capture" and send them to NEEDS:archive.
  2. The backfill commit is not on the head. The PR body still says the one-time backfill of the 255 URLs will land as a separate commit in this PR. Once merged, though, the first archive-resolve.yml run (scheduled, or dispatched with no_probe) does the same resolve into the rolling PR, with the replacements listed in its commit body. Wait for the commit here, or merge and let that first run be the backfill? Recommendation: merge without it, and update the PR body so it doesn't promise a commit that won't come. The rolling PR's diff is just as reviewable by itself.

4. Findings

Important

None new.

Resolved from the first review:

  • Reset counted as broken (utils/tidy_conf/links.py). probe now returns "connection" only when is_lasting_connection_failure (links.py:483-503) finds SSLError, ConnectionRefusedError, socket.gaierror or NameResolutionError in the chain. Everything else is "reset", which Probe.broken (links.py:418) does not count. The live-socket reset test returns error == "reset" and broken is false. The refused-port test still returns broken. Both cases fail on the old probe; see Tests.

5. Tests

Invariant coverage. Unchanged from the first review. The superseded-capture invariant is covered on the Tanzania fixture and on synthetic entries. The 255 real instances are evidenced only once the backfill runs (decision 2). For this round's fix, the invariant is "a ConnectionError that is not lasting never marks a link broken". TestProbe::test_errors covers it for the reset and RemoteDisconnected chains, and test_reset_by_a_live_server_is_not_broken covers a real socket.

Base vs change (tests/test_archive_resolve.py -k TestProbe; base is links.py from f9cdaaf, the change is 608fcdf):

  • Base: 3 failed, 12 passed, 30 deselected in 0.21s. The failures are test_errors[error3-reset-False], test_errors[error4-reset-False] and test_reset_by_a_live_server_is_not_broken. The DNS, refused and SSL cases already passed on base, which is expected: they were already broken before.
  • Change: 15 passed, 30 deselected in 0.17s
  • Change, with requests==2.31.0 and urllib3==2.2.1 (test_archive_resolve.py, test_link_redirects.py, test_mastodon_migration.py): 136 passed in 0.63s

Change, full checks (head 608fcdf):

  • pytest tests/ -q -p no:cacheprovider (REVIEW.md's uv line, without -x): 2628 passed, 4 skipped, 3 xfailed, 2 xpassed, 251 warnings in 109.35s (0:01:49)
  • uvx ruff@0.4.4 check utils tests: All checks passed!
  • python utils/sort_yaml.py (uv, REVIEW.md pins, without the removed --skip_links): exit 0. git diff --stat -- _data: _data/archive.yml | 53 +++, _data/conferences.yml | 53 ---. This is the same date-driven move as last round, not a finding.
  • npm ci && npm test: Test Suites: 15 passed, 15 total, Tests: 423 passed, 423 total

6. Bounds

  • Head commit. git rev-parse HEAD matched 608fcdf7dcb2aaca06120c6195c4b3514683eb4d.
  • Route. Cloned the repository, fetched refs/pull/528/head, and diffed against origin/main. REST reads worked.
  • Read in full:
    • the 608fcdf diff;
    • the Resolve archive links → Update the rolling PR steps of archive-resolve.yml, lines 92-246. commit_sha is emitted only after a successful push, so a failed push cannot append a COMMITTED:archive line for an unpushed commit.
  • Not re-read: the rest of the PR, which is unchanged since f9cdaaf and was reviewed then.
  • Skipped: Nits, per the re-review rule.
  • Not run: any Wayback request; either workflow.

Generated by Claude Code

@JesperDramsch JesperDramsch added Review Ready Runs the PR review Routine; the Routine removes it after posting and removed Review Ready Runs the PR review Routine; the Routine removes it after posting labels Nov 6, 2026
@JesperDramsch
JesperDramsch merged commit 74223f6 into main Nov 8, 2026
11 checks passed
@JesperDramsch
JesperDramsch deleted the ai-agent/gallant-dirac-yvp0kp branch November 8, 2026 21:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CI/CD dependencies Pull requests that update a dependency file Documentation Tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants