- Go 86.2%
- Shell 13.8%
## Intent AI agents writing tests.yaml for consumer repos need guidance on when and how to combine test spec features effectively, beyond the schema reference in the README. ## Key changes New docs/testing-guide.md with 10 best-practice patterns and 5 anti-patterns in What/Why/How triplet format. Includes a section on how AI agents should extend the guide in future. README updated with repo layout entry and cross-reference link. ## Details docs/testing-guide.md: - 10 patterns: max_size, timeouts, entrypoint testing, file permissions, config content assertions, setup vs run, expect_failure, allow patterns, one concern per test, service-based testing - 5 anti-patterns: tool-only testing, broad allow patterns, missing timeouts on services, schema duplication in comments, validation in setup - Extending This Guide section with rules for adding new entries README.md: - Add docs/ to repository layout - Add cross-reference to guide from Test Spec Format section |
||
|---|---|---|
| .forgejo/workflows | ||
| ci | ||
| dagger | ||
| docs | ||
| examples/sample-app | ||
| .gitignore | ||
| dagger.json | ||
| LICENSE | ||
| README.md | ||
| renovate.json | ||
dagger-modules
Shared Dagger CI/CD module and CI templates for git.xarif.de and gitlab.com.
Purpose
- Provides reusable Dagger functions (
BuildImage,PublishImage) as a remote Go module - Provides CI templates (Forgejo reusable workflow + GitLab CI include) so consumer repos need only thin wrappers
- Single source of truth for Dagger CLI installation, image build logic, registry push logic, and the test spec schema
- Consumed via
dagger call -m git.xarif.de/base/dagger-modules.git@main
Repository Layout
.
├── .forgejo/workflows/dagger-call.yml reusable workflow for Forgejo Actions
├── ci/
│ ├── dagger-call.gitlab-ci.yml GitLab CI include template
│ ├── dagger-call.sh shared call wrapper used by both templates
│ └── install-dagger.sh installs Dagger CLI version from dagger.json
├── dagger/
│ ├── main.go BuildImage / PublishImage entry points
│ ├── testing.go test orchestration (services, steps, validation)
│ ├── assert.go inspect / file / output assertions
│ └── types.go test spec YAML structs
├── docs/
│ └── testing-guide.md testing best practices (for AI agents)
├── examples/sample-app/ end-to-end consumer example
├── dagger.json Dagger engine version pin + module config
├── renovate.json Renovate config
└── LICENSE
Dagger Functions
Two entry points: BuildImage (build, optionally test) and PublishImage
(build, optionally test, then push). All build args / build secrets / platform
/ target / tests arguments are optional and apply to both.
BuildImage
Builds a container from a Dockerfile in the given source directory and, optionally, runs a YAML-defined test suite against the freshly-built container before returning it.
# Build only
dagger call -m git.xarif.de/base/dagger-modules.git@main build-image \
--source=./my-app \
--build-arg=VERSION=1.2.3 \
--build-arg=COMMIT=$(git rev-parse HEAD) \
--platform=linux/amd64
# Build and test
dagger call -m git.xarif.de/base/dagger-modules.git@main build-image \
--source=./my-app \
--tests=./my-app/tests.yaml
Parameters:
--source(required): directory containing the Dockerfile--dockerfile(optional, defaultDockerfile): path relative to source root--build-arg(repeatable, optional):KEY=VALUEpairs forwarded as DockerfileARGvalues--build-secret(repeatable, optional):*dagger.Secretvalues mountable asRUN --mount=type=secret,id=<name>inside the Dockerfile--platform(optional): target build platform (e.g.linux/amd64); defaults to engine native--target(optional): named stage in a multi-stage Dockerfile--tests(optional): YAML test specification file (see Test Spec Format). When provided, tests are executed against the built container; any failure aborts the call.--root(optional, default.): root directory of the repository. Mount paths in test specs are resolved relative to this directory, not--source. Defaults to the caller's working directory.
When --tests is omitted, the build is pure (no execution) and returns the
container lazily. When --tests is supplied, tests run eagerly and fail-fast.
PublishImage
Builds and pushes a container image to an OCI registry. Always pushes
latest plus the sanitized --ref-name. When --ref-name matches a SemVer
pattern (e.g. v1.2.3 or 1.2.3), additional floating tags vMAJOR.MINOR
and vMAJOR are auto-derived. If --tests is provided, tests must pass
before any push.
dagger call -m git.xarif.de/base/dagger-modules.git@main publish-image \
--source=./my-app \
--registry=git.xarif.de \
--username=myuser \
--password=env://REGISTRY_TOKEN \
--repository=myorg/myrepo \
--ref-name=v1.2.3 \
--tests=./my-app/tests.yaml
# Pushes: myorg/myrepo:latest, :v1.2.3, :v1.2, :v1
Parameters:
--source(required): directory containing the Dockerfile--registry(required): hostname or URL (protocol stripped automatically)--username(required): registry user--password(required,*dagger.Secret): useenv://VARto pull from an environment variable, orfile://pathto read from disk; never embed plaintext on the CLI--repository(required): image path, e.g.myorg/myrepo--ref-name(required): git ref for tagging (slashes sanitized to dashes; SemVer floating tags derived from cleanvX.Y.Z)--tests(optional): YAML test spec — if provided, tests must pass before push--extra-tag(repeatable, optional): additional ad-hoc tags to push--no-semver-tags(optional, default false): disable automatic floating-tag derivation--dockerfile,--build-arg,--build-secret,--platform,--target: same asBuildImage--root(optional, default.): same asBuildImage— mount paths resolve relative to this directory
Returns the digest of the ref-tagged push. Pre-release SemVer (v1.2.3-rc1)
is not floated, so a release-candidate tag does not move the v1
floating tag.
Test Spec Format
Tests are defined in a YAML file. Each test has a name and one or more of:
inspect (image metadata), steps (command execution / file assertions),
and services (background containers shared across steps). The schema is
strict and validated upfront — malformed specs fail before any container
is started.
For best practices on how to write effective test specs, see the Testing Best Practices Guide.
Steps
Steps are the core execution unit. Each step runs in a container — by default
the built image, or a custom image via with_image. Steps execute
sequentially and each is self-contained with its own env, mounts,
setup, run, files, and output.
tests:
- name: check-binaries
steps:
- run:
- sops --version
- kustomize version
- cmd: my-app --validate /dev/null
expect_failure: true
runaccepts strings (exit 0 expected) or{cmd, expect_failure}structsexpect_failure: trueinverts the expectation: the command must exit non-zerodescription(optional): human-readable label for log outputwith_image(optional): use a custom image instead of the built imageenv: environment variables for this stepmounts: mount files from the repository root (see Mounts)setup: commands run beforerun— output is not validated (no err/warn scan)files: file assertions (see below)output: output validation (see below)
A step must have run and/or files.
Services
Start background services shared across all steps. Services are a YAML map where the key is the hostname. Services are iterated in lexicographic key order (deterministic across runs).
tests:
- name: ssh-login
services:
sshd:
ports: [22]
env:
USER_PASSWORD: testpass
steps:
- with_image: alpine:latest
setup:
- apk add --no-cache openssh-client sshpass
run:
- sshpass -p testpass ssh -o StrictHostKeyChecking=no root@sshd date
With external dependencies:
tests:
- name: api-with-db
services:
db:
with_image: postgres:16-alpine
env: { POSTGRES_PASSWORD: test }
ports: [5432]
readiness:
command: "pg_isready -h db -U postgres"
timeout: 30s
interval: 2s
app:
ports: [8080]
env:
DATABASE_URL: "postgres://postgres:test@db:5432/postgres"
steps:
- with_image: alpine:latest
setup: [apk add --no-cache curl]
run:
- curl -f http://app:8080/health
Service fields:
with_image(optional): external image; omit to use the built imageports: ports to exposeenv: environment variablesmounts: mount files from the repository root into the service containerfiles: assert files inside the service containerreadiness(optional): probe to wait for before running step commandscommand: shell command (exit 0 = ready)timeout(default30s): max wait time; honors test-level timeout cancellationinterval(default2s): time between retries
All steps see all services. Services with readiness probes are checked
before each step's run commands.
Inspect (Image Metadata Assertions)
Verify container image metadata without executing the container. Catches Dockerfile regressions.
tests:
- name: image-metadata
inspect:
user: "999"
workdir: /home/argocd/cmp-server
entrypoint: ["/var/run/argocd/argocd-cmp-server"]
expose: [8080]
labels:
org.opencontainers.image.source: "https://git.xarif.de/docker/myapp"
env:
PATH: "/usr/local/bin:/usr/bin"
max_size: 500MiB
- All fields are optional — only specified fields are checked
envinsideinspectasserts env vars exist with values (does not set them)max_size: checks uncompressed rootfs size. AcceptsB,KB/KiB,MB/MiB,GB/GiB,TB/TiB— all binary (1024^n). The IECiBform is preferred for new specs;KB/MB/GBare kept as binary aliases for backward compatibility- Can combine with
steps
Output Validation
Per-step output validation. All run output (combined stdout+stderr) is
automatically scanned for word-bounded err / error / warn / warning
lines (case-insensitive). Identifiers like terraform or forecast no
longer trigger false matches.
steps:
- run:
- my-command --version
output:
allow:
- "(?i)warning.*deprecat"
expect:
- "my-command v\\d+\\.\\d+"
reject:
- "(?i)fatal|panic"
allow— lines matching any allow pattern are exempt from the err/warn scanexpect— each pattern must match somewhere in the outputreject— if any pattern matches anywhere, the test fails
All patterns are Go regular expressions. setup command output is not scanned.
File Assertions
Verify files or directories exist with expected type, permissions, and content. Can be used in steps (on the step's container) or in services (on the service container). All inputs are passed via environment variables internally, so paths containing special characters are handled safely.
steps:
- files:
- path: /usr/local/sbin/sops
mode: "755"
- path: /root/.ssh
type: directory
mode: "700"
- path: /etc/ssh/sshd_config
type: file
mode: "644"
contains: "PermitRootLogin"
- path: /etc/os-release
matches: "^ID=alpine$"
- path: /app/version.txt
equals: "1.0.0"
path(required) — absolute path inside the containermode(optional) — expected octal permission mode (e.g.,"755"; leading zeros stripped)type(optional) —"file"or"directory"contains(optional) — substring that must appear in the file contentmatches(optional) — regex pattern (multiline mode)equals(optional) — exact content (trailing newline stripped)
Mounts
Mount files from the repository root into containers. Available in steps and
services. Files are mounted with mode 644 so they are readable regardless
of the container's USER.
steps:
- mounts:
- source: test/fixtures/secret.enc.yaml
target: /tmp/secret.enc.yaml
run:
- cat /tmp/secret.enc.yaml
source: path relative to the--rootdirectory (defaults to the repository root)target: absolute path in the container
Timeout
Set a maximum duration for a test case. Prevents CI hangs. Test-level
timeouts cancel readiness probes too — a stuck pg_isready won't keep a
test alive past its deadline.
tests:
- name: startup-check
timeout: 30s
steps:
- run:
- my-app --health-check
Uses Go duration format (e.g., 30s, 1m, 2m30s). Default: no timeout.
Combination Rules
| Block | Combines with |
|---|---|
inspect |
steps |
steps |
services, inspect |
services |
steps (at least one with run) |
A test must have inspect, steps, or both.
CI Templates
GitLab: CI Include
Path: ci/dagger-call.gitlab-ci.yml
include:
- remote: 'https://git.xarif.de/base/dagger-modules/raw/branch/main/ci/dagger-call.gitlab-ci.yml'
publish-image:
extends: .dagger-call
variables:
DAGGER_FUNCTION: publish-image
DAGGER_ARGS: '--source=.'
For publish-image, registry/auth args are auto-constructed from
$CI_REGISTRY, $CI_REGISTRY_USER, $CI_REGISTRY_PASSWORD, $CI_PROJECT_PATH,
and $CI_COMMIT_REF_NAME.
All other dagger arguments — including --source=... — are passed via
DAGGER_ARGS. The shared script's tokenizer preserves quoted segments:
build-image:
extends: .dagger-call
variables:
DAGGER_FUNCTION: build-image
DAGGER_ARGS: '--source=. --dockerfile=Dockerfile.prod --build-arg=VERSION=$CI_COMMIT_SHORT_SHA'
Variables:
DAGGER_FUNCTION(required): function nameDAGGER_ARGS(required): all dagger arguments (supports quoted segments). Must include--source=...since bothbuild-imageandpublish-imagerequire it.DAGGER_USE_SHARED_MODULE(optional, defaulttrue): set tofalsefor local modules
Forgejo: Reusable Workflow
Path: .forgejo/workflows/dagger-call.yml
Requires a local mirror of actions/checkout at git.xarif.de/actions/checkout.
jobs:
publish-image:
uses: base/dagger-modules/.forgejo/workflows/dagger-call.yml@main
with:
dagger-function: publish-image
dagger-args: '--source=my-app --tests=my-app/tests.yaml'
secrets:
registry-password: ${{ secrets.REGISTRY_TOKEN }}
For publish-image, the workflow auto-constructs --registry, --username,
--password, --repository, and --ref-name from the github.* context.
This works around codeberg #12080 (caller with: expressions not interpolated
in Forgejo).
Inputs:
dagger-function(required): function name to calldagger-args(required): all dagger arguments (supports quoted segments). Must include--source=...since bothbuild-imageandpublish-imagerequire it. Avoidgithub.*expressions — they won't interpolate in the caller'swith:blockuse-shared-module(optional, defaulttrue):falseto use the consumer's own dagger moduledagger-modules-ref(optional, defaultmain): git ref of dagger-modules to check out
Secrets (passed via jobs.<job>.secrets: — NOT declared in
on.workflow_call.secrets, which Forgejo doesn't support):
registry-password(optional): OCI registry password, masked in logs
Examples
A runnable end-to-end example lives at examples/sample-app/.
It includes a multi-stage Dockerfile, a tests.yaml exercising
inspect/steps/files/output, and ready-to-copy GitLab + Forgejo CI snippets.
Forgejo-Specific Notes
actions/checkout@v4requires a local mirror atgit.xarif.de/actions/checkout(auto-synced from GitHub every 8h).- Forgejo
workflow_calldoes NOT interpolate${{ github.* }}expressions in the caller'swith:block (codeberg #12080). The reusable workflow works around this by usinggithub.*context directly in the inner job. FORGEJO_TOKENcannot push packages — use a user PAT withpackage:writeasREGISTRY_TOKEN.
GitLab-Specific Notes
CI_PROJECT_PATHfor projects in nested groups (group/subgroup/repo) is forwarded as-is to--repository. The push lands at<registry>/group/subgroup/repo, which is also where GitLab's Container Registry expects it.- The Container Registry must be enabled per-project (
Settings → General → Visibility, project features, permissions → Container registry). When disabled, pushes fail withdenied: requested access to the resource is denied—PublishImagewill surface this as a hint in its error message. - The shared image
docker:27.5.1-cliis pinned by digest and kept in lockstep with the dind service version. Renovate manages updates.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
push to <registry> was denied (insufficient_scope / unauthorized) |
Wrong token scope, disabled GitLab Container Registry, or missing org-level Forgejo PAT | See the GitLab-specific notes above; for Forgejo use a user PAT with package:write as REGISTRY_TOKEN (org-level if pushing into an org repo) |
module ... not found on dagger call -m |
Wrong git ref, or repo not reachable from the runner | Verify the ref exists; confirm the runner has read access to git.xarif.de/base/dagger-modules |
test "X" failed: step N: run: file not found: /tmp/_dagger_test_step_N.log |
The step's run: shell exited so abruptly the wrapper didn't write the log (e.g. the container's shell is missing) |
Add with_image: alpine:latest (or any image with sh) to the step |
| Test hangs at "service Y not ready" | Probe command exits 0 only when the service is fully ready — a too-eager probe may pass before the service binds; conversely a slow probe may keep failing past the deadline |
Tune readiness.timeout / readiness.interval; verify the probe command actually reflects readiness (e.g. pg_isready not just nc -z) |
output contains err/warn on a benign log line |
The auto err/warn scan is conservative | Add an output.allow regex to exempt the line, or move the noisy command into setup: (whose output is not scanned) |
invalid size "500 MB": ... |
Whitespace between the number and the unit suffix is not accepted | Use 500MB (no space) |
Quoted args in DAGGER_ARGS get split into separate tokens |
The CI was using an older template version | Update to the current ci/dagger-call.sh-based templates |
Local Usage
Consumer repos can call this module directly without any CI infrastructure:
# Build only, with build args
dagger call -m git.xarif.de/base/dagger-modules.git@main build-image \
--source=./my-app \
--build-arg=VERSION=$(git describe --tags --dirty)
# Build and run tests
dagger call -m git.xarif.de/base/dagger-modules.git@main build-image \
--source=./my-app --tests=./my-app/tests.yaml
# Build, test, and push (auto-derives v1, v1.0 floating tags from v1.0.0)
dagger call -m git.xarif.de/base/dagger-modules.git@main publish-image \
--source=./my-app \
--registry=git.xarif.de \
--username=$USER \
--password=env://REGISTRY_TOKEN \
--repository=myorg/myrepo \
--ref-name=$(git describe --tags) \
--tests=./my-app/tests.yaml
Versioning
Breaking changes to the test spec or function signatures land as commits with
! markers in their Conventional Commits subject (e.g. feat(api)!: ...).
Browse git log for the full history. Pin consumers to a specific git ref
(@vX.Y.Z or a SHA) to insulate them from surprise changes.
Renovate
Engine version in dagger.json is managed by Renovate via the dagger preset
from infra/renovate-config.