Skip to content

fix(pins): walk the action graph instead of glancing at it - #179

Merged
rldyourmnd merged 1 commit into
mainfrom
feat/action-closure
Aug 15, 2026
Merged

fix(pins): walk the action graph instead of glancing at it#179
rldyourmnd merged 1 commit into
mainfrom
feat/action-closure

Conversation

@rldyourmnd

Copy link
Copy Markdown
Contributor

Fixes what maintenance.yml filed automatically in #177 on its first successful run.

Three gaps in a check that claimed a graph property

1. It read YAML with a regular expression.

NESTED_USES = re.compile(r"^\s*uses:\s*(?P<ref>[^\s#]+)", re.MULTILINE)

uses: had to be the first non-whitespace token on the line, so the ordinary
composite form was invisible:

- uses: owner/action@v4      ->  []          # missed
- name: nested
  uses: owner/action@v4      ->  ['...@v4']  # seen

The sibling expression in the same file, for this repository's own workflows,
handles (?:-\s*)?. One group of difference.

2. It did not recurse. A nested reference that was itself a SHA ended the walk.
workflow -> A@sha -> B@sha -> C@tag passed.

3. It could not tell throttling from a refusal. ~43 sequential API calls with no
pacing hit a secondary rate limit; the 403 was reported as
unreachable: HTTP Error 403: Forbidden, which reads as a broken third party rather
than as this check asking too fast.

What it does now

References are read from the parsed document at any nesting — which covers
composite actions, reusable workflows, jobs.<id>.uses, and both step spellings
without encoding any of them, and cannot be defeated by key order.

The walk is breadth-first with a visited set and bounded in depth (6) and nodes
(400), so a cycle terminates and a hostile graph cannot run without end. Rate limits
are retried with backoff and named as rate limits. A missing definition stays a
finding about the pin; an unreadable one is a finding about the run rather than
silence.

Proof

Eleven self-tests run the walk over in-memory graphs — no network, so they assert
every run rather than whenever GitHub is reachable: both step forms, depth three, a
cycle, a reusable workflow, jobs.<id>.uses, a missing definition, an unreadable one,
an undigested image, and a local reference that must not be chased.

Mutation Result
nested refs read by regex again (misses - uses:) RED (caught)
recursion removed (depth 2+ unchecked) RED (caught)
an unreadable definition is swallowed RED (caught)
undigested docker image accepted RED (caught)
missing definition ignored RED (caught)
local reference chased as external RED (caught)

One self-test caught a bug in its own fixture before it caught anything else: the
fake fetcher keyed on repository alone, conflating an action with a reusable workflow
in the same repository.

The real closure now walks clean at every depth (~39s for 43 roots).

Scope decision, stated rather than silently taken

The plan also called for materialising the closure into the tree and checking it
in the blocking tier. I did not do that, deliberately: a network-derived file that
core validates would go red on every Dependabot pin bump, since Dependabot cannot
regenerate it. That trades a real, recurring barrier for marginal gain now that the
advisory lane actually reports (#172, #176). Filed separately so it stays a decision
rather than an omission.

Verification

  • validate_all — all tiers OK with GH_TOKEN; self-tests pass without one
  • actionlint — clean

check_transitive_action_pins claimed every reference reachable from a pinned
action is itself immutable, and established none of it.

It read YAML with a regular expression anchored on `uses:` as the first token
of a line, so the ordinary composite form `- uses: owner/action@ref` matched
nothing -- while the sibling expression for this repository's own workflows
handled `(?:-\s*)?`. One group of difference.

It did not recurse. A nested reference that was itself a SHA ended the walk,
so depth two and beyond were never looked at.

And it could not tell throttling from a refusal: ~43 sequential API calls with
no pacing hit a secondary rate limit, and the 403 was reported as
"unreachable" -- which reads as a broken third party rather than as this check
asking too fast. maintenance.yml filed exactly that on its first run (#177).

References are now read from the parsed document at any nesting, covering
composite actions, reusable workflows, `jobs.<id>.uses` and both step
spellings without encoding any of them. The walk is breadth-first with a
visited set and bounded in depth and nodes, so a cycle terminates. Rate limits
are retried with backoff and named as rate limits; an unreadable definition is
a finding about the run rather than silence.

Eleven self-tests exercise the walk over in-memory graphs with no network.
Six mutations were each caught, including reverting to the regex and removing
recursion. The real closure is clean at every depth.
@github-actions github-actions Bot added ci Continuous integration and removed ci Continuous integration labels Aug 15, 2026
@rldyourmnd
rldyourmnd merged commit b994c21 into main Aug 15, 2026
29 checks passed
@rldyourmnd
rldyourmnd deleted the feat/action-closure branch August 15, 2026 00:04
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.

1 participant