Files
devx/src/devx/ci/check_workflow_artifact_deps.py
T

164 lines
5.9 KiB
Python

"""Check that workflow jobs downloading artifacts depend on the uploading job.
This prevents the class of bug where a job downloads an artifact produced by
another job but does not declare that job in its ``needs`` list. When both
jobs run in parallel, the download fails because the artifact hasn't been
uploaded yet.
The check scans all workflow YAML files for:
- ``gitea-upload-artifact`` / ``actions/upload-artifact`` steps
- ``gitea-download-artifact`` / ``actions/download-artifact`` steps
For each download, it finds the job(s) that upload an artifact with a
matching name and verifies that at least one uploading job is in the
downloading job's ``needs`` list.
Artifact names with ``${{ ... }}`` expressions are matched literally
(both sides use the same expression, so they resolve to the same value
at runtime).
Usage::
python -m devx.ci.check_workflow_artifact_deps
python -m devx.ci.check_workflow_artifact_deps --workflow .gitea/workflows/ci.yml
Exit code 0 if all artifact dependencies are satisfied, 1 otherwise.
"""
from __future__ import annotations
import sys
from pathlib import Path
import click
import yaml
REPO_ROOT = Path.cwd()
WORKFLOWS_DIR = REPO_ROOT / ".gitea" / "workflows"
UPLOAD_ACTIONS = ("upload-artifact",)
DOWNLOAD_ACTIONS = ("download-artifact",)
def _is_artifact_action(uses: str, action_types: tuple[str, ...]) -> bool:
"""Check if a step's ``uses`` field references an artifact action."""
if not uses:
return False
uses_lower = uses.lower()
return any(action in uses_lower for action in action_types)
def _extract_artifact_info(workflow: dict) -> tuple[dict[str, list[str]], list[tuple[str, str, str]]]:
"""Extract artifact upload and download info from a workflow.
Returns:
uploads: Mapping of artifact_name → list of job names that upload it.
downloads: List of (job_name, artifact_name, step_name) tuples.
"""
uploads: dict[str, list[str]] = {}
downloads: list[tuple[str, str, str]] = []
jobs = workflow.get("jobs", {})
for job_name, job_def in jobs.items():
for step in job_def.get("steps", []):
uses = step.get("uses", "")
with_data = step.get("with", {})
artifact_name = with_data.get("name", "")
step_name = step.get("name", "")
if _is_artifact_action(uses, UPLOAD_ACTIONS):
if artifact_name:
uploads.setdefault(artifact_name, []).append(job_name)
elif _is_artifact_action(uses, DOWNLOAD_ACTIONS) and artifact_name:
downloads.append((job_name, artifact_name, step_name))
return uploads, downloads
def _check_workflow(filepath: Path) -> list[str]:
"""Check a single workflow file for missing artifact dependencies.
Returns a list of error messages (empty if all OK).
"""
errors: list[str] = []
content = filepath.read_text(encoding="utf-8")
try:
workflow = yaml.safe_load(content)
except yaml.YAMLError as exc:
return [f"{filepath}: cannot parse YAML: {exc}"]
if not isinstance(workflow, dict):
return [f"{filepath}: not a valid workflow (expected dict)"]
uploads, downloads = _extract_artifact_info(workflow)
jobs = workflow.get("jobs", {})
for dl_job, artifact_name, step_name in downloads:
uploading_jobs = uploads.get(artifact_name, [])
if not uploading_jobs:
# Artifact not uploaded in this workflow — may come from an
# external source (e.g., S3). Skip.
continue
dl_job_def = jobs.get(dl_job, {})
needs_raw = dl_job_def.get("needs", [])
needs = {needs_raw} if isinstance(needs_raw, str) else set(needs_raw or [])
# Check if any uploading job is in the download job's needs
if not any(uploader in needs for uploader in uploading_jobs):
# Check if the download step has continue-on-error: true
# (valid guard when the uploading job may be skipped due to
# Gitea Actions' needs skip behavior — the download will
# fail gracefully if the artifact doesn't exist).
dl_steps = dl_job_def.get("steps", [])
step_def = next((s for s in dl_steps if s.get("name", "") == step_name), {})
if step_def.get("continue-on-error") is True:
continue
uploaders_str = ", ".join(sorted(uploading_jobs))
errors.append(
f"{filepath.name}::{dl_job}: step '{step_name}' downloads "
f"artifact '{artifact_name}' produced by job(s) "
f"[{uploaders_str}] but none are in its 'needs' list "
f"(current needs: {sorted(needs) or 'none'}). "
f"Add the uploading job to 'needs' or guard the download "
f"with an if: condition checking the upload job's result."
)
return errors
@click.command()
@click.option(
"--workflow",
type=click.Path(exists=True, path_type=Path),
help="Check a specific workflow file (default: all in .gitea/workflows/).",
)
@click.option(
"--workflows-dir",
type=click.Path(exists=True, path_type=Path),
default=None,
help="Override the workflows directory (default: .gitea/workflows/).",
)
def main(workflow: Path | None, workflows_dir: Path | None) -> None:
"""Check that artifact download jobs depend on upload jobs."""
wdir = workflows_dir or WORKFLOWS_DIR
files = [workflow] if workflow else sorted(wdir.glob("*.yml"))
all_errors: list[str] = []
for f in files:
errors = _check_workflow(f)
all_errors.extend(errors)
if all_errors:
click.echo("[check-workflow-artifact-deps] FAIL: missing artifact dependencies found:")
for err in all_errors:
click.echo(f" - {err}")
sys.exit(1)
else:
click.echo("[check-workflow-artifact-deps] OK: all artifact downloads have upload jobs in needs.")
if __name__ == "__main__": # pragma: no cover
main()