Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .github/actions/workflowhub/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
name: Update WorkflowHub data
description: Generate validated Jekyll data from a configured WorkflowHub collection

runs:
using: composite
steps:
- name: Set up Python
uses: actions/setup-python@v7
with:
python-version: "3.12"

- name: Install dependencies
shell: bash
run: python -m pip install "requests>=2.32,<3" "PyYAML>=6,<7"

- name: Generate WorkflowHub data
shell: bash
run: python "$GITHUB_ACTION_PATH/workflowhub_finder.py"
198 changes: 198 additions & 0 deletions .github/actions/workflowhub/workflowhub_finder.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
#!/usr/bin/env python3
import os
import tempfile
from pathlib import Path

import requests
import yaml


DEFAULT_BASE_URL = "https://workflowhub.eu"
OUTPUT_PATH = Path("_data/data_workflows.yaml")
REQUEST_TIMEOUT = 30.0
REQUIRED_FIELDS = ("title", "url")


class WorkflowHubClient:
def __init__(self, base_url, collection_id, timeout=REQUEST_TIMEOUT):
self.base_url = base_url.rstrip("/")
self.collection_id = collection_id
self.timeout = timeout

def _get_json(self, path):
response = requests.get(f"{self.base_url}/{path}", timeout=self.timeout)
response.raise_for_status()
return response.json()

def _workflow_ids(self):
items = self._get_json(
f"collections/{self.collection_id}/items.json"
)["data"]
return [
item["relationships"]["asset"]["data"]["id"] for item in items
]

def _projects(self):
projects = self._get_json("projects.json")["data"]
return {
project["id"]: project["attributes"]["title"] for project in projects
}

@staticmethod
def _render_workflow(metadata, project_titles):
data = metadata["data"]
attributes = data["attributes"]
latest_version = attributes["latest_version"]
remote = next(
(
version.get("remote", "")
for version in attributes.get("versions", [])
if version.get("version") == latest_version
),
"",
)
project_ids = (
data.get("relationships", {}).get("projects", {}).get("data", [])
)
projects = list(
dict.fromkeys(
project_titles[item["id"]]
for item in project_ids
if item["id"] in project_titles
)
)
workflow_class = attributes.get("workflow_class") or {}

return {
"title": attributes.get("title", "").replace("_", " "),
"class": workflow_class.get("key", ""),
"url": data.get("links", {}).get("self", ""),
"remote_link": remote,
"edam_top": [
annotation["label"]
for annotation in attributes.get("topic_annotations", [])
] or "",
"edam_ops": [
annotation["label"]
for annotation in attributes.get("operation_annotations", [])
] or "",
"license": attributes.get("license") or "",
"updated_at": (attributes.get("updated_at") or "").split("T")[0],
"doi": attributes.get("doi") or "",
"projects": projects or "",
"tools": attributes.get("tools") or "",
}

def workflows(self):
project_titles = self._projects()
workflows = [
self._render_workflow(
self._get_json(f"workflows/{workflow_id}.json"), project_titles
)
for workflow_id in sorted(set(self._workflow_ids()), key=str)
]
return sorted(
workflows,
key=lambda workflow: (workflow["title"].casefold(), workflow["url"]),
)


def load_workflows(path):
try:
data = yaml.safe_load(path.read_text(encoding="utf-8"))
except (OSError, yaml.YAMLError) as error:
raise ValueError(f"Could not read valid YAML from {path}: {error}") from error
if not isinstance(data, list):
raise ValueError(f"{path} must contain a top-level list")
return data


def validate_workflows(workflows):
urls = set()
for index, workflow in enumerate(workflows, start=1):
if not isinstance(workflow, dict):
raise ValueError(f"Workflow {index} must be a mapping")
missing = [field for field in REQUIRED_FIELDS if not workflow.get(field)]
if missing:
raise ValueError(
f"Workflow {index} is missing required values: {', '.join(missing)}"
)
if workflow["url"] in urls:
raise ValueError(f"Duplicate workflow URL: {workflow['url']}")
urls.add(workflow["url"])


def serialise_workflows(workflows):
return yaml.safe_dump(
workflows,
default_flow_style=False,
sort_keys=True,
allow_unicode=True,
)


def update_workflow_file(workflows, output=OUTPUT_PATH):
baseline = load_workflows(output) if output.exists() else None
validate_workflows(workflows)
if baseline and not workflows:
raise ValueError("Refusing to replace a non-empty dataset with an empty result")
rendered = serialise_workflows(workflows)

if output.exists() and output.read_text(encoding="utf-8") == rendered:
return False

output.parent.mkdir(parents=True, exist_ok=True)
temporary_path = None
try:
with tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
dir=output.parent,
prefix=f".{output.name}.",
suffix=".tmp",
delete=False,
) as temporary_file:
temporary_file.write(rendered)
temporary_path = Path(temporary_file.name)
os.replace(temporary_path, output)
finally:
if temporary_path and temporary_path.exists():
temporary_path.unlink()
return True


def load_site_config(path):
try:
config = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
except (OSError, yaml.YAMLError) as error:
raise ValueError(f"Could not read site configuration from {path}: {error}") from error

integration = config.get("workflowhub")
if not isinstance(integration, dict) or not integration.get("enabled", False):
return None
if integration.get("collection_id") in (None, ""):
raise ValueError("workflowhub.collection_id is required when enabled")
return {
"base_url": integration.get("base_url") or DEFAULT_BASE_URL,
"collection_id": int(integration["collection_id"]),
}


def main():
settings = load_site_config(Path("_config.yml"))
if settings is None:
print("WorkflowHub integration is disabled; nothing to generate")
return

client = WorkflowHubClient(**settings)
workflows = client.workflows()
changed = update_workflow_file(workflows)
status = "Updated" if changed else "No changes to"
print(f"{status} {OUTPUT_PATH} ({len(workflows)} workflows)")


if __name__ == "__main__":
try:
main()
except (ValueError, requests.RequestException) as error:
raise SystemExit(f"WorkflowHub update failed: {error}") from error
48 changes: 48 additions & 0 deletions .github/workflows/workflowhub.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
name: Update WorkflowHub data

on:
workflow_call:
workflow_dispatch:

permissions:
contents: write
pull-requests: write

concurrency:
group: update-workflowhub-data
cancel-in-progress: false

jobs:
update:
runs-on: ubuntu-24.04
steps:
- name: Check out website
uses: actions/checkout@v7
with:
ref: ${{ github.event.repository.default_branch }}

- name: Check out WorkflowHub integration
uses: actions/checkout@v7
with:
repository: ${{ job.workflow_repository }}
ref: ${{ job.workflow_sha }}
path: .workflowhub-theme

- name: Update WorkflowHub data
uses: ./.workflowhub-theme/.github/actions/workflowhub

- name: Remove integration checkout
shell: bash
run: rm -rf -- .workflowhub-theme

- name: Create or update pull request
uses: peter-evans/create-pull-request@v8
with:
token: ${{ secrets.GITHUB_TOKEN }}
commit-message: Update WorkflowHub workflow data
title: Update WorkflowHub workflow data
body: Automated refresh of validated WorkflowHub collection data.
branch: automation/update-workflowhub-data
base: ${{ github.event.repository.default_branch }}
add-paths: _data/data_workflows.yaml
delete-branch: true
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ _site/
vendor/
.vscode/
Gemfile.lock
.ruby-version
.ruby-version
**/__pycache__/
4 changes: 4 additions & 0 deletions _config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ title: ELIXIR Toolkit Theme
description: "Flexible Jekyll theme using bootstrap 5 as CSS framework."
# Metadata description of the website

workflowhub:
enabled: true
collection_id: 40

dsw_deep_link_prefix: https://researchers.ds-wizard.org/knowledge-models/dsw:root:latest/preview?questionUuid=
# prefix for DSW deep links to a certain question

Expand Down
31 changes: 31 additions & 0 deletions _data/data_workflows.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
- class: nextflow
doi: 10.48546/workflowhub.workflow.2318.1
edam_ops:
- Protein interaction prediction
- Protein quaternary structure prediction
- Protein structure analysis
- Protein structure prediction
edam_top:
- Protein folds and structural domains
- Protein structure analysis
- Structure prediction
license: MIT
projects:
- Lucet Laboratory (WEHI)
remote_link: ''
title: nf-boltz-2
tools: ''
updated_at: '2026-09-28'
url: /workflows/2318?version=1
- class: nextflow
doi: ''
edam_ops: ''
edam_top: ''
license: MIT
projects:
- nf-core
remote_link: https://github.com/nf-core/proteinfold.git
title: nf-core/proteinfold
tools: ''
updated_at: '2026-05-13'
url: /workflows/1011?version=3
4 changes: 4 additions & 0 deletions _data/sidebars/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ subitems:
url: /resource_table
- title: Supported by
url: /supported_by
- title: WorkflowHub workflows table
url: /workflowhub_integration
- title: Versions and upgrades
icon: icon-refresh-cw
subitems:
Expand Down Expand Up @@ -70,6 +72,8 @@ subitems:
url: /general_page_7
- title: All training
url: /all_trainings
- title: Workflows
url: /workflows
- title: List of page IDs
url: /list_page_ids
- title: Page Not Found
Expand Down
Loading
Loading