ci: lint workflow invariants actionlint misses (concurrency, SHA pinning, payload deny-list)#2725
ci: lint workflow invariants actionlint misses (concurrency, SHA pinning, payload deny-list)#2725dgageot wants to merge 2 commits intodocker:mainfrom
Conversation
actionlint is already in CI but its validation stops at workflow syntax / shellcheck — it does not validate event-payload schemas, so 'github.event.issue.type' on an 'issues: labeled' trigger sails through even though that field doesn't exist in the labeled payload (the bug that shipped on PR docker#2645). scripts/workflow-lint.sh encodes three project-specific invariants documented in AGENTS.md § GitHub Actions: 1. PR-triggered workflows declare a 'concurrency:' block. 2. third-party 'uses:' references are pinned by 40-char SHA, not by tag or branch. 3. workflows do not reference fields on the post-mortem deny-list (currently 'github.event.issue.type', from PR docker#2645). The script runs as a step in the existing 'lint' job, after actionlint so failures don't shadow each other. It is also runnable locally: ./scripts/workflow-lint.sh The first run found one existing finding — pr-review-trigger.yml had no concurrency block. Added one keyed on the PR number so overlapping runs get cancelled per-PR.
docker-agent
left a comment
There was a problem hiding this comment.
Assessment: 🟡 NEEDS ATTENTION
Three confirmed issues in the newly-added scripts/workflow-lint.sh — all in logic that defines the invariants the script is supposed to enforce. The CI integration (ci.yml) and the concurrency: addition to pr-review-trigger.yml look correct.
| ref="$(printf '%s' "$ref" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+#.*$//; s/[[:space:]]+$//')" | ||
|
|
||
| # Skip empty / local / re-usable workflow refs. | ||
| case "$ref" in |
There was a problem hiding this comment.
[MEDIUM] docker/ namespace exemption is documented but not implemented
The comment at lines 65–68 explicitly states that the docker/ namespace is exempt from the SHA-pinning check, but the case statement only skips '', './'*, and '../'* — there is no 'docker/'* arm:
case "$ref" in
'' | './'* | '../'*)
continue
;;
esacAny docker/ action referenced without a 40-char SHA (e.g. docker/setup-buildx-action@v3) would be incorrectly flagged despite the documented exemption — or conversely, if the intent is for docker/ actions to also require SHA pinning, the comment is misleading and should be updated. Either way, the code and the comment disagree.
Suggested fix — if docker/ should truly be exempt, add it to the case arm:
case "$ref" in
'' | './'* | '../'* | 'docker/'*)
continue
;;
esacOr remove the exemption from the comment if docker/ actions are also required to be SHA-pinned (which is arguably the better security posture).
| "github.event.issue.type" | ||
| ) | ||
| for ref in "${broken_refs[@]}"; do | ||
| matches=$(grep -nE -- "$ref" "$WORKFLOWS_DIR"/*.yml "$WORKFLOWS_DIR"/*.yaml 2>/dev/null || true) |
There was a problem hiding this comment.
[MEDIUM] Check 3 uses regex grep (-E) but the comment promises literal matching
The comment directly above says "The check is a literal grep", but the implementation uses grep -nE (extended regex) — the dots in github.event.issue.type are regex metacharacters that match any character rather than a literal .:
matches=$(grep -nE -- "$ref" "$WORKFLOWS_DIR"/*.yml ...)While githubXeventYissueZtype is vanishingly unlikely in a YAML file today, this is a latent bug: any future deny-list entry containing [, (, *, +, or ? will either over-match or cause grep to error. The code also doesn't escape the string before passing it as a regex.
Suggested fix — use -F (fixed-string) to match the comment's guarantee:
matches=$(grep -nF -- "$ref" "$WORKFLOWS_DIR"/*.yml "$WORKFLOWS_DIR"/*.yaml 2>/dev/null || true)| # up in a comment, which we accept. | ||
| for f in "$WORKFLOWS_DIR"/*.yml "$WORKFLOWS_DIR"/*.yaml; do | ||
| [ -e "$f" ] || continue | ||
| if grep -qE '^\s*pull_request:' "$f"; then |
There was a problem hiding this comment.
[MEDIUM] Concurrency check misses compact on: [pull_request] array syntax
The pattern ^\s*pull_request: requires a trailing colon, so it only matches the expanded YAML map form. A workflow that declares triggers using the compact sequence syntax is silently skipped:
# These forms are missed by the current check:
on: [pull_request, push]
on: [pull_request]Both syntaxes are valid and commonly used across public GitHub Actions workflows. A new workflow added using this form would pass the lint check with no concurrency: block, defeating the invariant.
Suggested fix — match both forms:
if grep -qE '^\s*pull_request:|on:\s*\[([^]]*,\s*)?pull_request' "$f"; thenOr more simply, match pull_request anywhere in the on: block and accept the low false-positive rate the comment already acknowledges:
if grep -qE '^\s*pull_request[: \[]' "$f"; thenThree fixes from PR docker#2725 review: 1. Concurrency check now matches all three 'on:' forms — mapping ('pull_request:'), compact array ('on: [pull_request, ...]'), and sequence ('- pull_request'). The previous '^\s*pull_request:' regex only caught the mapping form, so a workflow declared with the compact array syntax could slip past the check with no 'concurrency:' block. Switched to '\bpull_request\b' which catches all three without matching 'pull_request_target' (a separate event with different semantics). 2. SHA-pinning comment no longer claims a 'docker/' exemption that doesn't exist in code. The 'case' arm only skips empty / './' / '../' refs, so 'docker/foo@v3' would be (correctly) flagged — the comment was misleading about the actual policy. Updated the comment to match: every third-party ref, including the 'docker/' namespace, must be SHA-pinned. (All 'docker/' actions in this repo already are.) 3. Deny-list check uses 'grep -nF' (fixed-string) instead of 'grep -nE' (extended regex) to match the 'literal grep' the comment promises. The dots in 'github.event.issue.type' were being interpreted as regex 'any character' wildcards, which is harmless today but a latent bug for any future deny-list entry containing regex metacharacters ('[', '(', '*', '+', '?').
actionlint is already in CI but it does not validate event-payload
schemas — that's how PR #2645 slipped through (
github.event.issue.typeis not a real field for the
issues: labeledtrigger). This PR addsa small bash check, run after actionlint in the existing
lintjob,that encodes three project-specific invariants:
concurrency:block — preventspiled-up runs on rapid pushes;
uses:references are pinned by 40-char SHA —defence-in-depth for supply-chain attacks;
(currently
github.event.issue.type, from PR docs(github): update issue templates and triage workflow #2645). Easy toextend in
scripts/workflow-lint.shwhenever a new bad patternshows up in review.
What it caught immediately
pr-review-trigger.ymlhad noconcurrency:block — fixed in thisPR by keying the group on the PR number so overlapping runs cancel
per-PR.
Cost
ci.yml(runs in the existinglintjob, no new job)..github/workflows/tree).new script together complete in well under a second.