From 991fe2db6bb4c03c35db3d2e7da75de5c31d91b3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 23:29:56 +0000 Subject: [PATCH] feat(workflow_audit): WF021 concurrency_missing_readonly (#365) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detect a read-only check workflow (runs on pull_request/push, permissions block with no `: write` scope) that lacks a top-level `concurrency:` block — rapid-push PRs queue redundant runs. Read-only gate + publisher/mutator skip-list (release, npm/jsr publish, Pages, mirror/sync, git push) keep out workflows where cancel-in-progress is unsafe. Threaded into audit/3. - check_concurrency_missing_readonly/1 + audit/3 wiring (call, findings, count) - test/workflow_audit_test.exs: sensitivity + 3 specificity cases - CHANGELOG md + adoc Verified locally (Elixir 1.14): isolated compile (no new warnings), real compiled module passes all 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 --- CHANGELOG.adoc | 12 +++++++ CHANGELOG.md | 1 + lib/rules/workflow_audit.ex | 60 ++++++++++++++++++++++++++++++++- test/workflow_audit_test.exs | 64 ++++++++++++++++++++++++++++++++++++ 4 files changed, 136 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index f1ce544c..b29b0e1a 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 89456c70..1aa192a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/lib/rules/workflow_audit.ex b/lib/rules/workflow_audit.ex index e780da30..46145d53 100644 --- a/lib/rules/workflow_audit.ex +++ b/lib/rules/workflow_audit.ex @@ -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: @@ -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), @@ -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) } @@ -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 diff --git a/test/workflow_audit_test.exs b/test/workflow_audit_test.exs index 92dff1b5..a2a79c65 100644 --- a/test/workflow_audit_test.exs +++ b/test/workflow_audit_test.exs @@ -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