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 4391569..f60d95c 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():