Skip to content

Validate every uses: value, and type the callsite (#59, #57) - #60

Merged
russwyte merged 1 commit into
mainfrom
fix/validate-action-pin-file
Aug 6, 2026
Merged

Validate every uses: value, and type the callsite (#59, #57)#60
russwyte merged 1 commit into
mainfrom
fix/validate-action-pin-file

Conversation

@russwyte

@russwyte russwyte commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Closes #59. Closes #57. Both are half of one gap, and auditing them turned up a case neither names.

What was wrong

#59: ActionPinFile.parse dropped every line its regex did not match, then folded over Field.values from ActionPins.Bootstrap. A typo'd key (setup-jav:) or a stray indent silently yielded the jar-baked pin, and loadOption returned Some because the file existed and was readable, so there was no diagnostic anywhere. A pin a consumer deliberately held back reverted on the next zipxWorkflowGenerate.

#57: ActionPins fields were String, so a pin could not reach Step.uses's inline check and a consumer had to handle a failure that could not happen.

The case neither issue names. checkout: actions/checkout (no @sha) passes the old regex, and Planner built the step by direct case-class construction:

Step(uses = Some(config.actions.checkout), `with` = checkoutWith)

That bypasses ActionRef entirely, and Render.checked only runs the uses-vs-run shape check. So an unpinned action rendered into ci.yml, and annotateUses stamped # v7.0.1 beside it, describing a pin that was not there. Confirmed on main before writing any of this, so the fix is against observed behaviour.

A probe over the real regex found the failures split into three classes, not the one #59 describes. ActionRef.make alone closes only part of it: an unknown key (setupJava2:, or the wrong-case Checkout:) still vanishes, and checkout: evil/malware@<sha> is a structurally valid ref pointing at the wrong action.

What changed

Types, so the bad value is unrepresentable. Step.uses, Job.uses, WorkflowCall.uses and all seven ActionPins fields are ActionRef. The literal pins keep their compile-time check for free. zio-blocks 0.0.51 derives a neotype as its underlying primitive, so Option[ActionRef] encodes exactly as Option[String] did and no rendered byte moves. StepBuilder.usesRef is now the in-zipx path; usesMake stays for genuinely raw strings.

parse returns Either, refusing four ways, one per probed class:

line refused because
setup-jav: actions/setup-java@abc123 not key: ref # version at all
setupJava2: actions/x@abc123 key is not an ActionPins.Field (message lists the legal keys)
checkout: actions/checkout ActionRef rejects an unpinned ref
checkout: evil/malware@abc123 valid ref, but the key names a different action

The last is the one a shape check cannot see; only the key's own Field.prefix can. ZipxPlugin.orFail reports it, so a present-but-unreadable file is a build error naming the line. An absent file still falls back to the jar defaults, which is the documented behaviour.

Three more defects, found by the tests rather than by reading.

  • A pin line with no # vX.Y.Z inherited the base version label, so annotateUses could stamp # v7.0.1 onto a SHA that was not v7.0.1. Same false-assurance shape as the unpinned ref. Found by the render/parse round-trip property.
  • The field-matching predicate used a bare startsWith, which filed actions/cache/restore@v4 under the actions/cache pin and failed to see that an unpinned uses: actions/checkout was the checkout pin at all. Now one shared namesAction (== prefix or prefix + "@") used by both parse and pullFromWorkflow.
  • String.contains and indexOf widen their argument to Any through StringOps, so an assertion comparing an ActionRef against rendered YAML compiles and never matches. One real test failure led to a sweep: Packs.scala and DependencyUpdates.scala each had assertions passing vacuously. Main sources were clean. Recorded in ScalaStewardWorkflowSpec's scaladoc so the next .unwrap is not read as decoration.

Tests

ActionPinFileSpec is rewritten: 10 negative cases grouped by defect class, each asserting isLeft and that the message names the line; 7 acceptances that must not regress (no space after the colon, tab separator, CRLF, no trailing newline, no version label, the committed pin file itself); and a line-number test that puts the bad line on line 6 so it cannot pass on an off-by-default.

Five properties, all sourced from ActionPins.Field.values so a new pin field is covered automatically:

  1. parse(render(pins)) == Right(pins) over generated pins (this is what found the version-label bug)
  2. any valid file with one field replaced by a malformed line is Left (ActionPinFile.parse silently ignores unrecognized lines, so a malformed pin file yields bootstrap pins #59's claim, generalized over which field breaks)
  3. for any two distinct fields, pinning key a to b.prefix + "@" + sha is Left, with the same-field case still parsing (all 49 pairs, not one example)
  4. any identifier not in Field.values is rejected as a key
  5. ActionRef accepts any owner/repo@40-hex, and an @ref alone never suffices (in NamesSpec)

ConsumerStepsSpec is the proof for #57: its throw AssertionError on an unreachable branch is deleted, replaced by Step.usesRef(pins.cache). That was test scope, so it never appeared in the throw grep; its removal is what the issue was filed for.

Verification

  • 543 tests: core 317, workflow 156, docs 52, central 18. Zero failures. (testFull, not test.)
  • zipxWorkflowGenerate and zipxActionsPull both leave .github/ byte-identical (git diff --exit-code). This is the real check that Option[ActionRef] renders as Option[String] did.
  • plugin/scripted zipx/generate-check passes, independent of the unit tests since it asserts literal gate strings.
  • All three pathological lines edited into the real pin file and run through the real plugin, then reverted:
    • setup-jav:.github/zipx/action-pins.yml:6: not a pin, a # comment, or a blank line; expected 'key: owner/action@ref # vX.Y.Z'
    • checkout: actions/checkout:5: invalid uses: value 'actions/checkout': add an @ref (a commit SHA pin); GitHub requires one and an unpinned action is a supply-chain risk
    • checkout: evil/malware@abc123:5: pin 'checkout' must name actions/checkout, but this ref is 'evil/malware@abc123'
  • grep -rn "throw \|makeOrThrow\|orThrow" modules/*/src/main still returns nothing.

Breaking

zipxActions := ActionPins.Defaults.copy(setupSbt = "sbt/setup-sbt@<sha>") becomes setupSbt = ActionRef("sbt/setup-sbt@<sha>"). That is the point: the ref is now checked while build.sbt compiles, and ActionRef.make returns an Either for a ref a build computes. Mechanical, the compiler names every site, pre-1.0. Documented on the Action pins page, which also gains a section on what a malformed pin file reports.

`ActionPinFile.parse` dropped every line its regex did not match, so a typo'd
key yielded the jar-baked pin instead: a pin a consumer had deliberately held
back reverted on the next generate, with no diagnostic anywhere. #57's half was
that `ActionPins` fields were `String`, so a pin could not reach `ActionRef`'s
check and a consumer had to handle a failure that could not happen.

Auditing them turned up a case neither issue named: `checkout: actions/checkout`
passes the old regex, and `Planner` built
`Step(uses = Some(config.actions.checkout))` by direct case-class construction,
which bypasses `ActionRef` entirely. So an unpinned action rendered into
`ci.yml`, and `annotateUses` stamped the version comment beside it, describing a
pin that was not there.

Both halves are closed at the tier that owns them:

- `Step.uses`, `Job.uses`, `WorkflowCall.uses` and all seven `ActionPins` fields
  are `ActionRef`, making an unvalidated ref unrepresentable rather than merely
  rejected. zio-blocks derives a neotype as its underlying primitive, so not one
  rendered byte moves.
- `parse` returns `Either`, refusing four ways: a line that is not
  `key: ref # version`, a key that is not an `ActionPins.Field`, a ref
  `ActionRef` rejects, and a ref that is valid but names a *different* action
  than its key does. Only `Field.prefix` can catch that last one.
- `ZipxPlugin.orFail` reports it, so a present-but-unreadable file is a build
  error naming the line. An absent file still falls back to the jar defaults.

Writing the properties found two more: a pin line with no `# vX.Y.Z` inherited
the base label, letting `annotateUses` stamp `# v7.0.1` onto a SHA that was not
v7.0.1; and the field-matching predicate used a bare `startsWith`, filing
`actions/cache/restore@v4` under the `actions/cache` pin. Grepping the tests for
the shape that hid the first turned up a third: `String.contains` and `indexOf`
widen their argument to `Any` through `StringOps`, so three assertions comparing
an `ActionRef` against rendered YAML compiled and passed vacuously.

`ConsumerStepsSpec` is the proof for #57: its `throw AssertionError` on an
unreachable branch is gone, replaced by `Step.usesRef(pins.cache)`.

Verified: 543 tests across core/workflow/central/docs; `zipxWorkflowGenerate`
and `zipxActionsPull` both leave `.github/` byte-identical; `plugin/scripted
zipx/generate-check` passes; the three pathological pin lines each fail the real
plugin naming the line; and `grep -rn "throw \|makeOrThrow\|orThrow"
modules/*/src/main` still returns nothing.

Closes #59
Closes #57
@russwyte
russwyte merged commit 7068cdf into main Aug 6, 2026
7 checks passed
@russwyte
russwyte deleted the fix/validate-action-pin-file branch August 6, 2026 14:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant