Skip to content

Commit 9387601

Browse files
feat(workflow_audit): WF021 concurrency_missing_readonly (#365) (#408)
WorkflowAudit WF021: flag a read-only check workflow (pull_request/push, permissions block with no write scope) lacking a top-level concurrency: block. Read-only gate + publisher/mutator skip-list keep out workflows where cancel-in-progress is unsafe. Threaded into audit/3; tests + changelog. Verified locally (Elixir 1.14): isolated compile with no new warnings, real compiled module passes 4 test scenarios, and zero findings on hypatia's own workflows (mirror.yml/scorecard.yml correctly excluded). Closes #365 https://claude.ai/code/session_01J8oLNn6MjKDRRUF65e2jLf
1 parent 64aa1a1 commit 9387601

4 files changed

Lines changed: 136 additions & 1 deletion

File tree

CHANGELOG.adoc

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

1313
=== Added
1414

15+
==== `WorkflowAudit` rule `WF021` concurrency-missing-readonly (2026-05-30, #365)
16+
17+
Flags a read-only check workflow (runs on `pull_request`/`push`, with a
18+
`permissions:` block carrying no `: write` scope) that lacks a top-level
19+
`concurrency:` block — rapid-push PRs then queue redundant runs. The
20+
read-only gate plus a publisher/mutator skip-list (release, npm/jsr publish,
21+
Pages deploy, repo mirror/sync, `git push`) keep out workflows where
22+
`cancel-in-progress` would be unsafe. Threaded into `audit/3`; covered in
23+
`test/workflow_audit_test.exs` for sensitivity (PR check missing concurrency)
24+
and specificity (concurrency present, publisher, write-scoped). Verified to
25+
produce zero findings on hypatia's own workflows. Cohort hypatia#333, pattern 6.
26+
1527
==== `CicdRules` rule `duplicate_cron_schedule` (2026-05-30, #362)
1628

1729
Flags workflows whose `on.schedule` carries redundant `cron:` triggers: two

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): WorkflowAudit WF021 `concurrency_missing_readonly` — flag read-only PR/push check workflows lacking a `concurrency:` block (#365)
2324
- feat(rules): CicdRules `duplicate_cron_schedule` — flag workflows with redundant cron entries on the same day-of-week / daily-subset (#362)
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/workflow_audit.ex

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ defmodule Hypatia.Rules.WorkflowAudit do
8383
scorecard_wrapper_missing_perms = check_scorecard_wrapper_missing_job_permissions(workflow_contents)
8484
workflow_linter_self_ref = check_workflow_linter_self_reference(workflow_contents)
8585
codeql_missing_actions = check_codeql_missing_actions_language(workflow_contents)
86+
concurrency_missing = check_concurrency_missing_readonly(workflow_contents)
8687

8788
%{
8889
findings:
@@ -92,7 +93,7 @@ defmodule Hypatia.Rules.WorkflowAudit do
9293
reusable_caller_context_self_checkout ++ missing_timeouts ++
9394
scorecard_publish_run ++ nonroot_container_eacces ++ orphan_reusable_pins ++
9495
ungated_secret_action ++ scorecard_wrapper_missing_perms ++
95-
workflow_linter_self_ref ++ codeql_missing_actions,
96+
workflow_linter_self_ref ++ codeql_missing_actions ++ concurrency_missing,
9697
missing_count: length(missing),
9798
unpinned_count: length(unpinned),
9899
wrong_pin_count: length(wrong_pins),
@@ -112,6 +113,7 @@ defmodule Hypatia.Rules.WorkflowAudit do
112113
scorecard_wrapper_missing_perms_count: length(scorecard_wrapper_missing_perms),
113114
workflow_linter_self_ref_count: length(workflow_linter_self_ref),
114115
codeql_missing_actions_count: length(codeql_missing_actions),
116+
concurrency_missing_count: length(concurrency_missing),
115117
workflow_count: length(workflow_files),
116118
standard_coverage: coverage_percentage(workflow_files)
117119
}
@@ -1418,4 +1420,60 @@ defmodule Hypatia.Rules.WorkflowAudit do
14181420
end)
14191421
end
14201422
end
1423+
1424+
# ─── WF021: Read-only check workflow missing `concurrency:` ───────────
1425+
#
1426+
# A workflow that runs on `pull_request`/`push` and is read-only (a
1427+
# `permissions:` block with no `: write` scope) wastes runner minutes on
1428+
# rapid-push PRs when it lacks a top-level `concurrency:` block with
1429+
# `cancel-in-progress`. The read-only condition is the safety gate:
1430+
# cancelling an in-flight run is only safe when the workflow neither
1431+
# publishes nor mutates. Publishers/mutators (release, npm/jsr publish,
1432+
# Pages deploy, repo mirror/sync, `git push`) are skipped.
1433+
#
1434+
# See hyperpolymath/hypatia#365 (cohort hypatia#333, pattern 6).
1435+
1436+
@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
1437+
1438+
@doc """
1439+
WF021: Detect a read-only check workflow (runs on pull_request/push, has a
1440+
`permissions:` block with no `: write` scope) that lacks a top-level
1441+
`concurrency:` block.
1442+
1443+
Sensitivity / specificity:
1444+
* Specific — the read-only gate (no `: write` anywhere) plus a
1445+
publisher/mutator skip-list keep release/publish/Pages/mirror
1446+
workflows out, where `cancel-in-progress` would be unsafe.
1447+
* Sensitive — fires on any PR/push read-only workflow missing a
1448+
top-level `concurrency:` key.
1449+
"""
1450+
def check_concurrency_missing_readonly(workflow_contents) do
1451+
Enum.flat_map(workflow_contents, fn {filename, content} ->
1452+
triggers? = Regex.match?(~r/pull_request|^\s+push:/m, content)
1453+
no_concurrency? = not Regex.match?(~r/^concurrency:/m, content)
1454+
1455+
read_only? =
1456+
Regex.match?(~r/^permissions:/m, content) and not String.contains?(content, ": write")
1457+
1458+
if triggers? and no_concurrency? and read_only? and not Regex.match?(@wf021_skip, content) do
1459+
[
1460+
%{
1461+
rule: "WF021",
1462+
type: :concurrency_missing_readonly,
1463+
file: filename,
1464+
severity: :low,
1465+
reason:
1466+
"read-only check workflow runs on pull_request/push but has no top-level " <>
1467+
"`concurrency:` block; rapid-push PRs queue redundant runs. Add " <>
1468+
"`concurrency: {group: \"${{ github.workflow }}-${{ github.ref }}\", " <>
1469+
"cancel-in-progress: true}`. Skipped for publishers/mutators where " <>
1470+
"cancelling is unsafe.",
1471+
fix_recipe: :add_workflow_concurrency
1472+
}
1473+
]
1474+
else
1475+
[]
1476+
end
1477+
end)
1478+
end
14211479
end

test/workflow_audit_test.exs

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -465,4 +465,68 @@ defmodule Hypatia.Rules.WorkflowAuditTest do
465465
assert [] = WorkflowAudit.check_reusable_caller_context_self_checkout(%{"reusable.yml" => wf})
466466
end
467467
end
468+
469+
describe "check_concurrency_missing_readonly/1 (WF021)" do
470+
test "flags a read-only PR/push check workflow with no concurrency" do
471+
wf = """
472+
name: CI
473+
on:
474+
pull_request:
475+
permissions: read-all
476+
jobs:
477+
test:
478+
runs-on: ubuntu-latest
479+
steps:
480+
- run: echo hi
481+
"""
482+
483+
[f] = WorkflowAudit.check_concurrency_missing_readonly(%{"ci.yml" => wf})
484+
assert f.rule == "WF021"
485+
assert f.severity == :low
486+
assert f.file == "ci.yml"
487+
end
488+
489+
test "silent when a top-level concurrency block is present" do
490+
wf = """
491+
name: CI
492+
on:
493+
pull_request:
494+
permissions: read-all
495+
concurrency:
496+
group: g-${{ github.ref }}
497+
cancel-in-progress: true
498+
jobs: {}
499+
"""
500+
501+
assert [] = WorkflowAudit.check_concurrency_missing_readonly(%{"ci.yml" => wf})
502+
end
503+
504+
test "silent for a publisher workflow (cancelling a release is unsafe)" do
505+
wf = """
506+
name: Release
507+
on:
508+
push:
509+
permissions: read-all
510+
jobs:
511+
publish:
512+
steps:
513+
- run: npm publish
514+
"""
515+
516+
assert [] = WorkflowAudit.check_concurrency_missing_readonly(%{"release.yml" => wf})
517+
end
518+
519+
test "silent when the workflow has a write permission scope" do
520+
wf = """
521+
name: Label
522+
on:
523+
pull_request:
524+
permissions:
525+
pull-requests: write
526+
jobs: {}
527+
"""
528+
529+
assert [] = WorkflowAudit.check_concurrency_missing_readonly(%{"labeler.yml" => wf})
530+
end
531+
end
468532
end

0 commit comments

Comments
 (0)