Skip to content

Commit b7007a8

Browse files
committed
feat(ci): add staging test workflow and harden staging infrastructure
- Add staging-tests.yaml workflow (trigger: label, /test-staging comment, manual dispatch) - Concurrency group ensures only one staging test run at a time - Unified TOKEN passing: all staging make targets accept TOKEN= argument - Token passed as env var (not CLI arg) to avoid leaking in ps aux - Fail-fast guard on all targets if TOKEN is missing or empty - Crash-safe fixture patching: on-disk .staging-backup files self-heal on next run - .staging-backup files added to .gitignore - Replaced GD_STAGING_TOKEN with TOKEN env var for consistency across clean/load/test
1 parent bbfdd97 commit b7007a8

File tree

7 files changed

+67
-33
lines changed

7 files changed

+67
-33
lines changed

.github/workflows/staging-tests.yaml

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ on:
1313
test_envs:
1414
description: 'Tox test environments to run (e.g. py312)'
1515
required: false
16-
default: 'py312'
16+
default: 'py314'
1717
test_filter:
1818
description: 'Pytest filter expression (-k flag)'
1919
required: false
@@ -52,20 +52,25 @@ jobs:
5252
- name: Set up Python
5353
uses: astral-sh/setup-uv@v6
5454
with:
55-
python-version: '3.12'
55+
python-version: '3.14'
5656

5757
- name: Install dependencies
5858
run: uv sync --all-groups --locked
5959

6060
- name: Clean staging environment
61-
run: make clean-staging TOKEN=${{ secrets.PYTHON_SDK_STG_API_KEY }}
61+
run: make clean-staging
62+
env:
63+
TOKEN: ${{ secrets.PYTHON_SDK_STG_API_KEY }}
6264

6365
- name: Load staging environment
64-
run: make load-staging TOKEN=${{ secrets.PYTHON_SDK_STG_API_KEY }}
66+
run: make load-staging
67+
env:
68+
TOKEN: ${{ secrets.PYTHON_SDK_STG_API_KEY }}
6569

6670
- name: Run staging tests
6771
run: |
6872
make test-staging \
69-
TOKEN=${{ secrets.PYTHON_SDK_STG_API_KEY }} \
70-
TEST_ENVS=${{ github.event.inputs.test_envs || 'py312' }} \
73+
TEST_ENVS=${{ github.event.inputs.test_envs || 'py314' }} \
7174
ADD_ARGS="${{ github.event.inputs.test_filter && format('-k {0}', github.event.inputs.test_filter) || '' }}"
75+
env:
76+
TOKEN: ${{ secrets.PYTHON_SDK_STG_API_KEY }}

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,6 @@ docs/.hugo_build.lock
3434

3535
# Export artifacts from Docker export-controller service
3636
packages/gooddata-sdk/tests/export/exports/default/
37+
38+
# Staging test fixture backups (created by conftest.py, self-heal on next run)
39+
*.staging-backup

Makefile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,14 +84,17 @@ test:
8484

8585
.PHONY: test-staging
8686
test-staging:
87+
@test -n "$(TOKEN)" || (echo "ERROR: TOKEN is required. Usage: make test-staging TOKEN=<api-token>" && exit 1)
8788
$(MAKE) -C packages/gooddata-sdk test-staging TOKEN=$(TOKEN)
8889

8990
.PHONY: clean-staging
9091
clean-staging:
92+
@test -n "$(TOKEN)" || (echo "ERROR: TOKEN is required. Usage: make clean-staging TOKEN=<api-token>" && exit 1)
9193
cd packages/tests-support && STAGING=1 TOKEN="$(TOKEN)" python clean_staging.py
9294

9395
.PHONY: load-staging
9496
load-staging:
97+
@test -n "$(TOKEN)" || (echo "ERROR: TOKEN is required. Usage: make load-staging TOKEN=<api-token>" && exit 1)
9598
cd packages/tests-support && STAGING=1 TOKEN="$(TOKEN)" python upload_demo_layout.py
9699

97100
.PHONY: release

packages/gooddata-sdk/tests/conftest.py

Lines changed: 46 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -43,12 +43,6 @@ def pytest_addoption(parser):
4343
default=str(default_config_path),
4444
help="Absolut path to test configuration",
4545
)
46-
parser.addoption(
47-
"--gd-test-token",
48-
action="store",
49-
default="",
50-
help="API token for staging tests",
51-
)
5246

5347

5448
@pytest.fixture(scope="session")
@@ -57,10 +51,11 @@ def test_config(request):
5751
with open(config_path) as f:
5852
config = yaml.safe_load(f)
5953

60-
# Override token from CLI argument (staging tests pass it via --gd-test-token)
61-
cli_token = request.config.getoption("--gd-test-token")
62-
if cli_token:
63-
config["token"] = cli_token
54+
# Override token from TOKEN env var (set by make test-staging TOKEN=...)
55+
if config.get("staging", False):
56+
env_token = os.environ.get("TOKEN")
57+
if env_token:
58+
config["token"] = env_token
6459

6560
return config
6661

@@ -179,20 +174,47 @@ def staging_preflight(test_config):
179174
]
180175

181176

182-
def _patch_file_for_staging(file_path: Path) -> str | None:
183-
"""Replace local JDBC URL/username with staging values. Returns original content for restore."""
177+
_STAGING_BACKUP_SUFFIX = ".staging-backup"
178+
179+
180+
def _backup_path(file_path: Path) -> Path:
181+
return file_path.with_suffix(file_path.suffix + _STAGING_BACKUP_SUFFIX)
182+
183+
184+
def _restore_from_backup(file_path: Path) -> None:
185+
"""Restore a file from its backup (left over from a previous interrupted run)."""
186+
backup = _backup_path(file_path)
187+
if backup.exists():
188+
file_path.write_text(backup.read_text())
189+
backup.unlink()
190+
logger.info(f"Restored from stale backup: {file_path}")
191+
192+
193+
def _patch_file_for_staging(file_path: Path) -> bool:
194+
"""Replace local JDBC URL/username with staging values. Writes backup to disk for crash safety."""
184195
if not file_path.exists():
185-
return None
196+
return False
186197
original = file_path.read_text()
187198
patched = original.replace(_LOCAL_DS_URL, _STAGING_DS_URL).replace(
188199
f"username: {_LOCAL_DS_USERNAME}", f"username: {_STAGING_DS_USERNAME}"
189200
)
190201
# Also handle JSON format (username as a JSON field)
191202
patched = patched.replace(f'"username": "{_LOCAL_DS_USERNAME}"', f'"username": "{_STAGING_DS_USERNAME}"')
192203
if patched != original:
204+
_backup_path(file_path).write_text(original)
193205
file_path.write_text(patched)
194206
logger.info(f"Patched for staging: {file_path}")
195-
return original
207+
return True
208+
return False
209+
210+
211+
def _restore_patched_file(file_path: Path) -> None:
212+
"""Restore a file from its backup and remove the backup."""
213+
backup = _backup_path(file_path)
214+
if backup.exists():
215+
file_path.write_text(backup.read_text())
216+
backup.unlink()
217+
logger.info(f"Restored original: {file_path}")
196218

197219

198220
def _find_gooddata_layouts_dirs() -> list[Path]:
@@ -236,21 +258,21 @@ def staging_patch_fixtures(test_config, staging_preflight):
236258
- Copies gooddata_layouts/default/ -> gooddata_layouts/<org_id>/ so the SDK
237259
can find layout files (it uses the org ID as the directory name)
238260
239-
Restores everything on teardown (regardless of test outcome).
261+
Uses on-disk backups so that interrupted runs self-heal on the next start.
240262
"""
263+
# Always restore leftover backups from a previous interrupted run
264+
for fpath in _STAGING_PATCH_FILES:
265+
_restore_from_backup(fpath)
266+
241267
if not test_config.get("staging", False):
242268
yield
243269
return
244270

245271
import shutil
246272

247273
# 1. Patch JDBC connection strings in fixture files
248-
originals: dict[Path, str] = {}
249-
for fpath in _STAGING_PATCH_FILES:
250-
original = _patch_file_for_staging(fpath)
251-
if original is not None:
252-
originals[fpath] = original
253-
logger.info(f"Patched {len(originals)} fixture files for staging")
274+
patched_files = [fpath for fpath in _STAGING_PATCH_FILES if _patch_file_for_staging(fpath)]
275+
logger.info(f"Patched {len(patched_files)} fixture files for staging")
254276

255277
# 2. Copy layout dirs (gooddata_layouts/default -> gooddata_layouts/<org_id>)
256278
from gooddata_sdk import GoodDataSdk
@@ -262,10 +284,9 @@ def staging_patch_fixtures(test_config, staging_preflight):
262284

263285
yield
264286

265-
# Restore file contents
266-
for fpath, content in originals.items():
267-
fpath.write_text(content)
268-
logger.info(f"Restored original: {fpath}")
287+
# Restore patched files from backups
288+
for fpath in patched_files:
289+
_restore_patched_file(fpath)
269290

270291
# Remove copied directories
271292
for d in copied_dirs:

packages/gooddata-sdk/tests/gd_test_config_staging.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# (C) 2024 GoodData Corporation
22
# Staging test configuration.
3-
# Token is passed via make test-staging TOKEN=... (see conftest.py).
3+
# Token is passed via TOKEN env var from make test-staging TOKEN=... (see conftest.py).
44
staging: true
55
host: "https://python-sdk-dex.dev-latest.stg11.panther.intgdc.com"
66
token: ""

packages/gooddata-sdk/tox.ini

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ dependency_groups =
1010
test
1111
pass_env =
1212
OVERWRITE
13+
TOKEN
1314
setenv =
1415
COVERAGE_CORE=sysmon
1516
commands =

project_common.mk

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,8 @@ test-ci:
6868

6969
.PHONY: test-staging
7070
test-staging:
71-
STAGING=1 uv run tox -v $(TOX_FLAGS) $(LOCAL_TEST_ENVS) -- --gd-test-config=tests/gd_test_config_staging.yaml --gd-test-token=$(TOKEN) $(LOCAL_ADD_ARGS)
71+
@test -n "$(TOKEN)" || (echo "ERROR: TOKEN is required. Usage: make test-staging TOKEN=<api-token>" && exit 1)
72+
TOKEN=$(TOKEN) STAGING=1 uv run tox -v $(TOX_FLAGS) $(LOCAL_TEST_ENVS) -- --gd-test-config=tests/gd_test_config_staging.yaml $(LOCAL_ADD_ARGS)
7273

7374
# this is effective for gooddata-sdk only now - it should be part of test fixtures
7475
# remove this target once implemented in pytest global fixture

0 commit comments

Comments
 (0)