Dagger
Search

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.2

Entrypoint

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
NameTypeDefault ValueDescription
sourceDirectory !-

repo root — the directory holding .toolchain-pins

checksString !-

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 count LINES (grep -cF counts lines, not hits) {kind:“absent”, file, regex, what} — POSIX ERE matches no line literal and regex may carry {pin_key} placeholders, which are substituted from .toolchain-pins. An undeclared key is a HARD failure, exactly as the original’s pin() exits 1 on one.

pinsFileString !".toolchain-pins"

path to the pins file, relative to source

annotateBoolean !false

emit ::error:: annotations (pass true from GitHub Actions)

Example
dagger -m github.com/wildbitca/daggerverse/guards@030c8167d01e0d6edc2f2e4a81aaa543fdc0a3b8 call \
 toolchain-pins --source DIR_PATH --checks string --pins-file string --annotate boolean
func (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
NameTypeDefault ValueDescription
sourceDirectory !-

repo root — the directory holding the pins file

repoString !-

this repo’s owner/name slug (pacha-app is wildbitca/pacha; there is no wildbitca/pacha-app)

tokenSecret !-

a token that can read the SIBLINGS’ pins file

refString !"main"

the sibling ref to read — main, the only long-lived branch

pinsFileString !".toolchain-pins"

path to the pins file, relative to source

cacheBustString !""

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.

annotateBoolean !false

emit ::error:: annotations (pass true from GitHub Actions)

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 boolean
func (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, 1515 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
NameTypeDefault ValueDescription
sourceDirectory !-

repo root

workflowPathString !".github/workflows/pipeline.yml"

the workflow to read, relative to source

modulePathString !".github/dagger"

the Dagger module to probe, relative to source

pairingsString !""

JSON array of CONDITIONAL rules — see pairings below. “” means this repo declares none.

daggerVersionString !""

the CLI to install in the probe container. LEAVE IT EMPTY: it is then read from the module’s own dagger.json engineVersion, which is the version this module is actually run with. Pass one only to deliberately probe with a different CLI than the pipeline uses.

tokenSecret -

a token that can read the private daggerverse repo. The nested probe session has no git credentials of its own — without this, a consumer with a private dependency never loads its module and NOTHING is verified. Omit it only for a module with no private dependencies; if one is needed and missing, the guard fails and says so.

timeoutSecondsInteger !300

deadline for the probe phase. This step carried timeout-minutes: 5 in YAML, and fusing steps into one dagger call would have deleted it and left only the 150-minute job ceiling (contract invariant 5). Measured 2026-09-05 against pacha-app: 84 flags over 7 calls, 1m50s. The error names what expired.

annotateBoolean !false

emit ::error:: annotations (pass true from GitHub Actions)

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 boolean
func (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
NameTypeDefault ValueDescription
sourceDirectory !-

repo root

filePathString !".github/dagger/src/index.ts"

the TypeScript file holding the embedded bash

marksString !""

JSON array of substrings identifying the literals to check. Default is pacha-app’s; a repo with a different script names its own.

spanString !""

JSON array of exactly two substrings that live on the FIRST and LAST line of the big script. Both must land in ONE literal.

esbuildVersionString !"0.25.9"

pinned esbuild used for the TypeScript parse

annotateBoolean !false

emit ::error:: annotations (pass true from GitHub Actions)

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 boolean
func (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)
}