From a06c52208b3f94f199bec4d54595dc0f1f7ca389 Mon Sep 17 00:00:00 2001 From: chross22 <52218551+chross22@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:21:16 -0400 Subject: [PATCH] Watch for citations that stop resolving Citations go stale without anyone touching them. Data centres reissue a DOI when a record is superseded and retire the old one, so a reference that was correct when written stops resolving on its own. Nothing in the package resolves a DOI at runtime, so nothing would ever notice. This is not hypothetical here. The AMOC reference shipped pointing at 10.5285/223b34a3-..., which BODC retired on publishing a newer RAPID release; it was found by hand this afternoon, having been wrong for some time. A quarterly workflow now resolves every DOI cited in the README, the catalogs, and NEWS.md, and opens one rolling issue naming any that are dead and where each is cited. Quarterly rather than monthly because these are retired on the timescale of dataset releases. It reads only. Replacing a dead citation means deciding which version of a record the package should track, which is a judgement rather than a lookup, so the check reports and stops. Two things it deliberately does not report. A DOI that fails HEAD is retried with GET, because some publishers refuse HEAD to anything not browser-shaped - the sf citation returns 503 to one and 200 to the other. And every DOI failing at once is treated as no network rather than as every citation dying simultaneously. Verified both ways: all twelve resolve today, and swapping one for the retired RAPID DOI produces the report naming it. Co-Authored-By: Claude Opus 5 --- .github/workflows/citation-check.yaml | 108 ++++++++++++++++ README.Rmd | 24 ++++ README.md | 25 ++++ inst/scripts/check_citations.R | 178 ++++++++++++++++++++++++++ 4 files changed, 335 insertions(+) create mode 100644 .github/workflows/citation-check.yaml create mode 100644 inst/scripts/check_citations.R diff --git a/.github/workflows/citation-check.yaml b/.github/workflows/citation-check.yaml new file mode 100644 index 0000000..a01d2dc --- /dev/null +++ b/.github/workflows/citation-check.yaml @@ -0,0 +1,108 @@ +# Checks that every DOI this package cites still resolves. +# +# Citations go stale without anyone touching them. Data centres reissue a DOI +# when a record is superseded and retire the old one, so a reference that was +# correct when written stops resolving on its own. This package has already +# shipped one such: the AMOC reference pointed at a RAPID DOI that BODC retired +# when it published a newer version of the series. +# +# Nothing in the package resolves a DOI at runtime, so nothing would ever notice. +# Hence a scheduled check rather than a test. +# +# It only reads. Replacing a dead citation means choosing which version of a +# record the package should track, which is a judgement rather than a lookup. + +name: Citation check + +on: + schedule: + # 07:00 UTC on the first of each quarter. DOIs are retired on the timescale + # of dataset releases - roughly yearly - so monthly would mostly be noise. + - cron: "0 7 1 1,4,7,10 *" + workflow_dispatch: + +permissions: + contents: read + issues: write + +jobs: + check: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: r-lib/actions/setup-r@v2 + with: + use-public-rspm: true + + - uses: r-lib/actions/setup-r-dependencies@v2 + with: + extra-packages: | + any::curl + any::jsonlite + any::pkgload + needs: check + + - name: Check the citations + id: check + run: | + set +e + Rscript inst/scripts/check_citations.R --markdown citations.md --json citations.json + echo "status=$?" >> "$GITHUB_OUTPUT" + shell: bash + + - name: Upload the report + if: always() + uses: actions/upload-artifact@v4 + with: + name: citation-report + path: | + citations.md + citations.json + if-no-files-found: ignore + + # Exit 2 is "no DOI resolved at all", which is a network problem rather + # than a citation problem. Filing an issue for it would train everyone to + # ignore these. + - name: Report an unreachable network + if: steps.check.outputs.status == '2' + run: | + echo "::warning::Could not resolve any DOI; no citation conclusion drawn." + + - name: Open or update an issue + if: steps.check.outputs.status == '1' + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const body = fs.readFileSync('citations.md', 'utf8'); + const title = 'A cited DOI no longer resolves'; + + // One rolling issue rather than a new one each quarter, so a + // citation left unfixed does not accumulate duplicates. + const existing = await github.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + labels: 'citations', + }); + + const footer = `\n\n---\n_Checked by [${context.workflow}](${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}) on ${new Date().toISOString().slice(0, 10)}._`; + + if (existing.data.length > 0) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: existing.data[0].number, + body: body + footer, + }); + } else { + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title, + body: body + footer, + labels: ['citations'], + }); + } diff --git a/README.Rmd b/README.Rmd index a02d2d5..c77eda3 100644 --- a/README.Rmd +++ b/README.Rmd @@ -1130,3 +1130,27 @@ the provider: `citation("datamatch")` gives this package's own entry, and `citation()` works on any of the above. + +### Keeping these current + +Citations go stale without anyone touching them. Data centres reissue a DOI when +a record is superseded and retire the old one, so a reference that was correct +when written stops resolving on its own. The `AMOC` entry here has already been +through that once, when BODC published a newer RAPID release. + +Two scheduled workflows watch for it, and open an issue rather than editing +anything, since choosing a replacement is a judgement about which version to +track: + +- **Citation check**, quarterly, resolves every DOI cited in the README, the + catalogs, and `NEWS.md`. +- **Copernicus catalog check**, monthly, compares the variable catalog against + the live Copernicus catalogue, since dataset identifiers are revised too. + +Both run on demand from the Actions tab, and locally: + +```{r citation-check, eval = FALSE} +# from the package root +system("Rscript inst/scripts/check_citations.R") +system("Rscript inst/scripts/check_catalog.R") +``` diff --git a/README.md b/README.md index 22b0226..aa478d0 100644 --- a/README.md +++ b/README.md @@ -1218,3 +1218,28 @@ the provider: `citation("datamatch")` gives this package's own entry, and `citation()` works on any of the above. + +### Keeping these current + +Citations go stale without anyone touching them. Data centres reissue a DOI when +a record is superseded and retire the old one, so a reference that was correct +when written stops resolving on its own. The `AMOC` entry here has already been +through that once, when BODC published a newer RAPID release. + +Two scheduled workflows watch for it, and open an issue rather than editing +anything, since choosing a replacement is a judgement about which version to +track: + +- **Citation check**, quarterly, resolves every DOI cited in the README, the + catalogs, and `NEWS.md`. +- **Copernicus catalog check**, monthly, compares the variable catalog against + the live Copernicus catalogue, since dataset identifiers are revised too. + +Both run on demand from the Actions tab, and locally: + + +``` r +# from the package root +system("Rscript inst/scripts/check_citations.R") +system("Rscript inst/scripts/check_catalog.R") +``` diff --git a/inst/scripts/check_citations.R b/inst/scripts/check_citations.R new file mode 100644 index 0000000..e6e4993 --- /dev/null +++ b/inst/scripts/check_citations.R @@ -0,0 +1,178 @@ +#!/usr/bin/env Rscript +# +# Check that every DOI this package cites still resolves. +# +# A dead DOI is worse than a missing one. It looks like a citation, so nobody +# checks it, and it sends a reader nowhere. This package has already shipped one: +# the AMOC reference pointed at 10.5285/223b34a3-..., which BODC retired when it +# published a newer version of the RAPID series. +# +# That is the failure this watches for, and it is not a mistake anyone made. Data +# centres reissue DOIs as records are superseded, so a citation that was correct +# when written goes stale on its own. Nothing in the package notices, because +# nothing resolves a DOI at runtime. +# +# Reads only. Reports what is dead and what has moved; changes nothing, because +# choosing a replacement citation is a judgement about which version the package +# should track. +# +# Usage: +# Rscript inst/scripts/check_citations.R [--markdown ] [--json ] +# +# Exits 0 when every DOI resolves, 1 when any is dead, 2 when the network could +# not be reached at all - which is not the same as a dead DOI and should not be +# reported as one. + +suppressMessages(library(jsonlite)) + +args <- commandArgs(trailingOnly = TRUE) +arg_value <- function(flag, default = NULL) { + hit <- match(flag, args) + if (is.na(hit) || hit == length(args)) default else args[hit + 1] +} +markdown_path <- arg_value("--markdown") +json_path <- arg_value("--json") + +suppressMessages(pkgload::load_all(quiet = TRUE)) + +# ---- every DOI the package cites -------------------------------------------- + +doi_pattern <- "10\\.[0-9]{4,9}/[-._;()/:A-Za-z0-9]+" + +collect_dois <- function() { + found <- list() + add <- function(doi, where) { + # The DOI pattern allows parentheses, because some real DOIs contain them, + # so it swallows the closing bracket of a markdown link. Trailing prose + # punctuation comes off here. + doi <- sub("[])>.,;]+$", "", doi) + + # "10.48670/moi-xxxxx" is the placeholder in Copernicus's own citation + # format, quoted in the README as an example. Resolving it would report a + # dead DOI that is not a citation at all. + if (grepl("x{3,}", doi)) return(invisible(NULL)) + + found[[length(found) + 1]] <<- data.frame(doi = doi, where = where, + stringsAsFactors = FALSE) + } + + # The catalogs are the authoritative place a citation lives; the README is + # where a reader looks for it. Both are checked, so they cannot drift apart + # without one of them failing here. + for (name in names(climate_indices())) { + reference <- climate_indices()[[name]]$reference + if (is.null(reference)) next + for (doi in regmatches(reference, gregexpr(doi_pattern, reference))[[1]]) { + add(doi, paste0("climate_indices()$", name)) + } + } + + for (file in c("README.md", "NEWS.md")) { + if (!file.exists(file)) next + text <- paste(readLines(file, warn = FALSE), collapse = "\n") + for (doi in unique(regmatches(text, gregexpr(doi_pattern, text))[[1]])) { + add(doi, file) + } + } + + all <- do.call(rbind, found) + # One row per DOI, listing everywhere it appears, so a dead one is reported + # once with all the places needing an edit. + stats::aggregate(where ~ doi, data = all, + FUN = function(w) paste(unique(w), collapse = ", ")) +} + +# ---- does it resolve? -------------------------------------------------------- + +# doi.org answers a HEAD for most registrars, but some publishers refuse it and +# return 405 or 503 to anything that is not a browser-shaped GET. A HEAD failure +# is therefore retried as a GET before being believed. +resolves <- function(doi) { + url <- paste0("https://doi.org/", doi) + for (method in c("HEAD", "GET")) { + status <- tryCatch({ + handle <- curl::new_handle(nobody = identical(method, "HEAD"), + followlocation = TRUE, timeout = 30L, + useragent = "datamatch citation check") + curl::curl_fetch_memory(url, handle)$status_code + }, error = function(e) NA_integer_) + + if (!is.na(status) && status >= 200 && status < 400) { + return(list(ok = TRUE, status = status)) + } + last <- status + } + list(ok = FALSE, status = last) +} + +# ---- check ------------------------------------------------------------------- + +if (!requireNamespace("curl", quietly = TRUE)) { + message("The 'curl' package is required. install.packages(\"curl\")") + quit(status = 2) +} + +dois <- collect_dois() +message("Checking ", nrow(dois), " DOI(s).") + +results <- lapply(seq_len(nrow(dois)), function(i) { + message(" ", dois$doi[i]) + result <- resolves(dois$doi[i]) + data.frame(doi = dois$doi[i], where = dois$where[i], + ok = result$ok, status = result$status %||% NA_integer_, + stringsAsFactors = FALSE) +}) +results <- do.call(rbind, results) + +# Every DOI failing to resolve almost always means no network rather than a +# simultaneous retirement of every citation. Reported as unreachable rather than +# as a wall of dead links. +if (all(!results$ok) && nrow(results) > 1) { + message("No DOI resolved, which looks like a network problem rather than ", + "a citation problem.") + quit(status = 2) +} + +dead <- results[!results$ok, ] + +# ---- report ------------------------------------------------------------------ + +if (!is.null(json_path)) { + jsonlite::write_json(results, json_path, auto_unbox = TRUE, pretty = TRUE) +} + +report <- c( + "## Citation check", + "", + paste0("Resolved **", sum(results$ok), "** of **", nrow(results), + "** cited DOIs on ", format(Sys.Date()), "."), + "" +) + +if (nrow(dead) == 0) { + report <- c(report, "Every DOI this package cites still resolves.") +} else { + report <- c(report, + "These no longer resolve. A dead DOI looks like a citation and sends the ", + "reader nowhere, so it is worth replacing rather than removing.", + "", + "| DOI | HTTP | Cited in |", + "|---|---|---|", + apply(dead, 1, function(r) { + paste0("| `", r[["doi"]], "` | ", r[["status"]], " | ", r[["where"]], " |") + }), + "", + "Data centres reissue DOIs when a record is superseded, so the usual fix is ", + "to find the current version rather than to drop the citation. BODC does ", + "this for each RAPID release, which is how the `AMOC` reference went stale ", + "once already.", + "", + "Choosing the replacement is a judgement about which version the package ", + "should track, so this check does not guess one.") +} + +text <- paste(report, collapse = "\n") +cat(text, "\n") +if (!is.null(markdown_path)) writeLines(text, markdown_path) + +quit(status = if (nrow(dead) == 0) 0 else 1)