164 lines
5.9 KiB
Python
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()
|