Contributing to swarmcli-charts
September 15, 2026 · View on GitHub
Thanks for contributing! This repo holds community charts for
SwarmCLI. Each chart is a Go
text/template that swarmcli renders into a Docker Swarm stack — these are
not Helm charts, so Helm tooling does not apply.
TL;DR
make new-chart NAME=mychart # scaffold a passing skeleton
# edit charts/mychart/{Chart.yaml,values.yaml,templates/stack.yaml.tmpl,README.md}
make test CHART=mychart # render + validate (exactly what CI runs)
make test # validate everything before opening a PR
Open a PR. CI runs the same make test automatically — including on PRs from
forks, before a maintainer reviews — so a chart that does not render never gets
that far.
Prerequisites
- Go (to build the swarmcli renderer from source — see below) and Docker
Compose v2 (
docker compose, used to validate rendered stacks). - mikefarah
yqv4 — required bymake test, which refuses to run without it: therequirements.yamlconsistency check and the charts'ci/render-check.shassertions are written in it. Install withgo install github.com/mikefarah/yq/v4@latest,snap install yq,brew install yq, or a release binary. Beware: theyqin Debian/Ubuntu apt is a different tool (a Pythonjqwrapper) —yq --versionmust saymikefarah. jqandgh— only needed to runscripts/generate-index.sh(the release path); it exits with an error without them.- Optional:
yamllint(pip install yamllint) formake lint.
make install-tools builds the renderer and tells you what else is missing.
Anatomy of a chart
charts/<name>/
Chart.yaml # name, version, appVersion, description (all required)
# + `# renovate: image=<repo>` directly above appVersion
values.yaml # default values
values.schema.json # optional JSON Schema — swarmcli validates values against it
templates/stack.yaml.tmpl # Go text/template → Docker Swarm stack
requirements.yaml # optional — external networks/secrets/configs (see below)
files/<name> # optional — files the chart ships, given to Swarm as a
# config or a secret by a `file:` key (see below)
README.md # what it deploys + a values table
ci/<case>-values.yaml # render fixtures (at least ci/default-values.yaml)
Templates use Go text/template with sprig
functions (minus env/expandenv/getHostByName) plus toYaml. The available
context is:
.Values— merged values (defaults ←-ffiles ←--set).Release.Name/.Release.Namespace/.Release.Revision.Chart.Name/.Chart.Version/.Chart.AppVersion
There is no .Capabilities or other Helm context.
swarmcli does not render in strict mode, so a typo like
{{ .Values.replcas }}silently becomes the literal<no value>instead of erroring.make testgreps for<no value>and fails — fix the reference.
Chart conventions
The existing charts share deliberate patterns — new charts must follow them.
Reference implementations: keycloak (routed, pluggable exposure), mariadb
(stateful), openclaw (both). make new-chart scaffolds all of this correctly and
make test enforces most of it.
Traefik-routed charts — anything exposing HTTP via the traefik chart:
- Deploy labels MUST carry
traefik.enable=true,traefik.constraint-label=<constraintLabel>andtraefik.swarm.network=<network>. The traefik chart's v3 swarm provider runsexposedByDefault=falseplus a constraint ontraefik.constraint-label, so a service without that label is never discovered (404 at the edge);traefik.docker.networkis the docker-provider selector, not the swarm provider's — on multi-network services it can resolve the wrong overlay IP (502/504). Full label contract: charts/traefik/README.md "Routing a service". - Default the
traefik.*values to the in-repo traefik chart: entrypointshttp/https(NOT Traefik's conventionalweb/websecure— a router bound to an entrypoint the instance doesn't define is dropped),certResolver: le,constraintLabel: traefik-public,redirectMiddleware: https-redirect(the traefik chart always defines it, independent of its dashboard). Operators running their own Traefik override these; say so in the chart README. - Render the HTTP router's redirect-middleware label only when TLS is on, so
the
tls: falsepath serves plain HTTP instead of redirecting into a nonexistent HTTPS router. - Real services should make exposure pluggable —
exposure.mode: traefik|published|none(keycloak/openclaw pattern); demo charts (whoami) may hardcode traefik mode.
Stateful charts — anything persisting to a Swarm volume (node-local):
- Single replica, pinned to the data node via
persistence.nodeLabel(default<chart>-data), rendered asnode.labels.<label> == trueONLY whilepersistence.enabled— the pin must never outlive the volume, or the documented ephemeral mode strands the taskPendingon a missing label (#55).nodeLabel: ""skips the pin (single-node swarm).placement.constraintsholds only EXTRA constraints and applies in all modes; never put the data pin there. - Offer host-path persistence:
persistence.volumePath(per-volume<x>Pathwhen there are several — see openclaw) bind-mounts an absolute host path, takes precedence overvolumeName, and suppresses the top-level named-volume block.failat render time when avolumeNamecontains/(docker compose otherwise emits a cryptic error). Acknowledge withhost-mountin the Chart.yamlswarmcli-charts/allowannotation (comma-separated with other keys) and note in a comment that the default named-volume render is clean. - Ship both fixtures:
ci/ephemeral-values.yaml(persistence off — must render no placement block) andci/bind-mount-values.yaml(host path — exercises the host-mount acknowledgment).
swarmcli floor — every Chart.yaml declares swarmcliVersion, the oldest
swarmcli whose chart engine renders it (>= 1.11.0 for most charts today).
This exists because CI renders with swarmcli main, which is newer than
anything a user has installed, so a chart can depend on unreleased behaviour and
still go green. That is not hypothetical: charts/zammad uses template control
flow in requirements.yaml (swarmcli #457), so it rendered in CI and failed on
every released swarmcli with an opaque
parse requirements.yaml: could not find expected ':'. It was unpublishable
until v1.13.0 shipped, and nothing told us.
- Raise the floor only to a RELEASED version.
scripts/floor-check.shproves a floor by downloading a real binary of it and rendering the chart. A floor naming an unreleased version cannot be proven, so it is reported as unverified and skipped — visible in the log, never silently passed. swarmcli charts lint --for-version Xchecks whether a floor admits X. Only floor-check proves the chart runs on it: a swarmcli binary carries one engine's behaviour and cannot emulate another's.- A chart that ships
files/, or accepts an operator's config throughvalues/, needs>= 1.13.0— see Files a chart ships. - Old swarmcli parses
Chart.yamlleniently and ignoresswarmcliVersionentirely — only swarmcli ≥ v1.13.0 enforces it. The floor protects users going forward; it cannot retroactively help anyone already on an old build.
Image pins — the image a chart deploys is pinned by Chart.yaml:appVersion
(every chart ships image.tag: "" and the template falls back to
.Chart.AppVersion). Renovate maintains those pins, and scripts/lint.sh enforces
the three rules that keep it working:
- Every
Chart.yamlcarries# renovate: image=<repo>on the line directly aboveappVersion, naming the same image asvalues.yamlimage.repository. Without it a new chart silently escapes Renovate and its image goes stale forever. - No
:latestanywhere invalues.yaml(or documented in the README). A floating tag changes what deploys without a commit, and Renovate can only keep a concrete tag fresh. - Never echo the appVersion into prose — not a
values.yamlcomment, not the README values table. Renovate editsChart.yamland touches neither, so the number drifts on the first bump. Say "defaults toappVersionin Chart.yaml".
make new-chart scaffolds all of this correctly. A chart whose image.repository
you change must have its renovate comment changed to match, or lint fails.
Secrets — always EXTERNAL Swarm secrets the operator pre-creates; charts
never create secrets or take secret values through values.yaml. Prefer the
image's *_FILE convention; if the image lacks one, read the mounted file in a
command/entrypoint wrapper ($$ compose-escapes the $) so the plaintext never
lands in the compose file or docker inspect. Document the
docker secret create pre-step in the chart README.
The bot maintaining the image pins is our own, run from the
renovate chart — see
docs/renovate-self-hosting.md for how it is
configured and which updates it holds back.
External resources (requirements.yaml)
If a stack attaches to an external network or mounts an external secret/config
(anything marked external: true in the rendered stack), declare it in an
optional requirements.yaml. swarmcli reads this file as a pre-flight before
it deploys:
networks:
- name: traefik-public # the external network's real name (required)
driver: overlay # optional, default "overlay"
attachable: true # optional, default true
autoCreate: true # optional, default true:
# true => swarmcli creates it if missing
# false => validate-only; a missing one is a hard
# error and is never auto-created
description: "Shared ingress overlay" # optional; shown when validation fails
secrets: # entries: { name, description } — validated, never
- name: db-password # auto-created (their content is not chart-supplied)
description: "Postgres password"
configs: [] # entries: { name, description }
The file is optional but authoritative when present: every external resource
the rendered stack references must be declared, or install (and make test)
fails. Without it, swarmcli falls back to auto-creating external networks as
attachable overlays. Use autoCreate: false for a network the operator
pre-provisions (e.g. a shared ingress) — and document such prerequisites in the
chart README.md too.
Modelling an optional secret
The obvious shape — secretName: "" meaning "feature off" — fails every
install, and not in the fixture that uses the feature: in the default one.
validateRequirements errors on secrets[i] has no name (same for networks and
configs). Because requirements.yaml is a template rendered with the release's
values, an unconditional entry with an empty default renders a nameless
declaration and the pre-flight refuses before anything deploys.
CI does not catch it. scripts/requirements-check.sh only checks that names the
manifest references are declared, so it reads an empty declaration as a harmless
extra.
Give the secret a real default name always, and gate its use with a separate
flag — charts/vaultwarden's auth.adminToken is the in-repo precedent:
auth:
rootPassword:
enabled: false # the gate
secretName: gitlab_root_password # always a real name
Declare the name unconditionally in requirements.yaml, and reference it from the
manifest only when the gate is on. An unreferenced declaration is inert — swarmcli
validates what the manifest uses — so over-declaring costs nothing, while a
nameless entry costs every install.
extra_hosts needs its own schema guard
If a chart exposes an extraHosts-style value, constrain it in
values.schema.json. Docker's own converter will not.
convertExtraHosts in docker/cli — identical in the pinned v28.5.1 and in
v29.6.2 — is:
if hostName, ipAddr, ok := strings.Cut(hostIP, ":"); ok {
hosts = append(hosts, ipAddr+" "+hostName) // SwarmKit notation
}
There is no else. An entry with no colon is dropped silently, and the
reversed ip:hostname order is accepted and written as a garbage /etc/hosts
line. Nothing upstream catches either: the v3 stack schema types extra_hosts as
a bare list_or_dict with no format check.
docker compose config does reject the colon-less form — but that is compose
v2/v5, which runs in chart CI and never against an operator's own values. So a
typo deploys clean and resolves nothing.
charts/swarmcli-cd guards it with a pattern on the schema items
(^[^\s:]+:[0-9A-Fa-f.:]+$), which swarmcli enforces at render time.
It is a template, not static YAML
swarmcli renders requirements.yaml as a Go template with the release's
values, so a declaration tracks whatever the operator overrode:
- name: "{{ .Values.exposure.network }}" # quote every substitution
Quote substitutions so the file still parses as YAML unrendered — that is what
lets yamllint (via scripts/lint.sh) check it, and it keeps the declaration
readable on disk. Prefer plain YAML with substitution only: it covers almost
every chart, because an entry a given configuration never references is simply
inert (swarmcli's pre-flight is manifest-driven), so you rarely need an if.
If you genuinely need control flow — the honest case is a user-supplied list,
which substitution cannot express — use it, and add # yamllint disable-file as
the first line:
# yamllint disable-file
networks:
{{- range .Values.extraNetworks }}
- name: "{{ . }}"
autoCreate: false
{{- end }}
A template action on its own line is not parseable YAML, and yamllint cannot lint
a template with control flow — the directive is the opt-out, and it is
load-bearing, not cruft. Nothing important is lost: swarmcli renders this file per
release and scripts/requirements-check.sh parses the rendered result for
every ci/*-values.yaml fixture, which is the form that actually matters. See
charts/zammad for a worked example.
Control flow here was impossible until swarmcli's chart loader stopped hard-parsing the raw bytes as YAML (Eldara-Tech/swarmcli#457). If you find an older comment claiming
range"would break yamllint", it is stale — that reasoning once cost a chart a user-facing feature.
Files a chart ships (files/)
A Swarm stack gives a config or a secret its content in exactly one way — a path.
There is no content: to inline with, whatever Compose itself accepts: the stack
schema the docker CLI validates against (config_schema_v3.9.json, which allows
only name/file/external/labels/template_driver) answers one with
configs.c1 Additional property content is not allowed
Files a chart carries live in files/, read recursively and keyed by their path
relative to the chart:
charts/<name>/
files/
app.conf
tls/ca.pem
# templates/stack.yaml.tmpl
services:
app:
configs:
- source: app-config
target: /etc/app/app.conf
configs:
app-config:
name: "{{ .Release.Name }}_app-config_{{ .Chart.Version }}" # see rotation, below
file: files/app.conf
Exactly three compose keys read a path — a config's file:, a secret's file:
and a service's env_file: — and the docker CLI resolves all three against the
directory of the compose file it is handed, reading them client-side as the
invoking operator. swarmcli writes that compose file to a temp directory, so an
unguarded relative path used to mean "a file in the system temp directory" and an
absolute one meant itself. Every path is therefore checked against the chart at
render time, and the refusals say what to do instead:
| Written in the manifest | Result |
|---|---|
files/app.conf | resolved against the chart |
values/<key> | resolved against the values — a config's file: only, see below |
app.conf | is outside files/ and values/ … reference it as 'files/app.conf' |
files/missing.conf | is not in the chart, and a chart may only read files it ships |
../../etc/passwd | escapes the chart |
/etc/app/app.conf | is an absolute path — for every chart, local directory included |
There is no .Files object in templates. files/ is carried with the chart
and referenced only by those keys; a template cannot read one, and cannot hash
one. So the rotating name below is a convention you write out, not something the
engine computes for you.
Rotation: a Swarm config is immutable
docker stack deploy answers changed content under an existing name by calling
ConfigUpdate, which the daemon refuses:
failed to update config myrelease_app-config_0.1.0: Error response from daemon:
rpc error: code = InvalidArgument desc = only updates to Labels are allowed
New content under a new name is the only mechanism Swarm offers, so give the
config an explicit name: that changes when the file does. For a file the chart
ships, that is the chart version — it is stamped from the release tag, so
every published version rotates exactly once and re-deploying the same version is
idempotent:
name: "{{ .Release.Name }}_app-config_{{ .Chart.Version }}"
The release name keeps two releases on one swarm apart. Superseded configs are
left in place — Swarm refuses to delete one still in use — and a custom name:
still carries the stack's namespace label, so docker stack rm collects them.
The trap this leaves is worth stating plainly: editing a files/ entry without
bumping the chart version fails the next upgrade of an existing release with the
error above. Fresh installs are unaffected, which is exactly why it hides during
development.
A config the operator supplies (values/)
The section above is about a file the chart ships. A config the operator
writes is the other half, and a values/<key> path is how a chart accepts one:
configs:
app-config:
# Content-derived, because the content is now whatever the operator passed.
name: "{{ .Release.Name }}_app-config_{{ .Values.appConfig | sha256sum | trunc 12 }}"
file: values/appConfig
# values.yaml — "" so the name above renders; the operator supplies the content
appConfig: ""
swarmcli charts install myrelease swarmcli-charts/mychart --set-file appConfig=./app.conf
--set-file <key>=<path> reads the file into .Values.<key> verbatim — none of
the comma splitting or type inference --set does, all of which would corrupt a
file. The key is the full values path, so a nested one is values/app.config.
An operator who forgets --set-file gets a refusal, not an empty config over a
working one:
configs.app-config.file: 'values/appConfig' is empty, and values/ names a value,
which the operator supplies (--set-file <key>=<path>, or a values file)
Two rules keep this as safe as the refusals above, and neither is symmetry for its own sake:
- Only a config's
file:may namevalues/. A secret's is refused, and so isenv_file:. Values are stored in the release record — a Docker Config readable by anyone with Docker access, for every retained revision — so secret material would land in the one place a secret exists to keep it out of. Secrets staydocker secret create+external: true. - The operator names the path, never the chart. A path typed on the operator's
own command line carries the authority they already have; a path a chart chose
does not, which is why an absolute
file:stays refused however the chart was loaded.
Declare the floor
A chart using files/ or values/ must declare swarmcliVersion: ">= 1.13.0" —
the release that introduced both. This is not a formality: an older swarmcli
parses Chart.yaml leniently, ignores the floor, carries none of the checks
above, and resolves file: files/app.conf against a temp directory of its own. It
does not fail; it deploys something else. Nothing can be done to an
already-released binary, which makes the constraint the only thing that turns that
into a refusal. scripts/floor-check.sh proves the floor by rendering the chart
with a real binary of that version.
Two consequences of the mechanism, both worth knowing before you ship a large file:
- The referenced files are stored in the release record, so a rollback deploys the bytes the original deploy sent rather than whatever the chart says today.
- That record is as readable as the manifest beside it, and it shares a ~500 KiB gzipped budget with it. An install whose record would not fit is refused, naming the sizes.
What the checks cover
-
make testcatches every refusal above.swarmcli charts templateresolves the file references, so a typo'd or missing path exits non-zero with an empty manifest — it is not an install-time-only failure. -
docker compose configdoes not. It accepts afile:pointing at nothing, so the gate is swarmcli's render step, not the compose step. -
yamllintruns over the wholecharts/tree, so a shipped.yamlfile must pass it like any other chart YAML. A file with a non-YAML extension is not linted. -
Nothing checks the file's contents. Most images can validate their own config; run it once by hand and keep the command in the chart README:
docker run --rm -v "$PWD/charts/x/files/x.yaml:/tmp/c:ro" <image> \ -config.file=/tmp/c -verify-config -
Packaging carries
files/.make packageand the release workflow tar the whole chart directory, and swarmcli readsfiles/out of the.tgz, so a chart installed from the repository behaves like the directory you tested.
Testing locally (== CI)
make test does, per chart × per ci/*-values.yaml fixture:
- render —
swarmcli charts templatemust succeed and emit a valid stack - no-value guard — fails on any
<no value>(missing-key typo) - compose-validate —
docker compose configmust accept the output - security scan — flags risky primitives unless acknowledged (see below)
- requirements check — every external resource the rendered stack uses must
be declared in
requirements.yaml(skipped if the chart has none)
Rendered output lands in .rendered/ for inspection; CI uploads it as an
artifact named rendered-stacks so reviewers can read the produced stack.
Testing end-to-end (real deploy)
make test proves a chart renders; make e2e proves it runs. Against a
local single-node swarm (docker swarm init), make e2e deploys each chart ×
fixture straight from your working tree — so you can test a chart before
you commit, open a PR, or publish anything — waits for the services to converge,
runs an optional per-chart smoke check, and tears the release down:
make e2e # all charts
make e2e CHART=mychart # just yours
It needs a live Swarm and pulls real images. The e2e.yml workflow runs this
in CI on a throwaway single-node swarm for charts that ship CI-provisionable
setup hooks (ci/e2e-setup.sh / ci/e2e-teardown.sh) — fork-safe, using only
public images and dummy on-runner secrets; the full local make e2e across every
chart stays your loop until each chart gains those hooks. See
docs/e2e-testing.md for prerequisites, the setup/teardown
hooks, a manual lifecycle walkthrough, writing a ci/e2e-check.sh smoke check,
and troubleshooting.
To exercise the consumer flow (repo add → search → install repo/chart)
against your unpublished chart, make local-repo serves the working tree as a
local HTTP repo — see docs/e2e-testing.md.
Security acknowledgments
Charts that need a dangerous primitive (Docker socket, host bind-mount,
privileged, host network/PID, cap_add) must acknowledge it in
Chart.yaml, or make test fails:
annotations:
swarmcli-charts/allow: "docker-socket,host-mount"
This keeps danger explicit and reviewable. See charts/swarm-cronjob for a real
example (it mounts the Docker socket by design). Risk keys: docker-socket,
host-mount, privileged, host-network, host-pid, cap-add.
Pull requests
- Keep one chart (or one logical change) per PR.
- Run
make testand keep the chart's README values table in sync withvalues.yaml. - The PR template has the checklist.
CI and scripting semantics that have bitten this repo
Three of these produced a green result on a check that had not passed, or a missing run that looked like a failure. All three are still live traps.
producer | grep -q under set -o pipefail reports a match as no match
grep -q exits at the first match. The producer's next write gets SIGPIPE
and dies with 141, and pipefail makes that the pipeline's status. So if
reads false and a || fail fires on a check that actually passed.
The tell is PIPESTATUS=141 0 — grep matched (0) and the producer was killed
(141). An early match is the trigger, not a large producer and not the pipe
buffer, and shell builtins are not exempt.
Capture first, then test:
out="$(producer)" # let it finish
grep -q PATTERN <<<"$out"
scripts/lint.sh enforces this repo-wide; keep new scripts clean.
A concurrency group cancels queued runs, not just running ones
cancel-in-progress: true kills the running job when a newer run arrives — that
much is the flag. But with cancel-in-progress: false, GitHub still keeps only
one pending run per group: an older queued run is cancelled when a newer one
enters.
Ten chart releases produced ten Pages Index runs — 8 cancelled, 1 running, 1
queued. Nothing failed, and the index still ended up complete because the job
rebuilds from all tags. But any gate keyed on "did workflow X succeed for this
commit" reads those cancellations as never ran, silently.
If you write such a gate, treat cancelled as unknown rather than as failure or
success, and re-check the durable artefact instead.
A GITHUB_TOKEN-dispatched run raises no downstream workflow_run
Publishing is two stages: Release Chart packages and creates the GitHub
release, then Pages Index regenerates index.yaml — chained by
on: workflow_run: workflows: ["Release Chart"].
release-reconcile.yml dispatches Release Chart with the default
GITHUB_TOKEN, and a run started that way raises no downstream event. The
chart is released and the index is never rebuilt, with nothing red anywhere.
That is why Pages Index also runs on a schedule. If you add another
workflow_run chain, give it the same fallback — or dispatch with a PAT, which
does raise the event.
Releasing (maintainers)
The git tag is the source of truth for the version. The easiest way to cut one is the workflow dispatch, which derives the next version from the newest existing tag and creates the tag for you:
gh workflow run release.yml -f chart=whoami -f bump=patch # or minor / major
Use it, not a hand-pushed tag, when releasing several charts at once: GitHub
silently drops tag-push events beyond 3 tags per git push, so some charts would
end up tagged but never published. One dispatch is always exactly one release.
Pushing a tag by hand still works:
git tag whoami/v0.2.0
git push origin whoami/v0.2.0
release.yml stamps the SemVer into Chart.yaml, packages the .tgz and
publishes a GitHub Release; pages-index.yml then rebuilds index.yaml on
GitHub Pages. The version: in
Chart.yaml is only a placeholder — the tag wins. Published versions are plain
SemVer (0.2.0); the leading v belongs to the git tag.
How the renderer is obtained
swarmcli is the renderer, so CI and make test need it. There are two ways to
get one, and the repo uses both on purpose:
scripts/install-swarmcli.shclones and builds swarmcli'smain. This is what the PR workflows use:mainrenders with the newest engine, so a change that breaks a chart shows up the moment it lands, before any release ships it.scripts/download-swarmcli.shdownloads a released binary and verifies its checksum.nightly.ymluses it to run the suite against the latest release, andfloor-check.shuses it to render each chart with the exact release itsChart.yamldeclares as its floor.
A source build rather than go install — which does resolve, since swarmcli's
module path is github.com/Eldara-Tech/swarmcli — because go install ...@main
goes through the module proxy, which can lag a just-pushed commit; a shallow clone
sees it immediately.
- Override the ref with
SWARMCLI_REF=<branch-or-tag>if needed, or the repo withSWARMCLI_REPO=<url>to build a fork. - If an upstream swarmcli change on
mainreds CI for reasons unrelated to your chart, setSWARMCLI_REFto a known-good commit and open a tracking issue.
Repo setup note (maintainers)
To make fork PRs auto-tested before review, enable Settings → Actions → General → Fork pull request workflows → Require approval for all outside collaborators' first workflow run. One click per new contributor, then CI runs and reports automatically.