From dbfc70af50253a4a690253b0e5483e29b5f78082 Mon Sep 17 00:00:00 2001 From: Rubens Panfili Date: Fri, 19 Jun 2026 14:39:06 +0200 Subject: [PATCH 1/4] fix(build): sort spec files for deterministic merge ordering --- utils/json_merge_generator.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/utils/json_merge_generator.py b/utils/json_merge_generator.py index 9941148a..76033f7e 100644 --- a/utils/json_merge_generator.py +++ b/utils/json_merge_generator.py @@ -10,11 +10,11 @@ # Gather all YAML files in the directory. The openapi merge operation takes the first security scheme and api server # that if finds, so that we provide them using the base.yaml file, see https://github.com/robertmassaioli/openapi-merge/tree/main/packages/openapi-merge#merging-behaviour # We do this because it happened that bogus yaml files broke the Python Client. -yaml_files = [os.path.join(yaml_directory, "base", "base.yaml")] + [ +yaml_files = [os.path.join(yaml_directory, "base", "base.yaml")] + sorted([ os.path.join(yaml_directory, f) for f in os.listdir(yaml_directory) if f.endswith(".yaml") -] +]) # Construct the JSON structure json_data = { From 56629193ddece9706da461ef8b78209218c9d6a9 Mon Sep 17 00:00:00 2001 From: Rubens Panfili Date: Fri, 19 Jun 2026 14:39:35 +0200 Subject: [PATCH 2/4] fix(build): promote global security to op-level before merge --- api/base/base.yaml | 2 ++ utils/promote_security.py | 57 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 utils/promote_security.py diff --git a/api/base/base.yaml b/api/base/base.yaml index 2246a918..63123807 100644 --- a/api/base/base.yaml +++ b/api/base/base.yaml @@ -33,3 +33,5 @@ components: type: oauth2 servers: - url: https://api.wordlift.io +security: + - ApiKey: [] diff --git a/utils/promote_security.py b/utils/promote_security.py new file mode 100644 index 00000000..a174daa8 --- /dev/null +++ b/utils/promote_security.py @@ -0,0 +1,57 @@ +""" +For every spec in ./api/ that declares a top-level `security:` block, +promote that requirement to each operation that lacks an explicit one, +then drop the global block. + +After this runs, the openapi-merge step has nothing to race over: +every operation carries its own security declaration, so the ordering +of specs in the merge is irrelevant to auth correctness. +""" +import os +import sys +import yaml + +HTTP_METHODS = {'get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'} +api_dir = './api' + +promoted_total = 0 + +for fname in sorted(os.listdir(api_dir)): + if not fname.endswith('.yaml'): + continue + + fpath = os.path.join(api_dir, fname) + with open(fpath) as f: + spec = yaml.safe_load(f) + + if not isinstance(spec, dict): + continue + + global_security = spec.get('security') + if not global_security: + continue + + paths = spec.get('paths', {}) or {} + promoted = 0 + + for path_item in paths.values(): + if not isinstance(path_item, dict): + continue + for method, operation in path_item.items(): + if method.lower() not in HTTP_METHODS: + continue + if not isinstance(operation, dict): + continue + if 'security' not in operation: + operation['security'] = global_security + promoted += 1 + + del spec['security'] + + with open(fpath, 'w') as f: + yaml.dump(spec, f, default_flow_style=False, allow_unicode=True, sort_keys=False) + + promoted_total += promoted + print(f'{fname}: injected global security into {promoted} operation(s), dropped global block') + +print(f'\nTotal operations promoted: {promoted_total}') From 392ed65847aeb4a572414b236f5ba64867d1952d Mon Sep 17 00:00:00 2001 From: Rubens Panfili Date: Fri, 19 Jun 2026 14:40:05 +0200 Subject: [PATCH 3/4] fix(build): normalize security scheme aliases in merged spec --- utils/normalize_security_schemes.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 utils/normalize_security_schemes.py diff --git a/utils/normalize_security_schemes.py b/utils/normalize_security_schemes.py new file mode 100644 index 00000000..cd28af46 --- /dev/null +++ b/utils/normalize_security_schemes.py @@ -0,0 +1,23 @@ +import sys + +# Maps security scheme names used in sub-specs to the canonical name defined in base/base.yaml. +# openapi-merge keeps only the first securitySchemes block it finds (base.yaml), so sub-spec +# scheme names survive only in operation-level security references — pointing to a now-undefined +# scheme and causing the generator to emit empty _auth_settings lists. +SECURITY_SCHEME_ALIASES = { + "WordliftApiKey": "ApiKey", +} + +merged_spec = "./wordliftApiSpec.yaml" + +with open(merged_spec, "r") as f: + content = f.read() + +for alias, canonical in SECURITY_SCHEME_ALIASES.items(): + count = content.count(alias) + if count: + content = content.replace(alias, canonical) + print(f"Replaced '{alias}' → '{canonical}' ({count} occurrence(s))") + +with open(merged_spec, "w") as f: + f.write(content) From 22ca703a70b8b92efc39df9d1797525e74bd8e54 Mon Sep 17 00:00:00 2001 From: Rubens Panfili Date: Fri, 19 Jun 2026 14:40:25 +0200 Subject: [PATCH 4/4] ci: gate publish on auth settings coverage and integrate pipeline steps --- .github/workflows/publish.yaml | 19 +++++++++++---- utils/check_auth_settings.py | 44 ++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 4 deletions(-) create mode 100644 utils/check_auth_settings.py diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index c1822fb4..1cf8202b 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -41,24 +41,35 @@ jobs: run: | python -m pip install --upgrade pip pip install -r requirements.pinned.txt - pip install build + pip install build pyyaml # Step 6: Run the Python script to generate json for openApi-merge - name: generate json for openApi-merge run: python3 ./utils/json_merge_generator.py - # Step 7: Merge the YAML files + # Step 7: Promote global security to operation level in each spec + - name: Promote global security to operation level + run: python3 ./utils/promote_security.py + + # Step 8: Merge the YAML files - name: Merge the yaml files run: npx openapi-merge-cli --config ./openapi-merge.json - # Step 8: Bump version in config.json + # Step 9: Normalize security scheme aliases in the merged spec + - name: Normalize security scheme aliases + run: python3 ./utils/normalize_security_schemes.py + + # Step 10: Bump version in config.json - name: Bump version in config.json run: python3 ./utils/bump.py - # Step 9: Generate the SDK + # Step 11: Generate the SDK - name: Generate the SDK run: npx @openapitools/openapi-generator-cli generate -i ./wordliftApiSpec.yaml -g python -c config.json --package-name 'wordlift_client' --library 'asyncio' --additional-properties=pythonVersion=3.8 + # Step 12: Validate auth settings — fail fast before committing a broken client + - name: Validate auth settings + run: python3 ./utils/check_auth_settings.py - name: Patch generated files run: | diff --git a/utils/check_auth_settings.py b/utils/check_auth_settings.py new file mode 100644 index 00000000..39a632d0 --- /dev/null +++ b/utils/check_auth_settings.py @@ -0,0 +1,44 @@ +""" +Validates that every operation in every generated API file has a non-empty +_auth_settings list. Exits non-zero if any are empty, so CI fails fast +before committing a broken client. + +Mirrors the pre-v1.175.0 baseline: all operations should carry 'ApiKey'. +""" +import os +import re +import sys + +api_dir = './wordlift_client/api' + +# Matches the opening of an _auth_settings block followed immediately by ] +# (with only whitespace between), meaning it is empty. +EMPTY_PATTERN = re.compile(r'_auth_settings: List\[str\] = \[\s*\]') +TOTAL_PATTERN = re.compile(r'_auth_settings: List\[str\] = \[') + +broken = [] +total_ops = 0 +total_files = 0 + +for fname in sorted(os.listdir(api_dir)): + if not fname.endswith('.py') or fname == '__init__.py': + continue + + total_files += 1 + content = open(os.path.join(api_dir, fname)).read() + + ops = len(TOTAL_PATTERN.findall(content)) + empty = len(EMPTY_PATTERN.findall(content)) + total_ops += ops + + if empty: + broken.append((fname, empty, ops)) + +if broken: + print(f'FAIL — {sum(e for _, e, _ in broken)} operation(s) with empty _auth_settings ' + f'across {len(broken)} file(s):\n') + for fname, empty, ops in broken: + print(f' {fname}: {empty}/{ops} broken') + sys.exit(1) + +print(f'OK — {total_ops} operation(s) across {total_files} file(s) all have auth settings')