zenodo-release-drift#

Detect drift between GitHub releases and Zenodo archives.

When a repository is connected to Zenodo via the GitHub–Zenodo webhook, every new release should be automatically archived. In practice the webhook silently fails, gets disabled, or simply falls behind. zenodo-release-drift surfaces those gaps so you can act on them.

Caveat emptor

zenodo-release-drift has both read-only and write commands, and they behave very differently:

  • check and lint are read-only. They only query the public GitHub and Zenodo APIs, never modify anything, and do not require a Zenodo token. (An optional GITHUB_TOKEN only raises GitHub’s rate limit — see Authentication.) Run them freely.

  • fix writes to Zenodo. It uploads files and publishes records on your behalf, minting real, permanent DOIs. Published Zenodo records generally cannot be deleted or unpublished, and new versions are attached to an existing concept the tool discovers automatically — which may not be one you created (see record ownership). Test against the sandbox (--sandbox) first and verify the target repository, version range, and record ownership before running against production. You run fix at your own risk.

Installation#

pip install zenodo-release-drift
# or with uv
uv add zenodo-release-drift

Quick start#

Check a single repository:

zenodo-release-drift check owner/repo

Scan every repository owned by a GitHub user or org:

zenodo-release-drift check example-org

Example output:

GitHub user or org: example-org
28 repos found, 5 with Zenodo integration.

Repository                  Code    Description              Details
--------------------------  ------  -----------------------  -------------------------------------------
example-org/example-repo    ZRD001  Release(s) not archived  15 release(s) not archived:
                                                             1.2.0, 1.1.4, 1.0.1, ...
example-org/example-repo    ZRD002  Zenodo out of date       Zenodo latest: 1.1.3  |  GitHub latest: 1.2.0

Exit code is 1 when drift is found (single-repo mode), 0 when clean — suitable for CI and pre-commit hooks.

Commands#

check#

zenodo-release-drift check [OPTIONS] TARGET

TARGET is either owner/repo (single repository) or a GitHub username/org (scans all owned repositories).

Option

Description

--json

Output findings as JSON

--markdown

Output as a Markdown table (single repo only)

--explain

Full human-readable explanation of each finding (single repo only)

lint#

Explicit single-repository check — useful in pre-commit hooks where the command name should be unambiguous.

zenodo-release-drift lint [OPTIONS] OWNER/REPO

Same options as check.

fix#

Upload releases that are missing from Zenodo back to the archive.

This command modifies live Zenodo records. It uploads files and publishes records that mint permanent DOIs, which generally cannot be deleted or unpublished. Always dry-run with --sandbox first. Unlike check and lint, fix requires a ZENODO_TOKEN.

zenodo-release-drift fix [OPTIONS] OWNER/REPO

Requires a ZENODO_TOKEN environment variable — a personal access token created at https://zenodo.org/account/settings/applications/.

Option

Description

--version

Upload only this specific version (default: all missing ones)

--from

Only upload versions at or above this semver (inclusive)

--to

Only upload versions at or below this semver (inclusive)

--since-latest

Only upload versions newer than the latest one already on Zenodo

--concept-doi

Link uploads under a specific concept DOI (overrides record discovery)

--force

With --version, upload even if that version is already archived

--sandbox

Target sandbox.zenodo.org instead of production

--json

Output results as JSON

--no-progress

Disable the download/upload progress bar

--verbose

Print diagnostic details (record lookup, newversion errors)

How versions are grouped: if Zenodo already holds records for the repository, each upload is created as a new version under the same concept DOI so all versions remain linked. If no existing records are found, a new concept is created. You can also pass --concept-doi to link uploads under a specific concept explicitly (useful when automatic discovery picks the wrong record). Metadata (authors, description, license, keywords) is read from the repository’s CITATION.cff when present, and each record’s publication date is set to the original GitHub release date.

Note on version ordering: Zenodo orders versions within a concept by the order they were added (the newversion chain), not by semver or publication date. Uploading an older release after a newer one already exists will place it ahead of the newer one in the version list. To avoid this, use --since-latest to upload only releases newer than the latest version already on Zenodo, which always appends forward and never reorders existing records.

Note on uploaded source content: the archive uploaded for each version is fetched from GitHub’s tag archive endpoint at the moment fix runs, and reflects where the tag points at that time. For the vast majority of repositories this is identical to the original release — tags are not normally moved after publishing. If a tag has been amended since the original release was made, the archive will reflect the current state of the tag rather than its historical state; this is a property of how git tags work rather than a limitation of this tool.

Important — record ownership

The ZENODO_TOKEN you supply must belong to the same Zenodo account that owns the existing records. When the original records were created by the GitHub–Zenodo webhook they are owned by whichever Zenodo account connected the webhook — not necessarily yours. If your token belongs to a different account the newversion API call will return HTTP 403 and the upload will fail with a clear hint message.

To resolve a 403:

  1. Log in to Zenodo as the record owner.

  2. Open the existing record and go to Edit → Share.

  3. Grant your account the Curator role, or ask the Zenodo support team to transfer ownership.

  4. Re-run fix once access is granted.

# Upload every missing release
export ZENODO_TOKEN=your-token-here
zenodo-release-drift fix owner/repo

# Upload a single specific release (skipped if already archived)
zenodo-release-drift fix owner/repo --version 1.2.3

# Re-upload a release that already exists as an additional new version
zenodo-release-drift fix owner/repo --version 1.2.3 --force

# Upload all missing releases from 1.0.0 onwards
zenodo-release-drift fix owner/repo --from 1.0.0

# Upload all missing releases up to and including 1.4.0
zenodo-release-drift fix owner/repo --to 1.4.0

# Upload missing releases within a range
zenodo-release-drift fix owner/repo --from 1.0.0 --to 1.4.0

# Upload only releases newer than what is already on Zenodo (safest for ordering)
zenodo-release-drift fix owner/repo --since-latest

# Link uploads under a specific concept DOI
zenodo-release-drift fix owner/repo --concept-doi 10.5281/zenodo.1234567

# Test against the Zenodo sandbox before touching production
export ZENODO_TOKEN=your-sandbox-token-here
zenodo-release-drift fix owner/repo --sandbox

version#

zenodo-release-drift version

Check codes#

Code

Description

ZRD001

A GitHub release exists with no matching Zenodo archive

ZRD002

The latest Zenodo version is behind the latest GitHub release

Authentication#

GitHub (GITHUB_TOKEN)#

By default the tool makes unauthenticated GitHub API calls (60 requests/hour limit). Set GITHUB_TOKEN to raise this to 5,000 requests/hour:

export GITHUB_TOKEN=ghp_...
zenodo-release-drift check my-org

Zenodo (ZENODO_TOKEN)#

Only the fix command uses a Zenodo token — check and lint are read-only and need no Zenodo credentials at all. fix requires a Zenodo personal access token with the deposit:write scope.

  1. Log in to https://zenodo.org (or https://sandbox.zenodo.org for testing).

  2. Go to Account → Applications → Personal access tokens.

  3. Create a token with the deposit:write scope.

  4. Export it before running fix:

export ZENODO_TOKEN=your-token-here
zenodo-release-drift fix owner/repo

Pre-commit hook#

Add to .pre-commit-config.yaml to gate commits on a single repository. Set args to the repository you want to check:

- repo: local
  hooks:
    - id: zenodo-release-drift
      name: Zenodo release drift
      entry: zenodo-release-drift lint
      args: ["owner/repo"]
      language: python
      pass_filenames: false
      always_run: true

Python API#

from zenodo_release_drift import lint_repo, lint_repo_explain

# Returns a list of finding dicts
findings = lint_repo("owner", "repo")

# Returns a Markdown string with explanations
report = lint_repo_explain("owner", "repo")

Development#

git clone https://github.com/d33bs/zenodo-release-drift
cd zenodo-release-drift
uv sync --all-groups
uv run poe pipeline   # pre-commit + tests