diff --git a/.github/actions/security-issues/action.yml b/.github/actions/security-issues/action.yml index 601986348..f47726679 100644 --- a/.github/actions/security-issues/action.yml +++ b/.github/actions/security-issues/action.yml @@ -39,7 +39,7 @@ runs: - name: Install Python Toolbox / Security tool shell: bash run: | - pip install exasol-toolbox==10.2.0 + pip install exasol-toolbox==10.2.1 - name: Create Security Issue Report shell: bash diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index de361871d..9627d4215 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -7,6 +7,7 @@ on: tags: - '**' - '!v*' + - '!latest' jobs: check-release-tag: diff --git a/doc/changes/changelog.md b/doc/changes/changelog.md index bda788fab..d7e789260 100644 --- a/doc/changes/changelog.md +++ b/doc/changes/changelog.md @@ -1,6 +1,7 @@ # Changelog * [unreleased](unreleased.md) +* [10.2.1](changes_10.2.1.md) * [10.2.0](changes_10.2.0.md) * [10.1.0](changes_10.1.0.md) * [10.0.0](changes_10.0.0.md) @@ -72,6 +73,7 @@ hidden: --- unreleased +changes_10.2.1 changes_10.2.0 changes_10.1.0 changes_10.0.0 diff --git a/doc/changes/changes_10.2.1.md b/doc/changes/changes_10.2.1.md new file mode 100644 index 000000000..98cdcb506 --- /dev/null +++ b/doc/changes/changes_10.2.1.md @@ -0,0 +1,7 @@ +# 10.2.1 - 2026-07-08 + +## Summary + +## Bug Fix + +* #920: Ensured extracted secrets are unique and alphabetically sorted from the custom workflows diff --git a/exasol/toolbox/templates/github/workflows/cd.yml b/exasol/toolbox/templates/github/workflows/cd.yml index 13ab7bf98..58eceed90 100644 --- a/exasol/toolbox/templates/github/workflows/cd.yml +++ b/exasol/toolbox/templates/github/workflows/cd.yml @@ -6,6 +6,7 @@ on: tags: - '**' - '!v*' + - '!latest' jobs: check-release-tag: @@ -29,6 +30,7 @@ jobs: name: Extension uses: ./.github/workflows/cd-extension.yml needs: + - check-release-tag - build-and-publish (% if custom_workflows["cd-extension"].secrets %) secrets: diff --git a/exasol/toolbox/util/release/changelog.py b/exasol/toolbox/util/release/changelog.py index 6d65bb517..adf59f5a5 100644 --- a/exasol/toolbox/util/release/changelog.py +++ b/exasol/toolbox/util/release/changelog.py @@ -167,7 +167,7 @@ def _create_versioned_changes(self, initial_content: str) -> None: section.intro = resolved_vulnerabilities.intro else: versioned.add_child(resolved_vulnerabilities) - self.versioned_changes.write_text(versioned.rendered) + self.versioned_changes.write_text(f"{versioned.rendered}\n") def prepare_release(self) -> Changelog: """ diff --git a/exasol/toolbox/util/release/cookiecutter.py b/exasol/toolbox/util/release/cookiecutter.py index 67b70d6af..4a152c495 100644 --- a/exasol/toolbox/util/release/cookiecutter.py +++ b/exasol/toolbox/util/release/cookiecutter.py @@ -17,4 +17,4 @@ def update_cookiecutter_default(version: Version) -> None: contents_as_dict["exasol_toolbox_version_range"] = f">={version},<{version.major+1}" updated_contents = dumps(contents_as_dict, indent=2) - COOKIECUTTER_JSON.write_text(updated_contents) + COOKIECUTTER_JSON.write_text(f"{updated_contents}\n") diff --git a/exasol/toolbox/util/workflows/custom_workflow_extractor.py b/exasol/toolbox/util/workflows/custom_workflow_extractor.py index 68f094614..aeab01b38 100644 --- a/exasol/toolbox/util/workflows/custom_workflow_extractor.py +++ b/exasol/toolbox/util/workflows/custom_workflow_extractor.py @@ -1,20 +1,28 @@ from __future__ import annotations from pathlib import Path -from typing import TypedDict from pydantic import ( BaseModel, ConfigDict, + field_validator, ) from exasol.toolbox.util.workflows.custom_workflow import CustomWorkflow -class CustomWorkflowEntry(TypedDict): +class CustomWorkflowEntry(BaseModel): + model_config = ConfigDict(frozen=True) + exists: bool secrets: tuple[str, ...] + @field_validator("secrets", mode="before") + @classmethod + def _normalize_secrets(cls, secrets: tuple[str, ...]) -> list[str]: + """Return unique secret names in alphabetical order.""" + return sorted(set(secrets)) + class CustomWorkflowExtractor(BaseModel): model_config = ConfigDict(frozen=True) @@ -39,21 +47,21 @@ def _build_custom_workflow_entry( custom_workflow = CustomWorkflow.load_from_file(file_path=file_path) secrets = custom_workflow.extract_secrets() - return { - "exists": file_path.exists(), - "secrets": secrets, - } + return CustomWorkflowEntry( + exists=file_path.exists(), + secrets=secrets, + ) def _build_merge_gate_entry( self, custom_workflows_dict: dict[str, CustomWorkflowEntry] ) -> CustomWorkflowEntry: - return { - "exists": True, - "secrets": custom_workflows_dict["merge-gate-extension"]["secrets"] - + custom_workflows_dict["slow-checks"]["secrets"] + return CustomWorkflowEntry( + exists=True, + secrets=custom_workflows_dict["merge-gate-extension"].secrets + + custom_workflows_dict["slow-checks"].secrets # from the `report.yml` + (self.sonar_token_name,), - } + ) def build_custom_workflow_dict( self, diff --git a/project-template/cookiecutter.json b/project-template/cookiecutter.json index 1abd7c612..bd994bfe4 100644 --- a/project-template/cookiecutter.json +++ b/project-template/cookiecutter.json @@ -9,7 +9,7 @@ "author_email": "opensource@exasol.com", "project_short_tag": "", "python_version_min": "3.10", - "exasol_toolbox_version_range": ">=10.2.0,<11", + "exasol_toolbox_version_range": ">=10.2.1,<11", "license_year": "{% now 'utc', '%Y' %}", "__repo_name_slug": "{{cookiecutter.package_name}}", "__package_name_slug": "{{cookiecutter.package_name}}", diff --git a/pyproject.toml b/pyproject.toml index d9ceb18f6..705eaa836 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "exasol-toolbox" -version = "10.2.0" +version = "10.2.1" description = "Your one-stop solution for managing all standard tasks and core workflows of your Python project." authors = [ { name = "Nicola Coretti", email = "nicola.coretti@exasol.com" }, diff --git a/test/integration/util/dependencies/audit_integration_test.py b/test/integration/util/dependencies/audit_integration_test.py index b130aabf6..d78723a73 100644 --- a/test/integration/util/dependencies/audit_integration_test.py +++ b/test/integration/util/dependencies/audit_integration_test.py @@ -159,16 +159,6 @@ def create_poetry_project( return project.dir -def without_vuln_descriptions(dep: PipAuditEntry): - def strip_description(entry: PipAuditEntry): - return {k: v for k, v in entry.items() if k != "description"} - - def without_descriptions(vulnerabilities): - return [strip_description(v) for v in vulnerabilities] - - return {k: (without_descriptions(v) if k == "vulns" else v) for k, v in dep.items()} - - def find_dependency(dependencies: list[PipAuditEntry], name: str) -> PipAuditEntry: generator = (d for d in dependencies if d["name"] == name) return next(generator) @@ -200,9 +190,8 @@ def test_pip_audit(create_poetry_project, sample_vulnerability): result = json.loads(audit_output) actual = find_dependency(result["dependencies"], vuln.package_name) - expected = { - "name": vuln.package_name, - "version": vuln.version, - "vulns": [vuln.pip_audit_vuln_entry], - } - assert without_vuln_descriptions(actual) == without_vuln_descriptions(expected) + assert actual.keys() == {"name", "version", "vulns"} + assert actual["vulns"][0].keys() == {"id", "fix_versions", "aliases", "description"} + assert actual["name"] == vuln.package_name + assert actual["version"] == vuln.version + assert vuln.fix_version in actual["vulns"][0]["fix_versions"] diff --git a/test/unit/util/workflows/custom_workflow_extractor_test.py b/test/unit/util/workflows/custom_workflow_extractor_test.py index 53e43e9f1..608fa15c3 100644 --- a/test/unit/util/workflows/custom_workflow_extractor_test.py +++ b/test/unit/util/workflows/custom_workflow_extractor_test.py @@ -3,6 +3,7 @@ import pytest from exasol.toolbox.util.workflows.custom_workflow_extractor import ( + CustomWorkflowEntry, CustomWorkflowExtractor, ) @@ -22,6 +23,20 @@ def custom_workflow_extractor(workflow_directory): ) +class TestCustomWorkflowEntry: + @staticmethod + def test_secrets_are_normalized_on_creation(): + custom_workflow_entry = CustomWorkflowEntry( + exists=True, + secrets=("ZETA_SECRET", "ALPHA_SECRET", "ALPHA_SECRET"), + ) + + assert custom_workflow_entry == CustomWorkflowEntry( + exists=True, + secrets=("ALPHA_SECRET", "ZETA_SECRET"), + ) + + class TestBuildCustomWorkflowEntry: @staticmethod def test_file_does_not_exist(custom_workflow_extractor): @@ -29,7 +44,10 @@ def test_file_does_not_exist(custom_workflow_extractor): workflow="slow-checks" ) - assert custom_workflow_entry == {"exists": False, "secrets": ()} + assert custom_workflow_entry == CustomWorkflowEntry( + exists=False, + secrets=(), + ) @staticmethod def test_file_does_not_contain_secrets( @@ -48,7 +66,10 @@ def test_file_does_not_contain_secrets( workflow=workflow ) - assert custom_workflow_entry == {"exists": True, "secrets": ()} + assert custom_workflow_entry == CustomWorkflowEntry( + exists=True, + secrets=(), + ) @staticmethod def test_file_contains_secret(custom_workflow_extractor, workflow_directory): @@ -68,37 +89,43 @@ def test_file_contains_secret(custom_workflow_extractor, workflow_directory): workflow=workflow ) - assert custom_workflow_entry == { - "exists": True, - "secrets": ("SLOW_CHECK_SECRET",), - } + assert custom_workflow_entry == CustomWorkflowEntry( + exists=True, + secrets=("SLOW_CHECK_SECRET",), + ) class TestBuildMergeGateEntry: @staticmethod def test_build_merge_gate_entry(custom_workflow_extractor): custom_workflows_dict = { - "merge-gate-extension": {"exists": True, "secrets": ("EXT_SECRET",)}, - "slow-checks": {"exists": True, "secrets": ("SLOW_SECRET",)}, + "merge-gate-extension": CustomWorkflowEntry( + exists=True, + secrets=("EXT_SECRET",), + ), + "slow-checks": CustomWorkflowEntry( + exists=True, + secrets=("SLOW_SECRET",), + ), } merge_gate_entry = custom_workflow_extractor._build_merge_gate_entry( custom_workflows_dict ) - assert merge_gate_entry == { - "exists": True, - "secrets": ("EXT_SECRET", "SLOW_SECRET", "SONAR_TOKEN"), - } + assert merge_gate_entry == CustomWorkflowEntry( + exists=True, + secrets=("EXT_SECRET", "SLOW_SECRET", "SONAR_TOKEN"), + ) class TestBuildCustomWorkflowDict: default_custom_workflow_dict = { - "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": 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",)), } def test_no_custom_workflows_exist(self, custom_workflow_extractor): @@ -136,14 +163,17 @@ def test_custom_workflow_written_to( custom_workflow_dict = custom_workflow_extractor.build_custom_workflow_dict() expected_custom_workflow_dict = self.default_custom_workflow_dict.copy() - expected_custom_workflow_dict[workflow] = custom_workflow_dict[workflow] = { - "exists": True, - "secrets": (secret,), - } + expected_custom_workflow_dict[workflow] = CustomWorkflowEntry( + exists=True, + secrets=(secret,), + ) if workflow in ("merge-gate-extension", "slow-checks"): - expected_custom_workflow_dict["merge-gate"]["secrets"] = ( - secret, - "SONAR_TOKEN", + expected_custom_workflow_dict["merge-gate"] = CustomWorkflowEntry( + exists=True, + secrets=( + secret, + "SONAR_TOKEN", + ), ) assert custom_workflow_dict == expected_custom_workflow_dict diff --git a/test/unit/util/workflows/render_yaml_test.py b/test/unit/util/workflows/render_yaml_test.py index 20f6164f9..7f0606f6f 100644 --- a/test/unit/util/workflows/render_yaml_test.py +++ b/test/unit/util/workflows/render_yaml_test.py @@ -490,8 +490,8 @@ def test_includes_extension_with_multiple_secrets(test_yml, project_config): merge-gate-extension: uses: ./.github/workflows/merge-gate-extension.yml secrets: - MERGE_GATE_SECRET: ${{ secrets.MERGE_GATE_SECRET }} ANOTHER_SECRET: ${{ secrets.ANOTHER_SECRET }} + MERGE_GATE_SECRET: ${{ secrets.MERGE_GATE_SECRET }} permissions: contents: read