mirror of
https://github.com/hanzoai/insights.git
synced 2026-10-30 16:15:31 +00:00
Mechanically re-derived: bin/debrand runs over a fresh import of posthog/posthog@fa1a9a60d4 (2026-08-06). The pipeline (.hanzo/workflows), the Dockerfiles and bin/docker* entrypoints, compose files, LLM.md and the squashed initial migration are restored from the shipped tree, same as the previous import. The prune's import-closure guard kept 21 products the new core now imports; dropping them is a refactor now, recorded rather than forced.
1540 lines
52 KiB
Python
1540 lines
52 KiB
Python
"""Tests for the workflow_lint package.
|
|
|
|
These tests are fully isolated — each rule gets a passing fixture and a
|
|
failing fixture written to a tmp_path. Avoids touching the live repo state
|
|
except for one smoke test against ``.github/workflows/`` which exercises the
|
|
parser end-to-end.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import textwrap
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from click.testing import CliRunner
|
|
from insightscli_commands.workflow_lint.check import CheckResult, WorkflowCheck
|
|
from insightscli_commands.workflow_lint.checks import CHECKS, _build_lookup, get_check
|
|
from insightscli_commands.workflow_lint.checks.cache_writes import (
|
|
_can_run_on_branch_ref,
|
|
_is_gated,
|
|
_push_trigger_is_default_only,
|
|
_violation_kind,
|
|
_write_kind,
|
|
)
|
|
from insightscli_commands.workflow_lint.checks.checkout_full_depth import CheckoutFullDepthCheck
|
|
from insightscli_commands.workflow_lint.checks.dorny_negation import DornyNegationCheck
|
|
from insightscli_commands.workflow_lint.checks.job_timeouts import JobTimeoutsCheck
|
|
from insightscli_commands.workflow_lint.checks.pr_concurrency import PrConcurrencyCheck
|
|
from insightscli_commands.workflow_lint.checks.pr_event_fanout import PrEventFanoutCheck
|
|
from insightscli_commands.workflow_lint.checks.required_gates import RequiredGateCheck
|
|
from insightscli_commands.workflow_lint.checks.semgrep_services_coverage import SemgrepServicesCoverageCheck
|
|
from insightscli_commands.workflow_lint.cli import cmd_lint_workflows
|
|
from insightscli_commands.workflow_lint.model import PR_TRIGGERS, Workflow, WorkflowParseError, read_workflows
|
|
|
|
|
|
def _write(dir_: Path, name: str, content: str) -> Path:
|
|
path = dir_ / name
|
|
path.write_text(textwrap.dedent(content).lstrip())
|
|
return path
|
|
|
|
|
|
def _read_all(dir_: Path) -> list[Workflow]:
|
|
return list(read_workflows(dir_))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# WorkflowReader / model
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestReadWorkflows:
|
|
def test_normalizes_on_true_key(self, tmp_path: Path) -> None:
|
|
# PyYAML parses `on:` as the boolean True. The reader must surface it
|
|
# as the original triggers regardless.
|
|
_write(
|
|
tmp_path,
|
|
"wf.yml",
|
|
"""
|
|
name: My
|
|
on:
|
|
pull_request:
|
|
branches: [master]
|
|
jobs: {}
|
|
""",
|
|
)
|
|
wf = next(read_workflows(tmp_path))
|
|
assert wf.is_pr_triggered, f"expected PR-trigger detection, got on={wf.on!r}"
|
|
|
|
def test_quoted_on_string_also_works(self, tmp_path: Path) -> None:
|
|
_write(
|
|
tmp_path,
|
|
"wf.yml",
|
|
"""
|
|
name: My
|
|
"on": pull_request
|
|
jobs: {}
|
|
""",
|
|
)
|
|
wf = next(read_workflows(tmp_path))
|
|
assert wf.is_pr_triggered
|
|
|
|
def test_non_pr_trigger(self, tmp_path: Path) -> None:
|
|
_write(
|
|
tmp_path,
|
|
"wf.yml",
|
|
"""
|
|
name: My
|
|
on: [push]
|
|
jobs: {}
|
|
""",
|
|
)
|
|
wf = next(read_workflows(tmp_path))
|
|
assert not wf.is_pr_triggered
|
|
assert "push" not in PR_TRIGGERS # sanity
|
|
|
|
def test_reusable_call_job_marked(self, tmp_path: Path) -> None:
|
|
_write(
|
|
tmp_path,
|
|
"wf.yml",
|
|
"""
|
|
name: My
|
|
on: [pull_request]
|
|
jobs:
|
|
call:
|
|
uses: ./.github/workflows/other.yml
|
|
""",
|
|
)
|
|
wf = next(read_workflows(tmp_path))
|
|
[job] = wf.jobs
|
|
assert job.is_reusable_call
|
|
assert job.uses == "./.github/workflows/other.yml"
|
|
|
|
def test_flattens_parallel_steps(self, tmp_path: Path) -> None:
|
|
_write(
|
|
tmp_path,
|
|
"wf.yml",
|
|
"""
|
|
name: My
|
|
on: [pull_request]
|
|
jobs:
|
|
build:
|
|
timeout-minutes: 5
|
|
runs-on: depot-ubuntu-latest
|
|
steps:
|
|
- run: echo before
|
|
- parallel:
|
|
- id: cache
|
|
uses: actions/cache/save@v5
|
|
- run: echo nested
|
|
""",
|
|
)
|
|
wf = next(read_workflows(tmp_path))
|
|
[job] = wf.jobs
|
|
assert [(step.id_, step.uses, step.run) for step in job.steps] == [
|
|
(None, None, "echo before"),
|
|
("cache", "actions/cache/save@v5", None),
|
|
(None, None, "echo nested"),
|
|
]
|
|
|
|
def test_parse_error_raises_typed(self, tmp_path: Path) -> None:
|
|
bad = tmp_path / "wf.yml"
|
|
bad.write_text("name: x\non: [\njobs: {}\n")
|
|
with pytest.raises(WorkflowParseError) as exc:
|
|
list(read_workflows(tmp_path))
|
|
assert exc.value.path == bad
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# JobTimeoutsCheck
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestJobTimeoutsCheck:
|
|
def test_passes_when_all_jobs_have_timeout(self, tmp_path: Path) -> None:
|
|
_write(
|
|
tmp_path,
|
|
"wf.yml",
|
|
"""
|
|
name: My
|
|
on: [pull_request]
|
|
jobs:
|
|
build:
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 10
|
|
steps:
|
|
- run: echo ok
|
|
""",
|
|
)
|
|
result = JobTimeoutsCheck().run(_read_all(tmp_path))
|
|
assert result.issues == []
|
|
|
|
def test_fails_when_timeout_missing(self, tmp_path: Path) -> None:
|
|
_write(
|
|
tmp_path,
|
|
"wf.yml",
|
|
"""
|
|
name: My
|
|
on: [pull_request]
|
|
jobs:
|
|
build:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- run: echo ok
|
|
""",
|
|
)
|
|
result = JobTimeoutsCheck().run(_read_all(tmp_path))
|
|
[issue] = result.issues
|
|
assert issue.workflow == "wf.yml"
|
|
assert issue.job == "build"
|
|
assert "timeout-minutes" in issue.message
|
|
|
|
def test_skips_reusable_call_jobs(self, tmp_path: Path) -> None:
|
|
_write(
|
|
tmp_path,
|
|
"wf.yml",
|
|
"""
|
|
name: My
|
|
on: [pull_request]
|
|
jobs:
|
|
call:
|
|
uses: ./.github/workflows/other.yml
|
|
""",
|
|
)
|
|
result = JobTimeoutsCheck().run(_read_all(tmp_path))
|
|
assert result.issues == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# PrConcurrencyCheck
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestPrConcurrencyCheck:
|
|
def test_passes_with_concurrency(self, tmp_path: Path) -> None:
|
|
_write(
|
|
tmp_path,
|
|
"ci-foo.yml",
|
|
"""
|
|
name: CI Foo
|
|
on: [pull_request]
|
|
concurrency:
|
|
group: x
|
|
cancel-in-progress: true
|
|
jobs:
|
|
build:
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 5
|
|
steps:
|
|
- run: echo ok
|
|
""",
|
|
)
|
|
assert PrConcurrencyCheck().run(_read_all(tmp_path)).issues == []
|
|
|
|
def test_string_concurrency_counts_as_present(self, tmp_path: Path) -> None:
|
|
_write(
|
|
tmp_path,
|
|
"ci-foo.yml",
|
|
"""
|
|
name: CI Foo
|
|
on: [pull_request]
|
|
concurrency: "${{ github.workflow }}-${{ github.head_ref || github.ref }}"
|
|
jobs:
|
|
build:
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 5
|
|
steps:
|
|
- run: echo ok
|
|
""",
|
|
)
|
|
assert PrConcurrencyCheck().run(_read_all(tmp_path)).issues == []
|
|
|
|
def test_fails_without_concurrency(self, tmp_path: Path) -> None:
|
|
_write(
|
|
tmp_path,
|
|
"ci-foo.yml",
|
|
"""
|
|
name: CI Foo
|
|
on: [pull_request]
|
|
jobs:
|
|
build:
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 5
|
|
steps:
|
|
- run: echo ok
|
|
""",
|
|
)
|
|
[issue] = PrConcurrencyCheck().run(_read_all(tmp_path)).issues
|
|
assert issue.workflow == "ci-foo.yml"
|
|
assert "concurrency" in issue.message
|
|
|
|
def test_fails_with_run_id_fallback(self, tmp_path: Path) -> None:
|
|
_write(
|
|
tmp_path,
|
|
"ci-foo.yml",
|
|
"""
|
|
name: CI Foo
|
|
on: [pull_request]
|
|
concurrency:
|
|
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
|
|
cancel-in-progress: true
|
|
jobs:
|
|
build:
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 5
|
|
steps:
|
|
- run: echo ok
|
|
""",
|
|
)
|
|
[issue] = PrConcurrencyCheck().run(_read_all(tmp_path)).issues
|
|
assert issue.workflow == "ci-foo.yml"
|
|
assert "github.run_id" in issue.message
|
|
assert "github.ref" in issue.message
|
|
|
|
def test_passes_with_job_level_concurrency_on_all_jobs(self, tmp_path: Path) -> None:
|
|
_write(
|
|
tmp_path,
|
|
"ci-foo.yml",
|
|
"""
|
|
name: CI Foo
|
|
on: [pull_request]
|
|
jobs:
|
|
build:
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 5
|
|
concurrency:
|
|
group: build-${{ github.head_ref || github.ref }}
|
|
cancel-in-progress: true
|
|
steps:
|
|
- run: echo ok
|
|
test:
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 5
|
|
concurrency:
|
|
group: test-${{ github.head_ref || github.ref }}
|
|
cancel-in-progress: true
|
|
steps:
|
|
- run: echo ok
|
|
""",
|
|
)
|
|
assert PrConcurrencyCheck().run(_read_all(tmp_path)).issues == []
|
|
|
|
def test_passes_with_partial_job_level_concurrency(self, tmp_path: Path) -> None:
|
|
"""Workflows managing concurrency at job-level for some (heavy) jobs pass."""
|
|
_write(
|
|
tmp_path,
|
|
"ci-foo.yml",
|
|
"""
|
|
name: CI Foo
|
|
on: [pull_request]
|
|
jobs:
|
|
build:
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 5
|
|
concurrency:
|
|
group: build-${{ github.head_ref || github.ref }}
|
|
cancel-in-progress: true
|
|
steps:
|
|
- run: echo ok
|
|
lightweight:
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 5
|
|
steps:
|
|
- run: echo ok
|
|
""",
|
|
)
|
|
assert PrConcurrencyCheck().run(_read_all(tmp_path)).issues == []
|
|
|
|
def test_skips_non_ci_prefix(self, tmp_path: Path) -> None:
|
|
_write(
|
|
tmp_path,
|
|
"shellcheck.yml",
|
|
"""
|
|
name: Shell
|
|
on: [pull_request]
|
|
jobs:
|
|
build:
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 5
|
|
steps:
|
|
- run: echo ok
|
|
""",
|
|
)
|
|
assert PrConcurrencyCheck().run(_read_all(tmp_path)).issues == []
|
|
|
|
def test_skips_non_pr_trigger(self, tmp_path: Path) -> None:
|
|
_write(
|
|
tmp_path,
|
|
"ci-foo.yml",
|
|
"""
|
|
name: CI Foo
|
|
on: [push]
|
|
jobs:
|
|
build:
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 5
|
|
steps:
|
|
- run: echo ok
|
|
""",
|
|
)
|
|
assert PrConcurrencyCheck().run(_read_all(tmp_path)).issues == []
|
|
|
|
def test_skips_listed_filenames(self, tmp_path: Path) -> None:
|
|
skip_name = next(iter(PrConcurrencyCheck.SKIP))
|
|
_write(
|
|
tmp_path,
|
|
skip_name,
|
|
"""
|
|
name: Skipped
|
|
on: [pull_request]
|
|
jobs:
|
|
build:
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 5
|
|
steps:
|
|
- run: echo ok
|
|
""",
|
|
)
|
|
assert PrConcurrencyCheck().run(_read_all(tmp_path)).issues == []
|
|
|
|
@pytest.mark.parametrize(
|
|
"name,triggers,group,cancel,flagged",
|
|
[
|
|
("publish.yml", "[pull_request, push]", "${{ github.workflow }}-${{ github.ref }}", "true", True),
|
|
("publish.yml", "[push]", "${{ github.workflow }}-${{ github.ref }}", "true", True),
|
|
("publish.yml", "[pull_request]", "${{ github.workflow }}-${{ github.ref }}", "true", False),
|
|
(
|
|
"publish.yml",
|
|
"[pull_request, push]",
|
|
"${{ github.workflow }}-${{ github.ref }}",
|
|
"${{ github.event_name == 'pull_request' }}",
|
|
False,
|
|
),
|
|
(
|
|
"publish.yml",
|
|
"[pull_request, push]",
|
|
"${{ github.workflow }}-${{ github.event_name == 'push' && github.sha || github.ref }}",
|
|
"true",
|
|
False,
|
|
),
|
|
# SKIP exempts a workflow from needing a block, never from cancelling master runs.
|
|
(
|
|
next(iter(PrConcurrencyCheck.SKIP)),
|
|
"[push]",
|
|
"${{ github.workflow }}-${{ github.ref }}",
|
|
"true",
|
|
True,
|
|
),
|
|
# A SHA on a non-push arm still leaves every push sharing one ref.
|
|
(
|
|
"publish.yml",
|
|
"[pull_request, push]",
|
|
"${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.sha || github.ref }}",
|
|
"true",
|
|
True,
|
|
),
|
|
("publish.yml", "[push]", "${{ github.workflow }}-${{ github.sha }}", "true", False),
|
|
],
|
|
)
|
|
def test_bare_cancel_flagged_only_when_it_can_kill_a_push_run(
|
|
self, tmp_path: Path, name: str, triggers: str, group: str, cancel: str, flagged: bool
|
|
) -> None:
|
|
_write(
|
|
tmp_path,
|
|
name,
|
|
f"""
|
|
name: Publish
|
|
on: {triggers}
|
|
concurrency:
|
|
group: {group}
|
|
cancel-in-progress: {cancel}
|
|
jobs:
|
|
build:
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 5
|
|
steps:
|
|
- run: echo ok
|
|
""",
|
|
)
|
|
issues = PrConcurrencyCheck().run(_read_all(tmp_path)).issues
|
|
assert bool(issues) is flagged
|
|
|
|
@pytest.mark.parametrize(
|
|
"marker,exempted",
|
|
[
|
|
("# insightscli-lint: allow-master-cancel -- cache warmer, latest wins", True),
|
|
("# insightscli-lint: allow-master-cancel", False),
|
|
("# insightscli-lint: allow-master-cancel --", False),
|
|
],
|
|
)
|
|
def test_master_cancel_marker_needs_a_reason_to_exempt(self, tmp_path: Path, marker: str, exempted: bool) -> None:
|
|
_write(
|
|
tmp_path,
|
|
"publish.yml",
|
|
f"""
|
|
name: Publish
|
|
on: [push]
|
|
concurrency:
|
|
group: ${{{{ github.workflow }}}}-${{{{ github.ref }}}}
|
|
{marker}
|
|
cancel-in-progress: true
|
|
jobs:
|
|
build:
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 5
|
|
steps:
|
|
- run: echo ok
|
|
""",
|
|
)
|
|
assert (PrConcurrencyCheck().run(_read_all(tmp_path)).issues == []) is exempted
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# PrEventFanoutCheck
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestPrEventFanoutCheck:
|
|
@pytest.mark.parametrize(
|
|
"workflow,budget,expected",
|
|
[
|
|
(
|
|
"""
|
|
name: Agent
|
|
on:
|
|
pull_request:
|
|
types: [closed]
|
|
pull_request_target:
|
|
types: [opened, reopened, ready_for_review, edited]
|
|
jobs: {}
|
|
""",
|
|
{"closed": 1},
|
|
[
|
|
"unscoped `edited` PR dispatch fanout is 1; budget is 0",
|
|
"unscoped `opened` PR dispatch fanout is 1; budget is 0",
|
|
"unscoped `ready_for_review` PR dispatch fanout is 1; budget is 0",
|
|
"unscoped `reopened` PR dispatch fanout is 1; budget is 0",
|
|
],
|
|
),
|
|
(
|
|
"""
|
|
name: New workflow
|
|
on: [pull_request]
|
|
jobs: {}
|
|
""",
|
|
{},
|
|
[
|
|
"unscoped `opened` PR dispatch fanout is 1; budget is 0",
|
|
"unscoped `reopened` PR dispatch fanout is 1; budget is 0",
|
|
"unscoped `synchronize` PR dispatch fanout is 1; budget is 0",
|
|
],
|
|
),
|
|
(
|
|
"""
|
|
name: Focused
|
|
on:
|
|
pull_request:
|
|
paths: [products/example/**]
|
|
jobs: {}
|
|
""",
|
|
{},
|
|
[],
|
|
),
|
|
# paths-ignore usually excludes a narrow slice, so it still fires on nearly every PR.
|
|
(
|
|
"""
|
|
name: Nearly everything
|
|
on:
|
|
pull_request:
|
|
paths-ignore: [docs/**]
|
|
jobs: {}
|
|
""",
|
|
{},
|
|
[
|
|
"unscoped `opened` PR dispatch fanout is 1; budget is 0",
|
|
"unscoped `reopened` PR dispatch fanout is 1; budget is 0",
|
|
"unscoped `synchronize` PR dispatch fanout is 1; budget is 0",
|
|
],
|
|
),
|
|
# Label-driven workflows are the intended use, and labels cannot burst.
|
|
(
|
|
"""
|
|
name: Opt in by label
|
|
on:
|
|
pull_request:
|
|
types: [labeled, unlabeled]
|
|
jobs: {}
|
|
""",
|
|
{},
|
|
[],
|
|
),
|
|
],
|
|
)
|
|
def test_counts_unscoped_pr_dispatches(
|
|
self, tmp_path: Path, workflow: str, budget: dict[str, int], expected: list[str]
|
|
) -> None:
|
|
_write(tmp_path, "workflow.yml", workflow)
|
|
|
|
result = PrEventFanoutCheck(budget=budget).run(_read_all(tmp_path))
|
|
|
|
assert [issue.message for issue in result.issues] == expected
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# DornyNegationCheck
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
_DORNY_USES = "dorny/paths-filter@v3"
|
|
|
|
|
|
class TestDornyNegationCheck:
|
|
def test_passes_without_dorny(self, tmp_path: Path) -> None:
|
|
_write(
|
|
tmp_path,
|
|
"wf.yml",
|
|
"""
|
|
name: x
|
|
on: [pull_request]
|
|
jobs:
|
|
j:
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 5
|
|
steps:
|
|
- run: echo ok
|
|
""",
|
|
)
|
|
assert DornyNegationCheck().run(_read_all(tmp_path)).issues == []
|
|
|
|
def test_passes_with_positive_filters_only(self, tmp_path: Path) -> None:
|
|
_write(
|
|
tmp_path,
|
|
"wf.yml",
|
|
f"""
|
|
name: x
|
|
on: [pull_request]
|
|
jobs:
|
|
j:
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 5
|
|
steps:
|
|
- uses: {_DORNY_USES}
|
|
with:
|
|
filters: |
|
|
backend:
|
|
- insights/**
|
|
""",
|
|
)
|
|
assert DornyNegationCheck().run(_read_all(tmp_path)).issues == []
|
|
|
|
def test_fails_on_negation_without_quantifier(self, tmp_path: Path) -> None:
|
|
_write(
|
|
tmp_path,
|
|
"wf.yml",
|
|
f"""
|
|
name: x
|
|
on: [pull_request]
|
|
jobs:
|
|
j:
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 5
|
|
steps:
|
|
- uses: {_DORNY_USES}
|
|
with:
|
|
filters: |
|
|
backend:
|
|
- insights/**
|
|
- "!docs/**"
|
|
""",
|
|
)
|
|
[issue] = DornyNegationCheck().run(_read_all(tmp_path)).issues
|
|
assert "negation" in issue.message
|
|
assert "predicate-quantifier" in issue.message
|
|
|
|
def test_passes_with_every_quantifier(self, tmp_path: Path) -> None:
|
|
_write(
|
|
tmp_path,
|
|
"wf.yml",
|
|
f"""
|
|
name: x
|
|
on: [pull_request]
|
|
jobs:
|
|
j:
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 5
|
|
steps:
|
|
- uses: {_DORNY_USES}
|
|
with:
|
|
predicate-quantifier: 'every'
|
|
filters: |
|
|
backend:
|
|
- insights/**
|
|
- "!docs/**"
|
|
""",
|
|
)
|
|
assert DornyNegationCheck().run(_read_all(tmp_path)).issues == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# SemgrepServicesCoverageCheck
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSemgrepServicesCoverageCheck:
|
|
def test_passes_when_all_services_are_covered(self, tmp_path: Path) -> None:
|
|
repo_root = tmp_path
|
|
(repo_root / "services" / "api").mkdir(parents=True)
|
|
(repo_root / "services" / "worker").mkdir()
|
|
workflows_dir = repo_root / ".github" / "workflows"
|
|
workflows_dir.mkdir(parents=True)
|
|
_write(
|
|
workflows_dir,
|
|
"ci-security.yaml",
|
|
"""
|
|
name: Security
|
|
on: [pull_request]
|
|
jobs:
|
|
semgrep-python:
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 5
|
|
steps:
|
|
- run: |
|
|
semgrep scan services/api/
|
|
semgrep scan services/worker/
|
|
semgrep-js:
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 5
|
|
steps:
|
|
- run: echo ok
|
|
""",
|
|
)
|
|
assert SemgrepServicesCoverageCheck(repo_root=repo_root).run(_read_all(workflows_dir)).issues == []
|
|
|
|
def test_fails_when_a_service_is_missing(self, tmp_path: Path) -> None:
|
|
repo_root = tmp_path
|
|
(repo_root / "services" / "api").mkdir(parents=True)
|
|
(repo_root / "services" / "worker").mkdir()
|
|
workflows_dir = repo_root / ".github" / "workflows"
|
|
workflows_dir.mkdir(parents=True)
|
|
_write(
|
|
workflows_dir,
|
|
"ci-security.yaml",
|
|
"""
|
|
name: Security
|
|
on: [pull_request]
|
|
jobs:
|
|
semgrep-python:
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 5
|
|
steps:
|
|
- run: semgrep scan services/api/
|
|
semgrep-js:
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 5
|
|
steps:
|
|
- run: echo ok
|
|
""",
|
|
)
|
|
[issue] = SemgrepServicesCoverageCheck(repo_root=repo_root).run(_read_all(workflows_dir)).issues
|
|
assert issue.workflow == "ci-security.yaml"
|
|
assert "services/worker/" in issue.message
|
|
|
|
def test_judges_tracked_services_only(self, tmp_path: Path) -> None:
|
|
# Build residue left by another branch (node_modules and friends, no
|
|
# tracked source) is not a service CI can scan, so it must not fail —
|
|
# while a tracked service that really is uncovered still must.
|
|
repo_root = tmp_path
|
|
for service in ("api", "worker"):
|
|
(repo_root / "services" / service).mkdir(parents=True)
|
|
(repo_root / "services" / service / "main.py").write_text("x = 1\n")
|
|
(repo_root / "services" / "stale" / "node_modules").mkdir(parents=True)
|
|
for command in (("init",), ("add", "services/api/main.py", "services/worker/main.py")):
|
|
subprocess.run(["git", *command], cwd=repo_root, check=True, capture_output=True)
|
|
workflows_dir = repo_root / ".github" / "workflows"
|
|
workflows_dir.mkdir(parents=True)
|
|
_write(
|
|
workflows_dir,
|
|
"ci-security.yaml",
|
|
"""
|
|
name: Security
|
|
on: [pull_request]
|
|
jobs:
|
|
semgrep-python:
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 5
|
|
steps:
|
|
- run: semgrep scan services/api/
|
|
semgrep-js:
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 5
|
|
steps:
|
|
- run: echo ok
|
|
""",
|
|
)
|
|
[issue] = SemgrepServicesCoverageCheck(repo_root=repo_root).run(_read_all(workflows_dir)).issues
|
|
assert "services/worker/" in issue.message
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# CheckoutFullDepthCheck
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCheckoutFullDepthCheck:
|
|
def test_passes_default_checkout(self, tmp_path: Path) -> None:
|
|
_write(
|
|
tmp_path,
|
|
"wf.yml",
|
|
"""
|
|
name: x
|
|
on: [pull_request]
|
|
jobs:
|
|
build:
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 5
|
|
steps:
|
|
- uses: actions/checkout@v6
|
|
""",
|
|
)
|
|
assert CheckoutFullDepthCheck().run(_read_all(tmp_path)).issues == []
|
|
|
|
def test_passes_blobless_full_depth_checkout(self, tmp_path: Path) -> None:
|
|
_write(
|
|
tmp_path,
|
|
"wf.yml",
|
|
"""
|
|
name: x
|
|
on: [pull_request]
|
|
jobs:
|
|
build:
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 5
|
|
steps:
|
|
- uses: actions/checkout@v6
|
|
with:
|
|
fetch-depth: 0
|
|
filter: blob:none
|
|
""",
|
|
)
|
|
assert CheckoutFullDepthCheck().run(_read_all(tmp_path)).issues == []
|
|
|
|
def test_passes_sparse_full_depth_checkout(self, tmp_path: Path) -> None:
|
|
_write(
|
|
tmp_path,
|
|
"wf.yml",
|
|
"""
|
|
name: x
|
|
on: [pull_request]
|
|
jobs:
|
|
build:
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 5
|
|
steps:
|
|
- uses: actions/checkout@v6
|
|
with:
|
|
fetch-depth: 0
|
|
sparse-checkout: |
|
|
rust/
|
|
proto/
|
|
""",
|
|
)
|
|
assert CheckoutFullDepthCheck().run(_read_all(tmp_path)).issues == []
|
|
|
|
def test_passes_explicit_allow_marker_with_reason(self, tmp_path: Path) -> None:
|
|
_write(
|
|
tmp_path,
|
|
"wf.yml",
|
|
"""
|
|
name: x
|
|
on: [push]
|
|
jobs:
|
|
mirror:
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 5
|
|
steps:
|
|
# insightscli-lint: allow-full-depth-checkout -- mirror needs full blobs
|
|
- name: Checkout mirror
|
|
uses: "actions/checkout@v6"
|
|
with:
|
|
fetch-depth: 0
|
|
""",
|
|
)
|
|
assert CheckoutFullDepthCheck().run(_read_all(tmp_path)).issues == []
|
|
|
|
def test_fails_full_depth_without_optimization(self, tmp_path: Path) -> None:
|
|
_write(
|
|
tmp_path,
|
|
"wf.yml",
|
|
"""
|
|
name: x
|
|
on: [pull_request]
|
|
jobs:
|
|
build:
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 5
|
|
steps:
|
|
- uses: actions/checkout@v6
|
|
with:
|
|
fetch-depth: "0"
|
|
""",
|
|
)
|
|
[issue] = CheckoutFullDepthCheck().run(_read_all(tmp_path)).issues
|
|
assert issue.workflow == "wf.yml"
|
|
assert issue.job == "build"
|
|
assert "fetch-depth: 0" in issue.message
|
|
|
|
def test_allow_marker_requires_reason(self, tmp_path: Path) -> None:
|
|
_write(
|
|
tmp_path,
|
|
"wf.yml",
|
|
"""
|
|
name: x
|
|
on: [push]
|
|
jobs:
|
|
mirror:
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 5
|
|
steps:
|
|
# insightscli-lint: allow-full-depth-checkout
|
|
- uses: actions/checkout@v6
|
|
with:
|
|
fetch-depth: 0
|
|
""",
|
|
)
|
|
[issue] = CheckoutFullDepthCheck().run(_read_all(tmp_path)).issues
|
|
assert "allow-full-depth-checkout" in issue.message
|
|
|
|
def test_allow_marker_does_not_apply_to_previous_checkout(self, tmp_path: Path) -> None:
|
|
_write(
|
|
tmp_path,
|
|
"wf.yml",
|
|
"""
|
|
name: x
|
|
on: [push]
|
|
jobs:
|
|
mirror:
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 5
|
|
steps:
|
|
- uses: "actions/checkout@v6"
|
|
with:
|
|
fetch-depth: 0
|
|
|
|
# insightscli-lint: allow-full-depth-checkout -- mirror needs full blobs
|
|
- uses: actions/checkout@v6
|
|
with:
|
|
fetch-depth: 0
|
|
""",
|
|
)
|
|
[issue] = CheckoutFullDepthCheck().run(_read_all(tmp_path)).issues
|
|
assert issue.step == "step[0]"
|
|
assert "fetch-depth: 0" in issue.message
|
|
|
|
def test_passes_filter_combined_with_sparse_checkout(self, tmp_path: Path) -> None:
|
|
_write(
|
|
tmp_path,
|
|
"wf.yml",
|
|
"""
|
|
name: x
|
|
on: [pull_request]
|
|
jobs:
|
|
build:
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 5
|
|
steps:
|
|
- uses: actions/checkout@v6
|
|
with:
|
|
fetch-depth: 0
|
|
filter: blob:none
|
|
sparse-checkout: |
|
|
rust/
|
|
""",
|
|
)
|
|
assert CheckoutFullDepthCheck().run(_read_all(tmp_path)).issues == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# CacheWriteGateCheck
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _cache_step(if_cond: str | None = None, key: str | None = None) -> dict:
|
|
step: dict = {"uses": "actions/cache@v4"}
|
|
if if_cond is not None:
|
|
step["if"] = if_cond
|
|
if key is not None:
|
|
step["with"] = {"key": key}
|
|
return step
|
|
|
|
|
|
class TestCacheWriteGateCheck:
|
|
@pytest.mark.parametrize(
|
|
"uses, with_, expected",
|
|
[
|
|
("actions/cache@v4", None, "cache (combined)"),
|
|
("actions/cache/save@v4", None, "cache/save"),
|
|
("actions/cache/restore@v4", None, None),
|
|
("actions/setup-node@v4", {"cache": "pnpm"}, "setup auto-cache"),
|
|
("actions/setup-node@v4", {}, None),
|
|
("actions/checkout@v4", None, None),
|
|
],
|
|
)
|
|
def test_write_kind(self, uses: str, with_: dict | None, expected: str | None) -> None:
|
|
step: dict = {"uses": uses}
|
|
if with_ is not None:
|
|
step["with"] = with_
|
|
assert _write_kind(step) == expected
|
|
|
|
@pytest.mark.parametrize(
|
|
"if_cond, gated",
|
|
[
|
|
("github.ref == 'refs/heads/master'", True),
|
|
("github.ref == 'refs/heads/main'", True),
|
|
("github.ref_name == 'master'", True),
|
|
# AND only narrows — still pinned to master
|
|
("github.ref == 'refs/heads/master' && github.event_name == 'push'", True),
|
|
# both OR alternatives pin to a default branch
|
|
("github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main'", True),
|
|
# the nit: negation runs on branches, not master
|
|
("github.ref != 'refs/heads/master'", False),
|
|
# the nit: OR re-opens the write to PRs
|
|
("github.ref == 'refs/heads/master' || github.event_name == 'pull_request'", False),
|
|
("", False),
|
|
],
|
|
)
|
|
def test_is_gated(self, if_cond: str, gated: bool) -> None:
|
|
assert _is_gated({"if": if_cond}, push_is_default_only=True) is gated
|
|
|
|
@pytest.mark.parametrize("push_is_default_only, gated", [(True, True), (False, False)])
|
|
def test_event_name_push_gate_depends_on_push_scope(self, push_is_default_only: bool, gated: bool) -> None:
|
|
# `event_name == 'push'` only pins to master when push can't fire on a branch
|
|
assert _is_gated({"if": "github.event_name == 'push'"}, push_is_default_only) is gated
|
|
|
|
@pytest.mark.parametrize(
|
|
"step, expected",
|
|
[
|
|
(_cache_step(), "cache (combined)"),
|
|
(_cache_step(if_cond="github.ref == 'refs/heads/master'"), None),
|
|
(_cache_step(key="pnpm-${{ github.event.pull_request.number }}"), None),
|
|
# the nit: previously these slipped through as gated
|
|
(_cache_step(if_cond="github.ref != 'refs/heads/master'"), "cache (combined)"),
|
|
(
|
|
_cache_step(if_cond="github.ref == 'refs/heads/master' || github.event_name == 'pull_request'"),
|
|
"cache (combined)",
|
|
),
|
|
],
|
|
)
|
|
def test_violation_kind(self, step: dict, expected: str | None) -> None:
|
|
assert _violation_kind(step, push_is_default_only=True) == expected
|
|
|
|
def test_setup_autocache_cannot_be_gated(self) -> None:
|
|
# gating the whole setup step would skip the toolchain on PRs, so the
|
|
# auto-save is always a violation regardless of `if:`
|
|
step = {"uses": "actions/setup-node@v4", "if": "github.ref == 'refs/heads/master'", "with": {"cache": "pnpm"}}
|
|
assert _violation_kind(step, push_is_default_only=True) == "setup auto-cache"
|
|
|
|
@pytest.mark.parametrize(
|
|
"on, can_run_on_branch",
|
|
[
|
|
("pull_request", True),
|
|
(["pull_request", "push"], True),
|
|
({"push": None}, True), # bare push fires on every branch
|
|
({"push": {"branches": ["master"]}}, False),
|
|
({"push": {"tags": ["*"]}}, False), # release pushes
|
|
("workflow_dispatch", False),
|
|
("schedule", False),
|
|
],
|
|
)
|
|
def test_can_run_on_branch_ref(self, on: object, can_run_on_branch: bool) -> None:
|
|
assert _can_run_on_branch_ref(on) is can_run_on_branch
|
|
|
|
@pytest.mark.parametrize(
|
|
"on, default_only",
|
|
[
|
|
("pull_request", True), # no push trigger
|
|
({"push": {"branches": ["master"]}}, True),
|
|
({"push": None}, False), # bare push leaks to branches
|
|
({"push": {"branches": ["master", "feature"]}}, False),
|
|
],
|
|
)
|
|
def test_push_trigger_is_default_only(self, on: object, default_only: bool) -> None:
|
|
assert _push_trigger_is_default_only(on) is default_only
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Registry / smoke
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestRegistry:
|
|
def test_check_ids_are_unique(self) -> None:
|
|
ids = [c.id for c in CHECKS]
|
|
assert len(ids) == len(set(ids)), f"duplicate check ids: {ids}"
|
|
|
|
def test_get_check_finds_each(self) -> None:
|
|
for c in CHECKS:
|
|
assert get_check(c.id) is c
|
|
|
|
def test_get_check_accepts_prefix(self) -> None:
|
|
for c in CHECKS:
|
|
prefix = c.id.split("-", 1)[0]
|
|
assert get_check(prefix) is c
|
|
assert get_check(prefix.lower()) is c
|
|
|
|
def test_get_check_unknown_returns_none(self) -> None:
|
|
assert get_check("not-a-real-id") is None
|
|
assert get_check("WF999") is None
|
|
|
|
def test_build_lookup_rejects_duplicate_prefix(self) -> None:
|
|
class _A(WorkflowCheck):
|
|
id = "WF001-alpha"
|
|
label = "a"
|
|
description = "a"
|
|
|
|
def run(self, workflows: list[Workflow]) -> CheckResult:
|
|
return CheckResult()
|
|
|
|
class _B(WorkflowCheck):
|
|
id = "WF001-beta"
|
|
label = "b"
|
|
description = "b"
|
|
|
|
def run(self, workflows: list[Workflow]) -> CheckResult:
|
|
return CheckResult()
|
|
|
|
with pytest.raises(ValueError, match="WF001"):
|
|
_build_lookup([_A(), _B()])
|
|
|
|
def test_run_returns_check_result(self, tmp_path: Path) -> None:
|
|
# A near-empty workflow set should not crash any check.
|
|
_write(
|
|
tmp_path,
|
|
"wf.yml",
|
|
"""
|
|
name: x
|
|
on: [push]
|
|
jobs: {}
|
|
""",
|
|
)
|
|
wfs = _read_all(tmp_path)
|
|
for check in CHECKS:
|
|
result = check.run(wfs)
|
|
assert isinstance(result, CheckResult)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# CLI
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCli:
|
|
def test_fanout_over_budget_fails_run(self, tmp_path: Path) -> None:
|
|
_write(
|
|
tmp_path,
|
|
"assigned.yml",
|
|
"""
|
|
name: Assigned
|
|
on:
|
|
pull_request:
|
|
types: [assigned]
|
|
jobs: {}
|
|
""",
|
|
)
|
|
|
|
result = CliRunner().invoke(
|
|
cmd_lint_workflows,
|
|
["--check", "WF008", "--workflows-dir", str(tmp_path)],
|
|
)
|
|
|
|
assert result.exit_code == 1
|
|
assert "1 issue(s) across 1 check(s)" in result.output
|
|
|
|
|
|
# The shape these fixtures guard against: a `changes` detector cleared with a bare
|
|
# `== "failure"`, then its outputs read to decide "nothing to test". Those outputs
|
|
# are empty on a cancelled job, so the gate exits 0 green with no tests run.
|
|
def _gate(body: str, condition: str = "always()") -> str:
|
|
return f"""
|
|
name: ci-thing
|
|
on: pull_request
|
|
jobs:
|
|
changes:
|
|
timeout-minutes: 5
|
|
steps:
|
|
- run: echo detect
|
|
build:
|
|
timeout-minutes: 5
|
|
steps:
|
|
- run: echo build
|
|
thing_tests:
|
|
name: Thing Tests Pass
|
|
needs: [changes, build]
|
|
timeout-minutes: 5
|
|
if: {condition}
|
|
steps:
|
|
- run: |
|
|
{textwrap.indent(textwrap.dedent(body).strip(), " " * 14)}
|
|
"""
|
|
|
|
|
|
SAFE_BODY = """
|
|
if [[ "${{ needs.changes.result }}" != "success" && "${{ needs.changes.result }}" != "skipped" ]]; then
|
|
exit 1
|
|
fi
|
|
if [[ "${{ needs.changes.outputs.thing }}" != "true" ]]; then
|
|
exit 0
|
|
fi
|
|
if [[ "${{ needs.build.result }}" != "success" && "${{ needs.build.result }}" != "skipped" ]]; then
|
|
exit 1
|
|
fi
|
|
"""
|
|
|
|
# One dependency allowlisted, one denylisted — the shape a global scan for
|
|
# result words calls clean.
|
|
MIXED_BODY = SAFE_BODY.replace(
|
|
"""if [[ "${{ needs.changes.result }}" != "success" && "${{ needs.changes.result }}" != "skipped" ]]; then""",
|
|
"""if [[ "${{ needs.changes.result }}" == "failure" ]]; then""",
|
|
)
|
|
|
|
# Two dependencies denylisted while the rest are allowlisted.
|
|
TWO_BAD_BODY = MIXED_BODY.replace(
|
|
"""if [[ "${{ needs.changes.outputs.thing }}" != "true" ]]; then""",
|
|
"""if [[ "${{ needs.affected.result }}" == "failure" ]]; then
|
|
exit 1
|
|
fi
|
|
if [[ "${{ needs.changes.outputs.thing }}" != "true" ]]; then""",
|
|
)
|
|
|
|
# Some gates route every result through one shell function, so no guard sits
|
|
# next to `needs.<dep>.result`.
|
|
HELPER_BODY = """
|
|
check() {
|
|
if [[ "$2" != "success" && "$2" != "skipped" ]]; then
|
|
exit 1
|
|
fi
|
|
}
|
|
check "Changes" "${{ needs.changes.result }}"
|
|
check "Build" "${{ needs.build.result }}"
|
|
"""
|
|
|
|
UNSAFE_HELPER_BODY = HELPER_BODY.replace(
|
|
"""if [[ "$2" != "success" && "$2" != "skipped" ]]; then""",
|
|
"""if [[ "$2" == "failure" ]]; then""",
|
|
)
|
|
|
|
# The positional is bound to a local before being tested, so the trace has to
|
|
# follow the assignment as well as the call site to reach the guard.
|
|
LOCAL_ALIAS_HELPER_BODY = """
|
|
check() {
|
|
local result="$2"
|
|
if [[ "$result" != "success" && "$result" != "skipped" ]]; then
|
|
exit 1
|
|
fi
|
|
}
|
|
check "Changes" "${{ needs.changes.result }}"
|
|
check "Build" "${{ needs.build.result }}"
|
|
"""
|
|
|
|
# Guard-shaped text in a comment or log line asserts nothing. The only executed
|
|
# test in these fixtures still clears everything but `failure`.
|
|
DECOY_COMMENT_BODY = UNSAFE_HELPER_BODY.replace(
|
|
""" check() {""",
|
|
""" check() {
|
|
# if [[ "$2" != "success" && "$2" != "skipped" ]]; then exit 1; fi""",
|
|
)
|
|
|
|
DECOY_ECHO_BODY = UNSAFE_HELPER_BODY.replace(
|
|
""" exit 1""",
|
|
""" echo 'if [[ "$2" != "success" && "$2" != "skipped" ]]; then exit 1; fi'
|
|
exit 1""",
|
|
)
|
|
|
|
NON_FAILING_ALLOWLIST_BODY = """
|
|
check() {
|
|
if [[ "$2" != "success" && "$2" != "skipped" ]]; then
|
|
echo "dependency did not succeed"
|
|
fi
|
|
}
|
|
check "Changes" "${{ needs.changes.result }}"
|
|
check "Build" "${{ needs.build.result }}"
|
|
"""
|
|
|
|
INVERTED_ALLOWLIST_BODY = """
|
|
check() {
|
|
if [[ "$2" == "success" ]]; then
|
|
exit 1
|
|
fi
|
|
}
|
|
check "Changes" "${{ needs.changes.result }}"
|
|
check "Build" "${{ needs.build.result }}"
|
|
"""
|
|
|
|
# Results that are only ever logged reach no comparison at all, which has to fail
|
|
# closed: the absence of a test is not evidence of a safe one.
|
|
LOGGED_ONLY_BODY = """
|
|
echo "changes: ${{ needs.changes.result }}"
|
|
echo "build: ${{ needs.build.result }}"
|
|
"""
|
|
|
|
BUILD_CALL = """ check "Build" "${{ needs.build.result }}\""""
|
|
|
|
# Helper calls whose trailing comment or label contains `()`. Discarding those lines
|
|
# loses the argument binding, which reports a gate that does allowlist its results.
|
|
COMMENTED_CALL_BODY = HELPER_BODY.replace(BUILD_CALL, f"{BUILD_CALL} # see check() above")
|
|
PAREN_LABEL_CALL_BODY = HELPER_BODY.replace(BUILD_CALL, """ check "Build ()" "${{ needs.build.result }}\"""")
|
|
|
|
# A job wired into `needs:` that the gate never tests. Judging only the results the
|
|
# body mentions would approve the two assertions that are here and say nothing
|
|
# about the dependency whose assertion was forgotten.
|
|
UNTESTED_DEPENDENCY_GATE = (
|
|
_gate(SAFE_BODY)
|
|
.replace(
|
|
" build:",
|
|
" lint:\n timeout-minutes: 5\n steps:\n - run: echo lint\n build:",
|
|
)
|
|
.replace("needs: [changes, build]", "needs: [changes, build, lint]")
|
|
)
|
|
|
|
# The env-loop form maps results into env and loops over the variable names.
|
|
ENV_LOOP_GATE = """
|
|
name: ci-thing
|
|
on: pull_request
|
|
jobs:
|
|
build:
|
|
timeout-minutes: 5
|
|
steps:
|
|
- run: echo build
|
|
thing_tests:
|
|
name: Thing Tests Pass
|
|
needs: [build]
|
|
timeout-minutes: 5
|
|
if: always()
|
|
steps:
|
|
- name: Check outcomes
|
|
env:
|
|
BUILD: ${{ needs.build.result }}
|
|
run: |
|
|
for var in BUILD; do
|
|
val="${!var}"
|
|
if [[ "$val" != "success" && "$val" != "skipped" ]]; then
|
|
exit 1
|
|
fi
|
|
done
|
|
"""
|
|
|
|
CROSS_STEP_ENV_GATE = """
|
|
name: ci-thing
|
|
on: pull_request
|
|
jobs:
|
|
build:
|
|
timeout-minutes: 5
|
|
steps:
|
|
- run: echo build
|
|
thing_tests:
|
|
name: Thing Tests Pass
|
|
needs: [build]
|
|
timeout-minutes: 5
|
|
if: always()
|
|
steps:
|
|
- name: Log outcome
|
|
env:
|
|
RESULT: ${{ needs.build.result }}
|
|
run: echo "$RESULT"
|
|
- name: Check unrelated value
|
|
env:
|
|
RESULT: success
|
|
run: |
|
|
if [[ "$RESULT" != "success" && "$RESULT" != "skipped" ]]; then
|
|
exit 1
|
|
fi
|
|
"""
|
|
|
|
|
|
# A gate whose display name doesn't end in "Pass", so only structural detection finds it.
|
|
def _off_convention_gate(marker: str = "", condition: str = "always()") -> str:
|
|
yaml_ = _gate(MIXED_BODY, condition=condition).replace("Thing Tests Pass", "Thing decision")
|
|
if marker:
|
|
yaml_ = yaml_.replace(" thing_tests:", f" # {marker}\n thing_tests:")
|
|
return yaml_
|
|
|
|
|
|
def _off_convention_env_gate() -> str:
|
|
return ENV_LOOP_GATE.replace("Thing Tests Pass", "Thing decision").replace(
|
|
'if [[ "$val" != "success" && "$val" != "skipped" ]]; then',
|
|
'if [[ "$val" == "failure" ]]; then',
|
|
)
|
|
|
|
|
|
def _nested_allow_marker_gate() -> str:
|
|
return _off_convention_gate().replace(
|
|
" changes:\n",
|
|
""" changes:
|
|
env:
|
|
# insightscli-lint: not-a-required-gate - nested keys cannot exempt a job
|
|
thing_tests:
|
|
""",
|
|
)
|
|
|
|
|
|
class TestRequiredGateCheck:
|
|
@pytest.mark.parametrize(
|
|
"content",
|
|
[
|
|
_gate(SAFE_BODY),
|
|
_gate(SAFE_BODY, condition="${{ always() }}"),
|
|
_gate(HELPER_BODY),
|
|
_gate(LOCAL_ALIAS_HELPER_BODY),
|
|
_gate(COMMENTED_CALL_BODY),
|
|
_gate(PAREN_LABEL_CALL_BODY),
|
|
ENV_LOOP_GATE,
|
|
],
|
|
ids=[
|
|
"inline-allowlist",
|
|
"wrapped-always",
|
|
"shared-helper",
|
|
"helper-via-local",
|
|
"helper-call-with-trailing-comment",
|
|
"helper-call-with-parens-in-label",
|
|
"env-block-loop",
|
|
],
|
|
)
|
|
def test_passes_when_every_dependency_is_allowlisted(self, tmp_path: Path, content: str) -> None:
|
|
_write(tmp_path, "ci-thing.yml", content)
|
|
assert RequiredGateCheck().run(_read_all(tmp_path)).issues == []
|
|
|
|
@pytest.mark.parametrize(
|
|
"body,expected_deps",
|
|
[(MIXED_BODY, ["changes"]), (TWO_BAD_BODY, ["affected", "changes"])],
|
|
ids=["one-denylisted-among-allowlisted", "two-denylisted-among-allowlisted"],
|
|
)
|
|
def test_flags_exactly_the_denylisted_dependencies(
|
|
self, tmp_path: Path, body: str, expected_deps: list[str]
|
|
) -> None:
|
|
_write(tmp_path, "ci-thing.yml", _gate(body))
|
|
issues = RequiredGateCheck().run(_read_all(tmp_path)).issues
|
|
assert sorted(i.message.split("'")[1] for i in issues) == expected_deps
|
|
|
|
@pytest.mark.parametrize(
|
|
"condition",
|
|
["${{ !cancelled() }}", "${{ always() && false }}", "${{ !always() }}"],
|
|
ids=["cancelled-condition", "conditional-always", "negated-always"],
|
|
)
|
|
def test_flags_gate_that_can_skip_itself(self, tmp_path: Path, condition: str) -> None:
|
|
_write(tmp_path, "ci-thing.yml", _gate(SAFE_BODY, condition=condition))
|
|
issues = RequiredGateCheck().run(_read_all(tmp_path)).issues
|
|
assert len(issues) == 1
|
|
assert "always()" in issues[0].message
|
|
|
|
# Every fixture can exit zero for a cancelled dependency despite mentioning
|
|
# the expected statuses or guard shape.
|
|
@pytest.mark.parametrize(
|
|
"body",
|
|
[
|
|
UNSAFE_HELPER_BODY,
|
|
DECOY_COMMENT_BODY,
|
|
DECOY_ECHO_BODY,
|
|
NON_FAILING_ALLOWLIST_BODY,
|
|
INVERTED_ALLOWLIST_BODY,
|
|
LOGGED_ONLY_BODY,
|
|
],
|
|
ids=[
|
|
"failure-only-helper",
|
|
"allowlist-guard-in-comment",
|
|
"allowlist-guard-in-echo",
|
|
"non-failing-allowlist",
|
|
"inverted-allowlist",
|
|
"results-only-logged",
|
|
],
|
|
)
|
|
def test_flags_gate_whose_results_reach_no_fail_closed_guard(self, tmp_path: Path, body: str) -> None:
|
|
_write(tmp_path, "ci-thing.yml", _gate(body))
|
|
issues = RequiredGateCheck().run(_read_all(tmp_path)).issues
|
|
assert sorted(i.message.split("'")[1] for i in issues) == ["build", "changes"]
|
|
assert all("fail-closed guard" in i.message for i in issues)
|
|
|
|
def test_flags_dependency_declared_in_needs_but_never_tested(self, tmp_path: Path) -> None:
|
|
_write(tmp_path, "ci-thing.yml", UNTESTED_DEPENDENCY_GATE)
|
|
issues = RequiredGateCheck().run(_read_all(tmp_path)).issues
|
|
assert [i.message.split("'")[1] for i in issues] == ["lint"]
|
|
assert "never reaches" in issues[0].message
|
|
|
|
def test_ignores_non_gate_jobs(self, tmp_path: Path) -> None:
|
|
# Worker jobs *should* use !cancelled() so they stop when superseded;
|
|
# only the collate gate is held to always().
|
|
_write(
|
|
tmp_path,
|
|
"ci-thing.yml",
|
|
"""
|
|
name: ci-thing
|
|
on: pull_request
|
|
jobs:
|
|
shards:
|
|
timeout-minutes: 5
|
|
if: ${{ !cancelled() }}
|
|
steps:
|
|
- run: echo test
|
|
""",
|
|
)
|
|
assert RequiredGateCheck().run(_read_all(tmp_path)).issues == []
|
|
|
|
# A gate named off-convention is still a gate, so detection can't key on the
|
|
# name alone.
|
|
def test_finds_gate_not_named_pass(self, tmp_path: Path) -> None:
|
|
_write(tmp_path, "ci-thing.yml", _off_convention_gate())
|
|
issues = RequiredGateCheck().run(_read_all(tmp_path)).issues
|
|
assert [i.message.split("'")[1] for i in issues] == ["changes"]
|
|
|
|
def test_finds_off_convention_gate_without_always(self, tmp_path: Path) -> None:
|
|
_write(tmp_path, "ci-thing.yml", _off_convention_gate(condition="${{ !cancelled() }}"))
|
|
issues = RequiredGateCheck().run(_read_all(tmp_path)).issues
|
|
assert any("unconditional `if: always()`" in issue.message for issue in issues)
|
|
assert [issue.message.split("'")[1] for issue in issues if "dependency" in issue.message] == ["changes"]
|
|
|
|
def test_finds_env_routed_gate_not_named_pass(self, tmp_path: Path) -> None:
|
|
_write(tmp_path, "ci-thing.yml", _off_convention_env_gate())
|
|
issues = RequiredGateCheck().run(_read_all(tmp_path)).issues
|
|
assert [issue.message.split("'")[1] for issue in issues] == ["build"]
|
|
|
|
def test_does_not_trace_step_env_into_another_step(self, tmp_path: Path) -> None:
|
|
_write(tmp_path, "ci-thing.yml", CROSS_STEP_ENV_GATE)
|
|
issues = RequiredGateCheck().run(_read_all(tmp_path)).issues
|
|
assert [issue.message.split("'")[1] for issue in issues] == ["build"]
|
|
|
|
@pytest.mark.parametrize(
|
|
"marker,exempted",
|
|
[
|
|
("insightscli-lint: not-a-required-gate", False),
|
|
("insightscli-lint: not-a-required-gate — decides a side effect, emits no check", True),
|
|
],
|
|
ids=["without-reason", "with-reason"],
|
|
)
|
|
def test_allow_marker_needs_a_reason_to_exempt(self, tmp_path: Path, marker: str, exempted: bool) -> None:
|
|
_write(tmp_path, "ci-thing.yml", _off_convention_gate(marker))
|
|
assert (RequiredGateCheck().run(_read_all(tmp_path)).issues == []) is exempted
|
|
|
|
def test_nested_allow_marker_does_not_exempt_job(self, tmp_path: Path) -> None:
|
|
_write(tmp_path, "ci-thing.yml", _nested_allow_marker_gate())
|
|
issues = RequiredGateCheck().run(_read_all(tmp_path)).issues
|
|
assert [issue.message.split("'")[1] for issue in issues] == ["changes"]
|
|
|
|
|
|
class TestLiveTreeSmoke:
|
|
"""Smoke test against the live ``.github/workflows/`` tree.
|
|
|
|
Asserts that parsing and every check execute without raising — does NOT
|
|
assert pass/fail of individual rules. This keeps the test stable as
|
|
workflows evolve while still catching crashes that fixtures miss.
|
|
"""
|
|
|
|
def test_live_tree_parses_and_checks_run(self) -> None:
|
|
from insightscli.manifest import REPO_ROOT
|
|
|
|
workflows_dir = REPO_ROOT / ".github" / "workflows"
|
|
if not workflows_dir.exists():
|
|
pytest.skip("no .github/workflows directory in this checkout")
|
|
workflows = list(read_workflows(workflows_dir))
|
|
for check in CHECKS:
|
|
assert isinstance(check.run(workflows), CheckResult)
|