From 7a832d8dd665c6befc2886db8bcc80c8f38ce4a0 Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Thu, 9 Jul 2026 10:48:39 +0200 Subject: [PATCH 1/6] Fix typos in changelogs --- doc/changes/changes_10.0.0.md | 4 ++-- doc/changes/changes_10.2.0.md | 2 +- doc/changes/changes_10.2.1.md | 2 -- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/doc/changes/changes_10.0.0.md b/doc/changes/changes_10.0.0.md index 216a0a2ed..d8ba3cd22 100644 --- a/doc/changes/changes_10.0.0.md +++ b/doc/changes/changes_10.0.0.md @@ -48,7 +48,7 @@ This release fixes vulnerabilities by updating dependencies: * #699: Added `all-extras` support to the Python environment GitHub action * #875: Added `name` attribute to generated workflow jobs using `-extension.yml` workflows -## Bug +## Bug Fix * #744: Updated nox DB-version handling to use `BaseConfig.minimum_exasol_version` instead hardcoded `7.1.9` @@ -80,4 +80,4 @@ This release fixes vulnerabilities by updating dependencies: * Updated dependency `pip-audit:2.10.0` to `2.10.1` * Updated dependency `pylint:4.0.5` to `4.0.6` * Updated dependency `pytest:9.0.3` to `9.1.1` -* Updated dependency `zizmor:1.25.2` to `1.26.1` \ No newline at end of file +* Updated dependency `zizmor:1.25.2` to `1.26.1` diff --git a/doc/changes/changes_10.2.0.md b/doc/changes/changes_10.2.0.md index a8848b691..e00721c99 100644 --- a/doc/changes/changes_10.2.0.md +++ b/doc/changes/changes_10.2.0.md @@ -6,7 +6,7 @@ This minor release adds automated vulnerability updates through Nox session `vulnerabilities:update` and improves the `dependency-update.yml`. It also includes a few workflow-related bug fixes and documentation updates. -## Bug +## Bug Fix * #909: Updated `cd.yml` workflow so that `cd-extension.yml` workflow depends on `build-and-publish`. This ensures that the custom release workflow only runs when the PyPi release was successful. * #910: Added `gh-pages.yml` to be ignored when `has_documentation=False` in the `PROJECT_CONFIG` diff --git a/doc/changes/changes_10.2.1.md b/doc/changes/changes_10.2.1.md index 98cdcb506..c29ae2b39 100644 --- a/doc/changes/changes_10.2.1.md +++ b/doc/changes/changes_10.2.1.md @@ -1,7 +1,5 @@ # 10.2.1 - 2026-07-08 -## Summary - ## Bug Fix * #920: Ensured extracted secrets are unique and alphabetically sorted from the custom workflows From c37d96c14555b69c336a006c4b5d6c0ebc4ebfeb Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Thu, 9 Jul 2026 11:20:27 +0200 Subject: [PATCH 2/6] Make permissions block present in workflows that call the custom workflows --- exasol/toolbox/templates/github/workflows/cd.yml | 6 +++++- exasol/toolbox/templates/github/workflows/ci.yml | 6 +++++- .../templates/github/workflows/fast-tests.yml | 6 +++++- .../templates/github/workflows/merge-gate.yml | 12 ++++++++++-- .../github/workflows/periodic-validation.yml | 6 +++++- 5 files changed, 30 insertions(+), 6 deletions(-) diff --git a/exasol/toolbox/templates/github/workflows/cd.yml b/exasol/toolbox/templates/github/workflows/cd.yml index 58eceed90..a856759cf 100644 --- a/exasol/toolbox/templates/github/workflows/cd.yml +++ b/exasol/toolbox/templates/github/workflows/cd.yml @@ -38,8 +38,12 @@ jobs: (( secret_name )): ${{ secrets.(( secret_name )) }} (% endfor %) (% endif %) + (% if custom_workflows["cd-extension"].permissions %) permissions: - contents: write + (% for permission_name, permission_level in custom_workflows["cd-extension"].permissions.items() %) + (( permission_name )): (( permission_level )) + (% endfor %) + (% endif %) (% endif %) (% if has_documentation %) diff --git a/exasol/toolbox/templates/github/workflows/ci.yml b/exasol/toolbox/templates/github/workflows/ci.yml index c3c095328..da524c3a7 100644 --- a/exasol/toolbox/templates/github/workflows/ci.yml +++ b/exasol/toolbox/templates/github/workflows/ci.yml @@ -15,8 +15,12 @@ jobs: (( secret_name )): ${{ secrets.(( secret_name )) }} (% endfor %) (% endif %) + (% if custom_workflows["merge-gate"].permissions %) permissions: - contents: read + (% for permission_name, permission_level in custom_workflows["merge-gate"].permissions.items() %) + (( permission_name )): (( permission_level )) + (% endfor %) + (% endif %) report: name: Report diff --git a/exasol/toolbox/templates/github/workflows/fast-tests.yml b/exasol/toolbox/templates/github/workflows/fast-tests.yml index 0d4156809..c0151676a 100644 --- a/exasol/toolbox/templates/github/workflows/fast-tests.yml +++ b/exasol/toolbox/templates/github/workflows/fast-tests.yml @@ -52,6 +52,10 @@ jobs: (( secret_name )): ${{ secrets.(( secret_name )) }} (% endfor %) (% endif %) + (% if custom_workflows["fast-tests-extension"].permissions %) permissions: - contents: read + (% for permission_name, permission_level in custom_workflows["fast-tests-extension"].permissions.items() %) + (( permission_name )): (( permission_level )) + (% endfor %) + (% endif %) (% endif %) diff --git a/exasol/toolbox/templates/github/workflows/merge-gate.yml b/exasol/toolbox/templates/github/workflows/merge-gate.yml index d68adf12c..8ee19db55 100644 --- a/exasol/toolbox/templates/github/workflows/merge-gate.yml +++ b/exasol/toolbox/templates/github/workflows/merge-gate.yml @@ -59,8 +59,12 @@ jobs: (( secret_name )): ${{ secrets.(( secret_name )) }} (% endfor %) (% endif %) + (% if custom_workflows["slow-checks"].permissions %) permissions: - contents: read + (% for permission_name, permission_level in custom_workflows["slow-checks"].permissions.items() %) + (( permission_name )): (( permission_level )) + (% endfor %) + (% endif %) (% if custom_workflows["merge-gate-extension"].exists %) merge-gate-extension: @@ -72,8 +76,12 @@ jobs: (( secret_name )): ${{ secrets.(( secret_name )) }} (% endfor %) (% endif %) + (% if custom_workflows["merge-gate-extension"].permissions %) permissions: - contents: read + (% for permission_name, permission_level in custom_workflows["merge-gate-extension"].permissions.items() %) + (( permission_name )): (( permission_level )) + (% endfor %) + (% endif %) (% endif %) # This job ensures inputs have been executed successfully. diff --git a/exasol/toolbox/templates/github/workflows/periodic-validation.yml b/exasol/toolbox/templates/github/workflows/periodic-validation.yml index 017f1c0fb..ae3adaa43 100644 --- a/exasol/toolbox/templates/github/workflows/periodic-validation.yml +++ b/exasol/toolbox/templates/github/workflows/periodic-validation.yml @@ -51,8 +51,12 @@ jobs: (( secret_name )): ${{ secrets.(( secret_name )) }} (% endfor %) (% endif %) + (% if custom_workflows["slow-checks"].permissions %) permissions: - contents: read + (% for permission_name, permission_level in custom_workflows["slow-checks"].permissions.items() %) + (( permission_name )): (( permission_level )) + (% endfor %) + (% endif %) report: name: Report From 5d7f0ed6090655fff96483c047fc6f6ecd8736c0 Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Thu, 9 Jul 2026 11:23:25 +0200 Subject: [PATCH 3/6] Add documentation for new feature --- .../github_workflows/workflow_variables.rst | 48 +++++++++++++++++-- 1 file changed, 45 insertions(+), 3 deletions(-) diff --git a/doc/user_guide/features/github_workflows/workflow_variables.rst b/doc/user_guide/features/github_workflows/workflow_variables.rst index 01f0ff384..fb6b8e8e5 100644 --- a/doc/user_guide/features/github_workflows/workflow_variables.rst +++ b/doc/user_guide/features/github_workflows/workflow_variables.rst @@ -19,10 +19,20 @@ standardized baseline that can be overridden in individual projects. :start-at: github_template_dict :end-before: @computed_field +.. _custom_workflow_metadata: + +Custom Workflow Metadata +^^^^^^^^^^^^^^^^^^^^^^^^ + +The PTB extracts metadata from reusable custom workflow files and exposes it +through :py:attr:`exasol.toolbox.config.BaseConfig.github_template_dict` under the +``custom_workflows`` entry. PTB-controlled workflow templates use that metadata +when they call reusable workflows. + .. _custom_workflow_secrets: -Custom Workflow Secrets -^^^^^^^^^^^^^^^^^^^^^^^ +Secrets +------- The PTB extracts secret names from reusable custom workflow files and exposes them through :py:attr:`exasol.toolbox.config.BaseConfig.github_template_dict` under the @@ -50,6 +60,38 @@ For example, ``slow-checks.yml`` can define its reusable workflow header like th Those extracted secret names are then made available to the PTB templates that reference the custom workflow. +.. _custom_workflow_permissions: + +Permissions +----------- + +The PTB extracts the effective GitHub permissions required by reusable custom +workflow files. It scans every job in the workflow, reads each job's +``permissions`` block, and combines the results into a single ordered mapping. + +When multiple jobs request the same permission, the most permissive level wins +while the first-seen key order is preserved. For example, if one job requests +``contents: read`` and another later job requests ``contents: write``, the final +mapping keeps ``contents`` once, with ``write`` as the level. + +Please only configure the minimum required permissions and granting the least required +access. In practice, ``contents: read`` is the most common baseline for workflows, and +other permissions should only be added when a particular step truly requires them. + +For example, a custom workflow can declare permissions like this: + +.. code-block:: yaml + + name: Slow-Checks + + on: + workflow_call: + + jobs: + run-integration-tests: + permissions: + contents: read + .. _workflow_matrix: Matrix Combinations @@ -64,7 +106,7 @@ Extending the Matrix ^^^^^^^^^^^^^^^^^^^^ If you need to expose additional values via the ``matrix.yml``, you can extend -:class:`exasol.toolbox.config.BaseConfig`. +:class:`exasol.toolbox.config.BaseConfig`. The example adds two additional matrix dimensions: A declared one `extra_matrix_value` and a computed one `computed_matrix_value`. Each of them From 620fde3e54e5e01e412b2de4f60a8e7b0b14a88e Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Thu, 9 Jul 2026 11:46:04 +0200 Subject: [PATCH 4/6] Add jinja test --- test/unit/util/workflows/render_yaml_test.py | 88 ++++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/test/unit/util/workflows/render_yaml_test.py b/test/unit/util/workflows/render_yaml_test.py index 7f0606f6f..61611c05e 100644 --- a/test/unit/util/workflows/render_yaml_test.py +++ b/test/unit/util/workflows/render_yaml_test.py @@ -521,6 +521,94 @@ def test_includes_extension_with_multiple_secrets(test_yml, project_config): yaml_dict = yaml_renderer.get_yaml_dict() assert yaml_renderer.get_as_string(yaml_dict) == cleandoc(expected_yaml) + @staticmethod + def test_includes_extension_with_custom_permissions(test_yml, project_config): + input_yaml = """ + jobs: + run-unit-tests: + name: Unit Tests (Python-${{ matrix.python-versions }}) + runs-on: "(( os_version ))" + permissions: + contents: read + strategy: + fail-fast: false + matrix: + python-versions: (( python_versions | tojson )) + + steps: + - name: Check out Repository + id: check-out-repository + uses: actions/checkout@v6 + + (% if custom_workflows["fast-tests-extension"].exists %) + fast-tests-extension: + name: Extension + uses: ./.github/workflows/fast-tests-extension.yml + (% if custom_workflows["fast-tests-extension"].permissions %) + permissions: + (% for permission_name, permission_level in custom_workflows["fast-tests-extension"].permissions.items() %) + (( permission_name )): (( permission_level )) + (% endfor %) + (% endif %) + (% endif %) + + """ + expected_yaml = """ + jobs: + run-unit-tests: + name: Unit Tests (Python-${{ matrix.python-versions }}) + runs-on: "ubuntu-24.04" + permissions: + contents: read + strategy: + fail-fast: false + matrix: + python-versions: ["3.10", "3.11", "3.12", "3.13", "3.14"] + + steps: + - name: Check out Repository + id: check-out-repository + uses: actions/checkout@v6 + + fast-tests-extension: + name: Extension + uses: ./.github/workflows/fast-tests-extension.yml + permissions: + contents: write + packages: read + id-token: write + + """ + workflow_directory = project_config.github_workflow_directory + workflow_directory.mkdir(parents=True, exist_ok=True) + (workflow_directory / "fast-tests-extension.yml").write_text(cleandoc(""" + name: Fast-Tests-Extension + + on: + workflow_call: + + jobs: + build-artifacts: + permissions: + contents: read + packages: read + publish-artifacts: + permissions: + contents: write + id-token: write + """)) + + content = cleandoc(input_yaml) + test_yml.write_text(content) + + yaml_renderer = YamlRenderer( + github_template_dict=project_config.github_template_dict, + file_path=test_yml, + ) + + yaml_dict = yaml_renderer.get_yaml_dict() + assert yaml_renderer.get_as_string(yaml_dict) == cleandoc(expected_yaml) + @staticmethod def test_jinja_variable_unknown(test_yml, yaml_renderer): input_yaml = """ From 24cfb7bbbd5301c4e0bdd3fd24698f32282e070d Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Thu, 9 Jul 2026 11:47:46 +0200 Subject: [PATCH 5/6] Prepare config test --- test/unit/config_test.py | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/test/unit/config_test.py b/test/unit/config_test.py index 580b816cb..5fa06445b 100644 --- a/test/unit/config_test.py +++ b/test/unit/config_test.py @@ -51,11 +51,31 @@ def test_works_as_defined(tmp_path, test_project_config_factory): "github_workflow_patcher_yaml": None, "github_template_dict": { "custom_workflows": { - "cd-extension": {"exists": False, "secrets": ()}, - "fast-tests-extension": {"exists": False, "secrets": ()}, - "merge-gate-extension": {"exists": False, "secrets": ()}, - "slow-checks": {"exists": False, "secrets": ()}, - "merge-gate": {"exists": True, "secrets": ("SONAR_TOKEN",)}, + "cd-extension": { + "exists": False, + "secrets": (), + "permissions": {}, + }, + "fast-tests-extension": { + "exists": False, + "secrets": (), + "permissions": {}, + }, + "merge-gate-extension": { + "exists": False, + "secrets": (), + "permissions": {}, + }, + "slow-checks": { + "exists": False, + "secrets": (), + "permissions": {}, + }, + "merge-gate": { + "exists": True, + "secrets": ("SONAR_TOKEN",), + "permissions": {"contents": "read"}, + }, }, "dependency_manager_version": "2.3.0", "has_documentation": True, From 10e54e9ed1a3d65c41cd7e2c51f902ae4e488480 Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Thu, 9 Jul 2026 11:48:47 +0200 Subject: [PATCH 6/6] Add extract_permissions --- doc/changes/unreleased.md | 4 + .../toolbox/util/workflows/custom_workflow.py | 59 ++++++++- .../workflows/custom_workflow_extractor.py | 28 ++++- .../custom_workflow_extractor_test.py | 116 +++++++++++++++++- .../util/workflows/custom_workflow_test.py | 72 +++++++++++ 5 files changed, 271 insertions(+), 8 deletions(-) diff --git a/doc/changes/unreleased.md b/doc/changes/unreleased.md index fb4737052..c6e7050dc 100644 --- a/doc/changes/unreleased.md +++ b/doc/changes/unreleased.md @@ -1,3 +1,7 @@ # Unreleased ## Summary + +## Feature + +* #922: diff --git a/exasol/toolbox/util/workflows/custom_workflow.py b/exasol/toolbox/util/workflows/custom_workflow.py index 49c375b61..941142e0e 100644 --- a/exasol/toolbox/util/workflows/custom_workflow.py +++ b/exasol/toolbox/util/workflows/custom_workflow.py @@ -1,5 +1,6 @@ from __future__ import annotations +from enum import IntEnum from pathlib import Path from pydantic import ( @@ -13,12 +14,43 @@ from exasol.toolbox.util.workflows.render_yaml import parse_yaml_text +class PermissionRank(IntEnum): + """GitHub permission levels ranked from least to most permissive.""" + + NONE = 0 + READ = 1 + WRITE = 2 + + @classmethod + def _missing_(cls, value: object) -> PermissionRank: + """Convert a GitHub permission string to its rank.""" + if isinstance(value, str): + for rank in cls: + if rank.name.lower() == value: + return rank + raise ValueError(f"Unknown GitHub permission level: {value!r}") + + +def merge_permissions(permission_maps: list[dict[str, str]]) -> dict[str, str]: + """Merge permission maps to keep the greater permission.""" + merged_permissions: dict[str, str] = {} + for permission_map in permission_maps: + for permission_name, requested_level in permission_map.items(): + current_level = merged_permissions.get(permission_name, None) + if current_level is None: + merged_permissions[permission_name] = requested_level + continue + if PermissionRank(requested_level) > PermissionRank(current_level): # type: ignore[arg-type] + merged_permissions[permission_name] = requested_level + return merged_permissions + + class CustomWorkflow(BaseModel): """A project-owned workflow used for seeded workflows and extensions. These workflows are seeded by the PTB or extend PTB-provided workflows, but they are maintained by the project itself rather than the PTB. See - `Custom Workflows `__. + `Custom Workflows `__. """ model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True) @@ -53,3 +85,28 @@ def extract_secrets(self) -> tuple[str, ...]: if secrets := workflow_call.get("secrets", {}): return tuple(secrets.keys()) return () + + def extract_permissions(self) -> dict[str, str]: + """Return the effective job permissions required by the workflow. + + The extractor scans all jobs and merges their ``permissions`` blocks into a + single mapping. When the same permission appears multiple times, the more + permissive level wins while preserving the first-seen order of the keys. + + For example, a custom workflow can declare permissions like this: + + .. code-block:: yaml + + name: Slow-Checks + + on: + workflow_call: + + jobs: + run-integration-tests: + permissions: + contents: read + """ + jobs = self.yaml_content.get("jobs", {}) + permission_maps = [job.get("permissions", {}) for job in jobs.values()] + return merge_permissions(permission_maps) diff --git a/exasol/toolbox/util/workflows/custom_workflow_extractor.py b/exasol/toolbox/util/workflows/custom_workflow_extractor.py index aeab01b38..8637fd7e3 100644 --- a/exasol/toolbox/util/workflows/custom_workflow_extractor.py +++ b/exasol/toolbox/util/workflows/custom_workflow_extractor.py @@ -8,7 +8,12 @@ field_validator, ) -from exasol.toolbox.util.workflows.custom_workflow import CustomWorkflow +from exasol.toolbox.util.workflows.custom_workflow import ( + CustomWorkflow, + merge_permissions, +) + +MINIMUM_GITHUB_PERMISSIONS: dict[str, str] = {"contents": "read"} class CustomWorkflowEntry(BaseModel): @@ -16,6 +21,7 @@ class CustomWorkflowEntry(BaseModel): exists: bool secrets: tuple[str, ...] + permissions: dict[str, str] @field_validator("secrets", mode="before") @classmethod @@ -23,6 +29,14 @@ def _normalize_secrets(cls, secrets: tuple[str, ...]) -> list[str]: """Return unique secret names in alphabetical order.""" return sorted(set(secrets)) + @field_validator("permissions", mode="before") + @classmethod + def _normalize_permissions( + cls, permissions: list[dict[str, str]] + ) -> dict[str, str]: + """Merge permission maps while preserving first-seen order.""" + return merge_permissions(permissions) + class CustomWorkflowExtractor(BaseModel): model_config = ConfigDict(frozen=True) @@ -43,13 +57,18 @@ def _build_custom_workflow_entry( file_path = self.github_workflow_directory / f"{workflow}.yml" secrets: tuple[str, ...] = () + permissions: list[dict[str, str]] = [] if file_path.is_file(): custom_workflow = CustomWorkflow.load_from_file(file_path=file_path) secrets = custom_workflow.extract_secrets() - + permissions = [ + MINIMUM_GITHUB_PERMISSIONS, + custom_workflow.extract_permissions(), + ] return CustomWorkflowEntry( exists=file_path.exists(), secrets=secrets, + permissions=permissions, ) def _build_merge_gate_entry( @@ -61,6 +80,11 @@ def _build_merge_gate_entry( + custom_workflows_dict["slow-checks"].secrets # from the `report.yml` + (self.sonar_token_name,), + permissions=[ + MINIMUM_GITHUB_PERMISSIONS, + custom_workflows_dict["merge-gate-extension"].permissions, + custom_workflows_dict["slow-checks"].permissions, + ], ) def build_custom_workflow_dict( diff --git a/test/unit/util/workflows/custom_workflow_extractor_test.py b/test/unit/util/workflows/custom_workflow_extractor_test.py index 608fa15c3..7295c1508 100644 --- a/test/unit/util/workflows/custom_workflow_extractor_test.py +++ b/test/unit/util/workflows/custom_workflow_extractor_test.py @@ -29,11 +29,13 @@ def test_secrets_are_normalized_on_creation(): custom_workflow_entry = CustomWorkflowEntry( exists=True, secrets=("ZETA_SECRET", "ALPHA_SECRET", "ALPHA_SECRET"), + permissions=[{"contents": "read"}], ) assert custom_workflow_entry == CustomWorkflowEntry( exists=True, secrets=("ALPHA_SECRET", "ZETA_SECRET"), + permissions=[{"contents": "read"}], ) @@ -47,6 +49,7 @@ def test_file_does_not_exist(custom_workflow_extractor): assert custom_workflow_entry == CustomWorkflowEntry( exists=False, secrets=(), + permissions=[], ) @staticmethod @@ -69,6 +72,7 @@ def test_file_does_not_contain_secrets( assert custom_workflow_entry == CustomWorkflowEntry( exists=True, secrets=(), + permissions=[{"contents": "read"}], ) @staticmethod @@ -92,6 +96,44 @@ def test_file_contains_secret(custom_workflow_extractor, workflow_directory): assert custom_workflow_entry == CustomWorkflowEntry( exists=True, secrets=("SLOW_CHECK_SECRET",), + permissions=[{"contents": "read"}], + ) + + @staticmethod + def test_file_contains_permissions(custom_workflow_extractor, workflow_directory): + workflow = "slow-checks" + workflow_path = workflow_directory / f"{workflow}.yml" + workflow_path.write_text(cleandoc(""" + name: Slow-Checks + + on: + workflow_call: + + jobs: + run-integration-tests: + permissions: + contents: read + packages: write + publish-results: + permissions: + contents: write + id-token: write + """)) + + custom_workflow_entry = custom_workflow_extractor._build_custom_workflow_entry( + workflow=workflow + ) + + assert custom_workflow_entry == CustomWorkflowEntry( + exists=True, + secrets=(), + permissions=[ + { + "contents": "write", + "packages": "write", + "id-token": "write", + } + ], ) @@ -102,10 +144,22 @@ def test_build_merge_gate_entry(custom_workflow_extractor): "merge-gate-extension": CustomWorkflowEntry( exists=True, secrets=("EXT_SECRET",), + permissions=[ + { + "contents": "read", + "packages": "read", + } + ], ), "slow-checks": CustomWorkflowEntry( exists=True, secrets=("SLOW_SECRET",), + permissions=[ + { + "packages": "write", + "id-token": "write", + } + ], ), } @@ -116,16 +170,31 @@ def test_build_merge_gate_entry(custom_workflow_extractor): assert merge_gate_entry == CustomWorkflowEntry( exists=True, secrets=("EXT_SECRET", "SLOW_SECRET", "SONAR_TOKEN"), + permissions=[ + { + "contents": "read", + "packages": "write", + "id-token": "write", + } + ], ) class TestBuildCustomWorkflowDict: default_custom_workflow_dict = { - "cd-extension": CustomWorkflowEntry(exists=False, secrets=()), - "fast-tests-extension": CustomWorkflowEntry(exists=False, secrets=()), - "merge-gate-extension": CustomWorkflowEntry(exists=False, secrets=()), - "slow-checks": CustomWorkflowEntry(exists=False, secrets=()), - "merge-gate": CustomWorkflowEntry(exists=True, secrets=("SONAR_TOKEN",)), + "cd-extension": CustomWorkflowEntry(exists=False, secrets=(), permissions=[]), + "fast-tests-extension": CustomWorkflowEntry( + exists=False, secrets=(), permissions=[] + ), + "merge-gate-extension": CustomWorkflowEntry( + exists=False, secrets=(), permissions=[] + ), + "slow-checks": CustomWorkflowEntry(exists=False, secrets=(), permissions=[]), + "merge-gate": CustomWorkflowEntry( + exists=True, + secrets=("SONAR_TOKEN",), + permissions=[{"contents": "read"}], + ), } def test_no_custom_workflows_exist(self, custom_workflow_extractor): @@ -166,6 +235,7 @@ def test_custom_workflow_written_to( expected_custom_workflow_dict[workflow] = CustomWorkflowEntry( exists=True, secrets=(secret,), + permissions=[{"contents": "read"}], ) if workflow in ("merge-gate-extension", "slow-checks"): expected_custom_workflow_dict["merge-gate"] = CustomWorkflowEntry( @@ -174,6 +244,42 @@ def test_custom_workflow_written_to( secret, "SONAR_TOKEN", ), + permissions=[{"contents": "read"}], ) assert custom_workflow_dict == expected_custom_workflow_dict + + @staticmethod + def test_merge_gate_permissions_are_aggregated_in_order(custom_workflow_extractor): + custom_workflows_dict = { + "merge-gate-extension": CustomWorkflowEntry( + exists=True, + secrets=(), + permissions=[ + { + "contents": "read", + "packages": "read", + } + ], + ), + "slow-checks": CustomWorkflowEntry( + exists=True, + secrets=(), + permissions=[ + { + "packages": "write", + "id-token": "write", + } + ], + ), + } + + merge_gate_entry = custom_workflow_extractor._build_merge_gate_entry( + custom_workflows_dict + ) + + assert list(merge_gate_entry.permissions.items()) == [ + ("contents", "read"), + ("packages", "write"), + ("id-token", "write"), + ] diff --git a/test/unit/util/workflows/custom_workflow_test.py b/test/unit/util/workflows/custom_workflow_test.py index 14fc74b53..27e2578d3 100644 --- a/test/unit/util/workflows/custom_workflow_test.py +++ b/test/unit/util/workflows/custom_workflow_test.py @@ -3,6 +3,7 @@ import pytest +from exasol.toolbox.util.workflows.custom_workflow import merge_permissions from exasol.toolbox.util.workflows.custom_workflow_extractor import CustomWorkflow @@ -24,6 +25,23 @@ class TestCustomWorkflow: required: true """) + yaml_with_permissions = cleandoc(""" + name: Build & Publish + + on: + workflow_call: + + jobs: + build: + permissions: + contents: read + packages: read + publish: + permissions: + contents: write + id-token: write + """) + yaml_without_secrets = cleandoc(""" name: Build & Publish @@ -31,6 +49,17 @@ class TestCustomWorkflow: workflow_call: """) + yaml_with_job_without_permissions = cleandoc(""" + name: Build & Publish + + on: + workflow_call: + + jobs: + build: + runs-on: ubuntu-latest + """) + def test_load_from_file(self, test_yml): test_yml.write_text(self.yaml_with_secrets) @@ -53,3 +82,46 @@ def test_extract_secrets_when_no_secrets_present(self, test_yml): secrets = custom_workflow.extract_secrets() assert secrets == () + + def test_extract_permissions_when_present(self, test_yml): + test_yml.write_text(self.yaml_with_permissions) + + custom_workflow = CustomWorkflow.load_from_file(file_path=test_yml) + permissions = custom_workflow.extract_permissions() + + assert list(permissions.items()) == [ + ("contents", "write"), + ("packages", "read"), + ("id-token", "write"), + ] + + def test_extract_permissions_when_no_jobs_present(self, test_yml): + test_yml.write_text(self.yaml_without_secrets) + + custom_workflow = CustomWorkflow.load_from_file(file_path=test_yml) + permissions = custom_workflow.extract_permissions() + + assert permissions == {} + + def test_extract_permissions_when_job_has_no_permissions(self, test_yml): + test_yml.write_text(self.yaml_with_job_without_permissions) + + custom_workflow = CustomWorkflow.load_from_file(file_path=test_yml) + permissions = custom_workflow.extract_permissions() + + assert permissions == {} + + def test_merge_permissions_keeps_most_permissive_level(self): + permissions = merge_permissions( + [ + {"contents": "read", "packages": "read"}, + {"contents": "write", "issues": "read"}, + {"packages": "read", "issues": "write"}, + ] + ) + + assert list(permissions.items()) == [ + ("contents", "write"), + ("packages", "read"), + ("issues", "write"), + ]