From bb50c3354da9713f25a7340a7efb8d81823b8df2 Mon Sep 17 00:00:00 2001 From: Samantha Wittke Date: Fri, 7 Aug 2026 22:17:42 +0300 Subject: [PATCH 1/4] add main metadata file --- metadata.yml | 125 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 metadata.yml diff --git a/metadata.yml b/metadata.yml new file mode 100644 index 0000000..edeb737 --- /dev/null +++ b/metadata.yml @@ -0,0 +1,125 @@ +# Master metadata for this lesson. +# +# CITATION.cff and bioschemas.yml are both generated from this file by +# generate_metadata_files.py upon push to main, and publish_to_zenodo.py reads it +# directly too. Edit this file, not the generated ones, it will generate CITATION.cff and bioschemas.yml upcon push to main branch. +# +# All values are quoted deliberately, so they always load as plain +# strings (not YAML dates/numbers) - see version below. + + +# Text. Base lesson title. " - CodeRefinery lesson" is appended +# automatically when generating CITATION.cff/bioschemas.yml and when +# publishing to Zenodo - do not include it here. +title: "Collaborative distributed version control" + +# Text. Used as CITATION.cff's abstract, bioschemas' description/about, +# and the start of the Zenodo description. +abstract: "This lesson builds on Introduction to version control and we apply branching and learn about pull requests (merge requests), forks, and collaboration using Git and GitHub." + +# Text (ISO 8601 date, YYYY-MM-DD), quoted so it isn't parsed as a +# YAML date. Used in CITATION.cff, bioschemas.yml, and the Zenodo +# deposit. Also used as CITATION.cff's date-released, since for this +# lesson the two always match - there's no separate date-released field. +version: "2026-08-07" + +# Text. The concept DOI, which stays constant across all versions - +# Zenodo assigns the version-specific DOI itself. Used in bioschemas.yml +# as identifier (https://doi.org/). +doi: "10.5281/zenodo.16925120" + +# Text (URL). The lesson's rendered website. Used as bioschemas' @id/url +# and linked in the Zenodo description. +url: "https://coderefinery.github.io/git-collaborative" + +# Text (SPDX license identifier, e.g. CC-BY-4.0). Converted to a full +# license URL for bioschemas.yml via SPDX_LICENSE_URLS in +# generate_metadata_files.py; passed through as-is to CITATION.cff and +# Zenodo. +license: "CC-BY-4.0" + +# Text (URL). Source repository, linked in the Zenodo description and +# included in CITATION.cff. +repository-code: "https://github.com/coderefinery/git-collaborative" + +# List of Text. Not used in CITATION.cff. Comma-joined for +# bioschemas.yml's keywords string, and used as Zenodo's keywords list. +keywords: + - "" + - "" + + +# Text or DefinedTerm per schema.org. bioschemas.yml only. E.g. +# "Beginner", "Intermediate", "Advanced". +educationalLevel: "Beginner" + +# Text (BCP47 language tag, e.g. "en-UK"). bioschemas.yml only. Not the +# same format Zenodo's own "language" field would expect (ISO 639-2/3), +# so this isn't reused for the Zenodo deposit. +inLanguage: "en-UK" + +# Text. bioschemas.yml only. Learning outcomes / what this lesson +# teaches. +teaches: "" + +# URL or CreativeWork per schema.org. bioschemas.yml only. The +# collection/series this lesson belongs to. +isPartOf: "https://coderefinery.org" + +# Audience (structured object) per schema.org - NOT plain Text, even +# though it's currently stored and emitted as a bare string. bioschemas.yml +# only. +audience: "" + +# Text, DefinedTerm, or URL per schema.org. bioschemas.yml only. +# Prerequisites/prior knowledge needed. +competencyRequired: "" + +# Text per schema.org. bioschemas.yml only. Describes accessibility +# features of the material. +accessibilitySummary: "" + +# Text or DefinedTerm per schema.org. bioschemas.yml only. E.g. +# "lesson", "exercise", "video". +learningResourceType: "lesson" + +# Disabled for now. List of CFF-style entries, same shape as authors +# below: either {name: "Org"} for organizations, or {family-names, +# given-names, orcid (optional)} for people. When enabled: mapped to +# CITATION.cff's contact field, bioschemas.yml's maintainer property, +# and Zenodo's contributors (as ContactPerson). +# maintainers: +# - family-names: "" +# given-names: "" + +# List of CFF-style author entries: either {name: "Org"} for +# organizations, or {family-names, given-names, orcid (optional)} for +# people. orcid, if given, must be the full https://orcid.org/... URL - +# publish_to_zenodo.py strips that prefix before sending it to Zenodo. +# Used for CITATION.cff authors, bioschemas.yml's author list +# (mapped to Person/Organization), and Zenodo's creators. +authors: +- name: "CodeRefinery" +- family-names: "Bast" + given-names: "Radovan" +- family-names: "Wikfeldt" + given-names: "Kjartan Thor" +- family-names: "Hellsvik" + given-names: "Johan" +- family-names: "Darst" + given-names: "Richard" +- family-names: "Razick" + given-names: "Sabry" +- family-names: "Pushpadas" + given-names: "Dhanya" +- family-names: "Lindi" + given-names: "Bjørn" +- family-names: "Pilstål" + given-names: "Robert" +- family-names: "Alim Rasel" + given-names: "Annajiat" +- family-names: "Suvilehto" + given-names: "Jyry" +- family-names: "Fouilloux" + given-names: "Anne" + From ed69a5305d0eee6be50b29e976d3a0a7c8d10638 Mon Sep 17 00:00:00 2001 From: Samantha Wittke Date: Fri, 7 Aug 2026 22:17:54 +0300 Subject: [PATCH 2/4] create reusing page, including rendered metadata file --- content/conf.py | 7 ++-- content/index.md | 2 +- content/lesson_metadata.py | 65 +++++++++++++++++++++++++++++++++++ content/reusing.md | 70 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 140 insertions(+), 4 deletions(-) create mode 100644 content/lesson_metadata.py create mode 100644 content/reusing.md diff --git a/content/conf.py b/content/conf.py index 36643b3..2c1c940 100644 --- a/content/conf.py +++ b/content/conf.py @@ -9,9 +9,9 @@ # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. # -# import os -# import sys -# sys.path.insert(0, os.path.abspath('.')) +import os +import sys +sys.path.insert(0, os.path.abspath('.')) # -- Project information ----------------------------------------------------- @@ -40,6 +40,7 @@ "sphinx_rtd_theme_ext_color_contrast", "sphinx_coderefinery_branding", "sphinx.ext.intersphinx", + "lesson_metadata", ] # Settings for myst_nb: diff --git a/content/index.md b/content/index.md index 561adc1..56c699b 100644 --- a/content/index.md +++ b/content/index.md @@ -75,5 +75,5 @@ PDF version CodeRefinery -Reusing +reusing ``` diff --git a/content/lesson_metadata.py b/content/lesson_metadata.py new file mode 100644 index 0000000..c459f36 --- /dev/null +++ b/content/lesson_metadata.py @@ -0,0 +1,65 @@ +"""Sphinx directive that renders metadata.yml as a table.""" + +import os + +import yaml +from docutils import nodes +from docutils.parsers.rst import Directive + + +def _author_name(author): + if "family-names" in author: + return f"{author['given-names']} {author['family-names']}" + return author["name"] + + +class LessonMetadataDirective(Directive): + """Renders the repository's metadata.yml as an HTML table.""" + + has_content = False + required_arguments = 0 + optional_arguments = 0 + + def run(self): + env = self.state.document.settings.env + metadata_path = os.path.join(env.srcdir, "..", "metadata.yml") + + with open(metadata_path, encoding="utf-8") as f: + meta = yaml.safe_load(f) + + fields = [ + ("Title", meta.get("title")), + ( + "Authors", + ", ".join(_author_name(a) for a in meta.get("authors", [])), + ), + ("Version", meta.get("version", "")), + ("DOI", meta.get("doi")), + ("License", meta.get("license")), + ("Lesson website", meta.get("url")), + ("Source repository", meta.get("repository-code")), + ("Keywords", ", ".join(meta.get("keywords", []))), + ("Educational level", meta.get("educationalLevel")), + ("Language", meta.get("inLanguage")), + ("Teaches", meta.get("teaches")), + ("Is part of", meta.get("isPartOf")), + ("Audience", meta.get("audience")), + ("Competency required", meta.get("competencyRequired")), + ("Accessibility summary", meta.get("accessibilitySummary")), + ("Learning resource type", meta.get("learningResourceType")), + ] + + rows = "\n".join( + f"{label}{value}" + for label, value in fields + if value + ) + + html = f'\n{rows}\n' + + return [nodes.raw("", html, format="html")] + + +def setup(app): + app.add_directive("lesson-metadata", LessonMetadataDirective) + return {"parallel_read_safe": True, "parallel_write_safe": True} diff --git a/content/reusing.md b/content/reusing.md new file mode 100644 index 0000000..63890f2 --- /dev/null +++ b/content/reusing.md @@ -0,0 +1,70 @@ +# Reusing + +This lesson material is free to reuse, adapt, and cite. This page collects +everything you need to do so. + +## How to cite this lesson + +If you use this lesson material, please cite it using this DOI: + + + +The machine-readable citation record is kept in `CITATION.cff` +at the root of this lesson's repository, which lists the full set of +contributing authors and stays up to date as the lesson evolves. + +## Structured metadata + +This lesson also publishes its metadata as machine-readable +[Bioschemas](https://bioschemas.org/) / [schema.org](https://schema.org/) +structured data, embedded as JSON-LD on every page of this site. + +All of this lesson's metadata is generated from a single source, +`metadata.yml`, +rendered here directly at build time: + +```{lesson-metadata} +``` + +## License + +### Website template + +The website template is maintained by [CodeRefinery](https://coderefinery.org/) +and rendered with [sphinx-lesson: structured lessons with Sphinx](https://coderefinery.github.io/sphinx-lesson/). + +### Instructional material + +All CodeRefinery instructional material is made available under the +[Creative Commons Attribution license (CC-BY-4.0)](https://creativecommons.org/licenses/by/4.0/). +The following is a human-readable summary of (and not a substitute for) +the [full legal text of the CC-BY-4.0 license](https://creativecommons.org/licenses/by/4.0/legalcode). + +You are free: + +- to **Share** - copy and redistribute the material in any medium or format +- to **Adapt** - remix, transform, and build upon the material + +for any purpose, even commercially. The licensor cannot revoke these freedoms as long as you follow these license terms: + +- **Attribution** - You must give appropriate credit + (mentioning that your work is derived from work that is Copyright + (c) CodeRefinery and, where practical, linking to + , provide + a [link to the license](https://creativecommons.org/licenses/by/4.0/), + and indicate if changes were made. You may do so in any + reasonable manner, but not in any way that suggests the licensor + endorses you or your use. + +**No additional restrictions** - You may not apply legal terms or technological +measures that legally restrict others from doing anything the license permits. +With the understanding that: + +- You do not have to comply with the license for elements of the material in + the public domain or where your use is permitted by an applicable exception + or limitation. +- No warranties are given. The license may not give you all of the + permissions necessary for your intended use. For example, other + rights such as publicity, privacy, or moral rights may limit how + you use the material. + From 2b79972ab822c3e3f37db3b92a768cf06c4512c8 Mon Sep 17 00:00:00 2001 From: Samantha Wittke Date: Fri, 7 Aug 2026 22:17:54 +0300 Subject: [PATCH 3/4] add tools to automatically generate cff and bioschemas --- .github/scripts/generate_metadata_files.py | 106 +++++++++++++++++++++ .github/workflows/generate-metadata.yml | 32 +++++++ .gitignore | 1 + 3 files changed, 139 insertions(+) create mode 100644 .github/scripts/generate_metadata_files.py create mode 100644 .github/workflows/generate-metadata.yml diff --git a/.github/scripts/generate_metadata_files.py b/.github/scripts/generate_metadata_files.py new file mode 100644 index 0000000..2a3744e --- /dev/null +++ b/.github/scripts/generate_metadata_files.py @@ -0,0 +1,106 @@ +"""Generate CITATION.cff and bioschemas.yml from metadata.yml.""" + +import json + +import yaml + +METADATA_FILE = "metadata.yml" +CITATION_FILE = "CITATION.cff" +BIOSCHEMAS_FILE = "bioschemas.yml" + +TITLE_SUFFIX = " - CodeRefinery lesson" + +SPDX_LICENSE_URLS = { + "CC-BY-4.0": "https://creativecommons.org/licenses/by/4.0/", +} + + +def load_yaml(path): + with open(path, encoding="utf-8") as f: + return yaml.safe_load(f) + + +def license_url(spdx_id): + return SPDX_LICENSE_URLS.get(spdx_id, spdx_id) + + +def bioschemas_authors(authors): + result = [] + for author in authors: + if "family-names" in author: + result.append( + { + "@type": "Person", + "name": f"{author['given-names']} {author['family-names']}", + } + ) + else: + result.append({"@type": "Organization", "name": author["name"]}) + return result + + +def write_citation_cff(meta): + citation = { + "cff-version": "1.2.0", + "message": ( + "If you use this lesson material, please cite it using " + "these metadata." + ), + "authors": meta["authors"], + "title": meta["title"] + TITLE_SUFFIX, + "type": "dataset", + "abstract": meta.get("abstract", ""), + "version": meta["version"], + "doi": meta["doi"], + "date-released": meta["version"], + "url": meta["url"], + "license": meta["license"], + "repository-code": meta["repository-code"], + } + + if meta.get("maintainers"): + citation["contact"] = meta["maintainers"] + + with open(CITATION_FILE, "w", encoding="utf-8") as f: + yaml.safe_dump(citation, f, sort_keys=False, allow_unicode=True) + + +def write_bioschemas(meta): + bioschemas = { + "@context": "https://schema.org/", + "@type": "LearningResource", + "@id": meta["url"], + "description": meta.get("abstract", ""), + "keywords": ", ".join(meta.get("keywords", [])), + "name": meta["title"] + TITLE_SUFFIX, + "author": bioschemas_authors(meta["authors"]), + # Disabled for now. + # "maintainer": bioschemas_authors(meta.get("maintainers", [])), + "about": meta.get("abstract", ""), + "audience": meta.get("audience", ""), + "competencyRequired": meta.get("competencyRequired", ""), + "educationalLevel": meta.get("educationalLevel", ""), + "identifier": f"https://doi.org/{meta['doi']}", + "inLanguage": meta.get("inLanguage", ""), + "learningResourceType": meta.get("learningResourceType", ""), + "license": license_url(meta["license"]), + "teaches": meta.get("teaches", ""), + "url": meta["url"], + "accessibilitySummary": meta.get("accessibilitySummary", ""), + "isPartOf": meta.get("isPartOf", ""), + "version": meta["version"], + } + + with open(BIOSCHEMAS_FILE, "w", encoding="utf-8") as f: + json.dump(bioschemas, f, indent=4) + f.write("\n") + + +def main(): + meta = load_yaml(METADATA_FILE) + write_citation_cff(meta) + write_bioschemas(meta) + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/generate-metadata.yml b/.github/workflows/generate-metadata.yml new file mode 100644 index 0000000..8dbc95b --- /dev/null +++ b/.github/workflows/generate-metadata.yml @@ -0,0 +1,32 @@ +name: Generate metadata files + +on: + push: + branches: [main] + paths: + - metadata.yml + +jobs: + generate: + name: Generate CITATION.cff and bioschemas.yml + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install dependencies + run: pip install pyyaml + + - name: Generate CITATION.cff and bioschemas.yml + run: python .github/scripts/generate_metadata_files.py + + - name: Commit generated files + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add CITATION.cff bioschemas.yml + git diff --staged --quiet || git commit -m "Regenerate CITATION.cff and bioschemas.yml from metadata.yml" + git push diff --git a/.gitignore b/.gitignore index 09dff1d..10eeb62 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ jupyter_execute /.ccls-cache/ /content/.auctex-auto/ /content/code/*/solution/build* +*DS_Store From 169ab85fc8c605520eb4464752a67eb6829346a0 Mon Sep 17 00:00:00 2001 From: Samantha Wittke Date: Fri, 7 Aug 2026 22:17:54 +0300 Subject: [PATCH 4/4] add tools to update zenodo entry upon release --- .github/scripts/publish_to_zenodo.py | 267 +++++++++++++++++++++++++++ .github/workflows/zenodo.yml | 27 +++ 2 files changed, 294 insertions(+) create mode 100644 .github/scripts/publish_to_zenodo.py create mode 100644 .github/workflows/zenodo.yml diff --git a/.github/scripts/publish_to_zenodo.py b/.github/scripts/publish_to_zenodo.py new file mode 100644 index 0000000..031a8ce --- /dev/null +++ b/.github/scripts/publish_to_zenodo.py @@ -0,0 +1,267 @@ +# Based on examples on https://developers.zenodo.org/ + +import json +import os +import requests +import yaml + +ACCESS_TOKEN = os.environ["ZENODO_TOKEN"] +CONCEPT_ID = os.environ["ZENODO_CONCEPT_ID"] + +print(CONCEPT_ID) +print(ACCESS_TOKEN) + +BASE_URL = "https://zenodo.org/api" + +TITLE_SUFFIX = " - CodeRefinery lesson" + + +json_headers = { + "Authorization": f"Bearer {ACCESS_TOKEN}", + "Content-Type": "application/json" +} + +upload_headers = { + "Authorization": f"Bearer {ACCESS_TOKEN}" +} + +def load_metadata(): + with open("metadata.yml", encoding="utf-8") as f: + return yaml.safe_load(f) + + +def authors_to_creators(authors): + creators = [] + + for author in authors: + if "family-names" in author: + creator = { + "name": ( + f"{author['family-names']}, " + f"{author['given-names']}" + ) + } + else: + creator = {"name": author["name"]} + + if author.get("orcid"): + creator["orcid"] = author["orcid"].removeprefix("https://orcid.org/") + + creators.append(creator) + + return creators + + + +# Load metadata from metadata.yml + +meta = load_metadata() + +description = meta.get("abstract", "") + +if meta.get("repository-code"): + description += ( + f'

Source code: ' + f'{meta["repository-code"]}

' + ) + +if meta.get("url"): + description += ( + f'

Lesson website: ' + f'{meta["url"]}

' + ) + +description += ( + '

CodeRefinery website: ' + 'https://coderefinery.org

' +) + +metadata = { + "title": meta["title"] + TITLE_SUFFIX, + "upload_type": "lesson", + "description": description, + "creators": authors_to_creators(meta["authors"]), + "keywords": meta.get("keywords", []), + "version": meta.get("version"), + "license": meta.get("license"), +} + +if meta.get("maintainers"): + contributors = authors_to_creators(meta["maintainers"]) + for contributor in contributors: + contributor["type"] = "ContactPerson" + metadata["contributors"] = contributors + + +# Discard any unpublished draft left over from a previous failed run. +# Zenodo only allows one unpublished new-version draft per concept at +# a time, so a stray one would make the newversion call below fail. + +r = requests.get( + f"{BASE_URL}/deposit/depositions", + params={"q": f"conceptrecid:{CONCEPT_ID}", "all_versions": "true"}, + headers=json_headers, +) + +r.raise_for_status() + +for dep in r.json(): + if not dep.get("submitted"): + print(f"Discarding leftover draft {dep['id']}") + requests.delete(dep["links"]["self"], headers=json_headers).raise_for_status() + + +# Find the current latest version of this concept. +# newversion only works when called on the latest version's own +# deposit ID, and that ID changes with every new release. + +r = requests.get( + f"{BASE_URL}/records", + params={"q": f"conceptrecid:{CONCEPT_ID}", "all_versions": "true"}, + headers=json_headers, +) + +r.raise_for_status() + +hits = r.json()["hits"]["hits"] + +latest_id = hits[0]["id"] +for hit in hits[1:]: + if hit["id"] > latest_id: + latest_id = hit["id"] + +print(f"Latest existing version: {latest_id}") + + +# Create new version of existing concept DOI + +r = requests.post( + f"{BASE_URL}/deposit/depositions/{latest_id}/actions/newversion", + headers=json_headers) + +r.raise_for_status() + +latest_draft = r.json()["links"]["latest_draft"] + + +# Get draft deposition information + +r = requests.get( + latest_draft, + headers=json_headers, +) + +r.raise_for_status() + +deposition = r.json() + +deposition_id = deposition["id"] +bucket_url = deposition["links"]["bucket"] + +print(f"Draft deposition: {deposition_id}") + + +# newversion copies over the previous version's files. Remove them so +# this version ends up with only its own zip and PDF. + +for f in deposition.get("files", []): + requests.delete( + f"{BASE_URL}/deposit/depositions/{deposition_id}/files/{f['id']}", + headers=json_headers, + ).raise_for_status() + + print(f"Removed inherited file {f['filename']}") + +# Upload lesson PDF from the gh-pages branch +# (built by sphinx.yml as OUTPUT_BASENAME.pdf, OUTPUT_BASENAME being +# "{owner}-{repo}") + +tag = os.environ["GITHUB_REF_NAME"] +repo_name = os.environ['GITHUB_REPOSITORY'] +owner, repo = repo_name.split("/", 1) + +pdf_source_name = f"{owner}-{repo}.pdf" +pdf_name = f"{owner}-{repo}-{tag}.pdf" + +pdf_url = ( + f"https://raw.githubusercontent.com/" + f"{repo_name}/gh-pages/{pdf_source_name}" +) + +pdf_download = requests.get(pdf_url) +pdf_download.raise_for_status() + +with open(pdf_name, "wb") as fp: + fp.write(pdf_download.content) + +with open(pdf_name, "rb") as fp: + r = requests.put( + f"{bucket_url}/{pdf_name}", + data=fp, + headers=upload_headers + ) + +r.raise_for_status() + +print(f"Uploaded {pdf_name}") + +# Upload release archive + + +archive_url = ( + f"https://github.com/" + f"{repo_name}" + f"/archive/refs/tags/{tag}.zip" +) + +archive_name = f"{owner}-{repo}-{tag}.zip" + +download = requests.get(archive_url) +download.raise_for_status() + +with open(archive_name, "wb") as fp: + fp.write(download.content) + +with open(archive_name, "rb") as fp: + r = requests.put( + f"{bucket_url}/{archive_name}", + data=fp, + headers=upload_headers + ) + +r.raise_for_status() + +print(f"Uploaded {archive_name}") + + +# Update metadata + +data = { + "metadata": metadata +} + +r = requests.put( + f"{BASE_URL}/deposit/depositions/{deposition_id}", + data=json.dumps(data), + headers=json_headers +) + +r.raise_for_status() + +print("Metadata updated") + + +# Publish + + +r = requests.post( + f"{BASE_URL}/deposit/depositions/{deposition_id}/actions/publish", + headers=json_headers, +) + +r.raise_for_status() + +record = r.json() + +print(f"Published DOI: {record.get('doi')}") +print(f"Record URL: {record['links']['html']}") diff --git a/.github/workflows/zenodo.yml b/.github/workflows/zenodo.yml new file mode 100644 index 0000000..89dee28 --- /dev/null +++ b/.github/workflows/zenodo.yml @@ -0,0 +1,27 @@ +name: Publish Release to Zenodo + +on: + release: + types: [published] + +jobs: + zenodo: + runs-on: ubuntu-latest + environment: Zenodo + + permissions: + contents: read + + env: + ZENODO_TOKEN: ${{ secrets.ZENODO_TOKEN }} + ZENODO_CONCEPT_ID: ${{ vars.ZENODO_CONCEPT_ID }} + + steps: + - uses: actions/checkout@v4 + + - name: Install dependencies + run: | + pip install requests pyyaml + + - name: Create Zenodo deposition + run: python .github/scripts/publish_to_zenodo.py \ No newline at end of file