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 |
Create a token on zenodo.org under avatar menu → Applications → Personal access tokens → New token. Scopes required:
deposit:writedeposit:actions — this is the one that performs newversion and publish; without it the
job fails partway throughThe 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.
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.
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 match1.0.0. Add a second rule, or widen the pattern to*, before the first 1.x release.
.zenodo.json in the mef90 repositoryStatic 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 & Marigo 1998; Bourdin, Francfort & 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.Family, Given form. Free-form strings are accepted but sort and render
inconsistently, and DataCite splits family from given on the comma.0000-0002-1312-9175), never URLs; the final
character may be X.description accepts HTML.DEPLOY2ZENODO_JSON names, and the CI
job generates that from this template..gitlab-ci.yml in the mef90 repositoryinclude:
- 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:
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.before_script replaces the template's, which installed curl and jq. They have to be
reinstalled here, plus git for git archive.ADD_* variables generate the IsNewVersionOf, IsPartOf, and IsCompiledBy
related identifiers. The first two only work on the update path, not when creating a record.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.
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.
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.
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:
/-/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.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.
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.0* needs widening before the first 1.x release.deploy2inveniordm, which targets Zenodo's current InvenioRDM API
rather than the legacy deposit API used here. Worth revisiting if the legacy API is retired.