From c91212274732c6cbfde4fb60e3208f0625378561 Mon Sep 17 00:00:00 2001 From: Damien Goutte-Gattat Date: Mon, 21 Sep 2026 22:27:25 +0100 Subject: [PATCH 1/5] Use latest version of GitHub Actions at seeding time. When seeding a repository (or updating a pre-existing one), make sure the GitHub Actions referenced in the GitHub workflows (if GitHub workflows are used at all) are (i) referenced using a commit ID rather than a tag name, and (ii) the commit ID points to the latest released version. This is done by having a small helper class (GitHubHelper) that encapsulates the required calls to the GitHub API to get the latest release information for a given project, and exposing that helper class to the templates, so that we can write the following in a template: ```yaml uses: actions/checkout@{{ gh.get_latest_release_sha("actions/checkout", "v7") }} ``` If we can get the required informations from GitHub, this will yield ```yaml uses: actions/checkout@XXXXX # vX.Y.Z ``` where `vX.Y.Z` is the latest release for the `actions/checkout` project, and `XXXXX` is the corresponding commit ID. In the event we could _not_ get the latest release info, then this will yield ```yaml uses: actions/checkout@v7 ``` closes INCATools/ontology-development-kit#1346 --- src/incatools/odk/github.py | 97 +++++++++++++++++++ src/incatools/odk/template.py | 9 +- .../odk/templates/_dynamic_workflows.jinja2 | 44 ++++----- 3 files changed, 126 insertions(+), 24 deletions(-) create mode 100644 src/incatools/odk/github.py diff --git a/src/incatools/odk/github.py b/src/incatools/odk/github.py new file mode 100644 index 0000000..8f06fa7 --- /dev/null +++ b/src/incatools/odk/github.py @@ -0,0 +1,97 @@ +# odkcore - Ontology Development Kit Core +# Copyright © 2026 ODK Developers +# +# This file is part of the ODK Core project and distributed under the +# terms of a 3-clause BSD license. See the LICENSE file in that project +# for the detailed conditions. + +from time import sleep +from typing import Any, Dict, Tuple + +import requests + +from .download import RETRIABLE_HTTP_ERRORS + + +class GitHubHelper(object): + """Helper class to interact with the GitHub API. + + For now, the only purpose of this class is to automatically obtain + the commit ID of the latest release of a GitHub Action. It may be + expanded for other purposes in the future. + """ + + cache: Dict[str, Tuple[str, str]] + + def __init__(self): + self.cache = {} + + def get_latest_release_sha(self, name: str, default: str) -> str: + """Gets the commit ID for the latest release of a GitHub project. + + :param name: The name of a GitHub project, in `owner/repo` form. + :param tag: The default tag to fallback to if we can't get the + required information from GitHub. + :returns: A string of the form `XXXX # TAG`, where `XXXX` is the + commit ID of the latest release and `TAG` is the + corresponding tag name; or just the value of the `default` + parameter if the latest commit ID could not be obtained. + """ + latest = self.get_latest_release(name) + if latest: + return f"{latest[1]} # {latest[0]}" + return default + + def get_latest_release(self, name: str) -> Tuple[str, str] | None: + """Gets the tag name and commit ID for the latest release of a GitHub project. + + :param name: The name of a GitHub project, in `owner/repo` form. + :returns: A tuple (TAG,XXXX), where `TAG` is the tag of the + latest release and `XXXX` is the corresponding commit ID; or + None if we could not obtain the information from GitHub. + """ + cached = self.cache.get(name) + if cached: + return cached + + try: + latest_release = self._query_github_api(f"repos/{name}/releases/latest") + tagname = latest_release["tag_name"] + + release_ref = self._query_github_api(f"repos/{name}/git/ref/tags/{tagname}") + sha = release_ref["object"]["sha"] + if release_ref["object"]["type"] != "commit": + release_tag = self._query_github_api(f"repos/{name}/git/tags/{sha}") + sha = release_tag["object"]["sha"] + + self.cache[name] = (tagname, sha) + return (tagname, sha) + except ( + KeyError, + requests.exceptions.ConnectTimeout, + requests.exceptions.ConnectionError, + requests.exceptions.HTTPError, + requests.exceptions.ReadTimeout, + ): + # We don't really care about what went wrong exactly (e.g. + # network issue or unexpected JSON content). + return None + + def _query_github_api(self, endpoint: str, max_retry: int = 4) -> Dict[str, Any]: + """Sends a query to the GitHub API and returns the JSON response.""" + headers = { + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2026-03-10", + } + n_try = 0 + while True: + response = requests.get( + f"https://api.github.com/{endpoint}", timeout=5, headers=headers + ) + if response.status_code == 200: + return response.json() + elif response.status_code in RETRIABLE_HTTP_ERRORS and n_try < max_retry: + n_try += 1 + sleep(1) + else: + response.raise_for_status() diff --git a/src/incatools/odk/template.py b/src/incatools/odk/template.py index a4910e8..651f25f 100644 --- a/src/incatools/odk/template.py +++ b/src/incatools/odk/template.py @@ -18,6 +18,7 @@ from defusedxml import ElementTree as DefusedElementTree from jinja2 import Template +from .github import GitHubHelper from .model import OntologyProject from .util import runcmd @@ -87,6 +88,7 @@ class Generator(object): project: OntologyProject templatedir: Path + gh_helper: GitHubHelper def __init__(self, project: OntologyProject, templatedir: Optional[str] = None): """Creates a new instance for the specified ontology project. @@ -100,6 +102,7 @@ def __init__(self, project: OntologyProject, templatedir: Optional[str] = None): self.templatedir = Path(templatedir) else: self.templatedir = DEFAULT_TEMPLATE_DIR + self.gh_helper = GitHubHelper() def generate(self, input: Path | str) -> str: """Renders one template file. @@ -112,10 +115,12 @@ def generate(self, input: Path | str) -> str: template = Template(file_.read()) if "ODK_VERSION" in os.environ: return template.render( - project=self.project, env={"ODK_VERSION": os.getenv("ODK_VERSION")} + project=self.project, + gh=self.gh_helper, + env={"ODK_VERSION": os.getenv("ODK_VERSION")}, ) else: - return template.render(project=self.project) + return template.render(project=self.project, gh=self.gh_helper) def generate_from_name(self, name: str) -> str: """Renders one template. diff --git a/src/incatools/odk/templates/_dynamic_workflows.jinja2 b/src/incatools/odk/templates/_dynamic_workflows.jinja2 index d18f8cd..1a4fa1c 100644 --- a/src/incatools/odk/templates/_dynamic_workflows.jinja2 +++ b/src/incatools/odk/templates/_dynamic_workflows.jinja2 @@ -80,7 +80,7 @@ jobs: # Steps represent a sequence of tasks that will be executed as part of the job steps: # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - - uses: actions/checkout@v6 + - uses: actions/checkout@{{ gh.get_latest_release_sha("actions/checkout", "v7") }} - name: Run ontology QC checks env: @@ -106,16 +106,16 @@ jobs: runs-on: ubuntu-latest container: obolibrary/odklite:{% if env is defined -%}{{env['ODK_VERSION'] or "latest" }}{%- else %}latest{% endif %} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@{{ gh.get_latest_release_sha("actions/checkout", "v7") }} # Checks-out main branch under "main" directory - - uses: actions/checkout@v6 + - uses: actions/checkout@{{ gh.get_latest_release_sha("actions/checkout", "v7") }} with: ref: master path: master - name: Diff classification run: export ROBOT_JAVA_ARGS=-Xmx6G; robot diff --labels True --left master/src/ontology/{{ project.id }}-edit.{{ project.edit_format }} --left-catalog master/src/ontology/catalog-v001.xml --right src/ontology/{{ project.id }}-edit.{{ project.edit_format }} --right-catalog src/ontology/catalog-v001.xml -f markdown -o edit-diff.md - name: Upload diff - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@{{ gh.get_latest_release_sha("actions/upload-artifact", "v7") }} with: name: edit-diff.md path: edit-diff.md @@ -123,11 +123,11 @@ jobs: runs-on: ubuntu-latest container: obolibrary/odklite:{% if env is defined -%}{{env['ODK_VERSION'] or "latest" }}{%- else %}latest{% endif %} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@{{ gh.get_latest_release_sha("actions/checkout", "v7") }} - name: Classify ontology run: cd src/ontology; make IMP=FALSE PAT=FALSE MIR=FALSE {{ project.id }}.owl - name: Upload PR {{ project.id }}.owl - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@{{ gh.get_latest_release_sha("actions/upload-artifact", "v7") }} with: name: {{ project.id }}-pr.owl path: src/ontology/{{ project.id }}.owl @@ -136,13 +136,13 @@ jobs: runs-on: ubuntu-latest container: obolibrary/odklite:{% if env is defined -%}{{env['ODK_VERSION'] or "latest" }}{%- else %}latest{% endif %} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@{{ gh.get_latest_release_sha("actions/checkout", "v7") }} with: ref: master - name: Classify ontology run: cd src/ontology; make IMP=FALSE PAT=FALSE MIR=FALSE {{ project.id }}.owl - name: Upload master {{ project.id }}.owl - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@{{ gh.get_latest_release_sha("actions/upload-artifact", "v7") }} with: name: {{ project.id }}-master.owl path: src/ontology/{{ project.id }}.owl @@ -154,21 +154,21 @@ jobs: runs-on: ubuntu-latest container: obolibrary/odklite:{% if env is defined -%}{{env['ODK_VERSION'] or "latest" }}{%- else %}latest{% endif %} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@{{ gh.get_latest_release_sha("actions/checkout", "v7") }} - name: Download master classification - uses: actions/download-artifact@v8 + uses: actions/download-artifact@{{ gh.get_latest_release_sha("actions/download-artifact", "v8") }} with: name: {{ project.id }}-master.owl path: src/ontology/{{ project.id }}-master.owl - name: Download PR classification - uses: actions/download-artifact@v8 + uses: actions/download-artifact@{{ gh.get_latest_release_sha("actions/download-artifact", "v8") }} with: name: {{ project.id }}-pr.owl path: src/ontology/{{ project.id }}-pr.owl - name: Diff classification run: export ROBOT_JAVA_ARGS=-Xmx6G; cd src/ontology; robot diff --labels True --left {{ project.id }}-master.owl/{{ project.id }}.owl --left-catalog catalog-v001.xml --right {{ project.id }}-pr.owl/{{ project.id }}.owl --right-catalog catalog-v001.xml -f markdown -o classification-diff.md - name: Upload diff - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@{{ gh.get_latest_release_sha("actions/upload-artifact", "v7") }} with: name: classification-diff.md path: src/ontology/classification-diff.md @@ -176,9 +176,9 @@ jobs: needs: [diff_classification, edit_file] runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@{{ gh.get_latest_release_sha("actions/checkout", "v7") }} - name: Download reasoned diff - uses: actions/download-artifact@v8 + uses: actions/download-artifact@{{ gh.get_latest_release_sha("actions/download-artifact", "v8") }} with: name: classification-diff.md path: classification-diff.md @@ -187,13 +187,13 @@ jobs: - name: Post reasoned comment env: GITHUB_TOKEN: {% raw %}${{ secrets.GITHUB_TOKEN }}{% endraw %} - uses: NejcZdovc/comment-pr@v2 + uses: NejcZdovc/comment-pr@{{ gh.get_latest_release_sha("NejcZdovc/comment-pr", "v2") }} with: file: "../../comment.md" identifier: "REASONED" - - uses: actions/checkout@v6 + - uses: actions/checkout@{{ gh.get_latest_release_sha("actions/checkout", "v7") }} - name: Download edit diff - uses: actions/download-artifact@v8 + uses: actions/download-artifact@{{ gh.get_latest_release_sha("actions/download-artifact", "v8") }} with: name: edit-diff.md path: edit-diff.md @@ -202,7 +202,7 @@ jobs: - name: Post comment env: GITHUB_TOKEN: {% raw %}${{ secrets.GITHUB_TOKEN }}{% endraw %} - uses: NejcZdovc/comment-pr@v2 + uses: NejcZdovc/comment-pr@{{ gh.get_latest_release_sha("NejcZdovc/comment-pr", "v2") }} with: file: "../../edit-comment.md" identifier: "UNREASONED" @@ -226,7 +226,7 @@ jobs: post_diff: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@{{ gh.get_latest_release_sha("actions/checkout", "v7") }} - name: Prepare release comment env: GITHUB_SHA: {% raw %}${{ github.sha }}{% endraw %} @@ -234,7 +234,7 @@ jobs: - name: Post reasoned comment env: GITHUB_TOKEN: {% raw %}${{ secrets.GITHUB_TOKEN }}{% endraw %} - uses: NejcZdovc/comment-pr@v2 + uses: NejcZdovc/comment-pr@{{ gh.get_latest_release_sha("NejcZdovc/comment-pr", "v2") }} with: github_token: {% raw %}${{ env.GITHUB_TOKEN }}{% endraw %} file: "../../comment.md" @@ -260,10 +260,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout main - uses: actions/checkout@v6 + uses: actions/checkout@{{ gh.get_latest_release_sha("actions/checkout", "v7") }} - name: Deploy docs - uses: mhausenblas/mkdocs-deploy-gh-pages@master + uses: mhausenblas/mkdocs-deploy-gh-pages@a31c6b13a80e4a4fbb525eeb7a2a78253bb15fa5 # Or use mhausenblas/mkdocs-deploy-gh-pages@nomaterial to build without the mkdocs-material theme env: GITHUB_TOKEN: {% raw %}${{ secrets.GITHUB_TOKEN }}{% endraw %} From e023cbab5542b1857ac84317c9c497713082ecbb Mon Sep 17 00:00:00 2001 From: Damien Goutte-Gattat Date: Tue, 22 Sep 2026 17:13:52 +0100 Subject: [PATCH 2/5] Better error handling when querying the GitHub API. This commit does two things: First, it simplifies the handling of exceptions thrown by the requests library. All such exceptions are derived from the single RequestException class, so since we are not interested in distinguishing between the various error conditions, we can simply catch RequestException instead of trying to catch all the various subclasses. Second, it deals with the possibility that we could receive a response that is not strictly speaking an error (HTTP status code < 400), but that does not contain what we need either (e.g. status code == 204). This is probably highly unlikely, but if it does happen, we must not enter into an infinite loop. --- src/incatools/odk/github.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/incatools/odk/github.py b/src/incatools/odk/github.py index 8f06fa7..f988757 100644 --- a/src/incatools/odk/github.py +++ b/src/incatools/odk/github.py @@ -9,6 +9,7 @@ from typing import Any, Dict, Tuple import requests +from requests.exceptions import RequestException from .download import RETRIABLE_HTTP_ERRORS @@ -66,13 +67,7 @@ def get_latest_release(self, name: str) -> Tuple[str, str] | None: self.cache[name] = (tagname, sha) return (tagname, sha) - except ( - KeyError, - requests.exceptions.ConnectTimeout, - requests.exceptions.ConnectionError, - requests.exceptions.HTTPError, - requests.exceptions.ReadTimeout, - ): + except (KeyError, RequestException): # We don't really care about what went wrong exactly (e.g. # network issue or unexpected JSON content). return None @@ -95,3 +90,6 @@ def _query_github_api(self, endpoint: str, max_retry: int = 4) -> Dict[str, Any] sleep(1) else: response.raise_for_status() + # We could get there upon receiving a non-error HTTP + # status (e.g. 203, 204) + raise RequestException(f"Unexpected status: {response.status_code}") From 1eaf2b61e3fbab2d541501ffca18d1b4f6a50390 Mon Sep 17 00:00:00 2001 From: Damien Goutte-Gattat Date: Wed, 23 Sep 2026 23:20:41 +0100 Subject: [PATCH 3/5] Add comment for the mhausenblas/mkdocs-deploy-gh-pages action. The `mhausenblas/mkdocs-deploy-gh-pages` action has not had a new release since 2023, but there has been some fixes on the master branch since then, so we need to use the master branch. We add a comment next to the tag for that action to make that a bit clearer. --- src/incatools/odk/templates/_dynamic_workflows.jinja2 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/incatools/odk/templates/_dynamic_workflows.jinja2 b/src/incatools/odk/templates/_dynamic_workflows.jinja2 index 1a4fa1c..a422605 100644 --- a/src/incatools/odk/templates/_dynamic_workflows.jinja2 +++ b/src/incatools/odk/templates/_dynamic_workflows.jinja2 @@ -263,7 +263,7 @@ jobs: uses: actions/checkout@{{ gh.get_latest_release_sha("actions/checkout", "v7") }} - name: Deploy docs - uses: mhausenblas/mkdocs-deploy-gh-pages@a31c6b13a80e4a4fbb525eeb7a2a78253bb15fa5 + uses: mhausenblas/mkdocs-deploy-gh-pages@a31c6b13a80e4a4fbb525eeb7a2a78253bb15fa5 # master @ 2024-07-19 # Or use mhausenblas/mkdocs-deploy-gh-pages@nomaterial to build without the mkdocs-material theme env: GITHUB_TOKEN: {% raw %}${{ secrets.GITHUB_TOKEN }}{% endraw %} From 9dd2ec0ecb0d60fa76a284162fe9751da92dd077 Mon Sep 17 00:00:00 2001 From: Damien Goutte-Gattat Date: Wed, 23 Sep 2026 23:24:32 +0100 Subject: [PATCH 4/5] Cache negative results. If we somehow could not get the latest release tag for a given GitHub Action, cache the negative result, so that we don't try again a millisecond later the next time we need the tag for that action again. --- src/incatools/odk/github.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/incatools/odk/github.py b/src/incatools/odk/github.py index f988757..1526a84 100644 --- a/src/incatools/odk/github.py +++ b/src/incatools/odk/github.py @@ -6,7 +6,7 @@ # for the detailed conditions. from time import sleep -from typing import Any, Dict, Tuple +from typing import Any, Dict, Set, Tuple import requests from requests.exceptions import RequestException @@ -23,9 +23,11 @@ class GitHubHelper(object): """ cache: Dict[str, Tuple[str, str]] + failure_cache: Set[str] def __init__(self): self.cache = {} + self.failure_cache = set() def get_latest_release_sha(self, name: str, default: str) -> str: """Gets the commit ID for the latest release of a GitHub project. @@ -52,7 +54,7 @@ def get_latest_release(self, name: str) -> Tuple[str, str] | None: None if we could not obtain the information from GitHub. """ cached = self.cache.get(name) - if cached: + if cached or name in self.failure_cache: return cached try: @@ -70,6 +72,7 @@ def get_latest_release(self, name: str) -> Tuple[str, str] | None: except (KeyError, RequestException): # We don't really care about what went wrong exactly (e.g. # network issue or unexpected JSON content). + self.failure_cache.add(name) return None def _query_github_api(self, endpoint: str, max_retry: int = 4) -> Dict[str, Any]: From ffea05c8db1a576129d0e7d4dbcea101f747e136 Mon Sep 17 00:00:00 2001 From: Damien Goutte-Gattat Date: Wed, 23 Sep 2026 23:25:57 +0100 Subject: [PATCH 5/5] Fix parameter name mismatch. --- src/incatools/odk/github.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/incatools/odk/github.py b/src/incatools/odk/github.py index 1526a84..7f66113 100644 --- a/src/incatools/odk/github.py +++ b/src/incatools/odk/github.py @@ -33,7 +33,7 @@ def get_latest_release_sha(self, name: str, default: str) -> str: """Gets the commit ID for the latest release of a GitHub project. :param name: The name of a GitHub project, in `owner/repo` form. - :param tag: The default tag to fallback to if we can't get the + :param default: The default tag to fallback to if we can't get the required information from GitHub. :returns: A string of the form `XXXX # TAG`, where `XXXX` is the commit ID of the latest release and `TAG` is the