from __future__ import annotations
import typing as typ
from publisher_token import token_check, token_faults
from step_conditions import TRUNK_CONTEXT, may_run
from workflow_reader import Workflow, pull_request_closure, scalars
COVERAGE_ACTION: typ.Final = "leynos/shared-actions/.github/actions/generate-coverage"
UPLOADER_ACTION: typ.Final = (
"leynos/shared-actions/.github/actions/upload-codescene-coverage"
)
CODESCENE_TOKEN: typ.Final = "cs_access_token"
CODESCENE_HOST: typ.Final = "codescene.io"
PUBLISHER_GROUP: typ.Final = "${{github.workflow}}-${{github.ref}}"
Found = list[tuple[Workflow, str, dict[str, typ.Any]]]
def coordinate(uses: object) -> str:
parts = str(uses).partition("@")[0].split("/", 2)
return "/".join([part.lower() for part in parts[:2]] + parts[2:])
def steps_using(workflows: list[Workflow], action: str) -> Found:
return [
(flow, job, step)
for flow in workflows
for job, step in flow.steps()
if coordinate(step.get("uses", "")) == action
]
def step_input(step: dict[str, typ.Any], key: str) -> str:
return str((step.get("with") or {}).get(key, "<unset>")).lower()
def codescene_contacts(workflows: list[Workflow]) -> list[str]:
lanes = pull_request_closure(workflows)
found = [
f"{flow.path}: {what}"
for flow in lanes
for text in scalars(flow.document)
for what in _contact_in(text.lower())
]
inherits = [
f"{flow.path}:{job}: forwards every secret"
for flow in lanes
for job, mapping in flow.jobs()
if str(mapping.get("secrets", "")).lower() == "inherit"
]
actions = [
f"{flow.path}:{job}: uses the uploader action"
for flow, job, _ in steps_using(lanes, UPLOADER_ACTION)
]
return found + inherits + actions
def _contact_in(text: str) -> list[str]:
marks = {
CODESCENE_TOKEN: "references the access token",
CODESCENE_HOST: "names the CodeScene host",
"cs-coverage": "runs the CodeScene CLI",
}
return [what for mark, what in marks.items() if mark in text]
def measuring_lanes(workflows: list[Workflow]) -> Found:
lanes = pull_request_closure(workflows)
jobs = {(flow.path, name): job for flow in lanes for name, job in flow.jobs()}
return [
(flow, name, step)
for flow, name, step in steps_using(lanes, COVERAGE_ACTION)
if may_run(jobs[flow.path, name], step)
]
def unratcheted_lanes(workflows: list[Workflow]) -> list[str]:
required = (("with-ratchet", "true"), ("publish-artefact", "false"))
return [
f"{flow.path}:{job}: {key} is {step_input(step, key)}"
for flow, job, step in steps_using(
pull_request_closure(workflows), COVERAGE_ACTION
)
for key, expected in required
if step_input(step, key) != expected
]
def publisher_faults(workflows: list[Workflow]) -> list[str]:
publishers = steps_using(workflows, UPLOADER_ACTION)
if len(publishers) != 1:
where = [f"{flow.path}:{job}" for flow, job, _ in publishers]
return [f"expected exactly one upload step, found {where}"]
flow, job, step = publishers[0]
faults = [
*_trigger_faults(flow),
*token_faults(flow, job, step),
*_concurrency_faults(flow, job),
*_generation_faults(flow, job, step),
*_suppression_faults(flow, job, step),
]
if step_input(step, "mode") != "upload":
faults.append(f"uploads in {step_input(step, 'mode')} mode")
return [
*(f"{flow.path}:{job}: {fault}" for fault in faults),
*_contacts_outside(workflows, (step, token_check(flow, job, step))),
]
def _contacts_outside(workflows: list[Workflow], kept: tuple[object, ...]) -> list[str]:
return [
f"{flow.path}: {what} outside the upload step"
for flow in workflows
for text in scalars(_without(flow.document, kept))
for what in _contact_in(text.lower())
]
def _without(node: object, kept: tuple[object, ...]) -> object:
if any(node is target for target in kept):
return None
if isinstance(node, dict):
return {key: _without(value, kept) for key, value in node.items()}
if isinstance(node, list):
return [_without(item, kept) for item in node]
return node
def _trigger_faults(flow: Workflow) -> list[str]:
faults = [] if flow.on_push_to_main_only else ["does not run on push to main"]
if flow.on_pull_request:
faults.append("also answers a pull request")
return faults
def _concurrency_faults(flow: Workflow, job: str) -> list[str]:
workflow_scope = flow.document.get("concurrency")
job_scope = dict(flow.jobs()).get(job, {}).get("concurrency")
group = _group_of(workflow_scope)
faults = [] if group else ["declares no concurrency group"]
if group and "".join(group.split()).lower() != PUBLISHER_GROUP:
faults.append(f"concurrency group {group!r} is not the workflow and ref")
for scope in (workflow_scope, job_scope):
if (
isinstance(scope, dict)
and scope.get("cancel-in-progress", False) is not False
):
faults.append("cancels the publisher in progress")
return faults
def _group_of(scope: object) -> str:
group = scope.get("group") if isinstance(scope, dict) else scope
return group.strip() if isinstance(group, str) else ""
def _generation_faults(
flow: Workflow, job: str, upload: dict[str, typ.Any]
) -> list[str]:
mapping = dict(flow.jobs())[job]
before = (mapping.get("steps") or [])[: (mapping.get("steps") or []).index(upload)]
generates = [
step
for step in before
if isinstance(step, dict)
and coordinate(step.get("uses", "")) == COVERAGE_ACTION
and may_run(mapping, step, TRUNK_CONTEXT)
]
return [] if generates else ["generates no coverage before the upload step"]
def _suppression_faults(
flow: Workflow, job: str, upload: dict[str, typ.Any]
) -> list[str]:
scopes = {"upload step": upload, "upload job": dict(flow.jobs())[job]}
return [
f"{name} sets continue-on-error"
for name, scope in scopes.items()
if scope.get("continue-on-error", False) is not False
]