Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions .github/workflows/citation-check.yaml
Original file line number Diff line number Diff line change
@@ -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'],
});
}
24 changes: 24 additions & 0 deletions README.Rmd
Original file line number Diff line number Diff line change
Expand Up @@ -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")
```
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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")
```
178 changes: 178 additions & 0 deletions inst/scripts/check_citations.R
Original file line number Diff line number Diff line change
@@ -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 <path>] [--json <path>]
#
# 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)
Loading