Skip to content

Commit 9ed7611

Browse files
feat(cicd_rules): duplicate_cron_schedule (#362) (#406)
Detect workflows whose on.schedule carries redundant cron triggers: 2+ entries on the same day-of-week field, or a daily `* * *` entry strictly covering a day-specific entry at the same HH:MM. Adds scan_/check_ functions, facade defdelegate, tests, and changelog entries. Verified at source (Elixir 1.14 locally): mix-format clean, isolated compile with zero warnings, and real-module oracles from hypatia#331 (tests.yml subset, verify-proofs.yml three-Monday, security-policy.yml negative). Closes #362 https://claude.ai/code/session_01J8oLNn6MjKDRRUF65e2jLf
1 parent af8a772 commit 9ed7611

5 files changed

Lines changed: 209 additions & 0 deletions

File tree

CHANGELOG.adoc

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,23 @@ https://semver.org/[Semantic Versioning].
1212

1313
=== Added
1414

15+
==== `CicdRules` rule `duplicate_cron_schedule` (2026-05-30, #362)
16+
17+
Flags workflows whose `on.schedule` carries redundant `cron:` triggers: two
18+
or more entries on the same day-of-week field (e.g. three Monday crons
19+
inherited from a workflow consolidation), or a daily `* * *` entry that
20+
strictly covers a day-specific entry at the same `HH:MM`. Root-fixed
21+
estate-wide in `hypatia#331` (`tests.yml`, `verify-proofs.yml`); distinct
22+
intentional triggers (different times, or different `if:` gates on the same
23+
day) are advisory only and never auto-removed.
24+
25+
Surfaced through the facade as
26+
`Hypatia.Rules.scan_duplicate_cron_schedules/1`; the pure predicate
27+
`CicdRules.check_duplicate_cron_schedules/2` is covered by
28+
`test/rules/cicd_rules_duplicate_cron_test.exs` for both sensitivity
29+
(same-day-of-week + daily-subset oracles) and specificity (different-time
30+
non-subset, single cron, commented-out lines).
31+
1532
==== `CicdRules` rule `scorecard_wrapper_missing_job_permissions` (2026-05-30, #390)
1633

1734
New forward-detection rule for a silent-CI-failure class: a

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ this project aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0
2020

2121
### Added
2222

23+
- feat(rules): CicdRules `duplicate_cron_schedule` — flag workflows with redundant cron entries on the same day-of-week / daily-subset (#362)
2324
- feat(rules): CicdRules `scorecard_wrapper_missing_job_permissions` — flag scorecard.yml wrappers that call the standards reusable but omit `security-events: write` on the calling job (#390)
2425
- feat(rules): AffineScript hand-port pitfalls — HANDLE-as-fn-name + OCaml float ops (#332)
2526
- feat(rules): wire 4 new rule modules through the facade (#326)

lib/rules/cicd_rules.ex

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -930,6 +930,116 @@ defmodule Hypatia.Rules.CicdRules do
930930
rel == @scorecard_wrapper_path or String.ends_with?(rel, "/" <> @scorecard_wrapper_path)
931931
end
932932

933+
# ---------------------------------------------------------------------------
934+
# Duplicate cron schedules (#362)
935+
# ---------------------------------------------------------------------------
936+
#
937+
# A workflow whose `on.schedule` lists multiple `cron:` entries that fire on
938+
# the same day-of-week is usually a consolidation artefact (merged-in source
939+
# workflows each carrying their own trigger): wasted runner minutes and log
940+
# noise. Two shapes are flagged:
941+
#
942+
# 1. >= 2 entries sharing the same day-of-week field (e.g. three `* * 1`
943+
# Monday crons inherited from pre-consolidation workflows).
944+
# 2. A daily entry (`* * *`) and a day-specific entry at the same HH:MM —
945+
# the specific entry is a strict subset of the daily one.
946+
#
947+
# Root-fixed estate-wide in hypatia#331 (tests.yml, verify-proofs.yml).
948+
# Distinct intentional triggers (different HH:MM, or different `if:` gates on
949+
# the same day) are advisory under shape (1) and never auto-removed.
950+
951+
@doc """
952+
Scan `repo_path`'s workflow files for redundant `cron:` schedules (#362).
953+
954+
Reads each `.github/workflows/*.{yml,yaml}` (root or nested monorepo copy)
955+
and returns the findings from `check_duplicate_cron_schedules/2` for each.
956+
957+
Returns `[%{rule:, severity:, file:, reason:, fix:, crons:}]`.
958+
"""
959+
def scan_duplicate_cron_schedules(repo_path) do
960+
Path.wildcard("#{repo_path}/**/*", match_dot: true)
961+
|> Enum.reject(&File.dir?/1)
962+
|> Enum.map(&Path.relative_to(&1, repo_path))
963+
|> Enum.filter(&workflow_file?/1)
964+
|> Enum.flat_map(fn rel ->
965+
case File.read(Path.join(repo_path, rel)) do
966+
{:ok, content} -> check_duplicate_cron_schedules(rel, content)
967+
{:error, _} -> []
968+
end
969+
end)
970+
end
971+
972+
@doc """
973+
Pure predicate behind `scan_duplicate_cron_schedules/1`: parse the `cron:`
974+
list entries in workflow `content` and return findings for `path`. Lines
975+
that are not 5-field cron expressions (and commented-out `# - cron:` lines)
976+
are ignored.
977+
"""
978+
def check_duplicate_cron_schedules(path, content) do
979+
crons = parse_cron_entries(content)
980+
981+
same_dow =
982+
crons
983+
|> Enum.group_by(& &1.dow)
984+
|> Enum.filter(fn {_dow, list} -> length(list) >= 2 end)
985+
|> Enum.map(fn {dow, list} ->
986+
raws = Enum.map(list, & &1.raw)
987+
988+
%{
989+
rule: :duplicate_cron_schedule,
990+
severity: :medium,
991+
file: path,
992+
reason:
993+
"#{length(list)} cron entries fire on the same day-of-week (#{dow_label(dow)}): #{Enum.join(raws, ", ")} — likely a consolidation artefact.",
994+
fix:
995+
"Collapse to a single cron unless the triggers serve distinct purposes (e.g. different `if:` gates); otherwise drop the redundant entries.",
996+
crons: raws
997+
}
998+
end)
999+
1000+
daily = Enum.filter(crons, &(&1.dow == "*"))
1001+
specific = Enum.reject(crons, &(&1.dow == "*"))
1002+
1003+
subsets =
1004+
for d <- daily, s <- specific, d.min == s.min and d.hour == s.hour do
1005+
%{
1006+
rule: :duplicate_cron_schedule,
1007+
severity: :medium,
1008+
file: path,
1009+
reason:
1010+
"cron `#{s.raw}` is a strict subset of the daily cron `#{d.raw}` (same HH:MM); the daily trigger already covers that day.",
1011+
fix:
1012+
"Drop the day-specific entry `#{s.raw}` — the daily `#{d.raw}` already runs at that time.",
1013+
crons: [d.raw, s.raw]
1014+
}
1015+
end
1016+
1017+
same_dow ++ subsets
1018+
end
1019+
1020+
defp workflow_file?(rel) do
1021+
String.contains?(rel, ".github/workflows/") and
1022+
(String.ends_with?(rel, ".yml") or String.ends_with?(rel, ".yaml"))
1023+
end
1024+
1025+
defp parse_cron_entries(content) do
1026+
~r/^\s*-\s*cron:\s*["']?([\d*,\/\- ]+?)["']?\s*(?:#[^\n]*)?$/m
1027+
|> Regex.scan(content)
1028+
|> Enum.map(fn [_, expr] -> String.trim(expr) end)
1029+
|> Enum.map(&parse_one_cron/1)
1030+
|> Enum.reject(&is_nil/1)
1031+
end
1032+
1033+
defp parse_one_cron(expr) do
1034+
case String.split(expr, " ", trim: true) do
1035+
[min, hour, _dom, _mon, dow] -> %{raw: expr, min: min, hour: hour, dow: dow}
1036+
_ -> nil
1037+
end
1038+
end
1039+
1040+
defp dow_label("*"), do: "every day"
1041+
defp dow_label(dow), do: "day-of-week #{dow}"
1042+
9331043
# ---------------------------------------------------------------------------
9341044
# CI/CD Waste Detection
9351045
# ---------------------------------------------------------------------------

lib/rules/rules.ex

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -428,6 +428,12 @@ defmodule Hypatia.Rules do
428428
"""
429429
defdelegate scan_scorecard_wrapper_permissions(repo_path, opts \\ []), to: CicdRules
430430

431+
@doc """
432+
Scan workflows for redundant `cron:` schedules firing on the same
433+
day-of-week (#362).
434+
"""
435+
defdelegate scan_duplicate_cron_schedules(repo_path), to: CicdRules
436+
431437
@doc """
432438
Run baseline-health checks (BH001-BH007): missing required_status_checks
433439
on main, deferred-migration TODOs in dep manifests, persistent >24h red
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# SPDX-License-Identifier: MPL-2.0
2+
3+
defmodule Hypatia.Rules.CicdRules.DuplicateCronTest do
4+
use ExUnit.Case, async: true
5+
6+
alias Hypatia.Rules.CicdRules
7+
8+
# #362 — multiple `cron:` entries firing on the same day-of-week are a
9+
# consolidation-artefact waste pattern. Two shapes: same exact day-of-week
10+
# field (>= 2 entries), and a daily `* * *` entry strictly covering a
11+
# day-specific entry at the same HH:MM. Oracles from hypatia#331.
12+
13+
@three_monday "on:\n schedule:\n - cron: 0 5 * * 1\n - cron: 30 4 * * 1\n - cron: 0 4 * * 1\n"
14+
@daily_plus_monday "on:\n schedule:\n - cron: \"0 3 * * *\"\n - cron: \"0 3 * * 1\"\n"
15+
@daily_plus_other_time "on:\n schedule:\n - cron: 0 2 * * *\n - cron: 0 3 * * 1\n"
16+
@single "on:\n schedule:\n - cron: '0 3 * * *'\n"
17+
@commented "on:\n schedule:\n - cron: 0 4 * * 1\n # - cron: 0 5 * * 1\n"
18+
19+
defp wf(dir, name, body) do
20+
path = Path.join([dir, ".github/workflows", name])
21+
File.mkdir_p!(Path.dirname(path))
22+
File.write!(path, body)
23+
end
24+
25+
setup do
26+
dir = Path.join(System.tmp_dir!(), "hyp-cron-#{:erlang.unique_integer([:positive])}")
27+
File.mkdir_p!(dir)
28+
on_exit(fn -> File.rm_rf!(dir) end)
29+
{:ok, dir: dir}
30+
end
31+
32+
describe "check_duplicate_cron_schedules/2 — sensitivity" do
33+
test "three crons on the same day-of-week (verify-proofs.yml oracle)" do
34+
assert [finding] =
35+
CicdRules.check_duplicate_cron_schedules("verify-proofs.yml", @three_monday)
36+
37+
assert finding.rule == :duplicate_cron_schedule
38+
assert finding.severity == :medium
39+
assert length(finding.crons) == 3
40+
end
41+
42+
test "daily strictly covers a same-time day-specific entry (tests.yml oracle)" do
43+
assert [finding] = CicdRules.check_duplicate_cron_schedules("tests.yml", @daily_plus_monday)
44+
assert finding.rule == :duplicate_cron_schedule
45+
assert finding.reason =~ "subset"
46+
end
47+
end
48+
49+
describe "check_duplicate_cron_schedules/2 — specificity" do
50+
test "daily + different-time day-specific is NOT a subset (security-policy.yml)" do
51+
assert CicdRules.check_duplicate_cron_schedules(
52+
"security-policy.yml",
53+
@daily_plus_other_time
54+
) ==
55+
[]
56+
end
57+
58+
test "a single cron is never flagged" do
59+
assert CicdRules.check_duplicate_cron_schedules("x.yml", @single) == []
60+
end
61+
62+
test "commented-out cron lines are ignored" do
63+
assert CicdRules.check_duplicate_cron_schedules("x.yml", @commented) == []
64+
end
65+
end
66+
67+
describe "scan_duplicate_cron_schedules/1" do
68+
test "walks workflow files and reports only the offending one", %{dir: dir} do
69+
wf(dir, "clean.yml", @single)
70+
wf(dir, "dirty.yml", @three_monday)
71+
assert [finding] = CicdRules.scan_duplicate_cron_schedules(dir)
72+
assert finding.file == ".github/workflows/dirty.yml"
73+
end
74+
end
75+
end

0 commit comments

Comments
 (0)