Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,18 @@ https://semver.org/[Semantic Versioning].

=== Added

==== `WorkflowAudit` rule `WF021` concurrency-missing-readonly (2026-05-30, #365)

Flags a read-only check workflow (runs on `pull_request`/`push`, with a
`permissions:` block carrying no `: write` scope) that lacks a top-level
`concurrency:` block — rapid-push PRs then queue redundant runs. The
read-only gate plus a publisher/mutator skip-list (release, npm/jsr publish,
Pages deploy, repo mirror/sync, `git push`) keep out workflows where
`cancel-in-progress` would be unsafe. Threaded into `audit/3`; covered in
`test/workflow_audit_test.exs` for sensitivity (PR check missing concurrency)
and specificity (concurrency present, publisher, write-scoped). Verified to
produce zero findings on hypatia's own workflows. Cohort hypatia#333, pattern 6.

==== `CicdRules` rule `duplicate_cron_schedule` (2026-05-30, #362)

Flags workflows whose `on.schedule` carries redundant `cron:` triggers: two
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ this project aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- feat(rules): WorkflowAudit WF021 `concurrency_missing_readonly` — flag read-only PR/push check workflows lacking a `concurrency:` block (#365)
- feat(rules): CicdRules `duplicate_cron_schedule` — flag workflows with redundant cron entries on the same day-of-week / daily-subset (#362)
- feat(rules): AffineScript hand-port pitfalls — HANDLE-as-fn-name + OCaml float ops (#332)
- feat(rules): wire 4 new rule modules through the facade (#326)
Expand Down
60 changes: 59 additions & 1 deletion lib/rules/workflow_audit.ex
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ defmodule Hypatia.Rules.WorkflowAudit do
scorecard_wrapper_missing_perms = check_scorecard_wrapper_missing_job_permissions(workflow_contents)
workflow_linter_self_ref = check_workflow_linter_self_reference(workflow_contents)
codeql_missing_actions = check_codeql_missing_actions_language(workflow_contents)
concurrency_missing = check_concurrency_missing_readonly(workflow_contents)

%{
findings:
Expand All @@ -92,7 +93,7 @@ defmodule Hypatia.Rules.WorkflowAudit do
reusable_caller_context_self_checkout ++ missing_timeouts ++
scorecard_publish_run ++ nonroot_container_eacces ++ orphan_reusable_pins ++
ungated_secret_action ++ scorecard_wrapper_missing_perms ++
workflow_linter_self_ref ++ codeql_missing_actions,
workflow_linter_self_ref ++ codeql_missing_actions ++ concurrency_missing,
missing_count: length(missing),
unpinned_count: length(unpinned),
wrong_pin_count: length(wrong_pins),
Expand All @@ -112,6 +113,7 @@ defmodule Hypatia.Rules.WorkflowAudit do
scorecard_wrapper_missing_perms_count: length(scorecard_wrapper_missing_perms),
workflow_linter_self_ref_count: length(workflow_linter_self_ref),
codeql_missing_actions_count: length(codeql_missing_actions),
concurrency_missing_count: length(concurrency_missing),
workflow_count: length(workflow_files),
standard_coverage: coverage_percentage(workflow_files)
}
Expand Down Expand Up @@ -1418,4 +1420,60 @@ defmodule Hypatia.Rules.WorkflowAudit do
end)
end
end

# ─── WF021: Read-only check workflow missing `concurrency:` ───────────
#
# A workflow that runs on `pull_request`/`push` and is read-only (a
# `permissions:` block with no `: write` scope) wastes runner minutes on
# rapid-push PRs when it lacks a top-level `concurrency:` block with
# `cancel-in-progress`. The read-only condition is the safety gate:
# cancelling an in-flight run is only safe when the workflow neither
# publishes nor mutates. Publishers/mutators (release, npm/jsr publish,
# Pages deploy, repo mirror/sync, `git push`) are skipped.
#
# See hyperpolymath/hypatia#365 (cohort hypatia#333, pattern 6).

@wf021_skip ~r/npm publish|jsr publish|gh-release|softprops|publish_results|peaceiris|deploy-pages|pages-build|mirror|GITLAB_SSH|BITBUCKET_SSH|git push|release:/i

@doc """
WF021: Detect a read-only check workflow (runs on pull_request/push, has a
`permissions:` block with no `: write` scope) that lacks a top-level
`concurrency:` block.

Sensitivity / specificity:
* Specific — the read-only gate (no `: write` anywhere) plus a
publisher/mutator skip-list keep release/publish/Pages/mirror
workflows out, where `cancel-in-progress` would be unsafe.
* Sensitive — fires on any PR/push read-only workflow missing a
top-level `concurrency:` key.
"""
def check_concurrency_missing_readonly(workflow_contents) do
Enum.flat_map(workflow_contents, fn {filename, content} ->
triggers? = Regex.match?(~r/pull_request|^\s+push:/m, content)
no_concurrency? = not Regex.match?(~r/^concurrency:/m, content)

read_only? =
Regex.match?(~r/^permissions:/m, content) and not String.contains?(content, ": write")

if triggers? and no_concurrency? and read_only? and not Regex.match?(@wf021_skip, content) do
[
%{
rule: "WF021",
type: :concurrency_missing_readonly,
file: filename,
severity: :low,
reason:
"read-only check workflow runs on pull_request/push but has no top-level " <>
"`concurrency:` block; rapid-push PRs queue redundant runs. Add " <>
"`concurrency: {group: \"${{ github.workflow }}-${{ github.ref }}\", " <>
"cancel-in-progress: true}`. Skipped for publishers/mutators where " <>
"cancelling is unsafe.",
fix_recipe: :add_workflow_concurrency
}
]
else
[]
end
end)
end
end
64 changes: 64 additions & 0 deletions test/workflow_audit_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -465,4 +465,68 @@ defmodule Hypatia.Rules.WorkflowAuditTest do
assert [] = WorkflowAudit.check_reusable_caller_context_self_checkout(%{"reusable.yml" => wf})
end
end

describe "check_concurrency_missing_readonly/1 (WF021)" do
test "flags a read-only PR/push check workflow with no concurrency" do
wf = """
name: CI
on:
pull_request:
permissions: read-all
jobs:
test:
runs-on: ubuntu-latest
steps:
- run: echo hi
"""

[f] = WorkflowAudit.check_concurrency_missing_readonly(%{"ci.yml" => wf})
assert f.rule == "WF021"
assert f.severity == :low
assert f.file == "ci.yml"
end

test "silent when a top-level concurrency block is present" do
wf = """
name: CI
on:
pull_request:
permissions: read-all
concurrency:
group: g-${{ github.ref }}
cancel-in-progress: true
jobs: {}
"""

assert [] = WorkflowAudit.check_concurrency_missing_readonly(%{"ci.yml" => wf})
end

test "silent for a publisher workflow (cancelling a release is unsafe)" do
wf = """
name: Release
on:
push:
permissions: read-all
jobs:
publish:
steps:
- run: npm publish
"""

assert [] = WorkflowAudit.check_concurrency_missing_readonly(%{"release.yml" => wf})
end

test "silent when the workflow has a write permission scope" do
wf = """
name: Label
on:
pull_request:
permissions:
pull-requests: write
jobs: {}
"""

assert [] = WorkflowAudit.check_concurrency_missing_readonly(%{"labeler.yml" => wf})
end
end
end
Loading