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)