Skip to content

ci: lint workflow invariants actionlint misses (concurrency, SHA pinning, payload deny-list)#2725

Open
dgageot wants to merge 2 commits intodocker:mainfrom
dgageot:ci/workflow-lint-invariants
Open

ci: lint workflow invariants actionlint misses (concurrency, SHA pinning, payload deny-list)#2725
dgageot wants to merge 2 commits intodocker:mainfrom
dgageot:ci/workflow-lint-invariants

Conversation

@dgageot
Copy link
Copy Markdown
Member

@dgageot dgageot commented May 9, 2026

actionlint is already in CI but it does not validate event-payload
schemas — that's how PR #2645 slipped through (github.event.issue.type
is not a real field for the issues: labeled trigger). This PR adds
a small bash check, run after actionlint in the existing lint job,
that encodes three project-specific invariants:

  1. PR-triggered workflows declare a concurrency: block — prevents
    piled-up runs on rapid pushes;
  2. Third-party uses: references are pinned by 40-char SHA
    defence-in-depth for supply-chain attacks;
  3. Workflows do not reference fields on a post-mortem deny-list
    (currently github.event.issue.type, from PR docs(github): update issue templates and triage workflow #2645). Easy to
    extend in scripts/workflow-lint.sh whenever a new bad pattern
    shows up in review.

What it caught immediately

pr-review-trigger.yml had no concurrency: block — fixed in this
PR by keying the group on the PR number so overlapping runs cancel
per-PR.

Cost

  • 1 step in ci.yml (runs in the existing lint job, no new job).
  • 1 portable bash script (~110 lines, self-tested in a fixture
    .github/workflows/ tree).
  • 0 net additions to the existing CI runtime — actionlint and the
    new script together complete in well under a second.

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.
@dgageot dgageot requested a review from a team as a code owner May 9, 2026 12:40
Copy link
Copy Markdown

@docker-agent docker-agent left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread scripts/workflow-lint.sh
ref="$(printf '%s' "$ref" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+#.*$//; s/[[:space:]]+$//')"

# Skip empty / local / re-usable workflow refs.
case "$ref" in
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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
    ;;
esac

Any 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
    ;;
esac

Or remove the exemption from the comment if docker/ actions are also required to be SHA-pinned (which is arguably the better security posture).

Comment thread scripts/workflow-lint.sh Outdated
"github.event.issue.type"
)
for ref in "${broken_refs[@]}"; do
matches=$(grep -nE -- "$ref" "$WORKFLOWS_DIR"/*.yml "$WORKFLOWS_DIR"/*.yaml 2>/dev/null || true)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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)

Comment thread scripts/workflow-lint.sh Outdated
# 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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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"; then

Or 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"; then

Three 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 ('[', '(', '*', '+', '?').
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants