From d7cc7bb225af8ee0741717bc993bddc8b99f13cb Mon Sep 17 00:00:00 2001 From: lmoresi Date: Wed, 2 Sep 2026 08:47:30 -0700 Subject: [PATCH 1/2] Say what is half-finished, without being asked A deposit sat unrecorded for a fortnight. Nothing was broken and nothing complained: the only thing that would have was the NEXT deposit, which refused and made the backlog somebody's problem at the worst moment. The weakness is not the manual steps -- a person merging the request to deposit is the right design -- it is that a step can fail to happen and leave no trace. scripts/outstanding.py names the half-finished states: identifiers written but never merged, a deposit asked for and never run, a note whose version has moved past the copy on its DOI, an archival note published without one, and a DOI with no record id. `pixi run outstanding` locally; exit 1 if anything is outstanding. A report nobody runs has the failure mode it was built to fix, so the `outstanding` workflow runs it weekly and keeps ONE issue: opened when something is outstanding, edited while it stays that way, closed when it clears, and silent otherwise. Detecting a note that has outrun its deposit needs a fact we were not recording. `archived_at` says when a copy was taken, and a git timestamp cannot tell a rewrite from a typo, so the deposit now stamps `archived_version` -- the note's own version at the moment it was deposited. `version != archived_version` is then exactly the state where the DOI serves older text than the site. Backfilled for the 43 deposited notes and added to the schema. Running it found a second defect, unrelated to the first and invisible until now: deposit-ready re-asks on every metadata change, because it checked deposit-queue.txt (what was MERGED) and not what was already open. One note had four open requests. Duplicate reminders are how a reminder becomes something you scroll past, so it now counts an open request as having asked. Underworld development team with AI support from Claude Code --- .github/workflows/deposit-ready.yml | 17 ++ .github/workflows/outstanding.yml | 88 +++++++++ articles/2-11-scaling/metadata.yml | 1 + .../metadata.yml | 1 + .../metadata.yml | 1 + .../metadata.yml | 1 + .../metadata.yml | 1 + articles/build-conda-packages/metadata.yml | 1 + .../metadata.yml | 1 + .../metadata.yml | 1 + .../metadata.yml | 1 + .../metadata.yml | 1 + .../free-surface-in-underworld/metadata.yml | 1 + .../metadata.yml | 1 + .../metadata.yml | 1 + .../metadata.yml | 1 + .../metadata.yml | 1 + .../metadata.yml | 1 + .../metadata.yml | 1 + .../metadata.yml | 1 + .../metadata.yml | 1 + .../metadata.yml | 1 + .../metadata.yml | 1 + .../particles-in-underworld3/metadata.yml | 1 + .../metadata.yml | 1 + .../metadata.yml | 1 + articles/scaling-in-underworld/metadata.yml | 1 + .../self-updating-repositories/metadata.yml | 1 + .../setting-up-full-multigrid/metadata.yml | 1 + .../metadata.yml | 1 + .../metadata.yml | 1 + .../metadata.yml | 1 + .../metadata.yml | 1 + .../metadata.yml | 1 + articles/underworld-2/metadata.yml | 1 + .../underworld-and-docker-part-1/metadata.yml | 1 + .../underworld-and-docker-part-2/metadata.yml | 1 + .../underworld-and-singularity/metadata.yml | 1 + .../underworld-low-fat-cloud/metadata.yml | 1 + articles/underworld-on-zenodo/metadata.yml | 1 + .../underworld3-come-and-get-it/metadata.yml | 1 + articles/untitled-2/metadata.yml | 1 + articles/untitled/metadata.yml | 1 + .../metadata.yml | 1 + articles/viscoelasticity/metadata.yml | 1 + pixi.toml | 6 + schemas/article-metadata.schema.json | 5 + scripts/deposit.py | 10 +- scripts/outstanding.py | 180 ++++++++++++++++++ tests/test_outstanding.py | 79 ++++++++ 50 files changed, 427 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/outstanding.yml create mode 100644 scripts/outstanding.py create mode 100644 tests/test_outstanding.py diff --git a/.github/workflows/deposit-ready.yml b/.github/workflows/deposit-ready.yml index 08ad9a1..3105578 100644 --- a/.github/workflows/deposit-ready.yml +++ b/.github/workflows/deposit-ready.yml @@ -31,7 +31,19 @@ jobs: - name: Which notes have no DOI id: pending + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | + # A request that is already OPEN counts as asked. deposit-queue.txt + # only records what has been merged, so without this the workflow + # re-asks on every metadata change and the same note accumulates + # requests -- four for one note, before this was noticed. Duplicate + # reminders are how a reminder becomes something you scroll past. + gh pr list --state open --limit 100 --json title \ + --jq '.[] | select(.title | startswith("Deposit: ")) | + .title | sub("^Deposit: "; "")' \ + | tr ',' '\n' | tr -d ' ' | sed '/^$/d' > .already-asked || true + echo "already asked: $(tr '\n' ' ' < .already-asked)" SLUGS=$(pixi run -q python3 -c " import sys; sys.path.insert(0, 'scripts'); import deposit queued = set() @@ -41,8 +53,13 @@ jobs: if line: queued.add(line) except FileNotFoundError: pass + try: + queued.update(l.strip() for l in open('.already-asked') if l.strip()) + except FileNotFoundError: + pass print(','.join(s for s in deposit.pending() if s not in queued)) ") + rm -f .already-asked echo "slugs=${SLUGS}" >> "$GITHUB_OUTPUT" echo "not yet queued: ${SLUGS:-none}" diff --git a/.github/workflows/outstanding.yml b/.github/workflows/outstanding.yml new file mode 100644 index 0000000..859f685 --- /dev/null +++ b/.github/workflows/outstanding.yml @@ -0,0 +1,88 @@ +name: outstanding + +# Every step of publishing here is deliberate: a person merges the request to +# deposit, a person merges the identifiers that come back. That is the right +# design, and its failure mode is that a step simply does not happen and +# nothing says so. A deposit sat unrecorded for a fortnight because the only +# thing that would have complained was the NEXT deposit -- which is the worst +# possible moment to find out. +# +# So this asks the question on a schedule instead of relying on anyone +# remembering to. It maintains ONE issue: opened when something is +# outstanding, edited while it stays outstanding, closed when it clears. It +# never opens a second one, and it says nothing at all when there is nothing +# to say. + +on: + schedule: + # Mondays, 22:00 UTC -- Tuesday morning in Canberra, so the week starts + # with the list rather than ending with it. + - cron: '0 22 * * 1' + workflow_dispatch: + +permissions: + contents: read + issues: write + pull-requests: read + +concurrency: + group: outstanding + cancel-in-progress: false + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: prefix-dev/setup-pixi@v0.8.1 + with: + cache: true + + - name: What is half-finished + id: check + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set +e + pixi run -q python3 scripts/outstanding.py > report.txt + echo "count=$?" >> "$GITHUB_OUTPUT" + set -e + cat report.txt + { + echo "## Outstanding" + echo '```' + cat report.txt + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + - name: Open, update or close the one issue + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + TITLE="Publishing steps not finished" + NUM=$(gh issue list --state open --limit 50 \ + --json number,title \ + --jq ".[] | select(.title == \"$TITLE\") | .number" | head -1) + + if [ "${{ steps.check.outputs.count }}" = "0" ]; then + if [ -n "$NUM" ]; then + gh issue close "$NUM" \ + --comment "Nothing outstanding as of $(date -u +%Y-%m-%d). Closed automatically." + echo "closed #$NUM" + else + echo "nothing outstanding, no issue open -- saying nothing" + fi + exit 0 + fi + + BODY=$(printf '%s\n\n```\n%s\n```\n\n%s\n' \ + "Steps that were started and not finished. Regenerated each Monday by the \`outstanding\` workflow; run \`pixi run outstanding\` to see the same thing locally." \ + "$(cat report.txt)" \ + "This issue closes itself when the list is empty.") + + if [ -n "$NUM" ]; then + gh issue edit "$NUM" --body "$BODY" + echo "updated #$NUM" + else + gh issue create --title "$TITLE" --body "$BODY" + fi diff --git a/articles/2-11-scaling/metadata.yml b/articles/2-11-scaling/metadata.yml index 8f3406d..4c16880 100644 --- a/articles/2-11-scaling/metadata.yml +++ b/articles/2-11-scaling/metadata.yml @@ -16,6 +16,7 @@ version: 1.0.0 legacy_doi: 10.59350/3wz3c-c8w65 archive_doi: 10.6084/m9.figshare.33193458 archived_at: 2026-08-10T04:19:43Z +archived_version: 1.0.0 license: CC-BY-4.0 canonical_path: /2-11-scaling/ legacy_paths: diff --git a/articles/30-years-of-citcom-ellipsis-and-underworld/metadata.yml b/articles/30-years-of-citcom-ellipsis-and-underworld/metadata.yml index 5159138..bbf6f93 100644 --- a/articles/30-years-of-citcom-ellipsis-and-underworld/metadata.yml +++ b/articles/30-years-of-citcom-ellipsis-and-underworld/metadata.yml @@ -13,6 +13,7 @@ version: 1.0.0 legacy_doi: 10.59350/hsp06-ag431 archive_doi: 10.6084/m9.figshare.33193530 archived_at: 2026-08-10T04:19:43Z +archived_version: 1.0.0 license: CC-BY-4.0 canonical_path: /30-years-of-citcom-ellipsis-and-underworld/ legacy_paths: diff --git a/articles/adding-zotero-references-to-a-webpage/metadata.yml b/articles/adding-zotero-references-to-a-webpage/metadata.yml index 6f1a2f2..4f6d175 100644 --- a/articles/adding-zotero-references-to-a-webpage/metadata.yml +++ b/articles/adding-zotero-references-to-a-webpage/metadata.yml @@ -13,6 +13,7 @@ version: 1.0.0 legacy_doi: 10.59350/qnz5b-4dt16 archive_doi: 10.6084/m9.figshare.33193416 archived_at: 2026-08-10T04:19:43Z +archived_version: 1.0.0 license: CC-BY-4.0 canonical_path: /adding-zotero-references-to-a-webpage/ legacy_paths: diff --git a/articles/ai-and-scientific-software-what-we-learned-rebuilding-underworld3/metadata.yml b/articles/ai-and-scientific-software-what-we-learned-rebuilding-underworld3/metadata.yml index 01cf1a2..ddffedc 100644 --- a/articles/ai-and-scientific-software-what-we-learned-rebuilding-underworld3/metadata.yml +++ b/articles/ai-and-scientific-software-what-we-learned-rebuilding-underworld3/metadata.yml @@ -13,6 +13,7 @@ version: 1.0.0 legacy_doi: 10.59350/rmayz-81x80 archive_doi: 10.6084/m9.figshare.33193560 archived_at: 2026-08-10T04:19:43Z +archived_version: 1.0.0 license: CC-BY-4.0 canonical_path: /ai-and-scientific-software-what-we-learned-rebuilding-underworld3/ legacy_paths: diff --git a/articles/alaska-moho-model-reproducible-research-with-containers/metadata.yml b/articles/alaska-moho-model-reproducible-research-with-containers/metadata.yml index eb81f56..6cedbb9 100644 --- a/articles/alaska-moho-model-reproducible-research-with-containers/metadata.yml +++ b/articles/alaska-moho-model-reproducible-research-with-containers/metadata.yml @@ -13,6 +13,7 @@ version: 1.0.0 legacy_doi: 10.59350/pn8gh-98592 archive_doi: 10.6084/m9.figshare.33193410 archived_at: 2026-08-10T04:19:43Z +archived_version: 1.0.0 license: CC-BY-4.0 canonical_path: /alaska-moho-model-reproducible-research-with-containers/ legacy_paths: diff --git a/articles/build-conda-packages/metadata.yml b/articles/build-conda-packages/metadata.yml index 06bdd29..c6ffa23 100644 --- a/articles/build-conda-packages/metadata.yml +++ b/articles/build-conda-packages/metadata.yml @@ -13,6 +13,7 @@ version: 1.0.0 legacy_doi: 10.59350/7x9h6-y1x53 archive_doi: 10.6084/m9.figshare.33193440 archived_at: 2026-08-10T04:19:43Z +archived_version: 1.0.0 license: CC-BY-4.0 canonical_path: /build-conda-packages/ legacy_paths: diff --git a/articles/compressible-convection-in-cartesian-coordinates-in-underworld3/metadata.yml b/articles/compressible-convection-in-cartesian-coordinates-in-underworld3/metadata.yml index d744393..793c247 100644 --- a/articles/compressible-convection-in-cartesian-coordinates-in-underworld3/metadata.yml +++ b/articles/compressible-convection-in-cartesian-coordinates-in-underworld3/metadata.yml @@ -13,6 +13,7 @@ version: 1.0.0 legacy_doi: 10.59350/g4ysn-pv176 archive_doi: 10.6084/m9.figshare.33193527 archived_at: 2026-08-10T04:19:43Z +archived_version: 1.0.0 license: CC-BY-4.0 canonical_path: /compressible-convection-in-cartesian-coordinates-in-underworld3/ legacy_paths: diff --git a/articles/constitutive-models-in-symbolic-form/metadata.yml b/articles/constitutive-models-in-symbolic-form/metadata.yml index a07411d..0e7f4f4 100644 --- a/articles/constitutive-models-in-symbolic-form/metadata.yml +++ b/articles/constitutive-models-in-symbolic-form/metadata.yml @@ -33,4 +33,5 @@ source: ghost-migration ghost_uuid: 137d7c0a-1a6b-412b-a605-069137faac95 repository_record_id: 33193590 archived_at: 2026-08-10T04:19:43Z +archived_version: 1.0.0 archive_published_at: 2026-08-10T04:28:12Z diff --git a/articles/craton-formation-and-the-onset-of-plate-tectonics/metadata.yml b/articles/craton-formation-and-the-onset-of-plate-tectonics/metadata.yml index 9810037..9b3d8fc 100644 --- a/articles/craton-formation-and-the-onset-of-plate-tectonics/metadata.yml +++ b/articles/craton-formation-and-the-onset-of-plate-tectonics/metadata.yml @@ -13,6 +13,7 @@ version: 1.0.0 legacy_doi: 10.59350/c4g09-htk29 archive_doi: 10.6084/m9.figshare.33193398 archived_at: 2026-08-10T04:19:43Z +archived_version: 1.0.0 license: CC-BY-4.0 canonical_path: /craton-formation-and-the-onset-of-plate-tectonics/ legacy_paths: diff --git a/articles/finding-particles-in-a-distributed-unstructured-mesh/metadata.yml b/articles/finding-particles-in-a-distributed-unstructured-mesh/metadata.yml index 7c16c00..60c531c 100644 --- a/articles/finding-particles-in-a-distributed-unstructured-mesh/metadata.yml +++ b/articles/finding-particles-in-a-distributed-unstructured-mesh/metadata.yml @@ -31,4 +31,5 @@ source: ghost-migration ghost_uuid: 1b9677b5-5308-4c90-a192-d29aad4000b7 repository_record_id: 33193611 archived_at: 2026-08-10T04:19:43Z +archived_version: 1.0.0 archive_published_at: 2026-08-10T04:28:42Z diff --git a/articles/free-surface-in-underworld/metadata.yml b/articles/free-surface-in-underworld/metadata.yml index cca7908..ac7dc1b 100644 --- a/articles/free-surface-in-underworld/metadata.yml +++ b/articles/free-surface-in-underworld/metadata.yml @@ -13,6 +13,7 @@ version: 1.0.0 legacy_doi: 10.59350/1a3e6-3v712 archive_doi: 10.6084/m9.figshare.33193503 archived_at: 2026-08-10T04:19:43Z +archived_version: 1.0.0 license: CC-BY-4.0 canonical_path: /free-surface-in-underworld/ legacy_paths: diff --git a/articles/getting-started-60-seconds-to-underworld/metadata.yml b/articles/getting-started-60-seconds-to-underworld/metadata.yml index 49f0fe2..b7f9805 100644 --- a/articles/getting-started-60-seconds-to-underworld/metadata.yml +++ b/articles/getting-started-60-seconds-to-underworld/metadata.yml @@ -12,6 +12,7 @@ version: 1.0.0 legacy_doi: 10.59350/3y92k-n4v30 archive_doi: 10.6084/m9.figshare.33193419 archived_at: 2026-08-10T04:19:43Z +archived_version: 1.0.0 license: CC-BY-4.0 canonical_path: /getting-started-60-seconds-to-underworld/ legacy_paths: diff --git a/articles/getting-started-with-pull-requests/metadata.yml b/articles/getting-started-with-pull-requests/metadata.yml index 7df7059..050f838 100644 --- a/articles/getting-started-with-pull-requests/metadata.yml +++ b/articles/getting-started-with-pull-requests/metadata.yml @@ -13,6 +13,7 @@ version: 1.0.0 legacy_doi: 10.59350/cj1d1-e4445 archive_doi: 10.6084/m9.figshare.33193434 archived_at: 2026-08-10T04:19:43Z +archived_version: 1.0.0 license: CC-BY-4.0 canonical_path: /getting-started-with-pull-requests/ legacy_paths: diff --git a/articles/how-many-processors-should-we-use-to-solve-problem-x/metadata.yml b/articles/how-many-processors-should-we-use-to-solve-problem-x/metadata.yml index 4adb8cd..ca2fe6c 100644 --- a/articles/how-many-processors-should-we-use-to-solve-problem-x/metadata.yml +++ b/articles/how-many-processors-should-we-use-to-solve-problem-x/metadata.yml @@ -16,6 +16,7 @@ version: 1.0.0 legacy_doi: 10.59350/tjm5s-sfs33 archive_doi: 10.6084/m9.figshare.33193554 archived_at: 2026-08-10T04:19:43Z +archived_version: 1.0.0 license: CC-BY-4.0 canonical_path: /how-many-processors-should-we-use-to-solve-problem-x/ legacy_paths: diff --git a/articles/how-to-install-underworld-on-mac-osx-big-sur-apple-silicon-m1/metadata.yml b/articles/how-to-install-underworld-on-mac-osx-big-sur-apple-silicon-m1/metadata.yml index a1b75c5..50a1f0a 100644 --- a/articles/how-to-install-underworld-on-mac-osx-big-sur-apple-silicon-m1/metadata.yml +++ b/articles/how-to-install-underworld-on-mac-osx-big-sur-apple-silicon-m1/metadata.yml @@ -13,6 +13,7 @@ version: 1.0.0 legacy_doi: 10.59350/8nsyv-btx30 archive_doi: 10.6084/m9.figshare.33193455 archived_at: 2026-08-10T04:19:43Z +archived_version: 1.0.0 license: CC-BY-4.0 canonical_path: /how-to-install-underworld-on-mac-osx-big-sur-apple-silicon-m1/ legacy_paths: diff --git a/articles/how-underworld3-turns-sympy-into-c/metadata.yml b/articles/how-underworld3-turns-sympy-into-c/metadata.yml index 04a47fa..34df7e7 100644 --- a/articles/how-underworld3-turns-sympy-into-c/metadata.yml +++ b/articles/how-underworld3-turns-sympy-into-c/metadata.yml @@ -38,4 +38,5 @@ source: ghost-migration ghost_uuid: ba7cc108-239e-4371-8182-2316f9e2cf7c repository_record_id: 33193572 archived_at: 2026-08-10T04:19:43Z +archived_version: 1.0.0 archive_published_at: 2026-08-10T04:27:41Z diff --git a/articles/ismip-hom-benchmark-experiments-using-underworld/metadata.yml b/articles/ismip-hom-benchmark-experiments-using-underworld/metadata.yml index 4d0e41e..4f23ef0 100644 --- a/articles/ismip-hom-benchmark-experiments-using-underworld/metadata.yml +++ b/articles/ismip-hom-benchmark-experiments-using-underworld/metadata.yml @@ -12,6 +12,7 @@ version: 1.0.0 legacy_doi: 10.59350/8196m-xmj49 archive_doi: 10.6084/m9.figshare.33193518 archived_at: 2026-08-10T04:19:43Z +archived_version: 1.0.0 license: CC-BY-4.0 canonical_path: /ismip-hom-benchmark-experiments-using-underworld/ legacy_paths: diff --git a/articles/mesh-variables-and-petsc-vectors-keeping-arrays-in-sync/metadata.yml b/articles/mesh-variables-and-petsc-vectors-keeping-arrays-in-sync/metadata.yml index 586f0db..70f18de 100644 --- a/articles/mesh-variables-and-petsc-vectors-keeping-arrays-in-sync/metadata.yml +++ b/articles/mesh-variables-and-petsc-vectors-keeping-arrays-in-sync/metadata.yml @@ -30,4 +30,5 @@ source: ghost-migration ghost_uuid: 28b160de-c9a0-4942-9a7e-74c84d21c7a4 repository_record_id: 33193578 archived_at: 2026-08-10T04:19:43Z +archived_version: 1.0.0 archive_published_at: 2026-08-10T04:27:52Z diff --git a/articles/moving-the-mesh-without-remaking-it/metadata.yml b/articles/moving-the-mesh-without-remaking-it/metadata.yml index a765a65..ae7a9df 100644 --- a/articles/moving-the-mesh-without-remaking-it/metadata.yml +++ b/articles/moving-the-mesh-without-remaking-it/metadata.yml @@ -37,4 +37,5 @@ source: native repository_record_id: 33241170 archive_doi: 10.6084/m9.figshare.33241170 archived_at: 2026-08-13T11:10:11Z +archived_version: 1.0.0 archive_published_at: 2026-08-13T11:11:48Z diff --git a/articles/new-features-of-the-surface-coupling-framework-in-underworld-2/metadata.yml b/articles/new-features-of-the-surface-coupling-framework-in-underworld-2/metadata.yml index 601991d..5002d7b 100644 --- a/articles/new-features-of-the-surface-coupling-framework-in-underworld-2/metadata.yml +++ b/articles/new-features-of-the-surface-coupling-framework-in-underworld-2/metadata.yml @@ -31,4 +31,5 @@ source: ghost-migration ghost_uuid: ba04ae00-5f7f-4655-89ef-c58d6060c9c7 repository_record_id: 33193557 archived_at: 2026-08-10T04:19:43Z +archived_version: 1.0.0 archive_published_at: 2026-08-10T04:27:06Z diff --git a/articles/our-journey-from-underworld2-to-underworld3/metadata.yml b/articles/our-journey-from-underworld2-to-underworld3/metadata.yml index e3a2620..84acebd 100644 --- a/articles/our-journey-from-underworld2-to-underworld3/metadata.yml +++ b/articles/our-journey-from-underworld2-to-underworld3/metadata.yml @@ -41,4 +41,5 @@ source: ghost-migration ghost_uuid: 6bfa4eab-e83e-4a57-8246-5c6ae2ecc353 repository_record_id: 33193566 archived_at: 2026-08-10T04:19:43Z +archived_version: 1.0.0 archive_published_at: 2026-08-10T04:27:30Z diff --git a/articles/particles-in-underworld3/metadata.yml b/articles/particles-in-underworld3/metadata.yml index cc16c94..de7180c 100644 --- a/articles/particles-in-underworld3/metadata.yml +++ b/articles/particles-in-underworld3/metadata.yml @@ -31,4 +31,5 @@ source: ghost-migration ghost_uuid: d7e1f38a-c62a-4b5f-bc55-0133fff3b2c3 repository_record_id: 33193599 archived_at: 2026-08-10T04:19:43Z +archived_version: 1.0.0 archive_published_at: 2026-08-10T04:28:32Z diff --git a/articles/physical-units-in-computational-geodynamics/metadata.yml b/articles/physical-units-in-computational-geodynamics/metadata.yml index e83c228..097f69e 100644 --- a/articles/physical-units-in-computational-geodynamics/metadata.yml +++ b/articles/physical-units-in-computational-geodynamics/metadata.yml @@ -33,4 +33,5 @@ source: ghost-migration ghost_uuid: f8e03b51-3f07-4bfd-92bc-cf4f468b78a9 repository_record_id: 33193587 archived_at: 2026-08-10T04:19:43Z +archived_version: 1.0.0 archive_published_at: 2026-08-10T04:28:02Z diff --git a/articles/running-underworld-in-a-browser/metadata.yml b/articles/running-underworld-in-a-browser/metadata.yml index 5387318..870b668 100644 --- a/articles/running-underworld-in-a-browser/metadata.yml +++ b/articles/running-underworld-in-a-browser/metadata.yml @@ -20,6 +20,7 @@ archive_doi: 10.6084/m9.figshare.33216996 repository_provider: figshare repository_record_id: 33216996 archived_at: 2026-08-31T17:36:06Z +archived_version: 1.1.0 archive_published_at: 2026-08-31T17:38:10Z subjects: methods: diff --git a/articles/scaling-in-underworld/metadata.yml b/articles/scaling-in-underworld/metadata.yml index 80abebd..49a7f7a 100644 --- a/articles/scaling-in-underworld/metadata.yml +++ b/articles/scaling-in-underworld/metadata.yml @@ -19,6 +19,7 @@ version: 1.0.0 legacy_doi: 10.59350/qvgm0-yz754 archive_doi: 10.6084/m9.figshare.33193452 archived_at: 2026-08-10T04:19:43Z +archived_version: 1.0.0 license: CC-BY-4.0 canonical_path: /scaling-in-underworld/ legacy_paths: diff --git a/articles/self-updating-repositories/metadata.yml b/articles/self-updating-repositories/metadata.yml index 323a108..f0d07f6 100644 --- a/articles/self-updating-repositories/metadata.yml +++ b/articles/self-updating-repositories/metadata.yml @@ -13,6 +13,7 @@ version: 1.0.0 legacy_doi: 10.59350/wftyb-vgs67 archive_doi: 10.6084/m9.figshare.33193428 archived_at: 2026-08-10T04:19:43Z +archived_version: 1.0.0 license: CC-BY-4.0 canonical_path: /self-updating-repositories/ legacy_paths: diff --git a/articles/setting-up-full-multigrid/metadata.yml b/articles/setting-up-full-multigrid/metadata.yml index 9eeeb44..4044b81 100644 --- a/articles/setting-up-full-multigrid/metadata.yml +++ b/articles/setting-up-full-multigrid/metadata.yml @@ -39,4 +39,5 @@ source: native repository_record_id: 33273984 archive_doi: 10.6084/m9.figshare.33273984 archived_at: 2026-08-17T21:37:48Z +archived_version: 1.0.0 archive_published_at: 2026-08-17T21:39:35Z diff --git a/articles/setting-up-underworld-dependencies/metadata.yml b/articles/setting-up-underworld-dependencies/metadata.yml index 7ccf419..c6fcf13 100644 --- a/articles/setting-up-underworld-dependencies/metadata.yml +++ b/articles/setting-up-underworld-dependencies/metadata.yml @@ -13,6 +13,7 @@ version: 1.0.0 legacy_doi: 10.59350/t7ghx-8f823 archive_doi: 10.6084/m9.figshare.33193509 archived_at: 2026-08-10T04:19:43Z +archived_version: 1.0.0 license: CC-BY-4.0 canonical_path: /setting-up-underworld-dependencies/ legacy_paths: diff --git a/articles/shear-bands-with-dilatancy-modelled-with-underworld/metadata.yml b/articles/shear-bands-with-dilatancy-modelled-with-underworld/metadata.yml index 7c94fe1..61256c1 100644 --- a/articles/shear-bands-with-dilatancy-modelled-with-underworld/metadata.yml +++ b/articles/shear-bands-with-dilatancy-modelled-with-underworld/metadata.yml @@ -13,6 +13,7 @@ version: 1.0.0 legacy_doi: 10.59350/awc90-63186 archive_doi: 10.6084/m9.figshare.33193365 archived_at: 2026-08-10T04:19:43Z +archived_version: 1.0.0 license: CC-BY-4.0 canonical_path: /shear-bands-with-dilatancy-modelled-with-underworld/ legacy_paths: diff --git a/articles/stress-recovery-in-underworld/metadata.yml b/articles/stress-recovery-in-underworld/metadata.yml index 09321e2..60b4a49 100644 --- a/articles/stress-recovery-in-underworld/metadata.yml +++ b/articles/stress-recovery-in-underworld/metadata.yml @@ -12,6 +12,7 @@ version: 1.0.0 legacy_doi: 10.59350/97evt-ays09 archive_doi: 10.6084/m9.figshare.33193446 archived_at: 2026-08-10T04:19:43Z +archived_version: 1.0.0 license: CC-BY-4.0 canonical_path: /stress-recovery-in-underworld/ legacy_paths: diff --git a/articles/symbolic-time-derivatives-in-underworld3/metadata.yml b/articles/symbolic-time-derivatives-in-underworld3/metadata.yml index 72c0b7d..fdc2c5e 100644 --- a/articles/symbolic-time-derivatives-in-underworld3/metadata.yml +++ b/articles/symbolic-time-derivatives-in-underworld3/metadata.yml @@ -32,4 +32,5 @@ source: ghost-migration ghost_uuid: 1eb496b6-8422-4b09-9443-2894629c35cf repository_record_id: 33193596 archived_at: 2026-08-10T04:19:43Z +archived_version: 1.0.0 archive_published_at: 2026-08-10T04:28:22Z diff --git a/articles/the-dynamics-of-continental-accretion/metadata.yml b/articles/the-dynamics-of-continental-accretion/metadata.yml index de6361e..ff78023 100644 --- a/articles/the-dynamics-of-continental-accretion/metadata.yml +++ b/articles/the-dynamics-of-continental-accretion/metadata.yml @@ -13,6 +13,7 @@ version: 1.0.0 legacy_doi: 10.59350/73ded-8k350 archive_doi: 10.6084/m9.figshare.33193179 archived_at: 2026-08-10T04:19:43Z +archived_version: 1.0.0 license: CC-BY-4.0 canonical_path: /the-dynamics-of-continental-accretion/ legacy_paths: diff --git a/articles/underworld-2/metadata.yml b/articles/underworld-2/metadata.yml index 381bb42..94ef812 100644 --- a/articles/underworld-2/metadata.yml +++ b/articles/underworld-2/metadata.yml @@ -13,6 +13,7 @@ version: 1.0.0 legacy_doi: 10.59350/fsmnk-jat73 archive_doi: 10.6084/m9.figshare.33193233 archived_at: 2026-08-10T04:19:43Z +archived_version: 1.0.0 license: CC-BY-4.0 canonical_path: /underworld-2/ legacy_paths: diff --git a/articles/underworld-and-docker-part-1/metadata.yml b/articles/underworld-and-docker-part-1/metadata.yml index 45aef4f..4289da4 100644 --- a/articles/underworld-and-docker-part-1/metadata.yml +++ b/articles/underworld-and-docker-part-1/metadata.yml @@ -13,6 +13,7 @@ version: 1.0.0 legacy_doi: 10.59350/y8762-pe280 archive_doi: 10.6084/m9.figshare.33193329 archived_at: 2026-08-10T04:19:43Z +archived_version: 1.0.0 license: CC-BY-4.0 canonical_path: /underworld-and-docker-part-1/ legacy_paths: diff --git a/articles/underworld-and-docker-part-2/metadata.yml b/articles/underworld-and-docker-part-2/metadata.yml index efb6a3d..cae2911 100644 --- a/articles/underworld-and-docker-part-2/metadata.yml +++ b/articles/underworld-and-docker-part-2/metadata.yml @@ -16,6 +16,7 @@ version: 1.0.0 legacy_doi: 10.59350/4cqwc-rth67 archive_doi: 10.6084/m9.figshare.33193341 archived_at: 2026-08-10T04:19:43Z +archived_version: 1.0.0 license: CC-BY-4.0 canonical_path: /underworld-and-docker-part-2/ legacy_paths: diff --git a/articles/underworld-and-singularity/metadata.yml b/articles/underworld-and-singularity/metadata.yml index 632e1fa..fd222da 100644 --- a/articles/underworld-and-singularity/metadata.yml +++ b/articles/underworld-and-singularity/metadata.yml @@ -13,6 +13,7 @@ version: 1.0.0 legacy_doi: 10.59350/hfgvn-dkk05 archive_doi: 10.6084/m9.figshare.33193545 archived_at: 2026-08-10T04:19:43Z +archived_version: 1.0.0 license: CC-BY-4.0 canonical_path: /underworld-and-singularity/ legacy_paths: diff --git a/articles/underworld-low-fat-cloud/metadata.yml b/articles/underworld-low-fat-cloud/metadata.yml index 804b67b..752bd1e 100644 --- a/articles/underworld-low-fat-cloud/metadata.yml +++ b/articles/underworld-low-fat-cloud/metadata.yml @@ -13,6 +13,7 @@ version: 1.0.0 legacy_doi: 10.59350/mw43c-vn265 archive_doi: 10.6084/m9.figshare.33193422 archived_at: 2026-08-10T04:19:43Z +archived_version: 1.0.0 license: CC-BY-4.0 canonical_path: /underworld-low-fat-cloud/ legacy_paths: diff --git a/articles/underworld-on-zenodo/metadata.yml b/articles/underworld-on-zenodo/metadata.yml index 82b7d43..9c009b3 100644 --- a/articles/underworld-on-zenodo/metadata.yml +++ b/articles/underworld-on-zenodo/metadata.yml @@ -13,6 +13,7 @@ version: 1.0.0 legacy_doi: 10.59350/at0ev-7re42 archive_doi: 10.6084/m9.figshare.33193404 archived_at: 2026-08-10T04:19:43Z +archived_version: 1.0.0 license: CC-BY-4.0 canonical_path: /underworld-on-zenodo/ legacy_paths: diff --git a/articles/underworld3-come-and-get-it/metadata.yml b/articles/underworld3-come-and-get-it/metadata.yml index 2d8d426..41e7f6d 100644 --- a/articles/underworld3-come-and-get-it/metadata.yml +++ b/articles/underworld3-come-and-get-it/metadata.yml @@ -13,6 +13,7 @@ version: 1.0.0 legacy_doi: 10.59350/z0c1r-rpg80 archive_doi: 10.6084/m9.figshare.33193533 archived_at: 2026-08-10T04:19:43Z +archived_version: 1.0.0 license: CC-BY-4.0 canonical_path: /underworld3-come-and-get-it/ legacy_paths: diff --git a/articles/untitled-2/metadata.yml b/articles/untitled-2/metadata.yml index 38a13ac..8ed06a2 100644 --- a/articles/untitled-2/metadata.yml +++ b/articles/untitled-2/metadata.yml @@ -13,6 +13,7 @@ version: 1.0.0 legacy_doi: 10.59350/ts8za-rv858 archive_doi: 10.6084/m9.figshare.33193380 archived_at: 2026-08-10T04:19:43Z +archived_version: 1.0.0 license: CC-BY-4.0 canonical_path: /untitled-2/ legacy_paths: diff --git a/articles/untitled/metadata.yml b/articles/untitled/metadata.yml index db55daa..b53e9f1 100644 --- a/articles/untitled/metadata.yml +++ b/articles/untitled/metadata.yml @@ -12,6 +12,7 @@ version: 1.0.0 legacy_doi: 10.59350/x638s-dpr14 archive_doi: 10.6084/m9.figshare.33193386 archived_at: 2026-08-10T04:19:43Z +archived_version: 1.0.0 license: CC-BY-4.0 canonical_path: /untitled/ legacy_paths: diff --git a/articles/using-python-virtual-environment-for-underworld-development/metadata.yml b/articles/using-python-virtual-environment-for-underworld-development/metadata.yml index 89c6572..c09efbf 100644 --- a/articles/using-python-virtual-environment-for-underworld-development/metadata.yml +++ b/articles/using-python-virtual-environment-for-underworld-development/metadata.yml @@ -13,6 +13,7 @@ version: 1.0.0 legacy_doi: 10.59350/681vw-w9h32 archive_doi: 10.6084/m9.figshare.33193515 archived_at: 2026-08-10T04:19:43Z +archived_version: 1.0.0 license: CC-BY-4.0 canonical_path: /using-python-virtual-environment-for-underworld-development/ legacy_paths: diff --git a/articles/viscoelasticity/metadata.yml b/articles/viscoelasticity/metadata.yml index d252b4e..b803722 100644 --- a/articles/viscoelasticity/metadata.yml +++ b/articles/viscoelasticity/metadata.yml @@ -12,6 +12,7 @@ version: 1.0.0 legacy_doi: 10.59350/3atx2-v4j54 archive_doi: 10.6084/m9.figshare.33191160 archived_at: 2026-08-10T04:19:43Z +archived_version: 1.0.0 license: CC-BY-4.0 canonical_path: /viscoelasticity/ legacy_paths: diff --git a/pixi.toml b/pixi.toml index f8acce0..cce2b00 100644 --- a/pixi.toml +++ b/pixi.toml @@ -87,6 +87,12 @@ test-unit = "pytest -q tests/" # densities whose right value depends on what the document is, and that is # the author's call rather than the build's. check-style = "python3 scripts/check_style.py" + +# What is half-finished: deposits started and not recorded, requests never +# merged, notes that have outrun their archival copy. Exits 1 if anything is +# outstanding, which is what lets the scheduled `outstanding` workflow raise +# an issue. Not part of `test`: an unfinished deposit is not a broken build. +outstanding = "python3 scripts/outstanding.py" test = { depends-on = ["test-unit", "validate", "test-dois", "test-assets"] } # Stage 0 migration tooling. Read-only against the live Ghost site. diff --git a/schemas/article-metadata.schema.json b/schemas/article-metadata.schema.json index f32faf1..055e603 100644 --- a/schemas/article-metadata.schema.json +++ b/schemas/article-metadata.schema.json @@ -253,6 +253,11 @@ ], "description": "When the archival PDF was made, ISO-8601 UTC. Stamped by the deposit at the moment the copy is taken, and again for each new version, so it always names the snapshot the record actually holds." }, + "archived_version": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$", + "description": "The note's `version` at the moment it was last deposited. `version` ahead of this means the note has outrun its archival copy and wants a new deposit." + }, "archive_published_at": { "type": [ "string", diff --git a/scripts/deposit.py b/scripts/deposit.py index 4dcd085..37a768b 100644 --- a/scripts/deposit.py +++ b/scripts/deposit.py @@ -616,8 +616,16 @@ def run(slug, provider, live, publish, new_version, delete_draft, if (new_version and rebuild) or not meta.get("archived_at"): stamp = datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0) set_field(slug, "archived_at", stamp.isoformat().replace("+00:00", "Z")) + # The note's own version, recorded as deposited. This is what makes a + # STALE deposit visible: `version` moves when an author decides the + # content has materially changed, so `version != archived_version` is + # the note having outrun its archival copy. Nothing else in the + # metadata says that -- archived_at only says when the copy was taken, + # and a git timestamp cannot tell a rewrite from a typo. + set_field(slug, "archived_version", meta.get("version") or "0.0.0") meta = load(slug) - steps.append("stamped archived_at %s" % stamp.isoformat()) + steps.append("stamped archived_at %s (version %s)" + % (stamp.isoformat(), meta.get("archived_version"))) # The reserved DOI has to be on the title page of the document it # identifies, so the PDF is rebuilt between reserving and uploading. In the diff --git a/scripts/outstanding.py b/scripts/outstanding.py new file mode 100644 index 0000000..37e33ce --- /dev/null +++ b/scripts/outstanding.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python3 +"""What is half-finished right now. + +Every step of publishing a note is deliberate -- a person merges the +request to deposit, a person merges the identifiers that come back -- and +that is the right design. Its failure mode is that a step can simply not +happen, and nothing says so. A deposit sat unrecorded for a fortnight +because the only thing that would have complained was the NEXT deposit, +which is exactly when it is least welcome. + +So this names the half-finished states. It reads metadata and, if `gh` is +available, open pull requests; it changes nothing. + + deposited, not recorded an identifiers pull request is still open, so + the repository does not know a record exists + and the duplicate-mint guard is blind + asked for, not deposited a deposit request is open on the queue + note ahead of its copy `version` has moved past `archived_version`: + the note has outrun what is on the DOI + never deposited archival, published, and has no DOI + guard blind an `archive_doi` with no `repository_record_id` + +Exit 1 if anything is outstanding, so a scheduled run can raise it. + +Usage: + python3 scripts/outstanding.py # report + python3 scripts/outstanding.py --json # for a workflow + python3 scripts/outstanding.py --no-net # metadata only +""" + +import argparse +import json +import pathlib +import re +import subprocess +import sys + +ROOT = pathlib.Path(__file__).resolve().parent.parent +ARTICLES = ROOT / "articles" +sys.path.insert(0, str(ROOT / "scripts")) + + +def field(text, key): + m = re.search(r"^%s:\s*(.+?)\s*$" % re.escape(key), text, re.M) + if not m: + return None + v = m.group(1).strip().strip('"').strip("'") + return None if v in ("null", "~", "") else v + + +def open_branches(prefix): + """Open pull requests whose branch starts with `prefix`. + + Returns None -- not [] -- when `gh` cannot answer, so the report can + say "not checked" rather than "nothing outstanding". The difference + matters: this exists because silence was mistaken for good news. + """ + try: + out = subprocess.run( + ["gh", "pr", "list", "--state", "open", "--limit", "100", + "--json", "number,title,headRefName,createdAt"], + capture_output=True, text=True, timeout=60, cwd=str(ROOT)) + except (OSError, subprocess.SubprocessError): + return None + if out.returncode != 0: + return None + try: + prs = json.loads(out.stdout or "[]") + except ValueError: + return None + return [p for p in prs if p["headRefName"].startswith(prefix)] + + +def survey(check_net=True): + import build_index + build_index.TYPES.update(build_index.article_types()) + + findings = {"unrecorded": None, "queued": None, + "stale": [], "undeposited": [], "blind": []} + + for md in sorted(ARTICLES.glob("*/metadata.yml")): + slug = md.parent.name + text = md.read_text(encoding="utf-8") + meta = build_index.read_yaml(md) + doi = field(text, "archive_doi") + rid = field(text, "repository_record_id") + version = field(text, "version") + archived = field(text, "archived_version") + + if doi and not rid: + findings["blind"].append(slug) + if not build_index.is_archival(meta): + continue + if not doi: + if field(text, "status") == "published": + findings["undeposited"].append(slug) + continue + # archived_version is absent on nothing after the backfill, but a + # note deposited by an older workflow would have none; say so + # rather than guessing it matches. + if version and archived and version != archived: + findings["stale"].append((slug, archived, version)) + elif version and not archived: + findings["stale"].append((slug, "unrecorded", version)) + + if check_net: + findings["unrecorded"] = open_branches("deposit/identifiers-") + findings["queued"] = open_branches("deposit/queue-") + return findings + + +def report(f): + lines, outstanding = [], 0 + + def head(title, n): + lines.append("") + lines.append("%s (%s)" % (title, n)) + + if f["unrecorded"] is None: + head("deposited, not recorded", "not checked -- gh unavailable") + elif f["unrecorded"]: + head("deposited, not recorded", len(f["unrecorded"])) + for p in f["unrecorded"]: + lines.append(" #%-5d %s (opened %s)" + % (p["number"], p["title"][:60], p["createdAt"][:10])) + lines.append(" -> merge these: until they land the repository does " + "not know the records exist") + outstanding += len(f["unrecorded"]) + + if f["queued"]: + head("asked for, not deposited", len(f["queued"])) + for p in f["queued"]: + lines.append(" #%-5d %s (opened %s)" + % (p["number"], p["title"][:60], p["createdAt"][:10])) + outstanding += len(f["queued"]) + + if f["stale"]: + head("note ahead of its archival copy", len(f["stale"])) + for slug, was, now in f["stale"]: + lines.append(" %-52s deposited %s, now %s" % (slug[:52], was, now)) + lines.append(" -> deposit a new version, or the DOI serves the " + "older text") + outstanding += len(f["stale"]) + + if f["undeposited"]: + head("published, never deposited", len(f["undeposited"])) + for slug in f["undeposited"]: + lines.append(" %s" % slug) + outstanding += len(f["undeposited"]) + + if f["blind"]: + head("archive_doi with no record id", len(f["blind"])) + for slug in f["blind"]: + lines.append(" %s" % slug) + outstanding += len(f["blind"]) + + if not outstanding: + lines.append("Nothing outstanding.") + return "\n".join(lines).lstrip("\n"), outstanding + + +def main(): + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("--json", action="store_true") + ap.add_argument("--no-net", action="store_true", + help="metadata only; do not ask gh about pull requests") + args = ap.parse_args() + + f = survey(check_net=not args.no_net) + text, n = report(f) + if args.json: + print(json.dumps({"outstanding": n, "report": text, "findings": f}, + indent=2, default=list)) + else: + print(text) + return 1 if n else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_outstanding.py b/tests/test_outstanding.py new file mode 100644 index 0000000..be156d6 --- /dev/null +++ b/tests/test_outstanding.py @@ -0,0 +1,79 @@ +"""The outstanding report names the half-finished states, and stays quiet +otherwise. + +The negative controls matter more than usual here. This exists because +silence was mistaken for good news, so a check that cannot answer must +say so rather than report nothing, and a clean repository must produce +no noise at all. +""" +import pathlib +import sys + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent + / "scripts")) +import outstanding # noqa: E402 + +CLEAN = {"unrecorded": [], "queued": [], "stale": [], "undeposited": [], + "blind": []} + + +def test_a_clean_repository_says_nothing(): + text, n = outstanding.report(dict(CLEAN)) + assert n == 0 + assert text == "Nothing outstanding." + + +def test_an_unanswerable_check_says_so_rather_than_nothing(): + """None is not []. If gh cannot answer, the report must not read as + 'nothing outstanding' -- that is the mistake this tool exists for.""" + f = dict(CLEAN, unrecorded=None) + text, n = outstanding.report(f) + assert "not checked" in text + assert n == 0 # unknown is not counted as outstanding + + +def test_unrecorded_identifiers_are_reported_with_the_reason(): + f = dict(CLEAN, unrecorded=[ + {"number": 25, "title": "Deposit identifiers from run 1", + "createdAt": "2026-08-17T21:39:38Z"}]) + text, n = outstanding.report(f) + assert n == 1 + assert "#25" in text and "2026-08-17" in text + assert "duplicate-mint" in text or "does not know" in text + + +def test_a_note_ahead_of_its_deposit_is_reported(): + f = dict(CLEAN, stale=[("running-underworld-in-a-browser", "1.0.0", "1.1.0")]) + text, n = outstanding.report(f) + assert n == 1 + assert "deposited 1.0.0, now 1.1.0" in text + + +def test_a_deposit_with_no_recorded_version_is_not_assumed_current(): + """A note deposited before archived_version existed must be reported, + not silently taken to match.""" + f = dict(CLEAN, stale=[("old-note", "unrecorded", "1.0.0")]) + text, n = outstanding.report(f) + assert n == 1 and "unrecorded" in text + + +def test_the_counts_add_up(): + f = {"unrecorded": [{"number": 1, "title": "t", "createdAt": "2026-01-01T00:00:00Z"}], + "queued": [{"number": 2, "title": "t", "createdAt": "2026-01-01T00:00:00Z"}], + "stale": [("a", "1.0.0", "1.1.0")], + "undeposited": ["b"], + "blind": ["c"]} + _text, n = outstanding.report(f) + assert n == 5 + + +def test_the_real_repository_surveys_without_network(): + """The metadata half must work with no gh and no network at all.""" + f = outstanding.survey(check_net=False) + assert f["unrecorded"] is None and f["queued"] is None + assert isinstance(f["stale"], list) + # every deposited note carries archived_version after the backfill, so + # nothing should be reported as having an unrecorded deposit version + assert not [s for s in f["stale"] if s[1] == "unrecorded"], f["stale"] + # and validate already forbids a doi with no record id + assert f["blind"] == [] From cf7cb51c26c197ce574760553764192ef9da0dd9 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Wed, 2 Sep 2026 09:49:14 -0700 Subject: [PATCH 2/2] Reserve the DOI on the request, so nothing has to come back afterwards The identifiers write-back was a gate with no decision in it. By the time that pull request opened the DOI was already public; merging it only told the repository what had happened, and the fortnight it once sat unmerged left the duplicate-mint guard blind the whole time. So the identifiers now go IN rather than come back. A note reaching main without a record gets a request that has already created the figshare draft, reserved the DOI and committed it into that note's metadata.yml -- both private, both reversible. Merging that request approves the deposit AND records it, in one act. The publish that follows finds everything it needs already on main, including archived_at, which the PDF prints and the package README states. That also removes the race. The approval used to live in one shared deposit-queue.txt appended to by every note at the same line, so two notes in flight conflicted and merging one broke the other -- measured, not feared, and the reason four duplicate requests piled up for one note. Per note metadata cannot collide. The trigger moves with it, from the queue file to articles/**/metadata.yml, narrowed to --approved: notes holding a reserved record that is not yet published. That set can only be entered by merging a request, so an ordinary metadata edit mints nothing. --all keeps its old meaning behind the manual deposit-all mode, where a person has chosen it. One thing still comes back: archive_published_at. It is bookkeeping -- the guard is satisfied without it -- and the outstanding report lists it until it is merged. The design test that covered the old mechanism is rewritten rather than dropped: it still asserts that nothing mints a DOI without somebody merging something, now by checking the push trigger uses --approved and never --all, and that the request workflow can reserve but never publish. Underworld development team with AI support from Claude Code --- .github/workflows/deposit-pdf.yml | 26 +++++---- .github/workflows/deposit-ready.yml | 83 +++++++++++++---------------- .github/workflows/deposit.yml | 40 ++++++++++---- PUBLISHING.md | 57 ++++++++++++++------ deposit-queue.txt | 15 ------ scripts/deposit.py | 75 ++++++++++++++++++++++++-- scripts/outstanding.py | 65 +++++++++++++++++----- tests/test_migration.py | 69 ++++++++++++++++-------- tests/test_outstanding.py | 40 ++++++++++++-- 9 files changed, 327 insertions(+), 143 deletions(-) delete mode 100644 deposit-queue.txt diff --git a/.github/workflows/deposit-pdf.yml b/.github/workflows/deposit-pdf.yml index 74fcf57..f959b3f 100644 --- a/.github/workflows/deposit-pdf.yml +++ b/.github/workflows/deposit-pdf.yml @@ -8,13 +8,13 @@ name: deposit-pdf # preview site is the wrong tool here. It costs minutes to publish a whole # site, and it still leaves you clicking through a page to reach the download. # -# So this builds the PDF for the queued notes and nothing else, and uploads it -# as a run artifact. Download it from the checks, read it, and merge if it is -# right. +# So this builds the PDF for the note being asked about and nothing else, and +# uploads it as a run artifact. Download it from the checks, read it, and merge +# if it is right. on: pull_request: - paths: ['deposit-queue.txt'] + paths: ['articles/**/metadata.yml'] concurrency: group: deposit-pdf-${{ github.ref }} @@ -30,19 +30,25 @@ jobs: steps: - uses: actions/checkout@v4 with: - # The base is needed to work out which lines this pull request ADDED. - # A queue entry that was already there has already been deposited, and - # rebuilding its PDF would say nothing about the decision at hand. + # The base is needed to work out what this pull request ADDED. A + # metadata edit that does not reserve a DOI is not a deposit request, + # and rebuilding a PDF for it would say nothing about any decision. fetch-depth: 0 - name: Work out which notes are being asked about id: slugs run: | BASE="${{ github.event.pull_request.base.sha }}" - ADDED=$(git diff "$BASE"...HEAD -- deposit-queue.txt \ - | sed -n 's/^+\([a-z0-9][a-z0-9-]*\)$/\1/p' | tr '\n' ',' | sed 's/,$//') + # A note whose metadata.yml GAINED an archive_doi in this pull + # request. That is what a deposit request is, and it excludes every + # other metadata edit -- a keyword, a corrected banner credit -- which + # would otherwise each cost a PDF build. + ADDED=$(git diff "$BASE"...HEAD -- 'articles/*/metadata.yml' \ + | awk '/^\+\+\+ b\/articles\//{split($2,a,"/"); slug=a[3]} + /^\+archive_doi:/{if (slug) print slug}' \ + | sort -u | tr '\n' ',' | sed 's/,$//') if [ -z "$ADDED" ]; then - echo "no slug added to the queue by this pull request" + echo "no DOI reserved by this pull request -- not a deposit request" echo "slugs=" >> "$GITHUB_OUTPUT" else echo "building: $ADDED" diff --git a/.github/workflows/deposit-ready.yml b/.github/workflows/deposit-ready.yml index 3105578..d192f39 100644 --- a/.github/workflows/deposit-ready.yml +++ b/.github/workflows/deposit-ready.yml @@ -29,68 +29,59 @@ jobs: with: cache: true - - name: Which notes have no DOI + - name: Which notes have no record at all id: pending - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - # A request that is already OPEN counts as asked. deposit-queue.txt - # only records what has been merged, so without this the workflow - # re-asks on every metadata change and the same note accumulates - # requests -- four for one note, before this was noticed. Duplicate - # reminders are how a reminder becomes something you scroll past. - gh pr list --state open --limit 100 --json title \ - --jq '.[] | select(.title | startswith("Deposit: ")) | - .title | sub("^Deposit: "; "")' \ - | tr ',' '\n' | tr -d ' ' | sed '/^$/d' > .already-asked || true - echo "already asked: $(tr '\n' ' ' < .already-asked)" SLUGS=$(pixi run -q python3 -c " import sys; sys.path.insert(0, 'scripts'); import deposit - queued = set() - try: - for line in open('deposit-queue.txt'): - line = line.split('#')[0].strip() - if line: queued.add(line) - except FileNotFoundError: - pass - try: - queued.update(l.strip() for l in open('.already-asked') if l.strip()) - except FileNotFoundError: - pass - print(','.join(s for s in deposit.pending() if s not in queued)) + print(','.join(deposit.unreserved())) ") - rm -f .already-asked echo "slugs=${SLUGS}" >> "$GITHUB_OUTPUT" - echo "not yet queued: ${SLUGS:-none}" + echo "no record yet: ${SLUGS:-none}" - - name: Ask, by pull request + # ONE pull request per note, each touching only that note's own + # metadata.yml. The queue file this replaced was appended to by every + # note at the same line, so two notes in flight conflicted and merging + # one broke the other -- measured, not feared. + - name: Reserve a DOI and ask, one note at a time if: steps.pending.outputs.slugs != '' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + FIGSHARE_TOKEN: ${{ secrets.FIGSHARE_TOKEN }} run: | - # RUN_ATTEMPT, not just RUN_ID: a re-run keeps the same run id, so - # without it the second attempt pushes to the branch the first one - # already created and is rejected non-fast-forward. Re-running a - # failed workflow is the obvious thing to do, and it must work. - BRANCH="deposit/queue-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" git config user.name "underworld-technical-notes" git config user.email "help@underworldcode.org" - git checkout -b "$BRANCH" for SLUG in $(echo "${{ steps.pending.outputs.slugs }}" | tr ',' ' '); do - echo "$SLUG" >> deposit-queue.txt - done - git add deposit-queue.txt - git commit -m "Deposit: ${{ steps.pending.outputs.slugs }} + # RUN_ATTEMPT, not just RUN_ID: a re-run keeps the same run id, so + # without it the second attempt pushes to the branch the first one + # already created and is rejected non-fast-forward. + BRANCH="deposit/${SLUG}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + git checkout -q main + git checkout -qb "$BRANCH" + # Reserve only: a draft and a DOI, both reversible, and nothing + # uploaded or published. What reaches this pull request is the + # identifiers -- so merging it both approves the deposit AND + # records what was minted, in one act. + pixi run deposit -- --slug "$SLUG" --live --reserve-only + git add "articles/$SLUG/metadata.yml" + git commit -m "Deposit: $SLUG - Opened automatically because these notes are on main without a DOI. - Merging this runs the deposit workflow." - git push origin "$BRANCH" - gh pr create --base main --head "$BRANCH" \ - --title "Deposit: ${{ steps.pending.outputs.slugs }}" \ - --body "These notes are published and have no DOI. + A draft and a DOI are reserved for this note; neither is public. The + identifiers are in its metadata.yml, so merging this approves the + deposit and records it in one step. - **Merging this mints one.** The deposit workflow reserves a DOI, rebuilds the PDF so the DOI is on its title page, uploads the PDF and the archive package, publishes the record, and opens a further pull request with the identifiers. + Opened automatically because the note is on main without a record." + git push origin "$BRANCH" + DOI=$(grep '^archive_doi:' "articles/$SLUG/metadata.yml" | cut -d' ' -f2) + gh pr create --base main --head "$BRANCH" \ + --title "Deposit: $SLUG" \ + --body "\`$SLUG\` is published and has no archival record. A figshare draft has been created and **$DOI** reserved for it. Neither is public yet. - A published DOI cannot be withdrawn, only superseded. Close this instead if a note is not ready — it will be offered again the next time anything changes, so nothing is lost by waiting. + **Merging this publishes the record at that DOI.** The deposit workflow rebuilds the PDF so the DOI is on its title page, uploads the PDF and the archive package, and publishes. The identifiers are already in this pull request, so nothing has to be recorded afterwards. + + A published DOI cannot be withdrawn, only superseded — and because figshare versions, a mistake is a new version rather than a lost identifier. + + Not ready? Close this. The reserved draft is then unused and shows up in the weekly *outstanding* issue, where \`pixi run deposit -- --slug $SLUG --live --delete-draft\` clears it. Underworld development team with AI support from [Claude Code](https://claude.com/claude-code)" + done diff --git a/.github/workflows/deposit.yml b/.github/workflows/deposit.yml index 5bcdfd7..28e77ba 100644 --- a/.github/workflows/deposit.yml +++ b/.github/workflows/deposit.yml @@ -12,16 +12,21 @@ name: deposit # Two ways in, and both are a person deciding. # -# A push to deposit-queue.txt means somebody merged a pull request titled -# "Deposit: ". That is the editorial act; the merge is the consent. The -# file is only ever changed by such a merge, so this cannot fire on its own. +# A reserved DOI reaching `main` means somebody merged a pull request titled +# "Deposit: ". That is the editorial act; the merge is the consent. A +# reserved record can only arrive that way, so this cannot fire on its own. +# +# The trigger watches metadata, not a queue file, because the approval now +# lives in the note's own metadata.yml -- one file per note, so two notes in +# flight cannot conflict. It is narrowed to `--approved` below for the same +# reason it is not `--all`: an unrelated metadata edit must not mint anything. # # workflow_dispatch is the manual route, for a re-run or a mode other than # deposit-all. on: push: branches: [main] - paths: ['deposit-queue.txt'] + paths: ['articles/**/metadata.yml'] workflow_dispatch: inputs: slug: @@ -148,11 +153,24 @@ jobs: FIGSHARE_TOKEN: ${{ secrets.FIGSHARE_TOKEN }} run: pixi run deposit -- --slug "${{ inputs.slug }}" --live --publish - # A queue merge has no inputs, so `mode` is empty and this is the step - # that runs. Anything already holding a record is skipped, so a stale line - # in the queue does nothing. + # An approval merge has no inputs, so `mode` is empty and this is the + # step that runs. + # + # `--approved`, NOT `--all`: it acts only on notes holding a RESERVED + # record that is not yet published, which is exactly the set somebody has + # merged a request for. With `--all` this trigger would deposit any + # archival note the moment an unrelated metadata change reached main -- + # the gate would be gone, and the first anyone knew would be the DOI. + - name: Publish what has been approved + if: github.event_name == 'push' + env: + FIGSHARE_TOKEN: ${{ secrets.FIGSHARE_TOKEN }} + run: pixi run deposit -- --approved --live --publish + + # The manual sweep keeps `--all`: run deliberately, by a person choosing + # the mode, it is allowed to reserve and publish in one go. - name: Deposit and publish everything outstanding - if: inputs.mode == 'deposit-all' || github.event_name == 'push' + if: inputs.mode == 'deposit-all' env: FIGSHARE_TOKEN: ${{ secrets.FIGSHARE_TOKEN }} run: pixi run deposit -- --all --live --publish @@ -215,9 +233,11 @@ jobs: git push origin "$BRANCH" gh pr create --base main --head "$BRANCH" \ --title "Deposit identifiers from run ${GITHUB_RUN_ID} (attempt ${GITHUB_RUN_ATTEMPT})" \ - --body "DOIs and record ids written by the deposit workflow (\`${{ inputs.mode }}\`). + --body "Publication timestamps written by the deposit workflow (\`${{ inputs.mode }}\`). + + Bookkeeping. The record id and DOI already reached \`main\` through the deposit request, so the guard against minting a second DOI is satisfied whether or not this lands; what is here is \`archive_published_at\`, which only affects which notes a batch re-version offers. - **Merge this.** Until it lands, the repository does not know these records exist, and the guard against minting a second DOI for the same note has nothing to check. + Merge it when convenient. It appears in the weekly *outstanding* issue until you do. Underworld development team with AI support from [Claude Code](https://claude.com/claude-code)" diff --git a/PUBLISHING.md b/PUBLISHING.md index 3ff1a20..aac326b 100644 --- a/PUBLISHING.md +++ b/PUBLISHING.md @@ -362,20 +362,43 @@ settled before code exists. ## The deposit is offered, not taken -A note reaching `main` without a DOI opens a pull request titled -**Deposit: **, adding it to `deposit-queue.txt`. Merging that pull -request runs the deposit; closing it does not, and the note is offered again -the next time anything changes. - -So the reminder is automatic and the decision is not. Nothing is deposited -because a note was published — only because somebody merged the request to -deposit it. That matters because a published DOI cannot be withdrawn, only -superseded. - -The deposit then opens a further pull request carrying the identifiers it -obtained. **Merge that too**: until it lands, the repository does not know the -record exists, and the guard against minting a second DOI for the same note -keys on the record id being present. - -Queue entries stay after the deposit. They are a log of what was approved, and -the deposit skips anything already holding a record, so a stale line is inert. +A note reaching `main` without a record gets a pull request titled +**Deposit: **. The workflow creates a figshare draft and reserves a DOI +for it — both private, both reversible — and commits the identifiers into that +note's own `metadata.yml`. So the request you are reading already contains the +DOI it is asking about, and `deposit-pdf` attaches the archival PDF to it. + +**Merging is the decision, and it is the only one.** The push runs the deposit, +which rebuilds the PDF with the DOI on its title page, uploads it with the +archive package, and publishes. Nothing has to be recorded afterwards: the +identifiers arrived with the approval. + +Closing the request deposits nothing. The draft is then unused, and it appears +in the weekly *outstanding* issue until it is either merged or cleared with +`--delete-draft`. A reserved DOI never resolves publicly, so an abandoned +request costs nothing but a line in that report. + +The reminder is automatic and the decision is not. Nothing is deposited because +a note was published — only because somebody merged the request. That matters +because a published DOI cannot be withdrawn, only superseded. + +### Why the approval lives in the note's metadata + +It used to live in one shared `deposit-queue.txt`, appended to by every note at +the same line. Two notes in flight therefore conflicted, and merging one broke +the other — so the approvals raced each other, and duplicate requests piled up +for the same note. Per-note files cannot collide. + +The trigger watches `articles/**/metadata.yml` and acts on `--approved`, which +is "holds a reserved record, not yet published". A reserved record can only +reach `main` through a merged request, so an ordinary metadata edit — a +keyword, a corrected credit — mints nothing. `--all`, which would deposit +anything undeposited, stays behind an explicit manual mode. + +### What still comes back afterwards + +One thing: `archive_published_at`, the moment figshare published. It arrives as +a small pull request, and it is bookkeeping — the record id and DOI are already +on `main`, so the guard against a second mint is satisfied whether or not it +lands. It only decides which notes a batch re-version offers. The *outstanding* +report lists it until it is merged. diff --git a/deposit-queue.txt b/deposit-queue.txt deleted file mode 100644 index eb2c8d9..0000000 --- a/deposit-queue.txt +++ /dev/null @@ -1,15 +0,0 @@ -# Notes approved for deposit, one slug per line. -# -# This file is how a DOI gets minted. A note reaching `main` without a record -# causes a pull request to be opened that adds it here; MERGING that pull -# request is the decision, and the push runs the deposit workflow. -# -# So: the reminder is automatic and the act is not. Nothing is deposited -# because a note was published -- only because somebody merged the request to -# deposit it. -# -# Entries stay after the deposit. They are a record of what was approved and -# when, and the workflow skips anything that already has a record, so a stale -# line is inert. -moving-the-mesh-without-remaking-it -setting-up-full-multigrid diff --git a/scripts/deposit.py b/scripts/deposit.py index 37a768b..d000634 100644 --- a/scripts/deposit.py +++ b/scripts/deposit.py @@ -510,6 +510,47 @@ def pending(): return [slug for _date, slug in sorted(ready)] +def unreserved(): + """Archival articles with no record at all, oldest first. + + The set a deposit REQUEST is opened for. Distinct from :func:`pending`, + which includes a note whose draft exists but is unpublished: under the + reserve-on-request flow that is a note already in flight -- its identifiers + are sitting in an open pull request, or a deposit is part way through -- and + asking again would open a second request for it. A draft that gets stuck is + caught by ``scripts/outstanding.py``, which is the tool for saying so. + """ + import build_index + build_index.TYPES.update(build_index.article_types()) + ready = [] + for path in sorted(ARTICLES.glob("*/metadata.yml")): + meta = build_index.read_yaml(path) + if build_index.is_archival(meta) and not meta.get("repository_record_id"): + ready.append((str(meta.get("publication_date") or ""), meta["slug"])) + return [slug for _date, slug in sorted(ready)] + + +def approved(): + """Archival articles holding a RESERVED record that is not yet published. + + A reserved DOI reaches `main` only by somebody merging the deposit request + that carries it, so this set is exactly "approved for deposit and not yet + deposited" -- which is what the push trigger acts on. Keying the trigger on + :func:`pending` instead would deposit any archival note the moment an + unrelated metadata change was merged, and the approval gate would be gone. + """ + import build_index + build_index.TYPES.update(build_index.article_types()) + ready = [] + for path in sorted(ARTICLES.glob("*/metadata.yml")): + meta = build_index.read_yaml(path) + if (build_index.is_archival(meta) + and meta.get("repository_record_id") + and not meta.get("archive_published_at")): + ready.append((str(meta.get("publication_date") or ""), meta["slug"])) + return [slug for _date, slug in sorted(ready)] + + def published(): """Archival articles that already hold a published record, oldest first. @@ -594,11 +635,22 @@ def run(slug, provider, live, publish, new_version, delete_draft, set_field(slug, "archive_doi", doi) steps.append("reserved %s" % doi) if not rebuild: + # Everything the archival copy needs, stamped HERE so the reserve + # is self-contained. These reach `main` by somebody merging the + # deposit request, and the publish that follows finds them already + # set. Stamped at publish instead, they would exist only on the + # runner and have to be written back afterwards -- which is the + # step that used to sit unmerged for a fortnight. + stamp = (datetime.datetime.now(datetime.timezone.utc) + .replace(microsecond=0).isoformat().replace("+00:00", "Z")) + set_field(slug, "archived_at", stamp) + set_field(slug, "archived_version", meta.get("version") or "0.0.0") + steps.append("stamped archived_at %s (version %s)" + % (stamp, meta.get("version"))) print("\n".join(" " + s for s in steps)) - print("\nThe DOI is now in metadata.yml. REBUILD THE PDF before " - "uploading, so the DOI is on its title page:\n" - " pixi run build\n" - "then run this again to upload.") + print("\nReserved and stopped. The identifiers are in " + "metadata.yml; commit them, and the deposit runs when they " + "reach main.") return # BEFORE the rebuild, because the PDF prints this date and the README states @@ -715,10 +767,19 @@ def main(): help="delete an unpublished draft and forget it") parser.add_argument("--all", action="store_true", help="every archival article not yet deposited") + parser.add_argument("--approved", action="store_true", + help="every article whose reserved DOI has been merged") + parser.add_argument("--reserve-only", action="store_true", + help="create the draft and reserve the DOI, then stop: " + "no PDF rebuild and no upload. What a deposit " + "REQUEST runs, so the identifiers can be reviewed " + "and merged before anything is published.") args = parser.parse_args() if args.publish and not args.live: sys.exit("--publish needs --live. Refusing to guess.") + if args.reserve_only and args.publish: + sys.exit("--reserve-only and --publish are opposites. Refusing to guess.") provider = Figshare(os.environ.get("FIGSHARE_TOKEN")) if args.live else None @@ -726,6 +787,10 @@ def main(): slugs = [args.slug] elif args.new_version: slugs = published() + elif args.approved: + slugs = approved() + elif args.reserve_only: + slugs = unreserved() else: slugs = pending() if not slugs: @@ -759,7 +824,7 @@ def main(): run(slug, provider, args.live, args.publish, args.new_version, args.delete_draft, rebuild=(args.live and not args.delete_draft - and not batch_rebuild)) + and not batch_rebuild and not args.reserve_only)) except DepositError as exc: failed.append((slug, str(exc))) print("REFUSED: %s" % exc, file=sys.stderr) diff --git a/scripts/outstanding.py b/scripts/outstanding.py index 37e33ce..c8a8b80 100644 --- a/scripts/outstanding.py +++ b/scripts/outstanding.py @@ -11,10 +11,13 @@ So this names the half-finished states. It reads metadata and, if `gh` is available, open pull requests; it changes nothing. - deposited, not recorded an identifiers pull request is still open, so - the repository does not know a record exists - and the duplicate-mint guard is blind - asked for, not deposited a deposit request is open on the queue + reserved, not published a DOI is reserved and the deposit never + finished: either the request is still open + (normal, and named), or it was closed and the + draft is now unused + timestamps not recorded a publication-timestamp pull request is open; + bookkeeping only, since the identifiers already + reached main through the request note ahead of its copy `version` has moved past `archived_version`: the note has outrun what is on the DOI never deposited archival, published, and has no DOI @@ -76,7 +79,7 @@ def survey(check_net=True): build_index.TYPES.update(build_index.article_types()) findings = {"unrecorded": None, "queued": None, - "stale": [], "undeposited": [], "blind": []} + "stale": [], "undeposited": [], "blind": [], "reserved": []} for md in sorted(ARTICLES.glob("*/metadata.yml")): slug = md.parent.name @@ -95,6 +98,12 @@ def survey(check_net=True): if field(text, "status") == "published": findings["undeposited"].append(slug) continue + # A reserved record that never got published. Normal while its + # request is open -- that is the gate doing its job -- and a stuck + # draft once it is not, which nothing else would ever mention. + if not field(text, "archive_published_at"): + findings["reserved"].append((slug, doi)) + continue # archived_version is absent on nothing after the backfill, but a # note deposited by an older workflow would have none; say so # rather than guessing it matches. @@ -105,7 +114,11 @@ def survey(check_net=True): if check_net: findings["unrecorded"] = open_branches("deposit/identifiers-") - findings["queued"] = open_branches("deposit/queue-") + every = open_branches("deposit/") + findings["queued"] = (None if every is None else + [p for p in every + if not p["headRefName"].startswith( + "deposit/identifiers-")]) return findings @@ -117,22 +130,46 @@ def head(title, n): lines.append("%s (%s)" % (title, n)) if f["unrecorded"] is None: - head("deposited, not recorded", "not checked -- gh unavailable") + head("timestamps not recorded", "not checked -- gh unavailable") elif f["unrecorded"]: - head("deposited, not recorded", len(f["unrecorded"])) + head("timestamps not recorded", len(f["unrecorded"])) for p in f["unrecorded"]: lines.append(" #%-5d %s (opened %s)" % (p["number"], p["title"][:60], p["createdAt"][:10])) - lines.append(" -> merge these: until they land the repository does " - "not know the records exist") + lines.append(" -> bookkeeping: the identifiers are already on main, " + "so nothing is at risk while these wait") outstanding += len(f["unrecorded"]) - if f["queued"]: - head("asked for, not deposited", len(f["queued"])) - for p in f["queued"]: + if f["reserved"]: + asked = {} + for p in (f["queued"] or []): + asked[p["title"].replace("Deposit: ", "").strip()] = p["number"] + head("reserved, not published", len(f["reserved"])) + for slug, doi in f["reserved"]: + if slug in asked: + lines.append(" %-46s %s request #%d open" + % (slug[:46], doi, asked[slug])) + else: + lines.append(" %-46s %s NO OPEN REQUEST -- draft unused" + % (slug[:46], doi)) + lines.append(" -> merge the request to publish, or clear an unused " + "draft with --delete-draft") + outstanding += len(f["reserved"]) + + # A request open against a note with nothing reserved: a leftover from the + # shared-queue design, or a reserve that failed. Either way it is asking + # for something that will not happen when merged. + reserved_slugs = {slug for slug, _doi in f["reserved"]} + orphan = [p for p in (f["queued"] or []) + if p["title"].replace("Deposit: ", "").strip() + not in reserved_slugs] + if orphan: + head("request open, nothing reserved", len(orphan)) + for p in orphan: lines.append(" #%-5d %s (opened %s)" % (p["number"], p["title"][:60], p["createdAt"][:10])) - outstanding += len(f["queued"]) + lines.append(" -> close these; the note is offered again on its own") + outstanding += len(orphan) if f["stale"]: head("note ahead of its archival copy", len(f["stale"])) diff --git a/tests/test_migration.py b/tests/test_migration.py index 561edb4..9fec097 100644 --- a/tests/test_migration.py +++ b/tests/test_migration.py @@ -1836,39 +1836,66 @@ def test_the_deposit_records_its_identifiers_through_a_pull_request(): def test_nothing_mints_a_doi_without_somebody_merging_something(): """The reminder is automatic; the act is not. - A note reaching main without a DOI opens a pull request adding it to the - queue. Merging that is the decision. The deposit fires on a push to - deposit-queue.txt — a file only a merge changes — or on manual dispatch. - It must never fire because a note was published. + A note reaching main without a record gets a pull request carrying a + RESERVED DOI -- a draft, not a publication, and reversible. Merging that + is the decision, and it is the only way a reserved record reaches main. + The deposit then fires on that push and publishes only what is reserved. + + So the property is unchanged from the queue-file design it replaced, and + the mechanism has moved: the trigger now watches metadata, and what keeps + it from firing on any old edit is `--approved`. """ - import re as _re deposit = (ROOT / ".github" / "workflows" / "deposit.yml").read_text(encoding="utf-8") - config = "\n".join(l for l in deposit.splitlines() if not l.lstrip().startswith("#")) - triggers = config.split("jobs:")[0] - assert "deposit-queue.txt" in triggers, "the queue merge is the consent" - # articles/** must NOT be a trigger: that would deposit on publication. - assert "articles/" not in triggers, \ - "a deposit must not fire because an article changed" + config = "\n".join(l for l in deposit.splitlines() + if not l.lstrip().startswith("#")) + push_step = config.split("github.event_name == 'push'")[1].split("- name:")[0] + assert "--approved" in push_step, \ + "the push-triggered deposit must act only on reserved records" + assert "--all" not in push_step, \ + "--all on the push trigger would deposit any note on an unrelated edit" + + # --all stays reachable, but only when a person chooses it + all_step = config.split("--all --live --publish")[0] + assert "inputs.mode == 'deposit-all'" in all_step.split("- name:")[-1], \ + "--all must be behind an explicit manual mode" ready = (ROOT / ".github" / "workflows" / "deposit-ready.yml").read_text(encoding="utf-8") ready_config = "\n".join(l for l in ready.splitlines() if not l.lstrip().startswith("#")) assert "gh pr create" in ready_config, "it must ASK, not deposit" - for forbidden in ("FIGSHARE_TOKEN", "--live", "--publish"): - assert forbidden not in ready_config, \ - "the reminder workflow must not be able to deposit anything (%s)" % forbidden + # It reserves now, which needs the token and --live. What it must never do + # is publish: everything it touches has to stay reversible. + assert "--reserve-only" in ready_config + assert "--publish" not in ready_config, \ + "the request workflow must never publish anything" -def test_the_queue_skips_what_is_already_deposited(): - """Entries stay after the deposit, as a record of what was approved. +def test_a_deposit_request_touches_only_its_own_note(): + """One pull request per note, each editing that note's own metadata. - So the queue is not a work list — it is a log, and the guard against acting - on a stale line is that the deposit skips anything holding a record. + The shared queue file this replaced was appended to by every note at the + same line, so two notes in flight conflicted and merging one broke the + other. Per-note files cannot collide. """ - queue = ROOT / "deposit-queue.txt" - assert queue.exists(), "the queue file is how a DOI gets minted" ready = (ROOT / ".github" / "workflows" / "deposit-ready.yml").read_text(encoding="utf-8") - assert "queued" in ready, "a note already in the queue must not be asked about twice" + assert 'git add "articles/$SLUG/metadata.yml"' in ready, \ + "the request must stage only the note it is about" + assert not (ROOT / "deposit-queue.txt").exists(), \ + "the shared queue file is what raced; it should be gone" + + +def test_the_request_carries_what_the_publish_needs(): + """Reserving stamps everything the archival copy needs, so nothing has to + be written back to main afterwards except a timestamp. + + archived_at is printed on the PDF and stated in the package README, so if + it were stamped at publish time it would exist only on the runner. + """ + src = (ROOT / "scripts" / "deposit.py").read_text(encoding="utf-8") + reserve = src.split("if not rebuild:")[1].split("return")[0] + for field in ("archived_at", "archived_version"): + assert field in reserve, \ + "%s must be stamped when the DOI is reserved" % field def test_no_directive_option_is_wrapped_over_two_lines(): diff --git a/tests/test_outstanding.py b/tests/test_outstanding.py index be156d6..55e813c 100644 --- a/tests/test_outstanding.py +++ b/tests/test_outstanding.py @@ -14,7 +14,7 @@ import outstanding # noqa: E402 CLEAN = {"unrecorded": [], "queued": [], "stale": [], "undeposited": [], - "blind": []} + "blind": [], "reserved": []} def test_a_clean_repository_says_nothing(): @@ -32,14 +32,41 @@ def test_an_unanswerable_check_says_so_rather_than_nothing(): assert n == 0 # unknown is not counted as outstanding -def test_unrecorded_identifiers_are_reported_with_the_reason(): +def test_unrecorded_timestamps_are_reported_as_bookkeeping(): + """They are no longer dangerous -- the identifiers reach main through the + request -- so the report must not describe them as if they were.""" f = dict(CLEAN, unrecorded=[ {"number": 25, "title": "Deposit identifiers from run 1", "createdAt": "2026-08-17T21:39:38Z"}]) text, n = outstanding.report(f) assert n == 1 assert "#25" in text and "2026-08-17" in text - assert "duplicate-mint" in text or "does not know" in text + assert "nothing is at risk" in text + + +def test_a_reserved_draft_with_no_request_is_distinguished_from_one_in_flight(): + """A reserved DOI whose request is open is the gate working. The same + DOI with no request is an unused draft, and only this says so.""" + in_flight = dict(CLEAN, + reserved=[("note-a", "10.0/x")], + queued=[{"number": 7, "title": "Deposit: note-a", + "createdAt": "2026-09-01T00:00:00Z"}]) + text, n = outstanding.report(in_flight) + assert n == 1 and "request #7 open" in text + assert "NO OPEN REQUEST" not in text + + stranded = dict(CLEAN, reserved=[("note-a", "10.0/x")]) + text, n = outstanding.report(stranded) + assert n == 1 and "NO OPEN REQUEST" in text + + +def test_a_request_with_nothing_reserved_is_reported(): + """A leftover from the shared-queue design, or a reserve that failed: + merging it would not deposit anything.""" + f = dict(CLEAN, queued=[{"number": 33, "title": "Deposit: note-b", + "createdAt": "2026-08-23T00:00:00Z"}]) + text, n = outstanding.report(f) + assert n == 1 and "#33" in text and "nothing reserved" in text def test_a_note_ahead_of_its_deposit_is_reported(): @@ -59,12 +86,15 @@ def test_a_deposit_with_no_recorded_version_is_not_assumed_current(): def test_the_counts_add_up(): f = {"unrecorded": [{"number": 1, "title": "t", "createdAt": "2026-01-01T00:00:00Z"}], - "queued": [{"number": 2, "title": "t", "createdAt": "2026-01-01T00:00:00Z"}], + "queued": [{"number": 2, "title": "Deposit: z", + "createdAt": "2026-01-01T00:00:00Z"}], + "reserved": [("a", "10.0/x")], "stale": [("a", "1.0.0", "1.1.0")], "undeposited": ["b"], "blind": ["c"]} _text, n = outstanding.report(f) - assert n == 5 + # 1 timestamps + 1 reserved + 1 orphan request + 1 stale + 1 never + 1 blind + assert n == 6 def test_the_real_repository_surveys_without_network():