Zenodo integration

Pushing a version tag to gitlab.com/mef90/mef90 publishes a new version of the Zenodo record and mints a fresh DOI for it. The work is done by deploy2zenodo, a shell script distributed as a GitLab CI template.

New versions attach to the existing concept record, so the DOI cited in README.md, 10.5281/zenodo.3242131, keeps resolving to whatever version was published last. It never needs updating.

Zenodo concept record 3242131 — concept DOI 10.5281/zenodo.3242131
Latest published version at time of writing 20517787 (0.5.2)
GitLab project mef90/mef90 (id 85749181)
Files in the mef90 repository .gitlab-ci.yml, .zenodo.json

1. Zenodo access tokens

Create a token on zenodo.org under avatar menu → Applications → Personal access tokens → New token. Scopes required:

  • deposit:write
  • deposit:actions — this is the one that performs newversion and publish; without it the job fails partway through

The token is displayed once. Create a second one the same way on sandbox.zenodo.org, which is a separate service with its own account and its own registration.

2. GitLab CI/CD variables

Settings → CI/CD → Variables. Four variables, all of type Variable:

Key Value Flags
DEPLOY2ZENODO_ACCESS_TOKEN the Zenodo token Masked, Protected, Hidden
DEPLOY2ZENODO_API_URL https://zenodo.org/api Protected
DEPLOY2ZENODO_DEPOSITION_ID 20517787 Protected
DEPLOY2ZENODO_SKIP_PUBLISH true (first run only, see §7) Protected

They live in the UI rather than in .gitlab-ci.yml on purpose: UI variables outrank job-level variables: in GitLab's precedence, so switching between sandbox and production is a settings change, not a commit.

DEPLOY2ZENODO_DEPOSITION_ID must be a version record id (20517787), not the concept id (3242131). deploy2zenodo calls GET /deposit/depositions/<id> and follows links.newversion; a concept id is not a deposition and the call fails. Nothing is lost — the script reads conceptrecid and conceptdoi off that record itself.

3. Protected tags

Settings → Repository → Protected tags → protect 0*, Allowed to create: Maintainers.

This is not optional. The access token is a Protected variable, which means GitLab injects it only into pipelines running on a protected branch or tag. An unprotected tag produces a pipeline where DEPLOY2ZENODO_ACCESS_TOKEN is empty and the job fails immediately.

The flip side: whoever can create a matching tag can run a pipeline that reads the token. Hence Maintainers rather than Developers.

0* will not match 1.0.0. Add a second rule, or widen the pattern to *, before the first 1.x release.

4. .zenodo.json in the mef90 repository

Static metadata, committed at the repository root. The CI job injects version, publication date, and the tag URL at build time. Standard unwrapped Zenodo form:

{
  "title": "mef90/vDef: variational models of defect mechanics",
  "upload_type": "software",
  "access_right": "open",
  "license": "BSD-2-Clause",
  "creators": [
    {"name": "Bourdin, Blaise", "affiliation": "McMaster University", "orcid": "0000-0002-1312-9175"}
  ],
  "contributors": [
    {"name": "Maurini, Corrado", "affiliation": "Sorbonne Université", "orcid": "0000-0003-1092-4461", "type": "Other"},
    {"name": "Tanné, Erwan", "affiliation": "ENSTA Bretagne", "type": "Other"},
    {"name": "Brach, Stella", "affiliation": "California Institute of Technology", "type": "Other"},
    {"name": "Marboeuf, Alexis", "affiliation": "McMaster University", "orcid": "0000-0001-9066-2320", "type": "Other"},
    {"name": "Knepley, Matthew", "affiliation": "University at Buffalo", "orcid": "0000-0002-2292-0735", "type": "Other"},
    {"name": "Frey, Jeffrey", "affiliation": "University of Delaware", "type": "Other"},
    {"name": "Scherer, Jean-Michel", "affiliation": "École Nationale Supérieure des Mines de Paris", "orcid": "0000-0002-0747-3893", "type": "Other"}
  ],
  "keywords": ["variational fracture", "phase-field", "PETSc", "Fortran", "finite elements"],
  "description": "<p>mef90/vDef is a Fortran/PETSc reference implementation of the variational approach to fracture (Francfort &amp; Marigo 1998; Bourdin, Francfort &amp; Marigo 2000, 2008).</p>",
  "related_identifiers": []
}

Notes:

  • creators is the citation author list — what renders as "Bourdin, B. (2026)" and what propagates to DataCite. It is an authorship decision, not a completeness one. People to credit without making them citation authors go in a separate contributors array, each entry requiring a type (Other, ProjectMember, Researcher, …). type is mandatory on every contributor — omitting it fails the metadata PUT with a 400 after the draft already exists.
  • Names go in Family, Given form. Free-form strings are accepted but sort and render inconsistently, and DataCite splits family from given on the comma.
  • ORCIDs are bare hyphenated identifiers (0000-0002-1312-9175), never URLs; the final character may be X.
  • description accepts HTML.
  • The file name is arbitrary; deploy2zenodo reads whatever DEPLOY2ZENODO_JSON names, and the CI job generates that from this template.

5. .gitlab-ci.yml in the mef90 repository

include:
  - remote: 'https://gitlab.com/deploy2zenodo/deploy2zenodo/-/releases/permalink/latest/downloads/deploy2zenodo.yaml'

deploy2zenodo:
  stage: deploy
  rules:
    - if: $CI_COMMIT_TAG
  variables:
    DEPLOY2ZENODO_JSON: "zenodo-metadata.json"
    DEPLOY2ZENODO_UPLOAD: "$CI_PROJECT_NAME-$CI_COMMIT_TAG.zip"
    DEPLOY2ZENODO_GET_METADATA: "zenodo-result.json"
    DEPLOY2ZENODO_ADD_IsNewVersionOf: "yes"
    DEPLOY2ZENODO_ADD_IsPartOf: "yes"
    DEPLOY2ZENODO_ADD_IsCompiledBy_DEPLOY2ZENODO: "yes"
  before_script:
    - apk add --no-cache curl jq git
    - publication_date=$(echo "$CI_COMMIT_TIMESTAMP" | grep -Eo '^[0-9]{4}-[0-9]{2}-[0-9]{2}')
    - |
      jq -c '{"metadata": .}
             | .metadata.version = "'"$CI_COMMIT_TAG"'"
             | .metadata.publication_date = "'"$publication_date"'"
             | .metadata.related_identifiers += [
                 {"relation": "isSupplementTo",
                  "scheme": "url",
                  "resource_type": "software",
                  "identifier": "'"$CI_PROJECT_URL"'/-/tree/'"$CI_COMMIT_TAG"'"}]' \
        .zenodo.json | tee "$DEPLOY2ZENODO_JSON" | jq -C .
    - git archive --format zip
        --prefix "$CI_PROJECT_NAME-$CI_COMMIT_TAG/"
        --output "$DEPLOY2ZENODO_UPLOAD" "$CI_COMMIT_SHA"
  artifacts:
    paths:
      - $DEPLOY2ZENODO_JSON
      - $DEPLOY2ZENODO_GET_METADATA

Three things in there are load-bearing and easy to get wrong:

  1. stage: deploy is mandatory. The included template declares its job as stage: .post. GitLab does not create a pipeline when every job in it lives in .pre or .post — it does so silently, with no error and no pipeline row. Omitting the stage means tag pushes appear to do nothing at all. build, test, and deploy are built-in, so no stages: block is needed.
  2. before_script replaces the template's, which installed curl and jq. They have to be reinstalled here, plus git for git archive.
  3. The three ADD_* variables generate the IsNewVersionOf, IsPartOf, and IsCompiledBy related identifiers. The first two only work on the update path, not when creating a record.

6. Test on sandbox first

Point the variables at sandbox: DEPLOY2ZENODO_API_URL=https://sandbox.zenodo.org/api, DEPLOY2ZENODO_DEPOSITION_ID=create NEW record, and the sandbox token. Nothing needs to be created on sandbox by hand — create NEW record is the instruction to make one.

Pass 1 — push a tag matching the protected pattern, e.g. 0.0.1-sandbox. The job log ends with

##################################################################
# add the id of the deposition/record on zenodo for the next run #
# id: 592897
#                                                                #
# DEPLOY2ZENODO_DEPOSITION_ID=592897
##################################################################

Put that id into the DEPLOY2ZENODO_DEPOSITION_ID variable.

Pass 2 — push a second tag, e.g. 0.0.2-sandbox. This exercises the update path, which is what every real release takes, so it is the pass that actually matters. Confirm on sandbox.zenodo.org that the record page shows a Versions panel with two entries and that the related identifiers include IsNewVersionOf pointing at the pass-1 DOI.

Sandbox DOIs use a test prefix (10.5072/…) and do not resolve at doi.org. That is expected. Published sandbox records cannot be deleted, which is harmless.

7. Switch to production

Set DEPLOY2ZENODO_API_URL=https://zenodo.org/api, DEPLOY2ZENODO_DEPOSITION_ID=20517787, and the zenodo.org token. For the first production release also add DEPLOY2ZENODO_SKIP_PUBLISH=true: the job then builds the draft and stops, so the result can be inspected at https://zenodo.org/uploads/<id> and published by hand. Remove that variable once one release has gone through cleanly.

Expect two visible changes on the record the first time, both intended: the title becomes "mef90/vDef: variational models of defect mechanics" instead of the old GitHub-integration title, and the license becomes BSD 2-Clause instead of the CC-BY-4.0 that was previously published.

8. Releasing

git tag -a 0.6.0 -m "Release 0.6.0"
git push origin 0.6.0

That is the whole process. An annotated tag is preferable — the message is available as a release note if a release: job is ever added.

To check the outcome:

glab ci list -R mef90/mef90
glab api projects/mef90%2Fmef90/jobs/<job-id>/trace
curl -sL https://doi.org/10.5281/zenodo.3242131 -o /dev/null -w '%{url_effective}\n'

The last command should land on the record just published.

If the draft needs correcting before publication, edit .zenodo.json and re-cut the tag:

git tag -d 0.6.0 && git push --delete origin 0.6.0
git tag -a 0.6.0 -m "Release 0.6.0" && git push origin 0.6.0

deploy2zenodo calls newversion again, Zenodo returns the same draft, and the metadata is overwritten. No orphan records are created.


Troubleshooting

A tag push produces no pipeline at all — "There are currently no pipelines". Almost certainly every job in the pipeline is in .pre or .post; GitLab declines to create such a pipeline and reports nothing. Check that deploy2zenodo has an explicit stage:. Note that a diagnostic job added to test this must also avoid .pre/.post, or it reproduces the symptom instead of isolating it.

Ruling the alternatives out, in increasing order of effort:

  • Pipeline editor (/-/ci/editor) — loads the committed file and validates it. The standalone lint page (/-/ci/lint) is a blank scratchpad and never loads the repository file.
  • Settings → CI/CD → General pipelines → CI/CD configuration file — must be empty.
  • Settings → General → Visibility, project features, permissions — the CI/CD toggle is nested under Repository, not a top-level row.
  • Build → Pipelines → New pipeline on main — "The resulting pipeline would have been empty" is the correct response here, since the only job is gated on $CI_COMMIT_TAG.

The job fails immediately on an empty access token. The tag does not match a protected-tag pattern. 0* matches 0.6.0 and 0.0.1-sandbox; it does not match v0.6.0, sandbox-test-0.0.1, or 1.0.0.

jq: parse error: Invalid numeric literal at line 1, column 20 followed by exit code 3. Zenodo returned an HTML error page — usually 504 Gateway Time-out — where JSON was expected, and deploy2zenodo pipes it straight into jq without checking. This is a Zenodo outage, not a configuration fault. Confirm with

curl -sS -o /dev/null -w "http=%{http_code} time=%{time_total}s\n" https://zenodo.org/api/records/20517787

A 504 after ~30 s on the public front page too means the service is down. Wait until responses are consistently fast (well under a second) before retrying — a failure partway through would leave a half-built draft on the real record. Retrying does not need a new tag:

glab api --method POST projects/mef90%2Fmef90/jobs/<job-id>/retry

The license reads bsd-2-clause-netbsd in the API output. An artifact of the legacy serialisation at /api/records/<id>, which reverse-maps license ids incorrectly. The stored value is correct; verify with

curl -sS -H "Accept: application/vnd.inveniordm.v1+json" https://zenodo.org/api/records/<id> \
  | jq '.metadata.rights[].id'

which returns bsd-2-clause. The record page renders "BSD 2-Clause Simplified License" with the right OSI link. Both bsd-2-clause and BSD-2-Clause in .zenodo.json resolve correctly.

A protected tag cannot be deleted. git push --delete is refused with "You can only delete protected tags using the web interface". The REST API works, however:

glab api --method DELETE projects/mef90%2Fmef90/repository/tags/<tag>
git tag -d <tag>

Discarding an unpublished Zenodo draft. https://zenodo.org/uploads/<id> → Delete, or

curl -X DELETE -H "Authorization: Bearer $ZENODO_TOKEN" \
  https://zenodo.org/api/deposit/depositions/<id>

Expect 204 No Content. The published record it was branched from is unaffected, and DEPLOY2ZENODO_DEPOSITION_ID should stay pointed at that published record.


Notes and open items

  • The concept record 3242131 dates from 2019 and originated with the Zenodo–GitHub integration on github.com/bourdin/mef90. That repository carries no webhooks and was archived on 2026-08-26, so nothing on GitHub can create competing versions of the concept.
  • ORCIDs are present for five of the eight people listed. Adding the rest improves indexing; Zenodo validates the MOD 11-2 checksum, so a typo fails the deposit with a 400 rather than being silently dropped.
  • The protected-tag pattern 0* needs widening before the first 1.x release.
  • deploy2zenodo also ships deploy2inveniordm, which targets Zenodo's current InvenioRDM API rather than the legacy deposit API used here. Worth revisiting if the legacy API is retired.