Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
311 changes: 311 additions & 0 deletions .github/workflows/ci-night.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,311 @@
name: CI Night - Daily Quality Gate

on:
schedule:
- cron: "0 3 * * *"
workflow_dispatch:

# Use bash explicitly so micromamba environment is active for all run steps.
defaults:
run:
shell: bash -el {0}

permissions:
actions: read
checks: write
contents: read

jobs:
quality:
name: Daily Test, Coverage and Lint
runs-on: self-hosted
timeout-minutes: 20

steps:
- name: Checkout develop branch
uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'workflow_dispatch' && github.ref_name || 'develop' }}

- name: Get current date
id: date
run: echo "date=$(date +%Y-%m-%d)" >> "${GITHUB_OUTPUT}"

- name: Create environment with micromamba
uses: mamba-org/setup-micromamba@v1
with:
environment-name: petals_env
environment-file: requirements/env_climada.yml
create-args: >-
python=3.12
make
cache-environment-key: env-${{ github.ref_name }}-${{ steps.date.outputs.date }}

- name: Update local conda environment
run: micromamba update -n climada_env -f requirements/env_climada.yml

- name: Install package and test tools
run: python -m pip install -e . pytest pytest-cov pylint

- name: Run pytest on climada_petals
id: pytest
run: |
set +e
mkdir -p tests_xml
python -m pytest \
--junitxml=tests_xml/tests.xml \
--cov \
--cov-config=.coveragerc \
--cov-report xml:coverage.xml \
--cov-report term:skip-covered \
climada_petals/
echo "exit_code=$?" >> "$GITHUB_OUTPUT"
exit 0

- name: Run pylint
id: pylint
run: |
set +e
python -m pylint --output-format=json climada_petals > pylint.json
echo "exit_code=$?" >> "$GITHUB_OUTPUT"
exit 0

- name: Build current quality metrics
id: current_metrics
env:
PYTEST_EXIT_CODE: ${{ steps.pytest.outputs.exit_code }}
run: |
python - <<'PY'
import json
import os
import xml.etree.ElementTree as ET
from pathlib import Path

coverage_pct = 0.0
coverage_file = Path("coverage.xml")
if coverage_file.exists():
root = ET.parse(coverage_file).getroot()
line_rate = root.attrib.get("line-rate")
if line_rate is not None:
coverage_pct = round(float(line_rate) * 100, 4)

lint_errors = 0
pylint_file = Path("pylint.json")
if pylint_file.exists() and pylint_file.stat().st_size > 0:
try:
issues = json.loads(pylint_file.read_text(encoding="utf-8"))
lint_errors = sum(1 for issue in issues if issue.get("type") in {"error", "fatal"})
except json.JSONDecodeError:
lint_errors = 0

tests_total = 0
tests_failures = 0
tests_errors = 0
junit_file = Path("tests_xml/tests.xml")
if junit_file.exists():
root = ET.parse(junit_file).getroot()
if root.tag == "testsuite":
tests_total = int(root.attrib.get("tests", 0))
tests_failures = int(root.attrib.get("failures", 0))
tests_errors = int(root.attrib.get("errors", 0))
elif root.tag == "testsuites":
tests_total = int(root.attrib.get("tests", 0))
tests_failures = int(root.attrib.get("failures", 0))
tests_errors = int(root.attrib.get("errors", 0))

pytest_exit = int(os.environ.get("PYTEST_EXIT_CODE", "1"))

metrics = {
"coverage_pct": coverage_pct,
"lint_errors": lint_errors,
"tests_total": tests_total,
"tests_failures": tests_failures,
"tests_errors": tests_errors,
"pytest_exit_code": pytest_exit,
}
Path("metrics.json").write_text(json.dumps(metrics, indent=2), encoding="utf-8")

with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as fh:
fh.write("## Current run metrics\n")
fh.write("| Metric | Value |\n")
fh.write("|---|---:|\n")
fh.write(f"| Coverage (%) | {coverage_pct:.4f} |\n")
fh.write(f"| Lint errors (pylint error+fatal) | {lint_errors} |\n")
fh.write(f"| Tests total | {tests_total} |\n")
fh.write(f"| Test failures | {tests_failures} |\n")
fh.write(f"| Test errors | {tests_errors} |\n")
fh.write(f"| Pytest exit code | {pytest_exit} |\n")
PY

- name: Download previous successful daily metrics
id: previous_metrics
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
WORKFLOW_FILE: daily-quality.yml
CURRENT_RUN_ID: ${{ github.run_id }}
TARGET_BRANCH: ${{ github.event_name == 'workflow_dispatch' && github.ref_name || 'develop' }}
run: |
python - <<'PY'
import io
import json
import os
import urllib.request
import zipfile
from pathlib import Path

token = os.environ["GH_TOKEN"]
repo = os.environ["GH_REPO"]
workflow_file = os.environ["WORKFLOW_FILE"]
current_run_id = int(os.environ["CURRENT_RUN_ID"])
target_branch = os.environ.get("TARGET_BRANCH", "develop")

def gh_get(url):
req = urllib.request.Request(
url,
headers={
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
)
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read().decode("utf-8"))

runs_url = (
f"https://api.github.com/repos/{repo}/actions/workflows/{workflow_file}/runs"
f"?branch={target_branch}&status=success&per_page=20"
)
runs_payload = gh_get(runs_url)
runs = runs_payload.get("workflow_runs", [])

previous_run = None
for run in runs:
run_id = run.get("id")
if run_id is not None and int(run_id) < current_run_id:
previous_run = run
break

out_path = Path(os.environ["GITHUB_OUTPUT"])
if previous_run is None:
with out_path.open("a", encoding="utf-8") as fh:
fh.write("found=false\n")
with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as fh:
fh.write(
f"\nNo previous successful run found on branch '{target_branch}'. Regression checks are skipped for this run.\n"
)
raise SystemExit(0)

run_id = previous_run["id"]
artifacts_url = f"https://api.github.com/repos/{repo}/actions/runs/{run_id}/artifacts?per_page=100"
artifacts_payload = gh_get(artifacts_url)
artifacts = artifacts_payload.get("artifacts", [])
match = next((a for a in artifacts if a.get("name") == "daily-quality-metrics"), None)

if match is None:
with out_path.open("a", encoding="utf-8") as fh:
fh.write("found=false\n")
with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as fh:
fh.write(
f"\nPrevious run #{run_id} exists but has no daily-quality-metrics artifact. Regression checks are skipped.\n"
)
raise SystemExit(0)

archive_url = match["archive_download_url"]
req = urllib.request.Request(
archive_url,
headers={
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
)
with urllib.request.urlopen(req) as resp:
zip_bytes = resp.read()

with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
with zf.open("metrics.json") as metrics_file:
previous_metrics = json.loads(metrics_file.read().decode("utf-8"))

Path("previous_metrics.json").write_text(json.dumps(previous_metrics, indent=2), encoding="utf-8")
with out_path.open("a", encoding="utf-8") as fh:
fh.write("found=true\n")
fh.write(f"run_id={run_id}\n")

with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as fh:
fh.write(
f"\nComparing against previous successful run on branch '{target_branch}': #{run_id}.\n"
)
PY

- name: Compare metrics and enforce quality gates
env:
PREVIOUS_FOUND: ${{ steps.previous_metrics.outputs.found }}
run: |
python - <<'PY'
import json
import os
import sys
from pathlib import Path

current = json.loads(Path("metrics.json").read_text(encoding="utf-8"))
previous_found = os.environ.get("PREVIOUS_FOUND", "false").lower() == "true"
previous = (
json.loads(Path("previous_metrics.json").read_text(encoding="utf-8"))
if previous_found and Path("previous_metrics.json").exists()
else None
)

test_failed = current.get("pytest_exit_code", 1) != 0
coverage_decreased = False
lint_errors_increased = False

if previous is not None:
coverage_decreased = current.get("coverage_pct", 0.0) < previous.get("coverage_pct", 0.0)
lint_errors_increased = current.get("lint_errors", 0) > previous.get("lint_errors", 0)

with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as fh:
fh.write("\n## Quality gate result\n")
fh.write("| Gate | Status |\n")
fh.write("|---|---|\n")
fh.write(f"| Test failures | {'FAIL' if test_failed else 'PASS'} |\n")
fh.write(f"| Coverage decrease vs previous daily run | {'FAIL' if coverage_decreased else 'PASS'} |\n")
fh.write(f"| Lint error increase vs previous daily run | {'FAIL' if lint_errors_increased else 'PASS'} |\n")

if test_failed or coverage_decreased or lint_errors_increased:
print("Quality gate failed.")
if test_failed:
print("- Tests failed (pytest returned non-zero).")
if coverage_decreased:
print("- Coverage decreased compared to previous successful daily run.")
if lint_errors_increased:
print("- Lint error count increased compared to previous successful daily run.")
sys.exit(1)

print("Quality gate passed.")
PY

- name: Publish test results
if: always()
uses: EnricoMi/publish-unit-test-result-action@v2
with:
files: tests_xml/tests.xml
check_name: Daily Pytest Results
comment_mode: off

- name: Upload quality reports
if: always()
uses: actions/upload-artifact@v4
with:
name: daily-quality-reports
path: |
tests_xml/tests.xml
coverage.xml
pylint.json

- name: Upload daily quality metrics
if: always()
uses: actions/upload-artifact@v4
with:
name: daily-quality-metrics
path: metrics.json
Loading
Loading