Reusable Dagger CI/CD modules for building and pushing container images
  • Go 86.2%
  • Shell 13.8%
Find a file
ThomasTSteinbach 8ad656c3fe docs: add testing best practices guide for AI agents
## 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
2026-05-20 12:22:45 +02:00
.forgejo/workflows refactor(ci)!: share dagger-call.sh, drop source convenience input, pin GitLab image 2026-05-19 13:21:52 +02:00
ci fix(ci): pass --root=. explicitly when calling remote module 2026-05-19 15:47:21 +02:00
dagger fix(dagger): measure image rootfs size portably in inspect.max_size 2026-05-19 19:33:37 +02:00
docs docs: add testing best practices guide for AI agents 2026-05-20 12:22:45 +02:00
examples/sample-app docs: rewrite README and add examples/sample-app 2026-05-19 13:22:15 +02:00
.gitignore feat: test framework v2 — metadata, failures, content, services, timeouts, readiness 2026-05-19 08:28:10 +02:00
dagger.json feat: init reusable dagger module with build-image and push-image 2026-05-17 21:32:54 +02:00
LICENSE feat: init reusable dagger module with build-image and push-image 2026-05-17 21:32:54 +02:00
README.md docs: add testing best practices guide for AI agents 2026-05-20 12:22:45 +02:00
renovate.json feat(renovate): add config extending shared dagger preset 2026-05-17 22:18:39 +02:00

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, default Dockerfile): path relative to source root
  • --build-arg (repeatable, optional): KEY=VALUE pairs forwarded as Dockerfile ARG values
  • --build-secret (repeatable, optional): *dagger.Secret values mountable as RUN --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): use env://VAR to pull from an environment variable, or file://path to 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 clean vX.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 as BuildImage
  • --root (optional, default .): same as BuildImage — 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
  • run accepts strings (exit 0 expected) or {cmd, expect_failure} structs
  • expect_failure: true inverts the expectation: the command must exit non-zero
  • description (optional): human-readable label for log output
  • with_image (optional): use a custom image instead of the built image
  • env: environment variables for this step
  • mounts: mount files from the repository root (see Mounts)
  • setup: commands run before run — 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 image
  • ports: ports to expose
  • env: environment variables
  • mounts: mount files from the repository root into the service container
  • files: assert files inside the service container
  • readiness (optional): probe to wait for before running step commands
    • command: shell command (exit 0 = ready)
    • timeout (default 30s): max wait time; honors test-level timeout cancellation
    • interval (default 2s): 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
  • env inside inspect asserts env vars exist with values (does not set them)
  • max_size: checks uncompressed rootfs size. Accepts B, KB/KiB, MB/MiB, GB/GiB, TB/TiB — all binary (1024^n). The IEC iB form is preferred for new specs; KB/MB/GB are 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 scan
  • expect — each pattern must match somewhere in the output
  • reject — 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 container
  • mode (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 content
  • matches (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 --root directory (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 name
  • DAGGER_ARGS (required): all dagger arguments (supports quoted segments). Must include --source=... since both build-image and publish-image require it.
  • DAGGER_USE_SHARED_MODULE (optional, default true): set to false for 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 call
  • dagger-args (required): all dagger arguments (supports quoted segments). Must include --source=... since both build-image and publish-image require it. Avoid github.* expressions — they won't interpolate in the caller's with: block
  • use-shared-module (optional, default true): false to use the consumer's own dagger module
  • dagger-modules-ref (optional, default main): 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@v4 requires a local mirror at git.xarif.de/actions/checkout (auto-synced from GitHub every 8h).
  • Forgejo workflow_call does NOT interpolate ${{ github.* }} expressions in the caller's with: block (codeberg #12080). The reusable workflow works around this by using github.* context directly in the inner job.
  • FORGEJO_TOKEN cannot push packages — use a user PAT with package:write as REGISTRY_TOKEN.

GitLab-Specific Notes

  • CI_PROJECT_PATH for 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 with denied: requested access to the resource is deniedPublishImage will surface this as a hint in its error message.
  • The shared image docker:27.5.1-cli is 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.