Supersedes #46, which asked to move long run: strings into YAML resource files. That route was investigated and rejected (see the comment there): zio-blocks cannot round-trip a block scalar nested in a step sequence, and a steps file relocates the unchecked string rather than removing it, needing an interpolation language the moment it references a module id or an action pin.
Problem
Four layers of the API were stringly typed, and each one had already produced a real defect or a comment apologising for one:
- Shell
run: bodies. s-interpolated bash, so a literal $ in the script has to be written $$, and ZipxCentral carried a warning comment where a type belonged (a $$ that means "escaped dollar" to Scala and "PID" to bash). Planner.affectedScript had a .replace("\n\n", "\n") to clean up after stripMargin.
- Expressions outside
env: and if:. with: values, outputs:, and step env: all took raw ${{ … }} strings, so the planner hand-assembled contains(fromJson(needs.affected.outputs.modules), 'api') as text.
Step validity. Flat and all-optional (fixed by derives Schema and the on-disk mapping), so Step() and Step(run = …, uses = …) both compile and both render YAML GitHub rejects.
- Reusable step groups. Bare
StepContext => List[Step] lambdas: no name for a diagnostic, no way to concatenate two, no way to gate a group at once, no identity to publish.
What landed
A layer per problem, bottom-up, each independently useful:
modules/shell (zipx.shell): a general shell AST. Script / Command / Word / ShTest over neotype-validated primitives. No zipx, GitHub, or zio-blocks dependency, so it is usable standalone. Command is an open trait with rendering as a method, deliberately not an enum: a consumer needing a construct zipx does not model implements it in their own build. The accepted cost is that a match over Command cannot be exhaustive.
Expr in zipx-workflow: the GHA expression AST, with EnvValue and JobCondition delegating to it. Expr.Call over a FunctionName newtype, so an unknown function does not compile, which is what lets the planner's gates be built rather than interpolated.
StepBuilder + render-time Step.validate: validity closed from both ends. Step.run(script) / Step.uses(ref) decide the mutually exclusive pair before any other field is set, and withInput exists only on the uses: builder, so with: on a run: step does not compile. Step.validate catches the other end (a hand-built or decoded step).
Steps in zipx-core: a named, composable bundle that extends (StepContext => List[Step]), so every existing field accepts one with no signature change. ++ concatenates, when(JobCondition) gates the whole bundle, and zipx-core is on Central so an org bundle is an ordinary published value.
Validation is compile-time where it can be
Every DSL type is a neotype newtype, so an invalid value is unconstructible and an invalid literal fails during compilation with the validator's own message. Smart constructors are inline def so they forward a literal into the check, with make-style siblings returning Either[String, A] for genuinely runtime input.
The rules cover what the shell and GitHub actually do, not the convenient subset: no ' inside '…' (it cannot be escaped), no } inside ${…} (it closes the expansion early), no leading tab on a script line (YAML block-scalar indentation must be spaces), ExitCode 0-255 (the shell truncates modulo 256), secret names rejecting the reserved GITHUB_ prefix case-insensitively while still admitting GITHUB_TOKEN, output names rejecting the disabled set-output / save-state, uses: refusing an unpinned owner/repo.
sh"…" splices are Word*, so a bare String splice does not compile: string interpolation is exactly how the untyped hole would come back, and there is deliberately no implicit String => Word.
No thrown exceptions in the DSL
A failure is removed in the strongest way available at each site, in order: make it unrepresentable with a type (Block is a head plus a tail, so an empty if branch is unconstructible; InlineCommand is the subtype whose render is total, so a compound command in a pipeline leg does not compile); failing that, check a literal at compile time; failing that, return Either[String, A] naming the offending value. Throwing is confined to ZipxPlugin.orFail, the one place a zipx failure becomes an sbt build error, because sbt's task contract is to throw.
Escape hatches: allowed, typed, and loud
Script.raw / Expr.Raw / JobCondition.Raw stay, because a consumer who cannot express something must not be blocked. Raw holds List[ScriptLine], so the type guarantees raw content cannot emit YAML GitHub fails to parse (no \r, no C0 controls, no leading tab) and there is no separate lint pass to forget. What it can still produce is broken shell, so the text is reported via Command.rawFragments and zipxWorkflowGenerate logs a warning naming the bundle. A bare lambda reports nothing, which is the honest incentive to use Steps.built.
Acceptance: the generated YAML did not move a byte
Proven three ways, since a migration this wide is only credible if the output is identical:
- Dogfood regenerate leaves
git diff empty across ci.yml, zipx-action-pins-sync.yml and zipx-scala-steward.yml.
plugin/scripted zipx/generate-check passes. Independent, because its assertGraph asserts the literal gate strings (contains(fromJson(needs.affected.outputs.modules), 'api'), !cancelled(), startsWith(github.ref, 'refs/tags/v')) that Expr.Call now builds.
PlannerSpec / RenderSpec / the docs pages pass unmodified. An edit needed there would have meant output moved.
Zero-diff only proves nothing moved, not which script is which, so ScriptRenderSpec supplies the other half: each migrated script pinned against the exact string its pre-migration source produced. examples/monorepo was built against publishLocal to prove the consumer-facing types work outside this repo.
Docs
A Shell and steps page where every example is compiled and its rendered output asserted, including the examples that demonstrate a failure: that an invalid hand-built Step is Left, and that a raw fragment warns and names its bundle while a bare lambda does not. autoImport re-exports the DSL so a build.sbt needs no imports.
Supersedes #46, which asked to move long
run:strings into YAML resource files. That route was investigated and rejected (see the comment there): zio-blocks cannot round-trip a block scalar nested in a step sequence, and a steps file relocates the unchecked string rather than removing it, needing an interpolation language the moment it references a module id or an action pin.Problem
Four layers of the API were stringly typed, and each one had already produced a real defect or a comment apologising for one:
run:bodies. s-interpolated bash, so a literal$in the script has to be written$$, andZipxCentralcarried a warning comment where a type belonged (a$$that means "escaped dollar" to Scala and "PID" to bash).Planner.affectedScripthad a.replace("\n\n", "\n")to clean up afterstripMargin.env:andif:.with:values,outputs:, and stepenv:all took raw${{ … }}strings, so the planner hand-assembledcontains(fromJson(needs.affected.outputs.modules), 'api')as text.Stepvalidity. Flat and all-optional (fixed byderives Schemaand the on-disk mapping), soStep()andStep(run = …, uses = …)both compile and both render YAML GitHub rejects.StepContext => List[Step]lambdas: no name for a diagnostic, no way to concatenate two, no way to gate a group at once, no identity to publish.What landed
A layer per problem, bottom-up, each independently useful:
modules/shell(zipx.shell): a general shell AST.Script/Command/Word/ShTestover neotype-validated primitives. No zipx, GitHub, or zio-blocks dependency, so it is usable standalone.Commandis an open trait with rendering as a method, deliberately not anenum: a consumer needing a construct zipx does not model implements it in their own build. The accepted cost is that a match overCommandcannot be exhaustive.Exprinzipx-workflow: the GHA expression AST, withEnvValueandJobConditiondelegating to it.Expr.Callover aFunctionNamenewtype, so an unknown function does not compile, which is what lets the planner's gates be built rather than interpolated.StepBuilder+ render-timeStep.validate: validity closed from both ends.Step.run(script)/Step.uses(ref)decide the mutually exclusive pair before any other field is set, andwithInputexists only on theuses:builder, sowith:on arun:step does not compile.Step.validatecatches the other end (a hand-built or decoded step).Stepsinzipx-core: a named, composable bundle that extends(StepContext => List[Step]), so every existing field accepts one with no signature change.++concatenates,when(JobCondition)gates the whole bundle, andzipx-coreis on Central so an org bundle is an ordinary published value.Validation is compile-time where it can be
Every DSL type is a neotype newtype, so an invalid value is unconstructible and an invalid literal fails during compilation with the validator's own message. Smart constructors are
inline defso they forward a literal into the check, withmake-style siblings returningEither[String, A]for genuinely runtime input.The rules cover what the shell and GitHub actually do, not the convenient subset: no
'inside'…'(it cannot be escaped), no}inside${…}(it closes the expansion early), no leading tab on a script line (YAML block-scalar indentation must be spaces),ExitCode0-255 (the shell truncates modulo 256), secret names rejecting the reservedGITHUB_prefix case-insensitively while still admittingGITHUB_TOKEN, output names rejecting the disabledset-output/save-state,uses:refusing an unpinnedowner/repo.sh"…"splices areWord*, so a bareStringsplice does not compile: string interpolation is exactly how the untyped hole would come back, and there is deliberately no implicitString => Word.No thrown exceptions in the DSL
A failure is removed in the strongest way available at each site, in order: make it unrepresentable with a type (
Blockis a head plus a tail, so an emptyifbranch is unconstructible;InlineCommandis the subtype whose render is total, so a compound command in a pipeline leg does not compile); failing that, check a literal at compile time; failing that, returnEither[String, A]naming the offending value. Throwing is confined toZipxPlugin.orFail, the one place a zipx failure becomes an sbt build error, because sbt's task contract is to throw.Escape hatches: allowed, typed, and loud
Script.raw/Expr.Raw/JobCondition.Rawstay, because a consumer who cannot express something must not be blocked.RawholdsList[ScriptLine], so the type guarantees raw content cannot emit YAML GitHub fails to parse (no\r, no C0 controls, no leading tab) and there is no separate lint pass to forget. What it can still produce is broken shell, so the text is reported viaCommand.rawFragmentsandzipxWorkflowGeneratelogs a warning naming the bundle. A bare lambda reports nothing, which is the honest incentive to useSteps.built.Acceptance: the generated YAML did not move a byte
Proven three ways, since a migration this wide is only credible if the output is identical:
git diffempty acrossci.yml,zipx-action-pins-sync.ymlandzipx-scala-steward.yml.plugin/scripted zipx/generate-checkpasses. Independent, because itsassertGraphasserts the literal gate strings (contains(fromJson(needs.affected.outputs.modules), 'api'),!cancelled(),startsWith(github.ref, 'refs/tags/v')) thatExpr.Callnow builds.PlannerSpec/RenderSpec/ the docs pages pass unmodified. An edit needed there would have meant output moved.Zero-diff only proves nothing moved, not which script is which, so
ScriptRenderSpecsupplies the other half: each migrated script pinned against the exact string its pre-migration source produced.examples/monorepowas built againstpublishLocalto prove the consumer-facing types work outside this repo.Docs
A
Shell and stepspage where every example is compiled and its rendered output asserted, including the examples that demonstrate a failure: that an invalid hand-builtStepisLeft, and that a raw fragment warns and names its bundle while a bare lambda does not.autoImportre-exports the DSL so abuild.sbtneeds no imports.