forked from HAL9000/cleveragents-core
00897be24a
## Summary Complete CI/CD pipeline definitions: release workflow, caching, status-check consolidation, and documentation. ### Changes **New: Release pipeline** (`.forgejo/workflows/release.yml`): - Triggered on `v*` tags - 3 jobs: `build-wheel` → `build-docker` → `create-release` - Builds wheel via `nox -s build`, Docker image via multi-stage Dockerfile - Creates Forgejo release via API with wheel artifact attached - Configurable registry push via `REGISTRY_*` secrets **Updated: CI pipeline** (`.forgejo/workflows/ci.yml`): - Added `actions/cache@v3` for `~/.cache/uv` on all 8 primary jobs (keyed on `pyproject.toml` hash) - Added `status-check` consolidation job depending on all required checks — single gate for branch protection **Updated: CONTRIBUTING.md**: - New CI/CD section: pipeline overview, job table, required merge checks, release process, secrets documentation **Tests**: - 13 new Behave scenarios validating workflow YAML structure, tag triggers, job definitions, dependencies - 9 new Robot tests validating file existence, content, nox session references ### Quality Gates | Session | Result | |---|---| | `nox -s lint` | PASS | | `nox -s typecheck` | PASS (0 errors) | | `nox -s unit_tests` | PASS (10,819 scenarios) | | `nox -s coverage_report` | 97.9% (>= 97%) | Closes #858 Reviewed-on: cleveragents/cleveragents-core#983 Co-authored-by: Brent E. Edwards <brent.edwards@cleverthis.com> Co-committed-by: Brent E. Edwards <brent.edwards@cleverthis.com>
192 lines
6.9 KiB
Python
192 lines
6.9 KiB
Python
"""Step definitions for CI workflow validation feature."""
|
|
|
|
from pathlib import Path
|
|
|
|
import yaml
|
|
from behave import given, then, when
|
|
|
|
|
|
@given('the CI workflow file at "{path}"')
|
|
def step_given_ci_workflow_file(context, path):
|
|
"""Store the CI workflow file path."""
|
|
context.ci_workflow_path = Path(path)
|
|
|
|
|
|
@then("the CI workflow file should exist")
|
|
def step_then_ci_workflow_exists(context):
|
|
"""Verify the CI workflow file exists."""
|
|
if not context.ci_workflow_path.exists():
|
|
raise AssertionError(
|
|
f"CI workflow file not found at {context.ci_workflow_path}"
|
|
)
|
|
|
|
|
|
@when("I parse the CI workflow YAML")
|
|
def step_when_parse_ci_workflow(context):
|
|
"""Parse the CI workflow YAML file."""
|
|
workflow_path = context.ci_workflow_path
|
|
if not workflow_path.exists():
|
|
raise FileNotFoundError(f"CI workflow file not found at {workflow_path}")
|
|
|
|
with workflow_path.open("r") as f:
|
|
context.ci_workflow = yaml.safe_load(f)
|
|
|
|
|
|
@then('the workflow should have a job named "{job_name}"')
|
|
def step_then_workflow_has_job(context, job_name):
|
|
"""Verify the workflow has a specific job."""
|
|
jobs = context.ci_workflow.get("jobs", {})
|
|
if job_name not in jobs:
|
|
raise AssertionError(
|
|
f"Job '{job_name}' not found in workflow. "
|
|
f"Available jobs: {list(jobs.keys())}"
|
|
)
|
|
|
|
|
|
@then('the job "{job_name}" should run "{command}"')
|
|
def step_then_job_runs_command(context, job_name, command):
|
|
"""Verify a job runs a specific command in one of its steps."""
|
|
jobs = context.ci_workflow.get("jobs", {})
|
|
job = jobs.get(job_name)
|
|
if job is None:
|
|
raise AssertionError(f"Job '{job_name}' not found in workflow")
|
|
|
|
steps = job.get("steps", [])
|
|
found = False
|
|
for step in steps:
|
|
run_cmd = step.get("run", "")
|
|
if command in run_cmd:
|
|
found = True
|
|
break
|
|
|
|
if not found:
|
|
step_runs = [s.get("run", "(no run)") for s in steps if "run" in s]
|
|
raise AssertionError(
|
|
f"Command '{command}' not found in job '{job_name}' steps. "
|
|
f"Step runs: {step_runs}"
|
|
)
|
|
|
|
|
|
@then('the workflow env should set "{key}" to "{value}"')
|
|
def step_then_workflow_env_set(context, key, value):
|
|
"""Verify a workflow-level environment variable is set."""
|
|
env = context.ci_workflow.get("env", {})
|
|
actual = env.get(key)
|
|
if actual != value:
|
|
raise AssertionError(f"Workflow env '{key}' expected '{value}', got '{actual}'")
|
|
|
|
|
|
@then('the job "{job_name}" should depend on "{dependency}"')
|
|
def step_then_job_depends_on(context, job_name, dependency):
|
|
"""Verify a job has a specific dependency."""
|
|
jobs = context.ci_workflow.get("jobs", {})
|
|
job = jobs.get(job_name)
|
|
if job is None:
|
|
raise AssertionError(f"Job '{job_name}' not found in workflow")
|
|
|
|
needs = job.get("needs", [])
|
|
if isinstance(needs, str):
|
|
needs = [needs]
|
|
|
|
if dependency not in needs:
|
|
raise AssertionError(
|
|
f"Job '{job_name}' does not depend on '{dependency}'. Dependencies: {needs}"
|
|
)
|
|
|
|
|
|
@then("the workflow should reference these nox sessions:")
|
|
def step_then_workflow_references_nox_sessions(context):
|
|
"""Verify all required nox sessions are referenced in the workflow."""
|
|
jobs = context.ci_workflow.get("jobs", {})
|
|
|
|
# Collect all run commands across all jobs
|
|
all_run_commands = ""
|
|
for job_data in jobs.values():
|
|
for step in job_data.get("steps", []):
|
|
run_cmd = step.get("run", "")
|
|
all_run_commands += run_cmd + "\n"
|
|
|
|
for row in context.table:
|
|
session_name = row["session"]
|
|
nox_call = f"nox -s {session_name}"
|
|
if nox_call not in all_run_commands:
|
|
raise AssertionError(
|
|
f"Nox session '{session_name}' (as '{nox_call}') "
|
|
f"not found in any CI workflow step"
|
|
)
|
|
|
|
|
|
@then("the workflow YAML should be valid")
|
|
def step_then_workflow_yaml_valid(context):
|
|
"""Verify the parsed YAML is a valid workflow (has jobs key)."""
|
|
if context.ci_workflow is None:
|
|
raise AssertionError("Workflow YAML parsed as None (empty file)")
|
|
if "jobs" not in context.ci_workflow:
|
|
raise AssertionError("Workflow YAML missing 'jobs' key")
|
|
if not isinstance(context.ci_workflow["jobs"], dict):
|
|
raise AssertionError("Workflow 'jobs' is not a mapping")
|
|
|
|
|
|
@then('the workflow should trigger on push tags matching "{pattern}"')
|
|
def step_then_workflow_triggers_on_tags(context, pattern):
|
|
"""Verify the workflow triggers on push tags matching a pattern."""
|
|
on_config = context.ci_workflow.get("on", context.ci_workflow.get(True, {}))
|
|
push_config = on_config.get("push", {})
|
|
tags = push_config.get("tags", [])
|
|
if pattern not in tags:
|
|
raise AssertionError(f"Tag pattern '{pattern}' not found in push tags: {tags}")
|
|
|
|
|
|
@then("the coverage job should enforce a 97% threshold")
|
|
def step_then_coverage_threshold_97(context):
|
|
"""Verify the coverage job references the 97% threshold."""
|
|
jobs = context.ci_workflow.get("jobs", {})
|
|
coverage_job = jobs.get("coverage")
|
|
if coverage_job is None:
|
|
raise AssertionError("Coverage job not found in workflow")
|
|
|
|
steps = coverage_job.get("steps", [])
|
|
all_run_commands = "\n".join(step.get("run", "") for step in steps if "run" in step)
|
|
if "97" not in all_run_commands:
|
|
raise AssertionError(
|
|
"Coverage job does not reference 97% threshold in any step"
|
|
)
|
|
|
|
|
|
@then('the workflow should trigger on push to "{branch}"')
|
|
def step_then_workflow_triggers_on_push(context, branch):
|
|
"""Verify the workflow triggers on push to a specific branch."""
|
|
on_config = context.ci_workflow.get("on", context.ci_workflow.get(True, {}))
|
|
push_config = on_config.get("push", {})
|
|
branches = push_config.get("branches", [])
|
|
if branch not in branches:
|
|
raise AssertionError(
|
|
f"Branch '{branch}' not found in push branches: {branches}"
|
|
)
|
|
|
|
|
|
@then('the workflow should trigger on pull_request to "{branch}"')
|
|
def step_then_workflow_triggers_on_pr(context, branch):
|
|
"""Verify the workflow triggers on pull_request to a specific branch."""
|
|
on_config = context.ci_workflow.get("on", context.ci_workflow.get(True, {}))
|
|
pr_config = on_config.get("pull_request", {})
|
|
branches = pr_config.get("branches", [])
|
|
if branch not in branches:
|
|
raise AssertionError(
|
|
f"Branch '{branch}' not found in pull_request branches: {branches}"
|
|
)
|
|
|
|
|
|
@then("at least one job should use actions/cache")
|
|
def step_then_at_least_one_job_uses_cache(context):
|
|
"""Verify at least one job uses actions/cache for dependency caching."""
|
|
jobs = context.ci_workflow.get("jobs", {})
|
|
for job_data in jobs.values():
|
|
for step in job_data.get("steps", []):
|
|
uses = step.get("uses", "")
|
|
if "actions/cache" in uses:
|
|
return
|
|
raise AssertionError(
|
|
"No job in the CI workflow uses actions/cache for dependency caching"
|
|
)
|