diff --git a/.github/actions/workflowhub/action.yml b/.github/actions/workflowhub/action.yml new file mode 100644 index 00000000..d731d4c4 --- /dev/null +++ b/.github/actions/workflowhub/action.yml @@ -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" diff --git a/.github/actions/workflowhub/workflowhub_finder.py b/.github/actions/workflowhub/workflowhub_finder.py new file mode 100644 index 00000000..fad1b0f8 --- /dev/null +++ b/.github/actions/workflowhub/workflowhub_finder.py @@ -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 diff --git a/.github/workflows/workflowhub.yml b/.github/workflows/workflowhub.yml new file mode 100644 index 00000000..0db239c0 --- /dev/null +++ b/.github/workflows/workflowhub.yml @@ -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 diff --git a/.gitignore b/.gitignore index eb417726..e0cc5eb3 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,5 @@ _site/ vendor/ .vscode/ Gemfile.lock -.ruby-version \ No newline at end of file +.ruby-version +**/__pycache__/ diff --git a/_config.yml b/_config.yml index 7071ba12..bc44d12c 100644 --- a/_config.yml +++ b/_config.yml @@ -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 diff --git a/_data/data_workflows.yaml b/_data/data_workflows.yaml new file mode 100644 index 00000000..b77b3f3b --- /dev/null +++ b/_data/data_workflows.yaml @@ -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 diff --git a/_data/sidebars/main.yml b/_data/sidebars/main.yml index 92f48727..4a5fa823 100644 --- a/_data/sidebars/main.yml +++ b/_data/sidebars/main.yml @@ -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: @@ -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 diff --git a/_includes/workflow-table.html b/_includes/workflow-table.html new file mode 100644 index 00000000..5d998a84 --- /dev/null +++ b/_includes/workflow-table.html @@ -0,0 +1,67 @@ +{%- assign workflows = site.data.data_workflows -%} +{%- if workflows -%} + {%- assign workflows = workflows | sort_natural: "title" -%} +{%- endif -%} +{%- if workflows and workflows.size > 0 -%} +Skip workflow table +
| Workflow | +Type | +Topics | +Operations | +License | +Updated | +Projects | +
|---|---|---|---|---|---|---|
| + {{ workflow.title | escape }} + {%- if workflow.remote_link != blank or workflow.doi != blank -%} + + {%- endif -%} + | +{{ workflow.class | escape }} | ++ {%- for topic in workflow.edam_top -%} + {{ topic | escape }}{% unless forloop.last %} {% endunless %} + {%- endfor -%} + | ++ {%- for operation in workflow.edam_ops -%} + {{ operation | escape }}{% unless forloop.last %} {% endunless %} + {%- endfor -%} + | +{{ workflow.license | escape }} | +{{ workflow.updated_at | escape }} | +
+ {%- for project in workflow.projects -%}
+ {{ project | escape }}{% unless forloop.last %} {% endunless %} + {%- endfor -%} + |
+