guards
── WHY THIS MODULE EXISTS ──────────────────────────────────────────────────Four scripts, three repos, and only a convention keeping them equal:
`check-shared-pins.sh` (257 lines) was byte-identical in `pacha/app`,
`pacha/services` and `pacha/web` — verified 2026-09-05, md5
f17371d3d80689412da67952d5ca8fec in all three. `check-toolchain.sh` was NOT:
app's copy is 217 lines and the other three are 93-96, and some of that gap is
a per-repo fact while the rest is drift nobody chose (§ "WHAT DIVERGED" below).
Every one of these guards encodes a failure that cost a red run to learn. The
comments carry those measurements, because a guard whose reason is lost is a
guard the next person deletes.
── THE RULE THESE WERE PORTED UNDER ────────────────────────────────────────
Written from the ORIGINALS' documented behaviour, and verified by BREAKING a
fixture and watching each one go red — never by reading the new code back.
A guard written from its own implementation cannot fail it; that mistake has
been made four times in this codebase in one night, and once it shipped a
guard that passed while the thing it guarded was broken.
── WHAT DIVERGED BETWEEN THE FOUR `check-toolchain.sh` COPIES ──────────────
PER-REPO FACTS (data, and they cross the boundary as the `checks` argument):
· which files hold a literal, which literal, and how many copies of it.
app asserts 15 things across a Dagger module, a workflow, `codemagic.yaml`
and `dagger.json`; ops asserts 3, one of them a Crossplane field.
· which keys exist at all — ops has `kustomize`/`gitleaks`/
`otel_reconciler_node` and no `node`, because it builds nothing.
· the Darwin-only block in app: it is the only repo with an iOS lane.
DRIFT (mechanism that one copy grew and the others never got, and which this
module therefore gives to ALL of them):
· `forbid` — only app has it. It exists because a count check cannot see a
FIFTH Codemagic lane added with `flutter: stable`: the four pinned copies
are still there and the count still passes. Nothing about that reasoning
is app-specific.
· the `::error title=…::` annotation on failure — only app has it, added
after run 33837097643 (2026-09-04): a job's logs cannot be downloaded
while the run is still going, and that run included a ~110-minute `ci`,
so a failure here left "Process completed with exit code 1" as the only
clue for nearly two hours. Annotations are readable the moment the step
ends. That is true in every repo.
· Spanish vs English messages, ✅ vs OK — cosmetic drift. English wins; it
is the house rule for every written artefact.
NOT PORTED: `--local`. See the note on `toolchainPins`.
── WHAT IS DELIBERATELY NOT HERE ───────────────────────────────────────────
`check-engine-parity.sh`. It exists ONLY because the Slack engine is vendored
three times and the sole thing keeping the copies equal was an md5 written in
`.engine-parity` — which on 2026-09-04 caught a real divergence months old.
This repository removes the vendoring: the engine is `slack/`, consumed as a
pinned dependency, and `dagger.json` records the version AND the resolved
commit. There is no second copy left to compare against, so porting it would
ship a guard whose subject does not exist — which is a guard that can only
ever report green. Contract §2.3 says the same: "the pin replaces
`.engine-parity` and `scripts/check-engine-parity.sh` entirely".
── COMPLEX ARGUMENTS TRAVEL AS JSON STRINGS ────────────────────────────────
Dagger's TypeScript SDK exposes structural types poorly across a module
boundary and a shape mismatch fails at call time with an unreadable error, so
every non-scalar crosses as a JSON string, documented on the function, parsed
and validated on entry. `Directory` and `Secret` cross natively.
── AND THE NAMING RULE ─────────────────────────────────────────────────────
No public parameter here has a digit followed by a letter. `e2eThing` comes
back from Dagger's kebab↔camel round-trip as `e2EThing` and makes the WHOLE
function uninvokable — invisible to `tsc`, and re-measured while writing
`daggerFlags` (see the transcript in that function's header). It is the exact
trap that guard exists to catch, so this module must not walk into it.
Installation
dagger install github.com/wildbitca/daggerverse/guards@v0.1.2Entrypoint
Return Type
Guards Example
dagger -m github.com/wildbitca/daggerverse/guards@030c8167d01e0d6edc2f2e4a81aaa543fdc0a3b8 call \
func (m *MyModule) Example() *dagger.Guards {
return dag.
Guards()
}@function
def example() -> dagger.Guards:
return (
dag.guards()
)@func()
example(): Guards {
return dag
.guards()
}Types
Guards 🔗
toolchainPins() 🔗
Assert that every version literal in the repo still equals the number
written in .toolchain-pins.
── WHY THIS IS A GREP AND NOT A GENERATOR ────────────────────────────── Deliberately. Templating the pins into the files at build time would make the Dagger module and the workflow depend on a generator, and both of them have to stay readable and runnable on their own. So the version lives literally where the tool is installed, and this compares the literals against one declared number.
── WHAT IT COST NOT TO HAVE IT ─────────────────────────────────────────
2026-08-20: the same pipeline compiled one pubspec.lock with Flutter
3.44.0 (Android, Dagger image) and 3.44.9 (iOS, macOS job) while the
developer machine ran 3.44.7; Maestro was cli-2.6.1 in CI and 2.7.0
locally; sops 3.13.3 vs 3.13.2; and the Node install resolved
latest-v24.x AT BUILD TIME — a floating pin dressed up as a pinned one.
Every one of those was invisible until someone went looking.
── WHY --local IS NOT PORTED ─────────────────────────────────────────
--local answers “do the binaries on THIS MACHINE match the pins”. Inside
a container the answer is always “the binaries in this image”, which is not
the question and cannot be made into it: the module never sees the host.
Porting it would produce a check that passes on a machine it never looked
at, which is worse than not having one. It stays a host-side script — and
the app repo runs it as the FIRST step of the macOS ios job, which is
precisely where it has to run, since Xcode and CocoaPods live on that host
and nowhere else.
Return Type
String !Arguments
| Name | Type | Default Value | Description |
|---|---|---|---|
| source | Directory ! | - | repo root — the directory holding |
| checks | String ! | - | JSON array of assertions. Each is one of
{kind:“present”, file, literal, what} — literal appears at least once
{kind:“count”, file, literal, count, what}
— literal appears on exactly
|
| pinsFile | String ! | ".toolchain-pins" | path to the pins file, relative to |
| annotate | Boolean ! | false | emit |
Example
dagger -m github.com/wildbitca/daggerverse/guards@030c8167d01e0d6edc2f2e4a81aaa543fdc0a3b8 call \
toolchain-pins --source DIR_PATH --checks string --pins-file string --annotate booleanfunc (m *MyModule) Example(ctx context.Context, source *dagger.Directory, checks string, pinsFile string, annotate bool) string {
return dag.
Guards().
Toolchainpins(ctx, source, checks, pinsFile, annotate)
}@function
async def example(source: dagger.Directory, checks: str, pinsfile: str, annotate: bool) -> str:
return await (
dag.guards()
.toolchainpins(source, checks, pinsfile, annotate)
)@func()
async example(source: Directory, checks: string, pinsFile: string, annotate: boolean): Promise<string> {
return dag
.guards()
.toolchainPins(source, checks, pinsFile, annotate)
}sharedPins() 🔗
Assert that the pins this repo shares with its siblings still equal THEIRS.
── WHY THIS IS SEPARATE FROM toolchainPins ───────────────────────────
toolchainPins only ever compares a repo against itself, and so does the
guard in every other repo. Each one can be perfectly self-consistent,
disagree with the others about a shared number, and keep every CI green
while doing it. That is the one skew nobody could see. This is the other
half.
── WHY IT READS THE API AND NOT A CHECKOUT ───────────────────────────── Comparing against a sibling directory answers “do these two directories agree”, which is not the question: a checkout can be on another branch, or stale, and then the guard passes GREEN against a number nobody is running. Naming the ref answers “do these two REPOS agree”, and it means the same thing on a laptop as in CI.
── WHY BLOCK AGAINST BLOCK, AND SYMMETRIC ──────────────────────────────
An earlier version took the sibling’s value by grepping its whole file for
key=. That passed while a sibling had DROPPED the key from its shared
block and kept pinning it locally: their contract had stopped saying the
number was shared, and this check went on saying the two agreed. So the
comparison is block to block, and it is symmetric — a key one side calls
shared and the other does not is reported, in both directions.
── IT FAILS ON WHAT IT COULD NOT READ ──────────────────────────────────
No token, an unreadable sibling, a sibling with no matching block, a
delimiter carrying no membership, an empty block, a padded marker: all
failures, never skips. The workflow decides WHETHER to run it (the if: on
the step); once it runs, silence is not an outcome.
── THE ONE THING THAT COULD NOT BE PORTED AS-IS ────────────────────────
The original works out which repo it is from $GITHUB_REPOSITORY or the
origin remote. A module function has neither: it sees a Directory, not
a checkout with a .git, and not the runner’s environment. So repo is a
parameter. It is the only per-repo fact this guard takes, it is the same
one the original derived, and an empty value fails loudly rather than
comparing nothing.
Return Type
String !Arguments
| Name | Type | Default Value | Description |
|---|---|---|---|
| source | Directory ! | - | repo root — the directory holding the pins file |
| repo | String ! | - | this repo’s |
| token | Secret ! | - | a token that can read the SIBLINGS’ pins file |
| ref | String ! | "main" | the sibling ref to read — |
| pinsFile | String ! | ".toolchain-pins" | path to the pins file, relative to |
| cacheBust | String ! | "" | MUST vary per run. Empty means “generate one”, never “reuse”: without it Dagger serves the previous exec from cache and the guard compares against whatever the siblings said the first time it ever ran. A network read that is cached is a network read that did not happen. |
| annotate | Boolean ! | false | emit |
Example
dagger -m github.com/wildbitca/daggerverse/guards@030c8167d01e0d6edc2f2e4a81aaa543fdc0a3b8 call \
shared-pins --source DIR_PATH --repo string --token env:MYSECRET --ref string --pins-file string --cache-bust string --annotate booleanfunc (m *MyModule) Example(ctx context.Context, source *dagger.Directory, repo string, token *dagger.Secret, ref string, pinsFile string, cacheBust string, annotate bool) string {
return dag.
Guards().
Sharedpins(ctx, source, repo, token, ref, pinsFile, cacheBust, annotate)
}@function
async def example(source: dagger.Directory, repo: str, token: dagger.Secret, ref: str, pinsfile: str, cachebust: str, annotate: bool) -> str:
return await (
dag.guards()
.sharedpins(source, repo, token, ref, pinsfile, cachebust, annotate)
)@func()
async example(source: Directory, repo: string, token: Secret, ref: string, pinsFile: string, cacheBust: string, annotate: boolean): Promise<string> {
return dag
.guards()
.sharedPins(source, repo, token, ref, pinsFile, cacheBust, annotate)
}daggerFlags() 🔗
Probe the Dagger CLI to prove every --flag the workflow passes EXISTS.
── WHY IT EXISTS ───────────────────────────────────────────────────────
2026-09-04, first run of the unified pipeline, on main:
Error: set call inputs: find arg "e2EParallelism"
The workflow passed --e2e-parallelism=5 and the module declared
e2eParallelism. What breaks is Dagger’s kebab↔camel round-trip when a
digit is followed by a letter: the flag is accepted, converted to
e2EParallelism, and then no argument by that name exists.
keystoreBase64 survives because its digit is last.
The bug is not the point. What did NOT see it is: that run passed tsc
--strict, the embedded-bash guard, 15⁄15 toolchain pins, and the YAML
parsed. None of them lie — both sides are correct SEPARATELY. Nobody was
looking at the seam, which is where the YAML names something the module has
to have.
── WHY IT PROBES THE CLI AND NEVER PARSES --help ─────────────────────
The first version of this guard parsed dagger call <fn> --help. That is
WRONG, and it was measured — re-measured here on 2026-09-05 against a probe
module on dagger v0.21.9:
--help prints CLI accepts CLI accepts
that spelling the workflow's
--keystore-base-64 yes yes (--keystore-base64)
--e-2-e-parallelism no no
So --help renders a PRESENTATION name that is not necessarily the one
pflag accepts, in both directions. A guard built on it gives a false
positive on --keystore-base64, which has been green for months, and sends
someone to “fix” something that works. The only source of truth is asking
the CLI whether it accepts THAT string.
unknown flag: X the flag does not exist
find arg "Y" the flag exists but matches no argument — the
digit trap, and the exact failure above
anything else the flag exists (it died on what the rest lacks)
── THE BLIND SPOT, MEASURED, AND WHY IT IS NOT CLOSED HERE ─────────────
The probe passes ONE flag and nothing else, so on a function with REQUIRED
arguments the CLI stops at required flag(s) "…" not set, which is stage 2
of 4, and never reaches set call inputs at stage 3 where find arg is
raised. Measured 2026-09-05 on a probe module:
call optional --e2e-parallelism=env:X -> find arg "e2EParallelism" (caught)
call ci --e2e-parallelism=env:X -> required flag(s) … not set (green)
call ci --e2e-parallelism=env:X --source=. --platform=x
-> find arg "e2EParallelism" (caught)
So satisfying the required flags WOULD close it — and that is exactly what
must not be done. Measured on the same module in the same session: with the
required flags satisfied and no unresolvable input left, the CLI proceeds
past stage 3 and EXECUTES the function. A guard that ran ci would boot the
emulators it exists to save. Anything that fails earlier (a missing path, an
unset env: secret) fails at stage 3a, before set call inputs, and masks
the very error being looked for. The blind spot is intrinsic to a safe
probe; it is documented rather than papered over, and unknown flag
detection is unaffected because pflag raises that at stage 1.
── THE SHADOWED-ACCESSOR TRAP, AND THE SWEEP THAT BOUNDED IT ───────────
Third member of the same family, measured 2026-09-05. A @func() named
secret shadows the CLI’s Address.secret, which is the path every
--flag=env:X and --flag=file:X goes through, and so it makes EVERY
Secret argument in that module uninvokable — including in functions that
have nothing to do with the offending one. tsc is clean and
dagger functions lists everything normally. The plain-progress output
shows the whole mechanism:
Address.secret: Secret!
┆ Probefix.secret(name: ""): String! <- the module's func, not the core one
Address.secret ERROR
! … cannot set field of type dagql.ObjectResult[…core.Secret] with dagql.String
Detected on the assign error and NOT on the function’s name. A name-based
check would flag legitimate code and would still miss the next member of
this family; keying on the failure means anything that shadows an accessor
is caught whatever it is called. It also costs nothing: the probe already
passes env:<unset> to every flag, so this is a third classification of
output that was already being collected.
WHICH NAMES ARE ACTUALLY POISONED — swept, not assumed. Ten accessors exist
on Address (container, directory, file, gitRef, gitRepository,
id, secret, service, socket, value). One fixture module per name,
each probed with a RESOLVABLE value for Secret, Directory, File and
Container so a value error could not mask a shadowing:
secret POISONS every Secret argument
id module does not load at all ("resolving module") — loud,
not silent, and therefore not this class of bug
the other 8 no effect measured on any of the four argument types
So secret is the only silent one today. The list is worth more than the
single case, and the detection does not depend on it.
── WHAT IT DOES NOT CHECK, ON PURPOSE ──────────────────────────────────
Only the flags of the MODULE’s function. In a chain like
dagger call end-to-end-artifacts --source=. … export --path=e2e-artifacts,
export is a core function on the returned Directory, not ours. The check
stops at the first token that is not a flag and the output SAYS so, so
nobody reads the green as “the whole line was checked”. Values are not
checked either (that env:FOO exists, that the file is there) — the run
says that, and asserting it here would be a guard that believes more than it
measures.
Return Type
String !Arguments
| Name | Type | Default Value | Description |
|---|---|---|---|
| source | Directory ! | - | repo root |
| workflowPath | String ! | ".github/workflows/pipeline.yml" | the workflow to read, relative to |
| modulePath | String ! | ".github/dagger" | the Dagger module to probe, relative to |
| pairings | String ! | "" | JSON array of CONDITIONAL rules — see |
| daggerVersion | String ! | "" | the CLI to install in the probe container. LEAVE IT EMPTY:
it is then read from the module’s own |
| token | Secret | - | a token that can read the private |
| timeoutSeconds | Integer ! | 300 | deadline for the probe phase. This step carried
|
| annotate | Boolean ! | false | emit |
Example
dagger -m github.com/wildbitca/daggerverse/guards@030c8167d01e0d6edc2f2e4a81aaa543fdc0a3b8 call \
dagger-flags --source DIR_PATH --workflow-path string --module-path string --pairings string --dagger-version string --timeout-seconds integer --annotate booleanfunc (m *MyModule) Example(ctx context.Context, source *dagger.Directory, workflowPath string, modulePath string, pairings string, daggerVersion string, timeoutSeconds int, annotate bool) string {
return dag.
Guards().
Daggerflags(ctx, source, workflowPath, modulePath, pairings, daggerVersion, timeoutSeconds, annotate)
}@function
async def example(source: dagger.Directory, workflowpath: str, modulepath: str, pairings: str, daggerversion: str, timeoutseconds: int, annotate: bool) -> str:
return await (
dag.guards()
.daggerflags(source, workflowpath, modulepath, pairings, daggerversion, timeoutseconds, annotate)
)@func()
async example(source: Directory, workflowPath: string, modulePath: string, pairings: string, daggerVersion: string, timeoutSeconds: number, annotate: boolean): Promise<string> {
return dag
.guards()
.daggerFlags(source, workflowPath, modulePath, pairings, daggerVersion, timeoutSeconds, annotate)
}daggerBash() 🔗
Syntax-check the bash that lives inside a TypeScript template literal, and check that the file containing it is still TypeScript.
── WHY ─────────────────────────────────────────────────────────────────
The Dagger module embeds a ~580-line bash script in a JS template literal,
and the template literal EATS BACKSLASHES: find … \\( -name x \\) reaches
bash as find … ( -name x ) and dies with “syntax error near unexpected
token”. esbuild does not catch it, because \( is perfectly valid
JavaScript — it is the wrong syntax to be checking. This checks the right
one: it reproduces JS’s escape handling, substitutes the ${…}
interpolations, and runs bash -n.
Cost of not having it: a full CI run — three emulators, ~20 minutes — that fails before the first flow, on a script that “compiled” fine.
── AND THE HOLE THAT THE SPAN CHECK CLOSES (run 33827086621) ───────────
An UNESCAPED BACKTICK inside the literal — written in a bash comment, where
it looks harmless — CLOSES the TS string. esbuild dies with “Expected ‘;’”,
the whole module stops compiling, and dagger call ci blows up in 53s.
The bash check did not see it, and not by an oversight: the extraction
regex looks for literals between UNESCAPED backticks, so when one is loose
it simply finds OTHER literals — shorter, and perfectly valid bash. It
printed a tick over a file esbuild rejects. The usual pattern: a guard
answering a different question from the one that matters — here “is this
valid bash?” instead of “is it still in one piece?”. So the two span
marks, which live on the script’s first and last line, must appear in ONE
literal.
── AND WHY IT ALSO PARSES THE TYPESCRIPT ───────────────────────────────
The symmetry is real and bit twice in one night, in two shapes of the same
hole — constructs bash understands that break the TS string:
an unescaped backtick in a comment -> closes the template literal
“${2:-}” unescaped -> invalid TS interpolation
(Expected “}” but found “:”)
Both passed bash -n without blinking, because the extracted bash IS
correct. What breaks is the file containing it.
── ONE DELIBERATE STRENGTHENING ──────────────────────────────────────── The original WARNS and returns 0 when esbuild is not installed, so on a machine without it the TypeScript half silently did not run. In here the container carries a pinned esbuild, so that path cannot happen; if the install fails, the exec fails and so does the guard. A guard that cannot check what it is checking fails.
Return Type
String !Arguments
| Name | Type | Default Value | Description |
|---|---|---|---|
| source | Directory ! | - | repo root |
| filePath | String ! | ".github/dagger/src/index.ts" | the TypeScript file holding the embedded bash |
| marks | String ! | "" | JSON array of substrings identifying the literals to check. Default is pacha-app’s; a repo with a different script names its own. |
| span | String ! | "" | JSON array of exactly two substrings that live on the FIRST and LAST line of the big script. Both must land in ONE literal. |
| esbuildVersion | String ! | "0.25.9" | pinned esbuild used for the TypeScript parse |
| annotate | Boolean ! | false | emit |
Example
dagger -m github.com/wildbitca/daggerverse/guards@030c8167d01e0d6edc2f2e4a81aaa543fdc0a3b8 call \
dagger-bash --source DIR_PATH --file-path string --marks string --span string --esbuild-version string --annotate booleanfunc (m *MyModule) Example(ctx context.Context, source *dagger.Directory, filePath string, marks string, span string, esbuildVersion string, annotate bool) string {
return dag.
Guards().
Daggerbash(ctx, source, filePath, marks, span, esbuildVersion, annotate)
}@function
async def example(source: dagger.Directory, filepath: str, marks: str, span: str, esbuildversion: str, annotate: bool) -> str:
return await (
dag.guards()
.daggerbash(source, filepath, marks, span, esbuildversion, annotate)
)@func()
async example(source: Directory, filePath: string, marks: string, span: string, esbuildVersion: string, annotate: boolean): Promise<string> {
return dag
.guards()
.daggerBash(source, filePath, marks, span, esbuildVersion, annotate)
}